시리즈파이썬으로 만드는 보안 자동화2편 · 공개 8편
인증 없이 스캔한 200은 대부분 로그인 페이지였다
requests·BeautifulSoup 로 만든 콘텐츠·폼 디스커버리를 localhost 의 DVWA 에 돌려, 디렉터리 스캔의 상태 코드가 인증 상태에 따라 무엇을 말하고 무엇을 감추는지 측정한 기록.
목차 5
2026-08-09 업데이트 — DVWA를 loopback에만 바인딩하고 후보 경로 수와 로그인 응답 판별 기준을 바로잡았다. 인증만으로 경로 존재 여부가 항상 판별된다는 설명과 soft-404 재현 조건도 이 실습의 범위에 맞게 조정했다.
디렉터리 스캔에서 200이 나오면 그 경로는 열려 있는 걸까
웹 콘텐츠 디스커버리는 초기 페이지에 링크되지 않은 경로 — 백업 파일, 개발용 엔드포인트, 관리 페이지 — 를 단어 목록으로 두드려 찾는 작업이다. 판정의 기준은 대개 상태 코드다. 200이면 있음, 404면 없음.
포트 스캐닝과 마찬가지로 이 개념 — 디렉터리 브루트포싱, gobuster·ffuf 같은 도구 — 는 강의에서 먼저 배웠다. 이 글은 그 판정을 requests로 직접 해 보면서, 200과 404가 실제로 무엇을 뜻하는지 대상의 인증 상태를 바꿔 가며 재 본 기록이다.
강의 실습으로 도구를 돌려 봤을 때는 ’200이 몇 개 나왔다’에서 멈췄는데, 그 200이 실제로 무엇인지는 확인하지 않았다. 직접 스크립트로 두드려 보면 그 안이 보일 것 같았다.
먼저 답부터 적으면, 로그인하지 않은 상태에서 얻은 200의 대부분은 찾으려던 페이지가 아니라 로그인 페이지였다. 상태 코드만 보면 그 둘이 구분되지 않았고, 최종 URL과 로그인 폼 같은 본문 특징·응답 길이를 함께 보거나 애초에 로그인하고 스캔해야 했다. 판정을 바꾼 것은 대상이 아니라 ’어떤 상태로 물었는가’였다.
콘텐츠 디스커버리가 보는 세 가지 신호
경로 하나를 두드렸을 때 돌아오는 것은 셋이다.
| 신호 | 뜻하는 것 | 함정 |
|---|---|---|
| 상태 코드 | 200·404·302… | 302는 ’다른 데로 가라’이지 없음이 아니다 |
| 최종 URL | 리다이렉트가 끝난 자리 | 따라가면 상태 코드가 바뀐다 |
| 응답 길이 | 본문 크기 | 내용이 다른데 상태가 같을 때 가른다 |
상태 코드 하나만 보면 판정이 흔들리는 이유가 여기 있다. 302(리다이렉트)를 어떻게 처리하느냐에 따라 같은 경로가 ’없음’도 되고 ’있음’도 된다. 이 글에서 잰 것은 그 흔들림의 크기다.
정답을 아는 대상 — 내가 띄운 DVWA
무엇이 실재하는 경로인지 미리 알아야 판정이 맞는지 볼 수 있다. 그래서 구조가 알려진 의도적 취약 앱 DVWA를 localhost 컨테이너로 직접 띄웠다.
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에만 두는 것을 안전한 기본값으로 안내한다.
후보 경로 20개를 정했다. 실재하는 DVWA 페이지 11개(공개 3개 + 로그인 뒤 8개)와, 흔히 찔러 보는 없는 경로 9개(admin/, backup.zip, .git/config 등)를 섞었다. 인증된 세션으로 한 번 훑어 ’실재 11개’를 정답표로 확정한 뒤, 그 정답을 스캔 결과와 대조했다.
1. 인증 없이 상태 코드만 보면
로그인하지 않은 세션으로 20개를 두드렸다. 리다이렉트를 따라가지 않은 상태 코드와, 따라간 뒤의 상태·최종 위치·길이를 나란히 놓았다.
경로 안 따라가면 따라가면 최종 길이
index.php →login 200 /login.php 1523
instructions.php 200 200 /instructions 14014
phpinfo.php →login 200 /login.php 1523
vulnerabilities/sqli/ →login 200 /login.php 1523
vulnerabilities/exec/ →login 200 /login.php 1523
vulnerabilities/csrf/ →login 200 /login.php 1523
docs/ 200 200 /docs/ 1136
config/ 200 200 /config/ 1385
admin/ 404 404 /admin/ 281
secret.php 404 404 /secret.php 285
backup.zip 404 404 /backup.zip 285
.git/config 404 404 /.git/config 286정답 실재: 11개
리다이렉트를 안 따라가면 '200'은 3개뿐 — 보호된 8개가 302라 존재하는데도 놓친다
따라가면 '200'이 11개로 늘지만 그중 8개가 길이 1523인 로그인 페이지다 — 문은 찾았어도 방은 못 본다상태 코드가 리다이렉트 처리에 따라 양쪽으로 어긋난다.
- 리다이렉트를 안 따라가면
200은 3개뿐이다. 로그인 뒤에 있는 8개(sqli,exec,phpinfo…)가 전부302라서, ’200이면 있음’으로 세면 실재하는 8개를 놓친다. 정작 찾고 싶은 취약점 페이지들이 여기 다 들어 있다. - 따라가면
200이 11개로 늘어 실재 수와 맞는 것처럼 보인다. 그런데 그중 8개는 길이가 전부 1523인 로그인 페이지다. 상태 코드는200이지만 내용은 ’로그인하라’다. 경로가 있다는 것까지는 맞혔어도, 얻은 것은 그 페이지가 아니라 벽이다.
한편 없는 경로 9개는 404로 정확히 걸러졌다. DVWA가 없는 경로에 진짜 404를 주기 때문인데, 만약 이 앱이 모든 경로에 200을 주는 종류였다면(soft-404) 그 구분마저 무너졌을 것이다. 여기서 404를 믿을 수 있는 것은 대상이 그렇게 동작한다는 것을 확인했기 때문이지, 스캐너가 보장하는 것이 아니다.
이 실행에서는 최종 URL /login.php와 길이 1523이 함께 반복된 것이 로그인 벽의 신호다. 길이는 유용한 한 칸이지만 유일한 구분자는 아니다. 최종 URL, 페이지 제목, 로그인 폼 같은 본문 특징도 같은 판단을 뒷받침한다. 상태 코드 한 칸만 보는 스캐너는 이 벽을 못 본다. OWASP WSTG도 진입점을 기록할 때 인증 여부와 리다이렉트·상태 코드·쿠키 등 요청과 응답의 맥락을 함께 남기도록 안내한다(WSTG 4.2 진입점 식별).
사고 사례 대신 이 오해가 번지는 경로를 적어 둔다. 리다이렉트 따라간 200을 "존재"로 기록 → 8개를 발견 목록에 올림 → 실제로는 로그인 페이지 → "접근 가능한 경로"로 보고 → 정작 보호된 콘텐츠는 확인 못 한 채 종료. 목록의 숫자는 늘어나는데 그중 다수가 같은 벽이다.
2. 로그인하고 같은 스캔을 하면
CSRF 토큰을 뽑아 로그인한 세션으로 똑같은 20개를 다시 두드렸다.
경로 상태 길이 정답 판정
index.php 200 6721 실재 정탐
phpinfo.php 200 90047 실재 정탐
vulnerabilities/sqli/ 200 4479 실재 정탐
vulnerabilities/exec/ 200 4167 실재 정탐
docs/ 200 1136 실재 정탐
admin/ 404 281 없음 정탐
secret.php 404 285 없음 정탐
.git/config 404 286 없음 정탐
정탐 11 / 오탐 0 / 미탐 0인증하니 이 20개 후보에서는 실재 11개가 전부 200으로 길이가 서로 다르게 나오고, 없는 9개는 404로 갈렸다. 위 표에 제시한 실재 응답 예시의 길이는 1136~90047이다. 같은 스캐너, 같은 대상인데 세션 하나로 판정이 완전히 달라졌다. 이 정답표에서 상태 코드가 다시 신호가 된 것은 스캐너를 고쳐서가 아니라 인증된 상태에서 물었기 때문이다.
이 대비가 콘텐츠 디스커버리의 순서를 정한다. 인증 뒤 콘텐츠를 찾는 것과 인증 벽 자체를 찾는 것은 다른 작업이고, 로그인하지 않은 채 얻은 목록은 대부분 그 벽이다.
3. 폼 디스커버리 — 로그인부터가 폼 문제다
2절에서 ’로그인한 세션’이라고 했는데, 그 로그인 자체가 폼 디스커버리 없이는 안 됐다. 아이디와 비밀번호만 보내 봤다.
토큰 없이 로그인 POST → 302 login.php (로그인 실패, 다시 login.php)
login.php 폼: method=post inputs=['username', 'password', 'Login', 'user_token']username·password만으로는 로그인이 안 되고 다시 로그인 페이지로 돌아온다. 폼에 user_token이라는 CSRF 토큰 필드가 있고, 이 값은 페이지를 열 때마다 새로 발급되므로 미리 알 수 없다. 로그인 페이지를 먼저 받아 폼을 읽고, 거기서 토큰을 뽑아 함께 보내야 한다.
그래서 크롤러의 순서가 ’로그인 → 크롤’이 아니라 ’폼을 읽음 → 토큰 추출 → 로그인 → 크롤’이 된다. BeautifulSoup으로 form과 input을 구조화하는 일이 편의 기능이 아니라 로그인의 전제다.
forms.append({
"action": urljoin(page_url, form.get("action") or page_url),
"method": (form.get("method") or "get").lower(),
"inputs": [
{"name": f.get("name"), "type": f.get("type", "text"),
"value": f.get("value", "")}
for f in form.find_all(["input", "textarea"])
if f.get("name")
],
})name이 없는 필드는 보낼 파라미터를 만들 수 없어 제외한다. 같은 방식으로 인증된 페이지의 폼도 읽으면 다음 점검 대상이 나온다 — 예를 들어 SQLi 페이지의 폼은 method=get에 id·Submit 입력을 가진 구조로, 이게 다음 글(입력값을 바꿔 보는 단계)의 출발점이 된다.
4. 스코프 제어는 속도가 아니라 정확성 문제다
크롤러는 링크를 따라가며 범위를 넓힌다. 같은 호스트만, 중복 없이 따라가는 것이 기본이고, 여기에 따라가면 안 되는 링크가 있다. DVWA의 모든 페이지에는 로그아웃 링크가 걸려 있다.
로그아웃 링크를 한 번 따라간 전후로 같은 보호 페이지를 찔러 봤다.
logout 전 sqli 접근: 200 /vulnerabilities/sqli/ (길이 4479)
logout 후 sqli 접근: 200 /login.php (길이 1523)링크 하나를 따라간 것으로 세션이 죽었다. 로그아웃 뒤에는 sqli 페이지가 다시 길이 1523의 로그인 페이지로 돌아온다 — 1절에서 본 그 벽이다. 크롤러가 로그아웃 링크를 큐에 넣고 방문하는 순간, 그 뒤로 방문하는 모든 페이지가 인증되지 않은 상태로 측정된다.
크롤을 두 방식으로 돌려 비교했다.
제외 없음(logout 포함) 방문 24 / 로그인으로 튕김 2, 21번째 방문부터 튕김
logout 제외 방문 23 / 로그인으로 튕김 0이 실행에서는 로그아웃이 21번째로 방문돼 뒤에 남은 페이지가 둘뿐이었지만, 그 위치는 링크를 훑는 순서에 달렸을 뿐이다. 로그아웃이 모든 페이지에 링크돼 있어 언젠가는 반드시 방문되고, 그 시점 이후의 측정은 전부 무효가 된다. 그래서 로그아웃을 큐에서 빼는 것은 ‘불필요한 방문을 줄이는’ 최적화가 아니라 ‘그 뒤 결과를 살리는’ 정확성 조치다.
디스커버리의 답은 어떤 상태로 묻는가가 정한다
네 번의 관찰이 한 곳을 가리킨다. 대상 DVWA는 내내 같았고, 판정을 바꾼 것은 어떤 상태에서 무엇을 보고 물었는가였다.
| 무엇을 바꿨나 | 결과 |
|---|---|
| 인증 여부 | 같은 스캔이 ’대부분 로그인 벽’에서 ’정탐 11/오탐 0’으로 |
| 리다이렉트를 따라갈지 | 안 따라가면 8개 누락, 따라가면 8개가 로그인 벽 |
| 상태 코드만 볼지 응답 특징도 볼지 | 같은 200 안에서 최종 URL·로그인 폼·길이로 내용과 벽을 가름 |
| 로그아웃 링크를 큐에 넣을지 | 그 뒤 모든 측정의 생사 |
그래서 콘텐츠 디스커버리를 만들 때 확인하는 순서를 이렇게 잡았다.
- 먼저 인증하고 스캔한다. 로그인 뒤 콘텐츠를 찾는 것과 로그인 벽을 찾는 것은 다른 작업이다. 인증 없이 얻은 목록은 대부분 후자다.
- 상태 코드와 응답 특징을 함께 본다. 같은
200이 실제 내용일 수도 로그인 벽일 수도 있으므로 최종 URL·제목·폼·정규화한 본문과 길이를 함께 비교한다. 404를 믿기 전에 대상이 없는 경로에 진짜404를 주는지 확인한다. 모든 경로에200을 주는 앱이면 상태 코드 판정 자체가 무너진다.- 로그인은 폼 디스커버리의 결과다. CSRF 토큰처럼 매번 바뀌는 값은 폼을 읽어 그 자리에서 뽑는다.
- 크롤 범위에서 로그아웃 같은 상태 파괴 링크를 뺀다. 속도가 아니라 정확성 때문이다.
이 다섯은 경로 존재와 접근 가능성, 실제로 받은 콘텐츠를 분리해 기록하기 위한 조건이다. 이 DVWA 버전과 20개 후보에서는 인증한 세션의 200·404가 정답표와 깨끗이 갈렸지만, 인증이 보편적인 존재 확인법이라는 뜻은 아니다. 다른 앱은 권한이 부족한 실재 경로를 403이나 404로 감추거나, 없는 경로에도 200인 공통 페이지를 돌려줄 수 있다. 알려진 실재·부재 경로를 기준선으로 두고 상태 코드, 리다이렉트, 본문 특징을 함께 비교해야 한다.
당시 실행 순서와 현재 재현 범위
아래는 수치를 얻을 때 사용한 일회성 로컬 하네스의 실행 순서다. 현재 이 저장소에서 그대로 실행할 수 있는 재현 절차는 아니다. 당시 사용한 discover.py와 disc_run.py는 현재 작업 트리와 도달 가능한 Git 이력에 남아 있지 않다.
[당시 로컬 작업 디렉터리]
docker run -d --rm --name dvwa-lab -p 127.0.0.1:4280:80 vulnerables/web-dvwa:latest
python disc_run.py # 당시: DB 생성 → 로그인 → 네 실험 실행
docker stop dvwa-lab # 당시: 실습 종료 후 컨테이너와 데이터 제거당시 기록상 discover.py의 session_login이 CSRF 로그인을, probe가 경로 판정을, extract_forms가 폼 추출을, crawl이 범위 제어 크롤을 맡았고 disc_run.py가 setup.php로 DB를 만들었다. 그러나 구현 파일이 보존되지 않았으므로 지금은 이 설명과 표만으로 후보 목록·요청 순서·판정 코드를 독립 검토하거나 다시 실행할 수 없다.
환경은 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를 고정한 뒤 표의 수치를 새로 측정해야 한다.
이 DVWA는 없는 경로에 진짜 404를 돌려주므로 후보만 더 넣어서는 soft-404가 재현되지 않는다. soft-404를 직접 확인하려면 모든 미등록 경로에 공통 200 페이지를 돌려주도록 만든 별도의 허가된 로컬 fixture나, 그런 동작이 이미 확인된 허가 대상이 필요하다. 그 환경에서 임의의 존재 불가능한 경로를 기준선으로 보내고, 최종 URL·제목·폼·정규화한 본문과 길이가 실재 페이지와 어떻게 겹치는지 비교한다. 이 글에서는 그 절차를 실행하지 않았다.
이 글에서 확인하지 못한 것은 세 가지다. 첫째, DVWA는 없는 경로에 진짜 404를 주는 앱이라 soft-404 상황은 개념으로만 짚었고 실제로 재현하지 못했다. 둘째, 단어 목록을 20개 후보로 직접 짰다 — gobuster가 쓰는 수천 개짜리 목록으로 돌렸을 때 오탐·미탐이 어떻게 달라지는지는 재지 않았다. 셋째, 발견한 폼에 실제로 입력값을 넣어 응답이 어떻게 변하는지는 다음 단계의 일이라 여기서는 폼 구조를 뽑는 데서 멈췄다.