● CWE-352 · OWASP A01:2021

クロスサイトリクエストフォージェリ(CSRF、CWE-352):仕組みと防ぎ方

別のサイトのページから、ログイン中のユーザーのブラウザにあなたのアプリのデータを変更するリクエストを送らせることができ、ブラウザはセッション Cookie を自動で付けるため、Cookie しか確認しないルートはその操作を実行してしまいます。シンクロナイザートークン、SameSite Cookie、Sec-Fetch-Site の確認でこれを防ぐ方法を、Flask と Flask-WTF の例で解説します。

わかりやすい解説 (ELI5)

あなたの会社には、あなたの「発送」トレイに置かれた署名入りの依頼票なら何でも処理する郵便室があります。そのトレイに手が届くのはあなただけなので、郵便室は依頼票を信頼しています。帰りがけに来客から、封をした封筒をトレイに入れておいてほしいと頼まれます。中には「私の荷物の配送先をすべて変更してください」という依頼票が入っています。郵便室はあなたのトレイでそれを見つけ、あなたが書いたものだと思って処理します。CSRF も同じで、ブラウザはあなたの銀行へのリクエストに、別のサイトが始めたものも含めて、必ずセッション Cookie を付けます。対策は、あなたの会社のページだけが押せる秘密の印鑑で、郵便室は印のない依頼票を断ります。

主要な概念と専門用語

アンビエントな認証情報
ブラウザは、どのページが始めたかに関係なく、サイトへのリクエストに Cookie や HTTP 認証を付けます。evil.example から app.example へのリクエストにも、Cookie の SameSite 属性が止めない限り app.example のセッション Cookie が付きます。
Cookie の SameSite 属性
Strict はクロスサイトのリクエストでは常に Cookie を送らず、Lax はトップレベルの GET ナビゲーションでだけ送り、None は常に送ります(Secure が必要)。Chromium は属性のない Cookie を Lax として扱いますが、Firefox と Safari は扱いません。また「同一サイト」には隣接するサブドメインも含まれます。
シンクロナイザートークン
セッションに結び付いたランダムな値で、サーバーはフォームやページごとに埋め込み、安全でないリクエストのたびに要求します。別のサイトは読めないので、正しいリクエストを偽造できません。Flask-WTF の CSRFProtect は csrf_token() で発行し、csrf_token フィールドか X-CSRFToken ヘッダーを確認します。
Fetch Metadata(Sec-Fetch-Site)
最近のブラウザはすべてのリクエストで Sec-Fetch-Site: cross-site、same-site、same-origin、none のいずれかを送り、ページからは偽造できません。same-origin 以外の安全でないメソッドを拒否すれば、サーバー側の 1 つの確認で CSRF を防げます。
GET による状態変更
GET でデータを変えるルートは <img> タグやリンクで起動でき、SameSite=Lax でもトップレベルの GET ナビゲーションでは Cookie が送られます。request.values はフォーム本文に加えてクエリ文字列も受け付けます。

ステップ・バイ・ステップの攻撃フロー

ステップ 1

被害者がログインしている

app.example のセッション Cookie を持っており、SameSite=None が付いているか、Lax を既定にしないブラウザで属性なしのまま送られます。

ステップ 2

攻撃者が用意したページを開く

https://app.example/account/email に送信する隠しフォームがあり、読み込み時に自動で送信されます。無害なテスト値 email=test@example.com を送るフォームはこの場合を示しています。

ステップ 3

ブラウザが Cookie 付きでリクエストを送る

リクエストには被害者のセッションが付いており、フォームが別のサイトにあったことをサーバーが知る手がかりはありません。

ステップ 4

サーバーがアカウントを変更する

メールアドレスが攻撃者のものに変わり、攻撃者はパスワードを再設定してアカウントを乗っ取ります。2008 年に Zeller と Felten は、ING Direct、YouTube、The New York Times でこの種の CSRF を示しました。

ソースコード比較:脆弱 vs 堅牢化

脆弱な実装
# app.py: ユーザーの意思を示す証拠はセッション Cookie だけ
import os

from flask import Flask, abort, request, session

from .db import db

app = Flask(__name__)
app.config.update(
    SECRET_KEY=os.environ["FLASK_SECRET_KEY"],
    SESSION_COOKIE_SECURE=True,
    SESSION_COOKIE_SAMESITE="None",
)


# GET でも動き、request.values はクエリ文字列も読む
@app.route("/account/email", methods=["GET", "POST"])
def change_email():
    user_id = session.get("user_id")
    if user_id is None:
        abort(401)
    db.execute("UPDATE users SET email = ? WHERE id = ?", (request.values["email"], user_id))
    return {"status": "ok"}
堅牢化されたセキュアパッチ
# app.py: トークン、SameSite Cookie、Fetch Metadata を併用
import os

from flask import Flask, abort, request, session
from flask_wtf.csrf import CSRFProtect

from .db import db

app = Flask(__name__)
app.config.update(
    SECRET_KEY=os.environ["FLASK_SECRET_KEY"],
    SESSION_COOKIE_SECURE=True,
    SESSION_COOKIE_HTTPONLY=True,
    SESSION_COOKIE_SAMESITE="Lax",
)
# POST には csrf_token が必須。フォームは {{ csrf_token() }} を描画し、fetch() は X-CSRFToken を送る
csrf = CSRFProtect(app)


# 別サイトが始めた書き込みはルートの前で拒否する
@app.before_request
def reject_cross_site_writes():
    if request.method not in ("GET", "HEAD", "OPTIONS"):
        if request.headers.get("Sec-Fetch-Site", "same-origin") not in ("same-origin", "none"):
            abort(403)


# POST のみ、データはフォーム本文から
@app.post("/account/email")
def change_email():
    user_id = session.get("user_id")
    if user_id is None:
        abort(401)
    db.execute("UPDATE users SET email = ? WHERE id = ?", (request.form["email"], user_id))
    return {"status": "ok"}

エンジニアリング&システム堅牢化チェックリスト

参考資料

← セキュリティ目録をすべて見る 脆弱性ガイド一覧 →