본문 바로가기

HTTP 요청·응답 조작과 웹 분석 기초

2장에서는 내가 서버를 하나 띄우고, 내가 그 서버에 요청을 보내는 정도의 HTTP를 다뤘습니다. 이번 장부터는 관점이 바뀝니다. 상대방 서버가 어떻게 반응하는지를 관찰하고, 조금씩 변형된 요청을 보내며 "이 서버는 어디가 약한가"를 찾아나섭니다. 이 관찰의 기본 도구가 requests.Session과 httpx, 그리고 Burp Suite입니다.

1. 왜 "HTTP 조작"부터 배우는가

SQL 인젝션, XSS, CSRF, SSRF… 웹 취약점 교과서가 드는 이름들은 많지만, 실제로 하는 일은 거의 하나입니다.

HTTP 요청의 어느 한 조각(쿼리스트링·바디·헤더·쿠키)을 바꿔 보고, 응답의 변화를 관찰하기.

이 조작이 자동화되면 스캐너가 되고, 사람이 한 땀 한 땀 조정하면 수동 모의해킹이 됩니다. 그래서 3장의 첫 걸음은 "요청을 내가 원하는 모양으로 만드는 법"을 다지는 것입니다.

법·윤리 주의: 이번 장의 실습은 모두 본인 PC의 127.0.0.1(로컬 테스트 서버, 다음 장에서 띄울 DVWA), 책 전용 API(dev.wenivops.co.kr/..., dummyjson.com), 그리고 httpbin.org에서만 진행합니다.

2. HTTP 조작을 위한 재정리

02-1의 3~5절에서 HTTP 메시지 구조·메서드·상태코드를 이미 훑었습니다. 여기서는 "조작 시 가장 자주 건드리는 네 군데"만 다시 짚고 넘어갑니다.

조작 지점무엇을 바꾸는가대표 취약점
URL 쿼리스트링?id=1 → ?id=1' OR 1=1 --SQLi, IDOR
요청 바디폼 필드·JSON 값SQLi, 커맨드 인젝션, 업로드
헤더User-Agent, Referer, X-Forwarded-ForWAF 우회, 접근제어 우회
쿠키세션 ID, 권한 플래그세션 하이재킹, 권한 상승

그리고 관찰해야 할 응답의 네 가지 신호를 항상 같이 기록합니다.

  1. 상태 코드: 200/302/401/500의 분포 변화.
  2. 본문 길이·타이틀: 상태 코드는 같은데 본문만 바뀌는 경우를 놓치지 말 것.
  3. 응답 시간 (res.elapsed). Blind SQLi·타이밍 공격의 핵심 단서.
  4. 헤더 변화: Set-Cookie, Location, X-* 커스텀 헤더.

02-1에서 만들어본 "거짓 404 서버"를 기억하세요. 상태 코드 하나만 믿지 않는다는 원칙은 이 장 전체를 관통합니다. 스캐너를 돌릴 때는 status_code만이 아니라 len(res.text), res.elapsed.total_seconds(), 주요 헤더 해시를 같은 행에 묶어 저장하세요.

3. 상태 있는 클라이언트

requests.get / requests.post만 쓰면 매번 새 TCP 연결과 매번 새 쿠키 저장소가 만들어집니다. 스캐너가 10,000개의 요청을 날리는 동안 이 낭비가 누적되면 속도와 탐지 회피 양쪽에서 손해입니다. Session은 이 두 문제를 한 번에 해결합니다.

3.1 공통 헤더와 쿠키를 재사용하기

# session_basics.py
import requests

session = requests.Session()
session.headers.update({
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                  "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36",
    "Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8",
})

# 같은 Session으로 보낸 요청은 헤더·쿠키·커넥션을 공유합니다.
res = session.get("https://httpbin.org/cookies/set/demo/42", timeout=5)
print(session.cookies.get_dict())        # {'demo': '42'}

res = session.get("https://httpbin.org/cookies", timeout=5)
print(res.json())                        # {'cookies': {'demo': '42'}}

httpbin.org/cookies/set/<name>/<value>는 Set-Cookie 헤더를 내려주는 엔드포인트입니다. Session은 이 쿠키를 자동으로 저장하고, 다음 요청에 자동으로 실어 보냅니다. 브라우저가 하는 일을 파이썬이 흉내내는 것이죠.

여기서 name과 value를 각각 다른 이름으로 수정을 해보세요.

session.cookies는 RequestsCookieJar라는 특수 객체입니다. 필요하면 session.cookies.set("sessionid", "abc", domain=".example.com", path="/")로 수동 주입도 가능합니다. 모의해킹 중 브라우저에서 로그인한 뒤 개발자 도구로 세션 쿠키만 복사해와 스크립트에 붙여넣는 흐름이 흔합니다.

3.2 로그인 흐름을 코드로 재현

2장에서 만들었던 블로그 API의 로그인 흐름을 Session 기반으로 다시 짜봅니다. 토큰을 헤더에 자동으로 싣도록 Session을 훈련시키면, 이후 요청은 전부 로그인된 상태로 흐릅니다.

# login_session.py
import requests

BASE = "https://dev.wenivops.co.kr/services/fastapi-crud/675"

session = requests.Session()
session.headers.update({"User-Agent": "BasecampSecurity/0.1"})

# 이 부분에 회원 가입이 되는 코드를 작성해주세요.

# ① 로그인
creds = {"username": "test1", "password": "test1234"}
res = session.post(f"{BASE}/login", json=creds, timeout=5)
token = res.json().get("access_token")
if not token:
    raise SystemExit(f"[!] 로그인 실패: {res.status_code} {res.text}")

# ② 이후 요청에 Bearer 토큰을 자동으로 실어주도록 설정
session.headers.update({"Authorization": f"Bearer {token}"})

# ③ 보호된 엔드포인트 호출
res = session.get(f"{BASE}/blog", timeout=5)
print(res.status_code, len(res.json()))

회원 가입이 되는 코드는 앞에서 가져와보세요. 코드를 완성해서 실행해보세요. 이 절차를 모두 이해하고 있는지 점검해보세요. 특히 session.headers.update에 "Authorization" 헤더를 올리는 부분이 핵심입니다. 이제 다음 코드 부터는 로그인 절차 없이 바로 원하는 요청을 진행할 수 있습니다.

4. 헤더 조작

4.1 WAF·CDN 우회 시그널

python-requests/2.31이라는 기본 User-Agent는 자동화 트래픽의 지문입니다. 대부분의 WAF·CDN(Cloudflare, Akamai, AWS WAF)이 이 문자열만 보고도 차단합니다. 그래서 모의해킹 도구는 최소한 다음 네 헤더를 "실제 브라우저처럼" 만들어 보냅니다.

REAL_BROWSER_HEADERS = {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                  "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36",
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    "Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8",
    "Accept-Encoding": "gzip, deflate, br",
}

방어자 관점: User-Agent 위장은 만능이 아닙니다. 현대 WAF는 TLS 핸드셰이크의 지문(JA3/JA4), HTTP/2 SETTINGS 프레임 순서, 헤더 필드 순서까지 보고 "Chrome이라고 주장하는 python-requests"를 가려냅니다. 그래서 고급 공격자는 curl-impersonate나 pycurl-cffi 같은 실제 브라우저의 TLS·HTTP 스택을 흉내내는 라이브러리를 씁니다.

방어 측에서는 UA만 보지 말고 지문 집합을 같이 보세요. 이걸 단일 필드 하나로 압축한 시그널이 JA3 해시입니다.

4.2 접근 제어 우회에 자주 쓰이는 헤더

허술한 서버는 "내부망 IP에서 오는 요청은 관리자 페이지를 허용"하는 식의 신뢰 경계 오판을 종종 합니다. 이때 공격자가 건드리는 헤더가 다음 세 가지입니다.

헤더서버가 기대하는 값공격자가 주입하는 값
X-Forwarded-For프록시가 실어준 진짜 클라이언트 IP127.0.0.1, 10.0.0.1
X-Real-IP동일동일
X-Original-URL내부 라우팅용 경로/admin
# header_bypass_probe.py: "내부망 IP로 위장" 시도
import requests

URL = "https://httpbin.org/anything"

for header_set in [
    {},  # 기본
    {"X-Forwarded-For": "127.0.0.1"}, # 내부망 IP로 위장
    {"X-Forwarded-For": "10.0.0.1, 203.0.113.5"}, # 프록시가 달아주는 형태로 위장
    {"X-Real-IP": "127.0.0.1"} # X-Real-IP로 위장
]:
    res = requests.get(URL, headers=header_set, timeout=5)
    echoed = res.json().get("headers", {})
    print(f"[+] sent={header_set}")
    print(f"    server saw X-Forwarded-For={echoed.get('X-Forwarded-For')}")

httpbin.org/anything은 받은 요청을 JSON으로 그대로 돌려줍니다. 이 "에코"를 보면 내가 보낸 헤더가 그대로 도달하는지, 프록시 구간에서 어떻게 변형되는지를 눈으로 확인할 수 있습니다.

자주 하는 오해. "IP를 위장했는데 어떻게 응답이 나한테 오지?"

이 실습을 돌려보면 한 가지 의문이 생깁니다. 분명히 X-Forwarded-For: 127.0.0.1을 보냈는데, 응답은 내 컴퓨터로 멀쩡히 돌아옵니다. IP를 위장한 거 아니었나요?

정답은 "위장한 게 아닙니다"입니다. 우리가 건드린 건 IP가 아니라, HTTP 요청 안에 들어있는 텍스트 한 줄이었거든요.

편지로 비유해 봅시다. 택배 봉투의 발신 주소에는 내 집 주소가 찍혀 있고, 편지 내용물에는 "저는 청와대에서 왔습니다"라고 쓰여 있다고 해볼게요. 택배 기사는 봉투만 보고 배달하니까, 답장은 당연히 내 집으로 옵니다. 편지 내용물의 "청와대에서 왔습니다"는 받는 사람이 그걸 믿을지 말지의 문제일 뿐, 배달 경로와는 아무 상관이 없습니다.

HTTP 요청도 똑같습니다.

계층예시 값비고
IP 헤더 (조작 불가)src = 내 공인 IP (x.x.x.x)
dst = httpbin 서버 IP
응답은 여기 적힌 src 주소로 돌아온다
TCP 헤더src_port / dst_port-
HTTP 페이로드 (조작 가능)GET /anything HTTP/1.1
Host: httpbin.org
X-Forwarded-For: 127.0.0.1
일반 텍스트 - 서버가 이 값을 믿을지 말지의 문제

빨간색 IP 헤더는 OS 커널이 알아서 붙이고, 파이썬 코드로는 건드릴 수 없습니다. 파란색 HTTP 페이로드 안의 X-Forwarded-For는 그저 텍스트라서 마음대로 조작할 수 있지만, 이건 배달 경로와 무관한 "자기소개"에 불과합니다.

그래서 이 공격은 이런 구조로 작동합니다.

  1. TCP 연결은 내 진짜 IP로 정상적으로 맺어짐 → 응답 받을 수 있음
  2. HTTP 헤더에만 "나 내부망이야"라는 거짓 자기소개를 끼워 넣음 → 서버가 속으면 인증 우회
  3. 결과적으로 응답도 받으면서 권한도 뚫음

만약 진짜로 IP 헤더의 발신 주소까지 조작하려면 IP Spoofing이라는 완전히 다른 기법이 필요한데, 이 경우 서버의 응답이 위조된 가짜 주소로 날아가버려서 공격자는 응답을 받을 수 없습니다. 그래서 IP Spoofing은 응답이 필요 없는 DDoS 증폭 공격 같은 데만 쓰이지, 관리자 페이지를 훔쳐보는 용도로는 못 씁니다.

실무 사례: 일부 어드민 패널은 X-Forwarded-For: 127.0.0.1만 달려 있으면 인증을 건너뛰도록 잘못 구현된 경우가 있습니다. 사내 BBB(보안 점검 도구)가 꼭 체크하는 항목 중 하나이고, 이 한 줄이 "내부 직원용"이라고 믿었던 관리자 페이지를 외부에 열어둡니다. 서버 측 방어 원칙은 간단합니다. 요청 헤더는 클라이언트가 자유롭게 조작 가능하다는 전제로, 신뢰 경계를 네트워크 레벨(방화벽·VPC·mTLS)에서 그어야 합니다.

5. 비동기를 다루는 현대적 클라이언트 httpx

requests는 훌륭하지만 한계가 있습니다.

  • 동기식 전용: 수천 URL을 빠르게 훑기 어려움 (스레드로 우회하거나 grequests·requests-futures를 얹어야 함)
  • HTTP/2 미지원: 최신 CDN·API 게이트웨이의 기본 프로토콜을 못 쓰는 경우 발생

httpx는 이 두 가지를 한 번에 해결하면서 requests와 거의 똑같은 API를 제공합니다. 다만 이 수업에 난이도를 넘어가는 부분이 있어 수업에서 진행하진 않습니다. 키워드만 알아도 충분히 바이브 코딩을 통해 실습할 수 있습니다.

책에서 너무 위험한 코드는 다루지 않습니다.

6. HTTP 프록시로 보는 모든 트래픽

requests와 httpx가 코드 레벨에서 HTTP를 다룬다면, Burp Suite는 브라우저·앱이 이미 보내고 있는 HTTP를 중간에서 가로채 분석·수정하는 도구입니다. 모의해킹 수행자의 책상에 한 자리를 차지하는 표준 도구이자, 오늘날 웹 취약점 분석의 공통 언어입니다.

이 수업에서는 Burp Suite 실습은 진행하지 않습니다. 개념(6.1)과 설치 방법(6.2)까지만 소개하고, 실습은 6.3에서 파이썬만으로 Burp의 "Proxy 히스토리"를 흉내내는 방식으로 대체합니다. 수업 밖에서 직접 Burp를 써보고 싶다면 6.2의 절차를 참고하세요.

6.1 Burp Suite 구조 한눈에 보기

  • Proxy: 모든 요청을 가로채 기록. 원하면 차단(intercept)해서 수정 후 전달.
  • Repeater: 한 요청을 반복해서 조금씩 변형. 수동 모의해킹의 주력.
  • Intruder: 쿼리스트링·바디 한 자리에 페이로드 리스트를 밀어 넣는 퍼저.
  • Scanner (유료판 전용). 자동 취약점 스캔.

6.2 설치와 브라우저 연동

  1. https://portswigger.net/burp/communitydownload 에서 Community Edition 다운로드.
  2. Burp 실행 → "Temporary project" → "Use Burp defaults" → Start.
  3. Proxy 탭 → Intercept is off (기본값으로 두는 편이 편합니다).
  4. 브라우저 프록시 설정 → 127.0.0.1:8080. Firefox라면 Foxy Proxy 같은 확장으로 빠르게 전환합니다.
  5. Burp의 CA 인증서를 브라우저에 설치. http://burp 접속 → "CA Certificate" 다운로드 후 브라우저의 신뢰 저장소에 등록.

6.3 파이썬만으로 트래픽 관찰

이 책의 실습은 전부 파이썬 코드 안에서 이뤄지기 때문에, 내가 보낸 요청이 무엇이었는지 돌아보는 건 Burp가 아니어도 충분히 가능합니다. requests.Session의 response 훅을 하나 달아두면, 이후 이 세션으로 나가는 모든 요청이 자동으로 기록됩니다. Burp의 Proxy > HTTP history 탭에 해당하는 역할입니다.

# mini_burp.py: 모든 요청을 콘솔과 파일에 동시에 남기는 "개인용 프록시 로그"
import requests
from datetime import datetime
from pathlib import Path

LOG_PATH = Path("proxy_log.txt")

def log_response(response, *args, **kwargs):
    now = datetime.now().strftime("%H:%M:%S")
    req = response.request
    line = (f"[{now}] {req.method:6} "
            f"{response.status_code} "
            f"len={len(response.content):>6} "
            f"elapsed={int(response.elapsed.total_seconds()*1000):>4}ms "
            f"{req.url}")
    print(line)
    with LOG_PATH.open("a", encoding="utf-8") as f:
        f.write(line + "\n")

session = requests.Session()
session.headers.update({"User-Agent": "BasecampSecurity/0.1"})
session.hooks = {"response": [log_response]}

# ──────────────────────────────────────────
# 이 아래로 session을 통해 보내는 모든 요청이 자동으로 로깅됩니다.
# ──────────────────────────────────────────
session.get("https://httpbin.org/get")
session.get("https://httpbin.org/status/404")
session.post("https://httpbin.org/post", json={"demo": 1})

스크립트를 실행하면 콘솔에 한 줄씩 출력되는 동시에 proxy_log.txt에 같은 내용이 누적됩니다. "방금 나간 요청이 뭐였지?"를 언제든 돌아볼 수 있는 개인용 히스토리가 생긴 셈입니다.

session.hooks는 뒤에서 만들 스캐너에 레이트 리미팅, 자동 리트라이, 응답 통계같은 공통 로직을 끼워 넣는 자리로도 그대로 재사용됩니다. "도구(Burp) 없이도 관찰성을 만들 수 있다"는 감각을 여기서 먼저 잡아두세요.

Burp를 언제 꺼내 쓰게 되는가: 위의 훅은 내가 짠 파이썬 스크립트의 트래픽을 관찰하는 데는 충분합니다. 하지만 브라우저가 실제로 어떤 요청을 보내는지 확인해야 하거나, 복잡한 로그인·CSRF 토큰·리디렉션 흐름을 손으로 재현해야 할 때, 나가는 값을 위조해야 할 때는 Burp을 대체하기 어렵습니다.

7. httpbin.org로 익히는 조작 감각

로컬 서버를 띄우기 전에, 공개 에코 서버에서 감을 잡습니다. httpbin.org는 "받은 요청을 그대로 돌려주는" 서버라 조작 결과를 눈으로 확인하기 좋습니다.

# httpbin_probe.py
import requests

URL = "https://httpbin.org/anything"

res = requests.get(
    URL,
    params={"id": "1' OR 1=1 --"},
    headers={
        "User-Agent": "BasecampSecurity/0.1",
        "X-Forwarded-For": "127.0.0.1",
    },
    cookies={"role": "admin"},
    timeout=5,
)

data = res.json()
print("args   :", data["args"])
print("headers:", {k: v for k, v in data["headers"].items() if k.startswith("X-") or k == "User-Agent"})
print("cookies:", data["headers"].get("Cookie"))

출력을 보면 내가 보낸 네 조작 지점이 서버에 어떻게 도달했는지 한 번에 정리됩니다. 이 감각은 다음 회차의 SQLi 퍼저를 만들 때 그대로 쓰입니다.

8. Claude와 함께하는 바이브 코딩

이번 회차에서 가장 쓸모 있는 프롬프트 세 개를 정리합니다. 앞 회차에서 쌓은 프롬프트 라이브러리에 그대로 추가해두면, 3장 전체에서 반복해 꺼내 쓰게 됩니다.

8.1 프롬프트

너는 파이썬 웹 점검 도구 개발자야. 아래 요구사항으로 코드를 작성해줘.

- 파이썬 3.11, requests 만 사용
- requests.Session 을 쓰고 User-Agent는 "BasecampSecurity/0.1"
- 로그인 엔드포인트: POST {BASE}/login, JSON 바디 {"username", "password"}
- 성공 시 response.json()["access_token"] 을 꺼내 이후 요청의 Authorization: Bearer 헤더로 자동 세팅
- 실패 시 상태코드와 본문 앞 200자를 stderr로 출력하고 SystemExit(1)
- 로그인 후 GET {BASE}/blog 를 호출해 응답 길이와 소요 시간을 출력

출력 형식:
1) ```python 코드 블록 하나
2) 3줄 이내 동작 요약

8.2 프롬프트

너는 파이썬 보안 점검 도구 개발자야.

요구사항:
- 입력: target URL, 선택적으로 HTTP 메서드 (기본 GET)
- 아래 헤더 조합을 돌아가며 요청을 보내고 각 응답의 status, len, elapsed_ms 를 CSV 로 저장
  * (없음)
  * X-Forwarded-For: 127.0.0.1
  * X-Forwarded-For: 10.0.0.1
  * X-Real-IP: 127.0.0.1
  * X-Original-URL: /admin
- requests.Session 과 timeout=5
- 출력 CSV 경로는 argparse --out (기본 "header_probe.csv")
- 각 행을 stdout에도 예쁘게 찍기

출력 형식:
1) ```python 코드 블록
2) 실행 예시 명령 1줄
3) 예상되는 CSV 컬럼 순서

9. 과제

# 관리자 페이지 우회 후 사용자 정보 덤프

## 과제 설명
FastAPI로 **의도적으로 허술한 미니 서버**를 먼저 만들고, 그 서버를 공격하는 **파이썬 스크립트**를 작성합니다. 공격은 두 단계로 이어집니다.

1. **헤더 한 줄로 `/admin` 우회**: 평범하게는 `403`. `X-Forwarded-For: 127.0.0.1` 을 실으면 관리자 페이지가 열리고, 응답 본문 안에 **dev 환경에서 쓰던 테스트 계정(`admin / 1234`) 안내**가 그대로 남아 있습니다. (staging 페이지의 주석·안내가 prod에 섞여 배포되는 흔한 사고)
2. **얻은 계정으로 로그인 후 유저 덤프**: `/login` 에 `admin / 1234` 로 로그인해 토큰을 받고, `/api/users` 에서 **전체 사용자 정보를 통째로** 받아 텍스트 파일로 저장합니다.

이번 과제의 핵심은 **"일단 텍스트로 받아 내는 것"** 입니다. 받은 데이터를 어떻게 해석할지(민감 필드 식별, 관리자 계정 추출, 피해 규모 추정) 는 파싱·정리 없이 **AI에게 통째로 맡기는 시나리오** 로 이어집니다.

## 요구 사항

### A. 취약한 서버 (FastAPI)
* `GET /`. 간단한 환영 메시지 (예: `"welcome"`)
* `GET /admin`. 기본은 `403 Forbidden`. 단, 요청 헤더에 `X-Forwarded-For: 127.0.0.1` 이 있으면 `200 OK` 와 함께 관리자 페이지(JSON 또는 HTML)를 반환. **응답 본문 어딘가에 `"admin / 1234"` 계정 정보가 평문으로 포함되어 있어야 함** (의도된 취약점)
* `POST /login`. 바디 `{"username", "password"}` 를 받아, `admin / 1234` 일 때만 `{"access_token": "..."}` 반환. 그 외엔 `401`
* `GET /api/users`. `Authorization: Bearer <token>` 이 올바르면 **전체 사용자 정보**(최소 10명, 각 사용자에 `id / name / email / phone / address` 정도의 다양한 필드)를 JSON 배열로 반환. 토큰이 없거나 틀리면 `401`

### B. 공격 스크립트 (requests)
* `requests.Session` 사용
* `/admin` 을 그냥 호출해 **403 차단** 을 먼저 확인
* 같은 `/admin` 을 `X-Forwarded-For: 127.0.0.1` 헤더와 함께 호출해 200을 받고, **응답 본문을 그대로 출력** (여기서 눈으로 `admin / 1234` 를 확인)
* `/login` 에 `admin / 1234` 로 POST 해서 토큰을 받고, `Session` 의 `Authorization: Bearer <token>` 헤더에 주입
* `/api/users` 를 호출해 받은 **응답 본문을 파싱하지 말고 통째로** `users_dump.txt` 로 저장 (파일 크기 출력)
* 마지막에 한 줄 요약 출력. 예: `"admin 우회 → 로그인 성공 → 사용자 데이터 XXX bytes 덤프 완료 (users_dump.txt)"`

### C. 실행
* 서버: `uvicorn vuln_server:app --port 8000` 으로 http://127.0.0.1:8000 에 띄워둠
* 공격: 별도 터미널에서 `python attack.py` 실행

### D. 덤프 데이터 AI 분석
저장한 `users_dump.txt` 를 Claude나 Cursor 같은 AI 도구에 **그대로 붙여넣고** 분석을 요청해보세요. 공격자가 유출된 데이터를 "어떻게 쓸지" 고민하는 실제 흐름을 흉내 내는 단계입니다.
* "이 덤프에서 관리자 권한 또는 내부 직원으로 추정되는 계정은?"
* "민감 정보로 분류될 수 있는 필드와 해당 행을 찾아줘."
* "이 데이터가 유출됐을 때 예상되는 피해 유형과 개인정보보호법상 위반 항목을 정리해줘."

## 고도화 과제
* 공격 스크립트에 응답 훅(`session.hooks`)을 달아, 공격 전 과정(403 → 우회 → 로그인 → 덤프)을 `attack.log` 에 순서대로 기록해보세요.
* 서버에 두 번째 우회 경로(예: 쿠키 `role=admin` 이 있으면 통과)를 몰래 하나 더 추가하고, 공격 스크립트가 두 경로를 번갈아 시도해 **성공한 경로를 로그에 명시**하도록 확장해보세요.