← 아티클 목록

시리즈파이썬으로 만드는 보안 자동화3편 · 공개 8편

파이썬 보안 자동화

페이로드 하나가 통과했다고 대상이 안전한 것은 아니다

requests 로 만든 SQLi·XSS 퍼저를 localhost 의 DVWA 에 돌려, 취약점 판정이 어떤 신호로 재고 어떤 페이로드로 묻는가에 따라 어떻게 뒤집히는지 다섯 실험으로 측정한 기록.

목차 5

2026-08-09 업데이트 — DVWA를 loopback에만 바인딩하고, 반사와 XSS 실행의 관계 및 폼 처리 분기의 해석을 바로잡았다. Δ길이 -59도 길이 신호 후보로 반영하고 재현 가능한 범위를 명확히 했다.

페이로드를 던졌는데 아무 반응이 없으면 그 대상은 안전한 걸까

취약점 자동 점검은 흔히 이렇게 그려진다. 공격 문자열(페이로드)을 입력점에 보내고, 응답이 이상하면 취약, 멀쩡하면 안전. 페이로드 목록을 크게 만들수록 잘 찾을 것 같다.

포트 스캐닝·콘텐츠 디스커버리와 마찬가지로 SQLi·XSS의 개념 — 주입이 왜 되는지, 어떤 페이로드를 쓰는지 — 는 강의에서 먼저 배웠다. 이 글은 그 판별을 requests로 직접 해 보면서, ’취약/안전’이라는 판정이 실제로 무엇에 달려 있는지 재 본 기록이다.

강의 실습으로 페이로드를 던져 봤을 때는 ’취약/안전’이라는 결과만 받았는데, 같은 대상인데도 무엇을 던지느냐에 따라 그 결과가 달라 보였다. 직접 하네스를 짜서 판정이 어디서 갈리는지 확인하고 싶었다.

먼저 답부터 적으면, 같은 취약점을 가진 대상이 어떤 페이로드로 어떤 신호를 보느냐에 따라 ’취약’도 ’안전’도 됐다. <script>는 막혔는데 <svg>는 통과했고, 시간 기반 주입은 보안 수준마다 다른 페이로드를 요구했다. 판정을 바꾼 것은 대상이 아니라 ’무엇으로 어떻게 물었는가’였다.

취약점 판별이 읽는 다섯 신호

페이로드 하나를 보냈을 때 정상 응답과 비교할 수 있는 신호는 다섯이고, 각각 한계가 있다.

신호 판별 방식 한계
SQL 오류 본문에 sql syntax 등 운영 환경은 오류를 숨긴다
응답 길이 기준선과의 차이 동적 페이지에서 흔들린다
시간 지연 SLEEP(3)이 약 3초 네트워크 지연과 섞인다
반사 페이로드가 본문에 그대로 반사가 실행은 아니다
상태 코드 500 등 서버 오류만으로 확정 못 한다

핵심은 이 표의 오른쪽 칸이다. 어느 신호도 혼자서는 ’취약’을 확정하지 못한다. 그래서 자동 판별은 페이로드를 많이 보내는 작업이 아니라, 정상과 공격의 차이를 다섯 신호로 한꺼번에, 같은 스키마로 수집하는 작업이다. 이 글에서 잰 것은 그 신호들이 각각 언제 켜지고 언제 거짓말하는가다.

정답을 아는 대상 — 보안 수준을 바꿀 수 있는 DVWA

무엇이 취약한지 미리 알아야 판정이 맞는지 볼 수 있다. 그래서 DVWA를 localhost 컨테이너로 띄웠다. DVWA는 취약점이 어디 있는지 알려진 학습용 앱이고, 무엇보다 low·medium·high 보안 수준을 바꿔 가며 ‘같은 취약점에 방어를 더한’ 상태를 만들 수 있다.

bash
docker run -d --rm --name dvwa-lab -p 127.0.0.1:4280:80 vulnerables/web-dvwa:latest

퍼저가 보내는 요청의 대상은 127.0.0.1:4280 하나뿐이고 내가 띄운 컨테이너다. 포트도 127.0.0.1에만 게시해 호스트의 외부 인터페이스에서는 받지 않게 했다. 호스트 IP를 생략하면 모든 주소에 포트가 게시돼 이 실습의 경계와 맞지 않는다. Docker 28.0.0 미만에는 같은 L2 구간에서 loopback 게시 포트에 닿을 수 있었던 예외도 있어 최신 Docker와 격리된 실습 환경을 전제로 한다. 스캐너·퍼저를 다루는 글에서 대상은 본인 소유이거나 명시적으로 허가된 것으로만 한정한다. DVWA 공식 저장소도 이 취약 앱은 loopback에만 두는 것을 안전한 기본값으로 안내한다.

입력점은 두 곳이다. SQLi 페이지(/vulnerabilities/sqli/)의 id 파라미터와 반사형 XSS 페이지(/vulnerabilities/xss_r/)의 name 파라미터. 각 실험은 공격 전에 정상 입력으로 기준선(길이·왕복 시간)을 먼저 잡는다.

1. 한 페이로드에 다섯 신호를 함께 잰다

low 수준의 SQLi 입력점에 페이로드 넷을 보내고, 각각 어떤 신호가 켜지는지 한 줄로 모았다.

기준선: 길이 4538, 왕복 4ms
페이로드                     상태     Δ길이   반사     지연  SQL오류
1'                      200   -4375    O   0.0s     O
1' OR '1'='1            200     288    O   0.0s     ·
1 OR 1=1                200       7    O   0.0s     ·
1' AND SLEEP(3)-- -     200     -59    ·   3.0s     ·

페이로드마다 켜지는 신호가 다르다.

  • 1' 는 SQL 문법을 깨서 오류 신호를 켠다. 응답이 짧은 오류 페이지가 되니 길이도 4375바이트 줄었다.
  • 1' OR '1'='1 는 모든 행을 덤프해 길이 신호를 켠다(+288). 오류는 안 난다.
  • 1' AND SLEEP(3)-- - 의 주된 신호는 3초 지연이다. 다만 응답도 기준선보다 59바이트 짧아졌다. 이는 뒤에서 잰 정상 변동폭 6바이트와 예시 절대 길이 임계값 50바이트를 모두 넘으므로, abs(Δ길이)를 보는 규칙에서도 확인 후보가 된다. 반사는 없었다.

여기서 1 OR 1=1 은 아무 신호도 제대로 켜지 못했다(Δ길이 7 ≈ 기준선 변동). low의 주입 문맥이 따옴표로 감싸인 '$id'라, 따옴표를 깨지 않는 이 페이로드는 그냥 문자열로 들어갔기 때문이다. 이건 3절로 이어진다.

이 표가 왜 다섯을 함께 남기는지를 보여 준다. 오류 신호 하나만 봤다면 OR과 SLEEP 페이로드는 ’반응 없음’으로 지나쳤을 것이다. 각 페이로드가 서로 다른 신호를 켜므로, 신호 하나에 의존하는 판별은 나머지를 구조적으로 놓친다.

2. 페이로드 하나로 물으면 보안 수준을 오판한다

반사형 XSS로 넘어가면 ’페이로드 선택’이 판정을 통째로 바꾼다. 세 보안 수준에 대표 페이로드 둘 — <script>와 <svg> — 을 보내고 반사되는지 봤다.

수준          <script> 반사     <svg> 반사
low                   O            O
medium          · (안전?)            O
high            · (안전?)            O

<script>alert(1)</script> 만 던지면 medium과 high는 반사가 안 돼서 ’안전’으로 보인다. DVWA가 그 수준에서 <script> 문자열을 걸러 내기 때문이다. 그런데 같은 수준에 <svg/onload=alert(1)> 를 던지면 세 수준 모두 반사된다. 필터가 <script>라는 특정 모양만 막고 있어서, 다른 모양의 페이로드는 그대로 통과한다.

즉 medium·high는 안전해진 것이 아니라 <script>라는 한 페이로드에만 안전하다. 페이로드를 하나만 쓰는 판별은 이 차이를 ’취약 → 안전’으로 잘못 읽는다. 카테고리마다 여러 모양을 던져야 하는 이유가 이것이다.

한 가지 경계를 분명히 해 둔다. 반사는 실행이 아니다. 여기서 잰 반사 O는 ’페이로드가 응답 본문에 그대로 들어 있다’는 뜻이지 ’브라우저에서 스크립트가 실행된다’는 증명이 아니다. 같은 문자열이 보여도 출력 위치와 인코딩·정화 방식에 따라 데이터로만 표시될 수 있다. 반대로 DOM 기반 XSS처럼 HTTP 응답에 페이로드가 그대로 나타나지 않아도 클라이언트 코드가 값을 위험한 sink에 넣어 실행하는 경우가 있으므로, 반사는 XSS 전체의 보편적인 필요조건도 아니다. 이 DVWA 반사형 입력점에서는 후보를 좁히는 신호로만 쓰고, 실제 실행 여부는 브라우저에서 확인해야 한다(OWASP의 반사형 XSS 테스트). 자동 판별이 낼 수 있는 것은 ’반사됨, 확인 필요’까지다.

3. 같은 취약점이 수준마다 다른 페이로드를 요구한다

2절이 XSS의 이야기라면, SQLi에서도 같은 일이 벌어진다. 시간 기반 주입(SLEEP)을 따옴표 문맥용과 숫자 문맥용 두 페이로드로 만들고, low(GET)와 medium(POST)에 교차로 던졌다.

수준       방식         따옴표 페이로드→지연       숫자 페이로드→지연
low      GET               3.0s             0.0s
medium   POST              0.0s             3.0s

대각선으로 갈린다. low에서는 따옴표 페이로드(1' AND SLEEP(3)-- -)가 3초를 만들고, medium에서는 숫자 페이로드(1 AND SLEEP(3))가 3초를 만든다. 시간 기반 SQLi 취약점은 두 수준 모두에 있다. 다만 low의 주입 문맥은 따옴표로 감싸여 있고 medium은 숫자 그대로라, 문맥에 맞는 페이로드만 실행된다.

여기서 함정은, 페이로드 목록에서 ’SQLi 시간 기반’으로 따옴표 버전 하나만 갖고 있으면 medium을 ’안전’으로 판정한다는 것이다. 방어가 취약점을 없앤 게 아니라 페이로드가 문맥과 안 맞았을 뿐인데, 결과 표에는 똑같이 ’반응 없음’으로 찍힌다. ’안전’이 ’이 페이로드로는 반응 없음’의 다른 이름일 수 있다.

4. 기준선을 재지 않으면 길이 델타는 신호가 못 된다

1절에서 1' OR '1'='1 이 길이를 288바이트 늘렸다. 이 숫자가 신호가 되려면 ’정상 응답은 얼마나 흔들리는가’를 알아야 한다. 정상 입력 다섯 개의 응답 길이를 재 봤다.

정상 입력 5개의 길이: [4538, 4539, 4534, 4540, 4536]  → 변동폭 6바이트
공격(1' OR '1'='1)의 Δ길이: 288바이트

정상 요청끼리도 길이가 6바이트 흔들린다(사용자마다 이름 길이가 다르니까). 공격의 288바이트는 이 변동폭을 훨씬 넘으니 신호가 된다. 하지만 그 판단은 변동폭 6을 먼저 쟀기 때문에 가능한 것이다.

여기 길이 임계값을 고정값(예: 50바이트)으로 박아 두면 대상이 바뀔 때 무너진다. 정상 변동이 큰 동적 페이지에서는 100바이트 차이가 노이즈일 수 있고, 반대로 응답이 거의 일정한 페이지에서는 20바이트도 신호다. 임계값은 ’정상 변동폭 위’에 둬야 하고, 그 값은 대상마다 달라서 재 봐야 안다. 기준선은 편의가 아니라 델타 신호의 전제다.

5. 폼 필드가 빠지면 의도한 처리 분기를 못 잴 수 있다

마지막은 요청을 구성하는 방식이 응답을 바꾸는 경우다. SQLi 폼에는 id 말고 Submit 필드가 있다. 공격 페이로드를 id에 넣되, Submit을 함께 보낼 때와 뺄 때를 비교했다.

Submit 포함: Δ길이 +288  (공격 반영됨)
Submit 누락: Δ길이 -59  (+288 덤프 반응은 사라짐)

Submit을 빼자 같은 페이로드에서 보였던 +288 덤프 반응은 사라지고, 응답은 기준선보다 59바이트 짧아졌다. 따라서 길이 변화 자체가 사라진 것도, 모든 신호가 꺼진 것도 아니다. abs(Δ길이) > 50 같은 예시 규칙이라면 이 응답도 길이 이상 후보로 남는다. 다만 +288을 공격 성공 신호로만 보던 판별기는 기대한 덤프 반응을 놓친다.

당시 기록은 이를 Submit이 없어 취약한 쿼리를 실행하는 폼 처리 분기로 들어가지 않은 결과로 해석했다. 요청이 서버에 도달했다는 점과 +288 반응이 사라졌다는 점은 표에 남아 있지만, 현재 하네스가 없어 그 분기 판정 코드를 다시 검토할 수는 없다. 재현 가능한 일반 원칙은 공격할 필드 하나만 바꾸고 나머지 폼 필드는 원래 값 그대로 함께 보내야 한다는 데까지다. 그렇지 않으면 취약점이 아니라 다른 처리 경로의 응답을 비교할 수 있다.

판정은 무엇으로 어떻게 묻는가가 정한다

다섯 번의 관찰이 한 곳을 가리킨다. 대상 DVWA의 취약점은 내내 거기 있었고, 판정을 바꾼 것은 스캐너가 무엇으로 어떻게 물었는가였다.

무엇을 바꿨나 결과
몇 개의 신호를 볼지 신호 하나면 다른 페이로드의 취약점을 놓친다
어떤 페이로드를 쓸지 <script>는 막히고 <svg>는 통과
페이로드가 문맥에 맞는지 같은 취약점이 수준마다 다른 페이로드를 요구
기준선을 쟀는지 안 재면 길이 델타가 신호가 못 된다
폼 필드를 보존했는지 빠지면 의도한 +288 덤프 반응 대신 다른 응답을 잴 수 있음

그래서 SQLi·XSS 판별기를 만들 때 확인하는 순서를 이렇게 잡았다.

  1. 한 신호로 결론 내지 않는다. 상태·길이·반사·지연·오류를 같은 스키마로 함께 남긴다. 페이로드마다 켜지는 신호가 다르다.
  2. 카테고리마다 여러 모양의 페이로드를 던진다. <script> 하나로 ’안전’이 나와도 <svg>로 다시 묻는다.
  3. ’반응 없음’을 ’안전’으로 기록하지 않는다. 페이로드가 문맥과 안 맞았을 수 있다.
  4. 공격 전에 기준선을 재고, 임계값을 그 변동폭 위에 둔다.
  5. 공격할 필드만 바꾸고 나머지 폼 필드는 원래 값으로 함께 보낸다. 반사는 실행이 아니고 반사되지 않았다는 사실만으로 XSS를 배제할 수도 없으므로, 자동 판별은 ’확인 필요’까지만 낸다.

이 다섯은 어느 것도 ’대상이 취약한가’를 바꾸지 않는다. DVWA는 내내 취약했다. 바뀐 것은 그 취약점이 결과 표에 ’취약’으로 남는가 ’안전’으로 남는가였고, 그것은 전부 스캐너 쪽의 선택이었다.

당시 실행 순서와 현재 재현 범위

아래는 수치를 얻을 때 사용한 일회성 로컬 하네스의 실행 순서다. 현재 이 저장소에서 그대로 실행할 수 있는 재현 절차는 아니다. 당시 사용한 fuzz.py와 fuzz_run.py는 현재 작업 트리와 도달 가능한 Git 이력에 남아 있지 않다.

[당시 로컬 작업 디렉터리]
docker run -d --rm --name dvwa-lab -p 127.0.0.1:4280:80 vulnerables/web-dvwa:latest
python fuzz_run.py    # 당시: DB 생성 → 로그인 → 다섯 실험 실행
docker stop dvwa-lab  # 당시: 실습 종료 후 컨테이너와 데이터 제거

당시 기록상 fuzz.py의 probe가 다섯 신호를 한 스키마로 남기고, fuzz가 페이로드 목록을 병렬로 보냈으며 set_security로 수준을 바꿨다. 그러나 구현 파일이 보존되지 않았으므로 지금은 이 설명과 표만으로 하네스의 요청 구성·판정 코드를 독립 검토하거나 다시 실행할 수 없다.

환경은 Python 3.14.6, requests·beautifulsoup4, vulnerables/web-dvwa:latest 이미지였다. 당시 이미지 digest도 남기지 않았다. Docker 문서가 설명하듯 digest는 특정 이미지 버전을 고정하는 식별자인데, latest 태그와 실행 파일이 모두 남지 않아 위 응답 길이와 필터 결과를 현재 독립적으로 재현할 수 없다.

DVWA 공식 저장소의 현재 실행 경로는 저장소를 받은 뒤 docker compose up -d를 쓰는 방식이고, 공식 Compose도 127.0.0.1:4280:80을 기본으로 둔다. 현재 공식 이미지에서 이 실험을 다시 돌리지는 않았다. 재현 가능한 실습으로 되살리려면 하네스와 의존성을 다시 만들고 이미지 digest를 고정한 뒤 표의 수치를 새로 측정해야 한다. 당시 결과는 SLEEP 지연이 3초로 깨끗이 잡힌 localhost 관찰로만 읽는다.

이 글에서 확인하지 못한 것은 세 가지다. 첫째, 반사가 실제로 브라우저에서 실행되는지는 확인하지 않았다 — 자동 판별은 ’반사됨’까지고, 실행 증명에는 브라우저가 필요하다. 둘째, SQLi high 수준은 입력이 별도 세션 페이지를 거치는 방식이라 이 GET/POST 하네스로 다루지 못했고, low·medium만 쟀다. 셋째, 페이로드 목록을 카테고리당 두셋으로 짰다 — 실제 스캐너가 쓰는 수백 개짜리 목록에서 오탐·미탐이 어떻게 달라지는지는 재지 않았다.