偽造リクエストで CSRF 防御を確かめる
目標
127.0.0.1:8303の送金サーバーに、セッションごとのCSRFトークン、Sec-Fetch-Siteの検査、Originの許可リストを順に追加し、偽造リクエストが403で拒否されるだけでなく、記録にも残らないことを自分で確認します。
なぜ重要なのか
CSRFが成立する理由は1つです。ブラウザーが宛先のCookieを自動で付けるため、攻撃ページから出発した送金も、サーバーにはユーザーが押した送金とまったく同じに見えます。そのため防御はすべて、「攻撃ページが真似できないシグナル」を要求する方式です。セッションに紐付いたトークンは攻撃ページが読めず、Sec-Fetch-Site・Originはブラウザーが付けるのでページが変更できません。SameSiteはそこに1枚の層を足すだけです。兄弟サブドメインは同じサイトとして扱われ、GETで状態を変えるとLaxのCookieがそのまま載ります。このラボの採点ツールは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セッションCookieを返し、GET /transfersはそのセッションの送金一覧を{"transfers":[{"to","amount","memo"}...]}として返します。GET /csrfはログイン済みのセッションに{"csrf":"<토큰>"}(プレースホルダーはトークンです)を返します。トークンは32バイト以上のランダム値(base64urlで43文字以上)で、同じセッションでは何回呼んでも同じ、セッションが違えば異なる必要があります。トークンはサーバーがセッション記録に保存しておく同期トークンであり、別のCookieとしては返しません。セッションなしで呼ぶと401です。POST /transfer(JSON{"to","amount","memo"})にX-CSRF-Tokenヘッダーがない場合や、サーバーが発行したことのない値の場合は、403を返して送金を記録しません。攻撃者がcsrf=XのようなCookieを仕込み、ヘッダーにもXを入れても403である必要があります(ヘッダーはCookieではなく、セッションに保存したトークンと比較します)。GET /transfer?to=...&amount=...&memo=...では送金が記録されてはいけません(レスポンスコードは問いません)。- bobのセッションで受け取った本物のトークンをaliceのセッションCookieと一緒に送ると403になり、aliceの送金として記録されてはいけません。
- 自分のセッションのトークンを
X-CSRF-Tokenに入れた送金は200になり、GET /transfersに記録されます。トークンの比較はhmac.compare_digestで行ってください。 - 状態を変えるリクエスト(POST)の
Sec-Fetch-Siteがcross-siteかsame-siteなら、トークンが合っていても403を返し、記録しません。same-originならトークンで判断して通し、ヘッダーがなければ(古いブラウザー・スクリプト)トークンで判断します。POST /loginも同じ検査を受け、クロスサイトのログインにはセッションCookieを渡しません。 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にログインしていて、セッションCookieは数時間前にSameSite=Laxを明示して発行されました。攻撃ページはhttps://evil.example.net、兄弟ページはhttps://blog.example.comです。次の7つのケースでそのCookieがリクエストに載るかどうかを判定し、/root/st/csrf/samesite.txtに항목=sentまたは항목=not_sentの形で(プレースホルダーは項目名です)1行ずつ書いてください。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のセッションに偽造送金を4種類(トークンなし、bobのトークン、Sec-Fetch-Site: cross-site、Origin: http://evil.example)、同じメモで送ってください。そのメモで記録された送金の数と正常な送金1件の結果をまとめて、/root/st/csrf/e2e.outにno_token=403 other_session=403 cross_site=403 bad_origin=403 valid=200 forged_recorded=0を1行で書いてください。
参考
- セッションCookieの値の取り出し:
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" ...で行えます(プレースホルダーは値です)。実際のブラウザーでは、この2つのヘッダーをページが変更できません。 - 検査の順序は、出どころのシグナル(Sec-Fetch-Site・Origin)→トークン→記録です。記録の後で検査すると、403を返しても送金が残ります。
- よくある失敗1: トークンを「発行したことがあるか」だけで検査するケースです。攻撃者が自分のアカウントで受け取ったトークンが通ります。
- よくある失敗2: Originを
startswithで比較するケースです。http://127.0.0.1:8303.evil.exampleが通ります。 - ステップ9の採点ツールは、結果ファイルとは別に、偽造4種類と
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セッションCookieを返し、GET /transfersはそのセッションの送金一覧を{"transfers":[{"to","amount","memo"}...]}として返します。
前のモジュールのCookieサーバーを土台にすれば足ります。セッション記録に、ユーザーと一緒にトークンと送金一覧を入れる場所を先に作っておくと、後のステップが楽です。
セッションごとのCSRFトークンを発行する
GET /csrfはログイン済みのセッションに{"csrf":"<토큰>"}(プレースホルダーはトークンです)を返します。トークンは32バイト以上のランダム値(base64urlで43文字以上)で、同じセッションでは何回呼んでも同じ、セッションが違えば異なる必要があります。トークンはサーバーがセッション記録に保存しておく同期トークンであり、別のCookieとしては返しません。セッションなしで呼ぶと401です。
トークンはセッションを作るときに一緒に作ってセッション記録の中に入れておくと、セッションと運命を共にします。セッション値と同じく、secretsモジュールで作ってください。
トークンなしの送金を拒否する
POST /transfer(JSON{"to","amount","memo"})にX-CSRF-Tokenヘッダーがない場合や、サーバーが発行したことのない値の場合は、403を返して送金を記録しません。攻撃者がcsrf=XのようなCookieを仕込み、ヘッダーにもXを入れても403である必要があります(ヘッダーはCookieではなく、セッションに保存したトークンと比較します)。GET /transfer?to=...&amount=...&memo=...では送金が記録されてはいけません(レスポンスコードは問いません)。
攻撃ページのフォームはCookieは載せますが、トークンは知りません。検査は送金を記録する前に行わないと、403に意味がありません。Cookieは攻撃者が兄弟サブドメインから仕込むこともできるので比較の基準にできず、リンク1つで送れるGETに状態変更を任せてはいけません。
トークンをセッションに紐付ける
bobのセッションで受け取った本物のトークンをaliceのセッションCookieと一緒に送ると403になり、aliceの送金として記録されてはいけません。
発行したトークン全体の一覧と比較すると、このステップで詰まります。リクエストに付いたセッションCookieで見つけた、そのセッションのトークンとだけ比較する必要があります。
正しいトークンを通す
自分のセッションのトークンを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も同じ検査を受け、クロスサイトのログインにはセッションCookieを渡しません。
このヘッダーはブラウザーが付け、ページのスクリプトは変更できません。すべてのPOSTが通る1か所で、トークンより先に見てください。兄弟サブドメインを信頼する理由がなければ、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にログインしていて、セッションCookieは数時間前にSameSite=Laxを明示して発行されました。攻撃ページはhttps://evil.example.net、兄弟ページはhttps://blog.example.comです。次の7つのケースでそのCookieがリクエストに載るかどうかを判定し、/root/st/csrf/samesite.txtに항목=sentまたは항목=not_sentの形で(プレースホルダーは項目名です)1行ずつ書いてください。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のCookieがクロスサイトのリクエストに載る条件は、2つが同時に満たされるときだけです。まず、出発地と宛先が同じサイトかどうかから分けてください。サイトはオリジンより広い概念です。#で始まる行はコメントとして残せます。
偽造リクエストの一式で一度に証明する
/root/st/csrf/e2e.shで、aliceのセッションに偽造送金を4種類(トークンなし、bobのトークン、Sec-Fetch-Site: cross-site、Origin: http://evil.example)、同じメモで送ってください。そのメモで記録された送金の数と正常な送金1件の結果をまとめて、/root/st/csrf/e2e.outにno_token=403 other_session=403 cross_site=403 bad_origin=403 valid=200 forged_recorded=0を1行で書いてください。
前のステップのリクエストをいくつかの関数にまとめると短くなります。偽造4件に同じメモを付けておくと、記録一覧にそのメモが何回出てくるかで一度に数えられます。