LabHub
Get started
배우기 러닝패스 코스

Sessions and Tokens — From the Browser to the Mesh

Test CSRF defenses with forged requests

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

목표

127.0.0.1:8303 의 이체 서버에 세션별 CSRF 토큰, Sec-Fetch-Site 검사, Origin 허용 목록을 차례로 붙이고, 위조 요청이 403 으로 거절될 뿐 아니라 기록에도 남지 않는 것을 직접 확인한다.

왜 중요한가

CSRF 가 성립하는 이유는 하나입니다. 브라우저가 목적지의 쿠키를 자동으로 붙이기 때문에, 공격 페이지에서 출발한 이체도 서버에는 사용자가 누른 이체와 똑같이 보입니다. 그래서 방어는 모두 "공격 페이지가 흉내 낼 수 없는 신호" 를 요구하는 방식입니다. 세션에 묶인 토큰은 공격 페이지가 읽을 수 없고, Sec-Fetch-Site·Origin 은 브라우저가 붙여 페이지가 바꿀 수 없습니다. SameSite 는 여기에 한 겹을 더할 뿐입니다. 형제 서브도메인은 같은 사이트로 취급되고, GET 으로 상태를 바꾸면 Lax 쿠키가 그대로 실립니다. 이 실습의 채점기는 curl 로 위조 요청을 직접 만들어, 응답 코드와 함께 이체 기록까지 확인합니다.

단계

  1. /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"}...]} 로 준다.
  2. GET /csrf 는 로그인 세션에 {"csrf":"<토큰>"} 을 준다. 토큰은 32바이트 이상 무작위(base64url 43자 이상)이고, 같은 세션에서는 몇 번을 불러도 같고 세션이 다르면 달라야 한다. 토큰은 서버가 세션 기록에 저장해 두는 동기화 토큰이며 별도 쿠키로 내려보내지 않는다. 세션 없이 부르면 401 이다.
  3. POST /transfer(JSON {"to","amount","memo"})에 X-CSRF-Token 헤더가 없거나 서버가 발급한 적 없는 값이면 403 을 주고 이체를 기록하지 않는다. 공격자가 csrf=X 같은 쿠키를 심고 헤더에도 X 를 넣어도 403 이어야 한다(헤더는 쿠키가 아니라 세션에 저장한 토큰과 비교한다). GET /transfer?to=...&amount=...&memo=... 로는 이체가 기록되지 않아야 한다(응답 코드는 상관없다).
  4. bob 세션에서 받은 진짜 토큰을 alice 세션 쿠키와 함께 보내면 403 이고 alice 의 이체로 기록되지 않아야 한다.
  5. 자기 세션의 토큰을 X-CSRF-Token 에 담은 이체는 200 이고 GET /transfers 에 기록된다. 토큰 비교는 hmac.compare_digest 로 한다.
  6. 상태를 바꾸는 요청(POST)의 Sec-Fetch-Sitecross-sitesame-site 면 토큰이 맞아도 403 이고 기록하지 않는다. same-origin 이면 토큰으로 판단해 통과시키고, 헤더가 없으면(옛 브라우저·스크립트) 토큰으로 판단한다. POST /login 도 같은 검사를 받아 교차 사이트 로그인에는 세션 쿠키를 주지 않는다.
  7. 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 이다.
  8. 사용자는 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)
  9. /root/st/csrf/e2e.sh 로 alice 세션에 위조 이체 네 가지(토큰 없음, bob 의 토큰, Sec-Fetch-Site: cross-site, Origin: http://evil.example)를 같은 메모로 보내고, 그 메모로 기록된 이체 수와 정상 이체 한 건의 결과를 더해 /root/st/csrf/e2e.outno_token=403 other_session=403 cross_site=403 bad_origin=403 valid=200 forged_recorded=0 을 한 줄로 적는다.

참고

이체 서버 띄우기

/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-Sitecross-sitesame-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.outno_token=403 other_session=403 cross_site=403 bad_origin=403 valid=200 forged_recorded=0 을 한 줄로 적는다.

앞 스텝의 요청들을 함수 몇 개로 묶으면 짧아집니다. 위조 네 건에 같은 메모를 붙여 두면 기록 목록에서 그 메모가 몇 번 나오는지로 한 번에 셀 수 있습니다.