위조 요청으로 CSRF 방어 확인하기
목표
127.0.0.1:8303 의 이체 서버에 세션별 CSRF 토큰, Sec-Fetch-Site 검사, Origin 허용 목록을 차례로 붙이고, 위조 요청이 403 으로 거절될 뿐 아니라 기록에도 남지 않는 것을 직접 확인한다.
왜 중요한가
CSRF 가 성립하는 이유는 하나입니다. 브라우저가 목적지의 쿠키를 자동으로 붙이기 때문에, 공격 페이지에서 출발한 이체도 서버에는 사용자가 누른 이체와 똑같이 보입니다. 그래서 방어는 모두 "공격 페이지가 흉내 낼 수 없는 신호" 를 요구하는 방식입니다. 세션에 묶인 토큰은 공격 페이지가 읽을 수 없고, Sec-Fetch-Site·Origin 은 브라우저가 붙여 페이지가 바꿀 수 없습니다. SameSite 는 여기에 한 겹을 더할 뿐입니다. 형제 서브도메인은 같은 사이트로 취급되고, GET 으로 상태를 바꾸면 Lax 쿠키가 그대로 실립니다. 이 실습의 채점기는 curl 로 위조 요청을 직접 만들어, 응답 코드와 함께 이체 기록까지 확인합니다.
단계
/root/st/csrf/app.py를 127.0.0.1:8303 에 띄운다.GET /healthz가 200 과{"ok":true}를 준다.POST /login(JSON{"user","password"},alice/wonderland,bob/builder)은__Host-sid세션 쿠키를 주고,GET /transfers는 그 세션의 이체 목록을{"transfers":[{"to","amount","memo"}...]}로 준다.GET /csrf는 로그인 세션에{"csrf":"<토큰>"}을 준다. 토큰은 32바이트 이상 무작위(base64url 43자 이상)이고, 같은 세션에서는 몇 번을 불러도 같고 세션이 다르면 달라야 한다. 토큰은 서버가 세션 기록에 저장해 두는 동기화 토큰이며 별도 쿠키로 내려보내지 않는다. 세션 없이 부르면 401 이다.POST /transfer(JSON{"to","amount","memo"})에X-CSRF-Token헤더가 없거나 서버가 발급한 적 없는 값이면 403 을 주고 이체를 기록하지 않는다. 공격자가csrf=X같은 쿠키를 심고 헤더에도X를 넣어도 403 이어야 한다(헤더는 쿠키가 아니라 세션에 저장한 토큰과 비교한다).GET /transfer?to=...&amount=...&memo=...로는 이체가 기록되지 않아야 한다(응답 코드는 상관없다).- bob 세션에서 받은 진짜 토큰을 alice 세션 쿠키와 함께 보내면 403 이고 alice 의 이체로 기록되지 않아야 한다.
- 자기 세션의 토큰을
X-CSRF-Token에 담은 이체는 200 이고GET /transfers에 기록된다. 토큰 비교는hmac.compare_digest로 한다. - 상태를 바꾸는 요청(POST)의
Sec-Fetch-Site가cross-site나same-site면 토큰이 맞아도 403 이고 기록하지 않는다.same-origin이면 토큰으로 판단해 통과시키고, 헤더가 없으면(옛 브라우저·스크립트) 토큰으로 판단한다.POST /login도 같은 검사를 받아 교차 사이트 로그인에는 세션 쿠키를 주지 않는다. Origin헤더가 있으면 허용 목록http://127.0.0.1:8303과 문자열 전체가 같아야 한다.http://evil.example,null,http://127.0.0.1:8303.evil.example,http://127.0.0.1:8300은 토큰이 맞아도 403 이고 기록하지 않는다.Origin: http://127.0.0.1:8303인 정상 이체는 200 이다.- 사용자는
https://bank.example.com에 로그인해 있고 세션 쿠키는 몇 시간 전에SameSite=Lax를 명시해 발급됐다. 공격 페이지는https://evil.example.net, 형제 페이지는https://blog.example.com이다. 아래 일곱 경우에 그 쿠키가 요청에 실리는지 판정해/root/st/csrf/samesite.txt에항목=sent또는항목=not_sent로 한 줄씩 적는다.link_getevil 페이지의 링크를 눌러https://bank.example.com/account로 이동(최상위 GET)form_postevil 페이지의 자동 제출 폼이https://bank.example.com/transfer로 POST(최상위 이동)img_getevil 페이지의<img src="https://bank.example.com/transfer?to=mallory">fetch_postevil 페이지의fetch("https://bank.example.com/transfer", {method:"POST", credentials:"include"})iframe_getevil 페이지가https://bank.example.com/account를 iframe 으로 로드sibling_form_postblog 페이지의 폼이https://bank.example.com/transfer로 POST(최상위 이동)link_get_transferevil 페이지의 링크https://bank.example.com/transfer?to=mallory&amount=100을 눌러 이동(최상위 GET)
/root/st/csrf/e2e.sh로 alice 세션에 위조 이체 네 가지(토큰 없음, bob 의 토큰,Sec-Fetch-Site: cross-site,Origin: http://evil.example)를 같은 메모로 보내고, 그 메모로 기록된 이체 수와 정상 이체 한 건의 결과를 더해/root/st/csrf/e2e.out에no_token=403 other_session=403 cross_site=403 bad_origin=403 valid=200 forged_recorded=0을 한 줄로 적는다.
참고
- 세션 쿠키 값 꺼내기:
curl -s -D - -o /dev/null ... | sed -n 's/^[Ss]et-[Cc]ookie: *__Host-sid=\([^;]*\).*/\1/p' - 위조 요청 흉내:
curl -b "__Host-sid=<값>" -H "Sec-Fetch-Site: cross-site" -H "Origin: http://evil.example" ...— 실제 브라우저에서는 이 두 헤더를 페이지가 바꿀 수 없습니다. - 검사 순서는 출처 신호(Sec-Fetch-Site·Origin) → 토큰 → 기록입니다. 기록 뒤에 검사하면 403 을 주고도 이체가 남습니다.
- 흔한 실수 1: 토큰을 '발급한 적 있는가' 로만 검사하는 것 — 공격자가 자기 계정으로 받은 토큰이 통과합니다.
- 흔한 실수 2: Origin 을
startswith로 비교하는 것 —http://127.0.0.1:8303.evil.example이 통과합니다. - 9번 채점기는 결과 파일과 별개로 위조 네 가지와
GET /transfer를 직접 다시 보내 기록되지 않는지 봅니다.
이체 서버 띄우기
/root/st/csrf/app.py 를 127.0.0.1:8303 에 띄운다. GET /healthz 가 200 과 {"ok":true} 를 준다. POST /login(JSON {"user","password"}, alice/wonderland, bob/builder)은 __Host-sid 세션 쿠키를 주고, GET /transfers 는 그 세션의 이체 목록을 {"transfers":[{"to","amount","memo"}...]} 로 준다.
앞 모듈의 쿠키 서버를 뼈대로 삼으면 됩니다. 세션 기록에 사용자와 함께 토큰과 이체 목록을 담을 자리를 미리 만들어 두면 뒤 스텝이 편합니다.
세션별 CSRF 토큰 발급하기
GET /csrf 는 로그인 세션에 {"csrf":"<토큰>"} 을 준다. 토큰은 32바이트 이상 무작위(base64url 43자 이상)이고, 같은 세션에서는 몇 번을 불러도 같고 세션이 다르면 달라야 한다. 토큰은 서버가 세션 기록에 저장해 두는 동기화 토큰이며 별도 쿠키로 내려보내지 않는다. 세션 없이 부르면 401 이다.
토큰은 세션을 만들 때 함께 만들어 세션 기록 안에 넣어 두면 세션과 운명을 같이합니다. 세션 값과 마찬가지로 secrets 모듈로 만드세요.
토큰 없는 이체 거절하기
POST /transfer(JSON {"to","amount","memo"})에 X-CSRF-Token 헤더가 없거나 서버가 발급한 적 없는 값이면 403 을 주고 이체를 기록하지 않는다. 공격자가 csrf=X 같은 쿠키를 심고 헤더에도 X 를 넣어도 403 이어야 한다(헤더는 쿠키가 아니라 세션에 저장한 토큰과 비교한다). GET /transfer?to=...&amount=...&memo=... 로는 이체가 기록되지 않아야 한다(응답 코드는 상관없다).
공격 페이지의 폼은 쿠키는 싣지만 토큰은 모릅니다. 검사는 이체를 기록하기 전에 해야 403 이 의미가 있습니다. 쿠키는 공격자가 형제 서브도메인에서 심을 수도 있으니 비교 기준이 될 수 없고, 링크 한 번으로 보낼 수 있는 GET 에는 상태 변경을 맡기지 않습니다.
토큰을 세션에 묶기
bob 세션에서 받은 진짜 토큰을 alice 세션 쿠키와 함께 보내면 403 이고 alice 의 이체로 기록되지 않아야 한다.
발급한 토큰 전체의 목록과 비교하면 이 스텝에서 막힙니다. 요청에 붙은 세션 쿠키로 찾은 그 세션의 토큰과만 비교해야 합니다.
올바른 토큰 통과시키기
자기 세션의 토큰을 X-CSRF-Token 에 담은 이체는 200 이고 GET /transfers 에 기록된다. 토큰 비교는 hmac.compare_digest 로 한다.
방어는 정상 요청을 통과시킬 때 완성됩니다. 문자열 비교 연산자는 첫 불일치에서 멈추므로 비교 시간이 값에 따라 달라지지 않는 함수를 쓰세요.
Sec-Fetch-Site 로 교차 사이트 요청 막기
상태를 바꾸는 요청(POST)의 Sec-Fetch-Site 가 cross-site 나 same-site 면 토큰이 맞아도 403 이고 기록하지 않는다. same-origin 이면 토큰으로 판단해 통과시키고, 헤더가 없으면(옛 브라우저·스크립트) 토큰으로 판단한다. POST /login 도 같은 검사를 받아 교차 사이트 로그인에는 세션 쿠키를 주지 않는다.
이 헤더는 브라우저가 붙이고 페이지 스크립트는 바꿀 수 없습니다. 모든 POST 가 거치는 한 곳에서 토큰보다 먼저 보세요. 형제 서브도메인을 믿을 이유가 없다면 same-site 도 교차로 다룹니다.
Origin 허용 목록 검사하기
Origin 헤더가 있으면 허용 목록 http://127.0.0.1:8303 과 문자열 전체가 같아야 한다. http://evil.example, null, http://127.0.0.1:8303.evil.example, http://127.0.0.1:8300 은 토큰이 맞아도 403 이고 기록하지 않는다. Origin: http://127.0.0.1:8303 인 정상 이체는 200 이다.
앞부분만 같은지 보는 비교는 공격자가 고른 도메인 이름에 속습니다. 샌드박스 iframe 같은 곳에서는 Origin 이 null 이라는 문자열로 옵니다.
SameSite=Lax 판정표 채우기
사용자는 https://bank.example.com 에 로그인해 있고 세션 쿠키는 몇 시간 전에 SameSite=Lax 를 명시해 발급됐다. 공격 페이지는 https://evil.example.net, 형제 페이지는 https://blog.example.com 이다. 아래 일곱 경우에 그 쿠키가 요청에 실리는지 판정해 /root/st/csrf/samesite.txt 에 항목=sent 또는 항목=not_sent 로 한 줄씩 적는다. link_get evil 페이지의 링크를 눌러 https://bank.example.com/account 로 이동(최상위 GET) · form_post evil 페이지의 자동 제출 폼이 https://bank.example.com/transfer 로 POST(최상위 이동) · img_get evil 페이지의 <img src="https://bank.example.com/transfer?to=mallory"> · fetch_post evil 페이지의 fetch("https://bank.example.com/transfer", {method:"POST", credentials:"include"}) · iframe_get evil 페이지가 https://bank.example.com/account 를 iframe 으로 로드 · sibling_form_post blog 페이지의 폼이 https://bank.example.com/transfer 로 POST(최상위 이동) · link_get_transfer evil 페이지의 링크 https://bank.example.com/transfer?to=mallory&amount=100 을 눌러 이동(최상위 GET)
Lax 쿠키가 교차 사이트 요청에 실리는 조건은 두 가지가 동시에 맞을 때뿐입니다. 먼저 출발지와 목적지가 같은 사이트인지부터 가르세요. 사이트는 출처보다 넓습니다. # 으로 시작하는 줄은 주석으로 둘 수 있습니다.
위조 요청 묶음으로 한 번에 증명하기
/root/st/csrf/e2e.sh 로 alice 세션에 위조 이체 네 가지(토큰 없음, bob 의 토큰, Sec-Fetch-Site: cross-site, Origin: http://evil.example)를 같은 메모로 보내고, 그 메모로 기록된 이체 수와 정상 이체 한 건의 결과를 더해 /root/st/csrf/e2e.out 에 no_token=403 other_session=403 cross_site=403 bad_origin=403 valid=200 forged_recorded=0 을 한 줄로 적는다.
앞 스텝의 요청들을 함수 몇 개로 묶으면 짧아집니다. 위조 네 건에 같은 메모를 붙여 두면 기록 목록에서 그 메모가 몇 번 나오는지로 한 번에 셀 수 있습니다.