【実務・中級編】クロスサイトリクエストフォージェリ(CSRF)の仕組みとトークン検証の実装 – アプリケーションセキュリティ & 安全な開発防御ガイド

やあ、みんな。セキュリティチームのチーフエンジニアだ。今日も一日お疲れさん。

今日は「クロスサイトリクエストフォージェリ(CSRF)」の話をしよう。OWASP Top 10ではかつて常連だったものの、近年は「A07: Identification and Authentication Failures」に統合されたり、SameSite Cookieの普及で影が薄くなったと感じている人もいるかもしれないな。

だが、忘れてはいけない。CSRFは決して過去の脅威ではない。むしろ、その「見えにくさ」と「対策の抜け穴」が、今もなお攻撃者にとって魅力的なターゲットであり続けている。俺たちのチームでも、過去にヒヤリとしたケースが何度かあった。教科書通りの対策だけでは防ぎきれない、現場での泥臭い知見を今日は共有したいと思う。

特に、システムの運用やWebアプリ開発に日々携わる君たちには、単なる概念理解に留まらず、具体的な攻撃手法と、それを完全に防御するための実装、そして設定の細部にまで目を向けてほしい。

狙われる「意図しないリクエスト」:CSRF攻撃の核心

まず、CSRFがどんな攻撃なのか、その本質から深掘りしていこう。一般的な説明では「ユーザーが意図しないリクエストを、攻撃者のサイトを介して実行させる」とあるが、これは少々抽象的だ。

攻撃者の視点に立ってみると、CSRFは「正規ユーザーが認証済みの状態で、そのブラウザが自動的に認証情報を付与して送るリクエストの仕組みを悪用する」攻撃だ。つまり、ターゲットとなるWebアプリケーションが発行したセッションクッキーを、ユーザーのブラウザが無意識に攻撃者の仕掛けたリクエストに添付して送信してしまう、という点にキモがある。

攻撃のメカニズムとPoC(Proof of Concept)

例えば、君たちが運営するWebサービスに、以下のようなパスワード変更機能があったとしよう。




このフォームは `POST` リクエストで `/user/change_password` に `new_password` を送信する。もしCSRF対策が施されていない場合、攻撃者は以下のようなHTMLを自身の悪意あるサイトに仕掛けるだけで、攻撃を試みることができる。

攻撃PoCの例1:隠しフォームによる自動送信





お得なキャンペーンにご参加ください!

豪華プレゼントが当たるチャンス!今すぐクリック!

(このページを読み込むと自動的にバックグラウンドで処理が実行されます)







正規ユーザーが `your-target-app.com` にログインした状態で、この悪意あるサイト `malicious.example.com` を訪問すると、ブラウザは自動的に `https://your-target-app.com/user/change_password` へ `POST` リクエストを送信する。この際、ブラウザは `your-target-app.com` 向けのセッションクッキーを自動的に添付するため、サーバー側では正規ユーザーからの有効なリクエストとして処理されてしまうんだ。

結果として、ユーザーは意図せずパスワードを変更され、攻撃者にアカウントを乗っ取られる可能性がある。これはパスワード変更だけでなく、退会処理、設定変更、管理者権限でのユーザー追加など、状態を変更するあらゆる処理がCSRFの対象となり得る。

攻撃PoCの例2:GETリクエストでの状態変更(最悪のアンチパターン)

これはもう絶対にやめてほしいアンチパターンだが、残念ながら世の中にはまだ存在する。



もし、ユーザー削除のような重要な操作が `GET` リクエストで実行できてしまう場合、攻撃はさらに容易になる。





見たらラッキー!限定クーポンはこちら!

クーポン情報を取得中…


(裏で何か悪さをしているとは知らずに、ユーザーはクーポンを待つ)


画像タグ `` の `src` 属性は、ブラウザが画像を読み込むために自動的に `GET` リクエストを送信する。この特性を悪用し、正規ユーザーがログイン済みの状態でこのページを開くと、`your-target-app.com` のセッションクッキーが添付された `GET` リクエストが送信され、`id=123` のユーザーが削除されてしまうかもしれない。

GETリクエストでの状態変更は、CSRF対策の有無に関わらず、非常に危険な脆弱性を作り出す。 これは、Web開発における基本的なセキュリティ原則の一つとして、常に心に刻んでおいてほしい。

堅牢な防御策の基本:Anti-CSRFトークンによる実装

これらの攻撃を防ぐための最も効果的で広く採用されているのが「Anti-CSRFトークン」だ。その本質は、「ブラウザが自動的に送信しない、予測不能な秘密の値」をリクエストに含めることで、正規のユーザーからのリクエストであることをサーバー側で確認する、という点にある。

Anti-CSRFトークンの生成と検証フロー

1. トークンの生成: サーバーサイドで、ユーザーのセッションごとに、暗号学的に安全なランダムな文字列(トークン)を生成する。
2. トークンの保存: 生成したトークンをサーバー側のセッションに保存する。
3. トークンの埋め込み:

  • HTMLフォームの場合: `` のように隠しフィールドとしてフォーム内に埋め込む。
  • JavaScriptでAJAX/Fetchリクエストを送信する場合:リクエストヘッダー(例: `X-CSRF-Token`)やリクエストボディに含める。

4. リクエストの送信: ユーザーがフォームを送信したり、JavaScriptでリクエストを発行すると、CSRFトークンも一緒にサーバーへ送られる。
5. トークンの検証: サーバー側で、送られてきたトークンと、サーバーのセッションに保存されているトークンが一致するかを確認する。

  • 一致した場合: 正当なリクエストとして処理を続行。
  • 一致しない場合: CSRF攻撃とみなし、リクエストを拒否(HTTP 403 Forbidden など)。

6. トークンの再生成(オプション): 一度使用したトークンは無効化し、次のリクエストのために新しいトークンを生成することが望ましい。これにより、トークンの「ワンタイム性」を確保し、リプレイ攻撃を防ぐ。

実装例:PHPとPython (Flask)

実際にコードで見てみよう。ここでは、簡単なフォームと、それを処理するサーバーサイドの例を示す。

PHPでの実装例

まず、セッションを開始し、CSRFトークンを生成・保存する部分だ。





パスワード変更

パスワード変更フォーム







`hash_equals()` は、文字列の比較を定数時間で行うため、タイミング攻撃を防ぐ上で非常に重要だ。単なる `==` ではなく、必ずこれを使うように。

Python (Flask) での実装例

Flaskの場合、WTFormsなどのライブラリを使えばCSRF対策はより簡潔になるが、ここでは基本的な仕組みを理解するために手動で実装してみよう。

app.py
from flask import Flask, render_template, request, session, redirect, url_for, flash
import os
import secrets # 暗号学的に安全な乱数生成

app = Flask(__name__)
app.secret_key = os.urandom(24) # セッション管理のために必須。本番では環境変数等で設定

CSRFトークンを生成・セッションに保存するヘルパー関数
def generate_csrf_token():
if ‘csrf_token’ not in session:
session[‘csrf_token’] = secrets.token_hex(32) # 32バイトのランダムなHEX文字列
return session[‘csrf_token’]

CSRFトークンを検証するヘルパー関数
def verify_csrf_token(token):
# セッションにトークンが存在し、かつ送信されたトークンと一致するかを確認
# Python 3.6+ の secrets.compare_digest でタイミング攻撃を防止
return ‘csrf_token’ in session and secrets.compare_digest(session[‘csrf_token’], token)

@app.before_request
def setup_csrf_token():
# 全てのリクエストの前にCSRFトークンを生成(GETリクエストも含む)
generate_csrf_token()

@app.route(‘/’)
def index():
return render_template(‘index.html’, csrf_token=session[‘csrf_token’])

@app.route(‘/change_password’, methods=[‘POST’])
def change_password():
# POSTリクエストのCSRFトークンを検証
if not verify_csrf_token(request.form.get(‘csrf_token’)):
flash(‘CSRFトークンが無効です。’, ‘error’)
return redirect(url_for(‘index’)) # エラーページやフォームに戻す

# トークン検証後、新しいパスワードを取得して処理
new_password = request.form.get(‘new_password’)
if new_password:
# ここにパスワード変更のビジネスロジックを記述
# 例: パスワードのバリデーションとハッシュ化、DB更新など…
flash(f”パスワード ‘{new_password}’ が正常に変更されました!”, ‘success’)

# セキュリティ強化のため、トークンを消費したら新しいトークンを生成し直す
del session[‘csrf_token’]
generate_csrf_token() # 次のフォーム表示のために新しいトークンを生成

return redirect(url_for(‘index’))

if __name__ == ‘__main__’:
app.run(debug=True)

`templates/index.html`:




パスワード変更

パスワード変更フォーム

{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}

    {% for category, message in messages %}

  • {{ message }}
  • {% endfor %}

{% endif %}
{% endwith %}







JavaScript (Fetch API) での利用例

SPA(Single Page Application)などでAPIを呼び出す場合、CSRFトークンをHTMLフォームに埋め込むのではなく、JavaScriptで取得してリクエストヘッダーに含めるのが一般的だ。

// HTML側のどこかにCSRFトークンを埋め込んでおく
// 例:
// または、初回ロード時にAPIで取得する

document.addEventListener(‘DOMContentLoaded’, () => {
const changePasswordButton = document.getElementById(‘changePasswordButton’);
const newPasswordInput = document.getElementById(‘newPassword’);

changePasswordButton.addEventListener(‘click’, async () => {
const newPassword = newPasswordInput.value;
// 例: metaタグからCSRFトークンを取得
const csrfToken = document.querySelector(‘meta[name=”csrf-token”]’).getAttribute(‘content’);

try {
const response = await fetch(‘/api/change_password’, {
method: ‘POST’,
headers: {
‘Content-Type’: ‘application/json’,
‘X-CSRF-Token’: csrfToken // ヘッダーにCSRFトークンを含める
},
body: JSON.stringify({ new_password: newPassword })
});

if (response.ok) {
const result = await response.json();
console.log(‘パスワード変更成功:’, result);
alert(‘パスワードが変更されました!’);
// トークンを再生成する必要がある場合は、サーバーから新しいトークンを受け取って更新する
// 例: const newCsrfToken = response.headers.get(‘X-New-CSRF-Token’);
// document.querySelector(‘meta[name=”csrf-token”]’).setAttribute(‘content’, newCsrfToken);
} else {
console.error(‘パスワード変更失敗:’, response.status, response.statusText);
alert(‘パスワード変更に失敗しました。’);
}
} catch (error) {
console.error(‘通信エラー:’, error);
alert(‘通信エラーが発生しました。’);
}
});
});

サーバー側では、`X-CSRF-Token` ヘッダーからトークンを取得して検証することになる。

次世代の防御:SameSite属性の活用

Anti-CSRFトークンは非常に強力な防御策だが、実装ミスや漏れが発生する可能性もゼロではない。そこで、ブラウザ側でCSRF攻撃を未然に防ぐための強力な機能として「SameSite属性」がある。

これは、クッキーがクロスサイトリクエストで送信されるかどうかを制御する属性で、近年、主要ブラウザのデフォルト設定が変更されたことで、その重要性が飛躍的に高まった。

SameSite属性とは?

`SameSite` 属性は、クッキーをセットする際にサーバーが指定する。これにより、ブラウザは「現在のサイトと同じドメインからのリクエストの場合のみクッキーを送信する」といった制御を行うことができる。

設定値は主に以下の3つだ。

1. `SameSite=Lax` (デフォルト)

  • 最も一般的な設定。主要ブラウザの多くが、`SameSite` 属性が明示されていないクッキーに対してこの挙動をデフォルトとする。
  • クロスサイトリクエストの場合でも、トップレベルナビゲーション(ユーザーがリンクをクリックして移動するなど)や、GETリクエストによるフォーム送信など、一部の安全なリクエストではクッキーが送信される。
  • それ以外の `POST` リクエストや `