CSRFの生の挙動を知りたい~FastAPIとZAPを用いて検証してみた

初めまして、テックドクターでエンジニアをしている金子です。

CSRF(クロスサイト・リクエスト・フォージェリ)は非常に古典的な攻撃手法ですが、現代のWeb開発においてもその発生原理を正確に理解しておくことは重要だと思っています。

今回の記事では、CSRFについて、実際に手を動かして検証してみます。

この記事でやること

CSRFの基本的な仕組みを理解するために、わざと脆弱なサーバーを作成します。加えて「罠サイト」も用意して攻撃を再現し、何が起きているのかを脆弱性診断ツールのOWASP ZAPで観察します。

また、防御策としてCSRFトークンの有効性についても検証していきます。

※CSRF攻撃において、被害者に踏ませるためのページを本記事では「罠サイト」と呼びます。罠サイトは攻撃者が用意するもので、被害者がページを開いただけで標的サイトへリクエストが飛ぶ仕掛けが仕込まれています。

こんな人におすすめ

  • CSRFの名前は知っているが、実際どうやって攻撃が成立するのかピンときていない
  • フレームワークが守ってくれているため、生の脆弱な挙動を見たことがない
  • 攻撃の原理を自分の言葉で説明できるようになりたい

検証環境

  • ターゲットサーバー:FastAPI
  • 罠サイト(攻撃者):HTML + JavaScript(JSフレームワークは使いません)
  • ツール:OWASP ZAP、FoxyProxy

※今回は簡易的な検証のため、ターゲットサーバー、罠サイト共にローカル環境に設置します。

第1章:CSRFの仕組み

CSRFとは

CSRF(Cross-Site Request Forgery) は、日本語では「リクエスト強要」とも呼ばれます。被害者のブラウザを踏み台にして、本人の意図しないリクエストをWebアプリケーションに送信させる攻撃手法です。

攻撃の本質は「認証済みセッションの悪用」にあります。Webアプリケーションはリクエストに含まれるCookieを見て「この人は誰か」を判断しますが、そのリクエストが「本人の意思で送られたものか」までは検証していないことが多いです。CSRFではこの点が悪用されます。

攻撃者は罠サイトを用意し、被害者がそのページを開いた瞬間、被害者のブラウザから標的サイトへ不正なリクエストを自動送信させます。このとき被害者がたまたま標的サイトにログイン中であれば、ブラウザにより自動的にCookieが不正リクエストにも付与されます。そのため、サーバ側から見れば正規ユーザーからの正当なリクエストと区別がつかず、不正リクエストが処理されてしまうというわけです。

CSRFのしくみ(引用:IPA(独立行政法人 情報処理推進機構)より)

攻撃が成立する条件

CSRF攻撃が成立するには、以下の3つの条件が同時に満たされる必要があります。

  • 被害者が標的サイトにログイン済みである……ブラウザに標的サイトのセッションCookieが保存されており、有効な状態であること。ログアウト済み、またはセッション切れの場合は攻撃が成立しません。
  • 標的サイトがリクエストの正当性を検証していない……サーバ側で、CSRFトークンの検証、Refererヘッダのチェックなどを行っていない状態。つまり、「誰から送られたリクエストか」は見ているものの、「本人が意図して送ったリクエストか」までは確認しない実装になっている必要があります。
  • 被害者が攻撃者の用意した罠ページにアクセスする……メール内のリンク、SNSの投稿、不正広告など経路は様々です。被害者がそのページを開いた瞬間、隠されたフォームやimgタグによって標的サイトへリクエストが飛びます。多くの場合、被害者は攻撃が行われたことに気づきません。

※CSRFの仕組みについて、より詳細に知りたい方は次のサイトを参考にしてみてください。
安全なウェブサイトの作り方 - 1.6 CSRF(クロスサイト・リクエスト・フォージェリ) | 情報セキュリティ | IPA 独立行政法人 情報処理推進機構

具体的なインシデント例

PC遠隔操作事件(2012年)……Web掲示板のCSRF脆弱性を悪用し、無関係な一般市民のPCから犯罪予告を投稿させた事件です。被害者は誤認逮捕され、そのうち一人の大学生は自主退学に追い込まれました。サービス側のアクセスログには被害者のIPアドレスしか残らないため、当初は被害者が犯人だと疑われるなど、CSRFの「踏み台」としての恐ろしさを社会問題として浮き彫りにした事例です。

第2章:脆弱な環境を作る

では実際に検証を進めていきましょう。
まずは攻撃ターゲットとして、脆弱性を持つサーバーを構築します。

FastAPIによる簡単なサーバーの実装

このサーバーは下記のような機能を持っています。

  • ログイン機能(ログイン時、ブラウザにCookieをセット)
  • ユーザー情報としてID、パスワードのほか残高情報を持つ
  • パスワード変更APIが存在(CSRFトークンなし)

実装コードはこのようにしました。

from fastapi import FastAPI, Response, Cookie, HTTPException
from pydantic import BaseModel

app = FastAPI()

# 簡易ユーザーDB
users = {"alice": {"password": "pass123", "balance": 10000}}
sessions = {}

@app.post("/login")
def login(username: str, password: str, response: Response):
    if users.get(username, {}).get("password") == password:
        session_id = f"session_{username}"
        sessions[session_id] = username
        response.set_cookie("session_id", session_id, samesite="none", secure=False)
        return {"message": "ログイン成功"}
    raise HTTPException(401, "認証失敗")

class TransferRequest(BaseModel):
    to_user: str
    amount: int

@app.post("/transfer")  # ← CSRFトークンなし!
def transfer(req: TransferRequest, session_id: str = Cookie(None)):
    username = sessions.get(session_id)
    if not username:
        raise HTTPException(401, "未ログイン")
    users[username]["balance"] -= req.amount
    return {"message": f"{req.amount}円を送金しました"}

標的サイトの作成

次に標的となるサイトのページを作成しました。以下のような機能を持っています。

  • credentials: 'include' でCookieがやり取りする

実装コードの一部がこちらです。(重要な箇所のみ。HTML、CSSの全部、JavaScriptの一部は割愛します)

// ログイン時(ポイント:credentials: 'include')
  const res = await fetch(`${API_BASE}/token`, {
      method: 'POST',
      body: formData,
      credentials: 'include'  // ← Cookieを受け取る
  });

// 送金時(ポイント:Cookieが自動送信される)
  const res = await fetch(`${API_BASE}/transfers`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
      credentials: 'include'  // ← Cookieが自動で付く
  });

実装した画面

上記により、このような画面ができました。

  • ログイン画面

画面キャプチャ

  • 新規登録画面

画面キャプチャ

  • ダッシュボード

画面キャプチャ

第3章:罠サイトを作る

次に罠サイトを作成します。これは実際のCSRF攻撃においては攻撃者が作成するものです。下記のような機能を持っています。

  • 隠しフォームを持つ
  • フォーム自動送信のJavaScriptが存在(今回はボタンクリック時)

実装コード(CSSは割愛)

<form id="csrfForm" action="https://localhost:8443/api/transfers" method="POST">
      <input type="hidden" name="receiver_user_id" value="attacker">
      <input type="hidden" name="amount" value="5000">
  </form>
  
  <script>
  // 被害者がクリックすると...
  document.getElementById('claimBtn').addEventListener('click', async () => {
      await fetch('https://localhost:8443/api/transfers', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
              receiver_user_id: 'attacker',
              amount: 5000
          }),
          credentials: 'include'  // ← 被害者のCookieが送られる!
      });
  });
  </script>

実装した画面

画面キャプチャ

ここまでで、検証に必要な環境の作成が完了しました。

第4章:OWASP ZAPで検証

いよいよ、ツールを使ってCSRFの挙動を検証していきます。使用するツールは2つです。

OWASP ZAP

OWASP(Open Web Application Security Project)が開発するオープンソースのセキュリティテストツールです。ローカルプロキシとして動作し、ブラウザとWebサーバー間を流れるHTTP通信をすべて傍受・記録できます。

主な機能は以下の通りです。

  • リクエスト/レスポンスの可視化……ブラウザが裏で送っているリクエストの中身(ヘッダ、Cookie、POSTパラメータ等)を確認できる
  • リクエストの改ざん・再送信……傍受したリクエストを編集して再送信することで、パラメータ操作の影響を検証できる
  • 自動スキャン……既知の脆弱性パターンを自動検出する機能もあります。(本章では使用しません)
FoxyProxy

プロキシ設定の切り替えを簡単にするブラウザ拡張機能です。ChromeFirefoxに対応しています。
通常、ZAPのようなローカルプロキシを使うにはブラウザのネットワーク設定を手動で変更する必要がありますが、FoxyProxyを使えばワンクリックで「ZAP経由」と「直接接続」を切り替えられます。検証作業中に頻繁にプロキシのON/OFFを行う場面で便利です。

攻撃の再現

下準備として、被害者のアカウントと攻撃者のアカウントをそれぞれ作成しました。単純に作成するだけなので、画像の貼付は行いません。作成時の条件とユーザIDのみ記載します。

  • 被害者のユーザーID:test_target1
  • 攻撃者のユーザーID:attacker1
  • 初期値として設定した残高:いずれのユーザーも10,000円

以下、攻撃成立までのフローです。

1. 被害者ユーザーとして正規サイトにログインします。
画面キャプチャ
画面キャプチャ
ここでZAPを確認すると、バックエンドからバックエンドからアクセストークンが発行され、Cookieに保存されたことが分かります。
画面キャプチャ


2. 別タブで罠サイトを開きます。
画面キャプチャ
画面キャプチャ


3. 「景品を受け取る」ボタンをクリックします。

その結果、Javascriptにより攻撃者に送金をするリクエストがバックエンドに送信されます。
このとき、ZAPでCookieヘッダーを見てみると、被害者がログインしたときと同じAccessTokenが使われてしまっていることがわかります。

画面キャプチャ

リクエストヘッダーとJSONペイロードも確認してみましょう。

receiver_user_id(送金を受けるアカウント)には攻撃者のアカウントが、amount(送金額)には5000円が設定されています。(このとき、sender_id(送金者)のような送金者を特定するパラメータがありませんが、バックエンドではCookieのアクセストークンによって送金者を特定できるので、仕組みとしてはsender_idを指定しなくても送金処理を実行できます。)

送金完了後に被害者のダッシュボードで画面を更新すると、残高が5000に減っていることが分かります。

画面キャプチャ

また、攻撃者のアカウントにログインすると残高が15,000に増えていることが分かります。

画面キャプチャ

以上で、CSRF攻撃が成立するまでの挙動が確認できました。

第5章:CSRFへの防御策を検証する

攻撃について理解したところで、次は防御策についても検証してみたいと思います。
CSRFへの防御策のひとつに、CSRFトークンの実装が挙げられます。

CSRFトークンの仕組み

  1. 発行: ユーザーがフォームのある画面(送金画面など)を開いたとき、サーバーは乱数で作った、「予測不可能なトークン」を生成し、HTMLの隠しフィールド(input type="hidden")に埋め込んでユーザーに渡します
  2. 送信:ユーザーが送信ボタンを押すと、入力データと一緒にこの「トークン」もサーバーへ送られます
  3. 検証: サーバーは、送られてきたトークンが、ステップ1で自分が発行したものと一致するかを確認します。
    • 一致すれば……「正規の画面からの操作だ」と判断して処理を実行
    • 不一致/トークンなしなら……「不正なリクエストだ」と判断して拒否

CSRFトークンの実装

実際に実装してみましょう。全てを掲載すると長くなるので、実装したコードのうち重要な部分のみを例として掲載します。

1. CSRFトークンの生成とCookie設定(ログイン時)

# auth.py - ログイン時にCSRFトークンをCookieにセット
import secrets

def generate_csrf_token() -> str:
    return secrets.token_hex(32)

# ログインエンドポイント内
csrf_token = generate_csrf_token()
response.set_cookie(
    key="csrf_token",
    value=csrf_token,
    httponly=False,  # JSから読み取り可能にする(重要)
    samesite="none",
    secure=True,
)


2. CSRFトークンの検証(サーバー側)

# auth.py - CSRFトークン検証関数
def verify_csrf_token(
    csrf_token_cookie: str | None = Cookie(alias="csrf_token"),
    csrf_token_header: str | None = Header(alias="X-CSRF-Token"),
) -> None:
    """CookieとヘッダーのCSRFトークンを比較"""
    if csrf_token_cookie is None or csrf_token_header is None:
        raise HTTPException(status_code=403, detail="CSRF token missing")

    if csrf_token_cookie != csrf_token_header:
        raise HTTPException(status_code=403, detail="CSRF token mismatch")

# transfer.py - 送金エンドポイントで検証を適用
@router.post("/transfers")
def transfer_money(
    request: TransferRequest,
    current_user: Annotated[UserAuth, Depends(get_current_user)],
    _csrf: Annotated[None, Depends(verify_csrf_token)],  # ← これを追加
):
    ...


3. CSRFトークンをヘッダーに含める(フロントエンド)

// dashboard.html - CookieからCSRFトークンを取得してヘッダーに含める
function getCsrfToken() {
    const cookies = document.cookie.split(';');
    for (const cookie of cookies) {
        const [name, value] = cookie.trim().split('=');
        if (name === 'csrf_token') return value;
    }
    return null;
}

// 送金リクエスト
const res = await fetch('/api/transfers', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': getCsrfToken()  // ← これを追加
    },
    body: JSON.stringify(data),
    credentials: 'include'
});

再検証

ここまででCSRFトークンの実装が完了しました。

では再びOWASP ZAPを用いて、防御策がちゃんと機能しているか検証してみましょう。

1. 被害者のアカウントにログインします。
画面キャプチャ
ZAPを確認すると、アクセストークンとCSRFトークンが発行されていることがわかります。
画面キャプチャ


2. 別タブで罠サイトを開き、「景品を受け取る」ボタンをクリックします。

ZAPを確認してみると……
画面キャプチャ
X-CSRF-Tokenがリクエストのヘッダーに存在しないことと、403エラーが出て不正なリクエストが処理されていないことが確認できます。

無事、CSRF攻撃を防御することができました!


3. 念のため、正常時のリクエストも確認します。

現在の残高は5,000円です。

画面キャプチャ

ダッシュボードより2,500円の送金操作を行いました。

画面キャプチャ

送金が実施され、残高が2,500円になりました。

ZAPでリクエストの内容を確認してみましょう。
X-CSRF-Tokenがリクエストのヘッダーに含まれていることが分かります。
画面キャプチャ

送金先である、攻撃者のアカウントにログインをして残高を確認します。
画面キャプチャ
残高が増えていました。

正常なリクエストは、正常に処理されることが確認できました。

他の防御方法

なお、CSRF対策としてCSRFトークンが唯一の手段ではありません。
他にも下記のような対策が考えられます。

  • SameSite属性の設定
  • Referer/Originヘッダの検証

本記事の趣旨から逸れるのでこれらについて詳しくは説明しませんが、複数の防御策を組み合わせ、耐攻撃性の高い実装をすることが重要です。

まとめ

今回の検証を通じて、現代のフレームワークは開発者が意識せずともCSRFから守ってくれているという事実を、肌で感じることができました。

最近はAIを使って実装をする機会が増えましたが、生成されたコードでは「何となく動くけれど、仕組みはよく分からない」という状態に陥ることも少なくありません。

こうした「何となく」の知識を確かなものにするためには、時間をかけて手を動かし、実践するプロセスもやはり重要だと改めて感じました。


似顔絵
書いた人:金子