セキュリティ●●●○○

CSRF

Cross-Site Request Forgery ・ しーさーふ

ログイン中の利用者のブラウザに、罠ページから本人の意図しない操作を送らせる攻撃。

✦ 30秒で試す — 航海犯人の手元には、あなたのログイン情報が一つもない罠のフォームが自動で送られると、送金は?予想してみる

概要

CSRF(Cross-Site Request Forgery、クロスサイトリクエストフォージェリ。「シーサーフ」とも読みます)は、あるサイトにログインしている利用者を罠ページに誘い、そのサイトへ本人が意図しないリクエストを送らせる攻撃です。送金、パスワードやメールアドレスの変更、掲示板への書き込みといった「状態を変える操作」が狙われます。攻撃者は利用者のパスワードもセッションIDも知りません。それでも操作は「本人による正規のリクエスト」としてサーバに受け付けられてしまいます。

鍵になるのは、ブラウザが Cookie を扱うやり方です。ブラウザは、あるサイト宛てのリクエストを送るとき、そのサイトの Cookie を自動で添えます。この自動添付は、リクエストのきっかけが銀行自身のページなのか、まったく別のサイトの HTML(画像タグやフォーム)や JavaScript なのかを長いあいだ区別しませんでした。ログイン状態を保つセッションの Cookie は、いわば利用者の認証済みの証です。罠ページはこの証を直接は盗めませんが、ブラウザに「持って行かせる」ことはできる、というのが CSRF の核心です。

なぜ生まれたか

HTTP はもともと、リクエストごとに「誰からか」を覚えていない(ステートレスな)仕組みです。ログイン状態を保つために、サーバがセッションIDを Cookie に入れて渡し、ブラウザが以降のリクエストへ自動で添える、という使い方が1990年代後半に定着しました。利用者が意識しなくてもログイン状態が続く便利さは、そのまま「ブラウザは、頼まれればどこへでも証を持って行く」という性質でもありました。

サーバから見ると、正規の画面から送られたリクエストも、罠ページから送られたリクエストも、有効なセッション Cookie が付いていれば見分けがつきません。「Cookie が付いている=本人がこの画面で操作した」と暗黙に信じていたわけです。この欠落は古くから「混乱した代理人(confused deputy)」問題として知られていた構図の Web 版で、2001年に Peter Watkins が Bugtraq で CSRF と名付けたことで広く知られるようになりました。以後、リクエストの出どころを確かめる対策(CSRFトークン、Origin ヘッダの検証)がアプリ側に広まり、2016年以降はブラウザ側の SameSite属性 や Fetch Metadata という「そもそも証を持って行かない・出どころを正直に申告する」仕組みが加わりました。CSRF は Web アプリの代表的な脆弱性として、対策の歴史ごと理解しておく価値があります。

詳細

攻撃の流れ: 送っているのは被害者のブラウザ

被害者は bank.example にログイン済みで、ブラウザにセッション Cookie が残っているとします。ここで大事なのは、罠サイトのサーバが銀行へ直接リクエストを送るのではない、という点です。罠サイトのサーバが直接送っても、攻撃者は Cookie を持っていないので「未ログインの誰か」にしかなれません。送るのはあくまで被害者のブラウザで、だから Cookie が付きます。

銀行サーバ bank.example罠サイト evil.example被害者のブラウザ被害者銀行サーバ bank.example罠サイト evil.example被害者のブラウザ被害者被害者は銀行にログイン済み。ブラウザは bank の Cookie を持っているフォームがスクリプトで自動送信されるメールのリンクから罠ページを開くページを取得自動送信フォーム入りの HTMLPOST /transfer に bank の Cookie を自動で添付セッションが有効なので本人の操作と判断送金完了

罠は驚くほど単純です。状態変更を GET で受け付けてしまうサイトなら、<img src="https://bank.example/transfer?to=evil&amount=100000"> という画像タグを罠ページに置くだけで、ページを開いた瞬間にブラウザが銀行へ GET リクエストを送り、Cookie を添えます。POST が必要でも、見えない <form> を JavaScript で自動送信すれば同じです。フォームの送信は昔から別サイト宛てにも許されている操作なので、ブラウザは止めません。被害者は「リンクを開いた」だけで、送金ボタンを押した覚えはありません。

同じ仕組みで、逆向きの「ログイン CSRF」も成立します。罠ページが攻撃者自身のアカウントでログインするフォームを被害者のブラウザに送らせると、被害者は気づかないまま攻撃者のアカウントで操作を続け、入力したカード情報や検索履歴を攻撃者のアカウントに残してしまいます。ログインフォームにも CSRF 対策が要るのはこのためです。

対策の考え方: 「自動で付くもの」と「手渡しのもの」

CSRF の対策は、突き詰めると「ブラウザが自動で付けるもの(Cookie)」だけで本人確認をやめることです。構造を図にすると、罠ページが持って行けないものが何かが見えてきます。

被害者のブラウザ(独立した参加者。Cookie を持つのはここ)罠ページ evil.example<form action=“bank/transfer”>送金先: 攻撃者の口座トークン: 持っていない互いの中身は読めない(同一オリジンポリシー)銀行の送金画面 bank.example<input type=“hidden” name=“token” value=“9f3a…”>トークン: ページの中にだけあるCookie 入れsession=abc…宛先が bank ならどのタブ発でも添付+Cookie銀行サーバbank.exampleCookie → 誰のセッションかトークン → どの画面からか罠から: Cookie ✓ トークン ✕正規: Cookie ✓ トークン ✓→ 手渡しの札で見分ける
Cookie は宛先を見てブラウザが自動で付ける。トークンは正規のページの中にしかなく、罠ページは読めないので持って行けない

CSRFトークンは古くからの王道です。サーバはフォームを返すときに、推測できない乱数(トークン)を hidden フィールドに埋め込み、同じ値をセッション側にも覚えておきます。送信されたトークンがセッションの値と一致しなければ拒否します。これが効くのは、トークンが Cookie と違って「ページの中にだけある、手渡しの札」だからです。罠ページのスクリプトは、同一オリジンポリシー(別オリジンのページやレスポンスの中身を読ませないブラウザの原則。緩める仕組みが CORS)のために銀行の画面の中身を読めず、トークンの値を知る手段がありません。SPA では、トークンを独自ヘッダ(X-CSRF-Token など)で送る形もよく使われます。

銀行サーバブラウザ罠ページ銀行の画面銀行サーバブラウザ罠ページ銀行の画面送金画面を GET・Cookie 付きフォームに token=9f3a を埋めて返す画面を表示送信する・token=9f3aPOST token=9f3a と Cookietoken が一致するので送金自動送信フォーム・token なしPOST Cookie だけtoken 不一致で 403

Origin ヘッダの検証は、もう一つの確かな手段です。ブラウザは POST などのリクエストに、どのオリジンのページから出たかを Origin ヘッダで付けます。Cookie と同じく自動で付くものですが、こちらは「出どころを正直に申告する」情報で、ページのスクリプトからは書き換えられません。サーバが Origin: https://bank.example 以外の状態変更リクエストを拒否すれば、罠ページ発のリクエストは弾けます。さらに新しい Fetch Metadata では、ブラウザが Sec-Fetch-Site: cross-site(別サイトから)・same-origin(同じオリジンから)などを付けるので、「別サイト発の状態変更は受け付けない」という方針をサーバの入口1か所で書けます。2023年までに主要ブラウザがすべて対応しました。

SameSite属性は、Cookie 側に「別サイト発のリクエストには付けない」と書く方法です(詳しくは SameSite属性)。SameSite=Lax なら、別サイトからのフォーム POST や img の読み込みには Cookie が付かないので、CSRF の前提そのものが崩れます。ただし注意が要ります。属性を書かない Cookie を Lax として扱うのは Chrome・Edge などの Chromium 系ブラウザだけで、Firefox と Safari は既定では Lax 扱いしません。Lax でも別サイトからのリンク遷移(トップレベルの GET)には Cookie が付くので、状態変更を GET で受けている箇所は守れません。「サイト」はサブドメインを区別しないので、同じ登録ドメインの別のサブドメインにある穴を踏み台にされると素通りします。SameSite は既定値に頼らず明示し、トークンか Origin 検証と重ねるのが定石です。

下の部品では、罠ページの img タグや自動送信フォームをつかんで銀行へ放ってみてください。ブラウザの Cookie 入れを通ると Cookie が自動で付くこと、攻撃者のサーバから直接送っても Cookie が付かないこと、トークンを有効にすると罠のリクエストだけが弾かれることを確かめられます。

⚡ 体験: 罠ページのリクエストを銀行へ放つ
銀行の守り
被害者のブラウザ(bank.example にログイン中)https://evil.example罠ページhttps://bank.example送金画面フォームに秘密の札はないCookie 入れsession=abc…宛先 bank.example銀行サーバalice の残高 ¥1,000,000セッションCookie銀行サーバのログ攻撃者サーバ口座 ¥0・ブラウザの外<img> で GET自動送信 POST送金フォーム直接 POST
○ 罠で送金を通す○ 攻撃者が直接送る○ 守りで罠を止める○ 守りの下で正規の送金
放ったリクエストのシーケンス図(ブラウザが間に立つ)
ページ(タブ)ブラウザ銀行サーバ攻撃者サーバまだ何も放っていません

罠ページ(evil.example)の「<img> で GET」をドラッグして銀行サーバへ放ってください。タップするとおまかせで送ります。

誤解しやすい点

よくある誤解が「CORS を正しく設定すれば CSRF は防げる」というものです。CORS が制限するのはレスポンスの「読み取り」で、フォーム送信や img の読み込みのような単純なリクエストは、CORS の許可がなくてもサーバに届いて処理されます。CSRF は結果を読めなくても送金が通れば目的を果たすので、この範囲では CORS は防御になりません。一方、Content-Type: application/json の POST や独自ヘッダ付きのリクエストは、ブラウザが先に OPTIONS で問い合わせる「プリフライト」の対象になり、サーバが許可しなければ本番のリクエストは送られません。「JSON しか受け付けず、Content-Type を厳密に確かめる API」が CSRF に強いのはこのためですが、フォーム形式(application/x-www-form-urlencoded)も受け付けてしまう実装だと抜け道が残ります。

状態を変える操作を GET で受け付けないことも基本です。GET はリンクや画像の読み込みで簡単に発生し、SameSite=Lax も通してしまいます。

最後に XSS との関係です。XSS は「攻撃者のスクリプトを被害者のブラウザで、そのサイトの一員として実行させる」攻撃、CSRF は「被害者のブラウザに、別サイトから意図しないリクエストを送らせる」攻撃です。XSS が成立していると、攻撃者のスクリプトは銀行のページの中から動くので、トークンを読むことも、同じオリジンから Origin ヘッダ付きのリクエストを送ることもできます。CSRF 対策は「そのサイトに XSS がないこと」を土台に成り立っています。

試問

被害者は bank.example にログインしたまま、罠ページ(evil.example)を開いています。罠ページの「img で GET」「自動送信 POST」、銀行の画面の「送金フォーム」、ブラウザの外にいる攻撃者サーバの「直接 POST」を銀行へ放てます。銀行の守りは「なし(Cookie だけ)」「CSRFトークン」「Origin 検証」から選べます。

✦ 命題挑戦中

罠ページからの送金が通ってしまうのを確かめてから、銀行の守りを選んで、罠の送金だけを止め、本人の送金は通せ

  • 守りのない銀行で、罠ページのリクエストによる送金が通るのを見る
  • 守りを選び、罠ページのリクエストを銀行の門で止める
  • 同じ守りのまま、銀行の送金フォームからの送金は通る
⚡ 体験: 罠ページのリクエストを銀行へ放つ
銀行の守り
被害者のブラウザ(bank.example にログイン中)https://evil.example罠ページhttps://bank.example送金画面フォームに秘密の札はないCookie 入れsession=abc…宛先 bank.example銀行サーバalice の残高 ¥1,000,000セッションCookie銀行サーバのログ攻撃者サーバ口座 ¥0・ブラウザの外<img> で GET自動送信 POST送金フォーム直接 POST
放ったリクエストのシーケンス図(ブラウザが間に立つ)
ページ(タブ)ブラウザ銀行サーバ攻撃者サーバまだ何も放っていません

罠ページ(evil.example)の「<img> で GET」をドラッグして銀行サーバへ放ってください。タップするとおまかせで送ります。