22
31

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「CSRFって美味しいの?」←ハンズオンで実装して理解してみた

Last updated at Posted at 2025-08-25

はじめに

CSRF攻撃について、技術書や記事で読んで「なるほど、理解した」と思っていました。

しかし、実際にチームメンバーに説明してみると、「なぜブラウザがCookieを自動送信するのが問題なの?」「Same-Origin Policyがあるのになぜ防げないの?」といった質問に対して、曖昧な回答しかできませんでした。

概念的な理解だけでは、実際の攻撃手法や対策の必要性を他の人に納得してもらうのは難しいものです。そこで、CSRF攻撃を実際に再現できる環境を作って、攻撃の仕組みと対策を体験してみることにしました。

この記事では、TypeScriptとExpressを使って脆弱性のあるWebアプリケーションと攻撃サイトを実装し、実際にCSRF攻撃を成功させた後、適切な対策を施して攻撃を防ぐまでの過程を記録しています。

読み終わった後に得られること

  • CSRF攻撃の技術的メカニズムを具体的に説明できる
  • なぜセッション認証だけでは不十分なのかを理解できる
  • CSRFトークンによる対策の仕組みと実装方法を習得できる
  • 実際の開発で見落としがちなセキュリティポイントを把握できる

なお、Githubリポジトリはこちらですので、合わせて参照してみてください。

CSRF攻撃の技術的メカニズム

攻撃が成立する条件

CSRF(Cross-Site Request Forgery)攻撃は、以下の条件が揃った時に成立します

  1. ユーザーが標的サイトで認証済み(セッションCookieが有効)
  2. 攻撃者が標的サイトの操作エンドポイントを把握
  3. ブラウザが攻撃サイトから標的サイトへのリクエストでCookieを自動送信
  4. 標的サイトがリクエストの送信元を検証していない

ブラウザのCookie送信仕様

重要なのは、ブラウザがHTTPリクエストを送信する際の動作です

# 攻撃サイト(evil.com)から標的サイト(target.com)へのリクエスト
POST /update-profile HTTP/1.1
Host: target.com
Origin: http://evil.com
Cookie: sessionid=abc123; csrftoken=xyz789  # 自動で送信される
Content-Type: application/x-www-form-urlencoded

email=attacker@evil.com

ブラウザは、リクエストの送信元に関係なく、対象ドメインに紐づくCookieを自動的に送信します。この仕様により、攻撃者はユーザーの認証状態を悪用できます。

Same-Origin Policyの限界

Same-Origin Policy(SOP)は、異なるオリジン間でのデータ読み取りを制限しますが、リクエストの送信自体は制限しません

// 攻撃者ができること
fetch('https://target.com/api/transfer', {
  method: 'POST',
  body: JSON.stringify({amount: 1000, to: 'attacker'}),
  credentials: 'include'
}); // リクエスト送信は成功

// 攻撃者ができないこと
.then(response => response.json())  // レスポンス読み取りはSOPでブロック

この非対称性が、CSRF攻撃を可能にしている根本的な要因です。

環境構築

既存のプロジェクト構成を活用して、CSRF攻撃のデモ環境を構築します。

プロジェクト構成の確認

csrf-demo/
├── src/
│   ├── vulnerable/         # 脆弱性のある実装
│   │   ├── index.ts        # 標的サイト
│   │   └── trap.ts         # 攻撃サイト
│   └── secure/             # 対策済み実装
│       ├── index.ts        # 標的サイト(対策版)
│       └── trap.ts         # 攻撃サイト(同一)
├── sessions/               # セッションファイル格納
├── package.json
└── tsconfig.json

起動手順

# 脆弱性版の起動(標的サイト:3000, 攻撃サイト:3001)
npm run running

# 対策済み版の起動(標的サイト:3000, 攻撃サイト:3001)
npm run running2

脆弱性のある実装の分析

CSRF攻撃がどのように成立するのかを理解するため、まず脆弱性のある実装を詳細に分析してみましょう。

セッション管理の実装

現在の実装を確認してみます

// src/vulnerable/index.ts (一部抜粋)
app.use(session({
  store: new FileStoreSession({
    path: './sessions',
    ttl: 86400,
    reapInterval: 3600,
  }),
  secret: 'your-secret-key',
  resave: false,
  saveUninitialized: false,
  cookie: { secure: false } // HTTPS以外でも動作
}));

// 認証チェックミドルウェア
function requireAuth(req: express.Request, res: express.Response, next: express.NextFunction) {
  if (req.session.user) {
    next(); // セッションにユーザー情報があれば認証OK
  } else {
    res.redirect('/login');
  }
}

この実装自体は一般的なセッション管理の手法ですが、CSRF攻撃の文脈では不十分であることが分かります。認証チェックはセッションの存在のみを確認しており、リクエストの正当性は検証していません。

脆弱なエンドポイントの特徴

セッション管理の次に、実際の操作を行うエンドポイントを見てみましょう

// プロフィール更新処理(脆弱性あり)
app.post('/update-profile', requireAuth, (req, res) => {
  const { email } = req.body;
  req.session.email = email; // セッションに保存
  console.log(`${req.session.user}のメールアドレスを${email}に更新しました`);
  res.send(`プロフィールが更新されました。<a href="/profile">プロフィール画面に戻る</a>`);
});

問題点の分析:

  • リクエストの送信元を検証していない
  • CSRFトークンなどの保護機構がない
  • セッション認証のみに依存している

このエンドポイントは認証されたユーザーからのリクエストであることは確認していますが、そのリクエストが本当にユーザーの意図したものなのかは判断できません。この「意図の確認」ができていないことが、CSRF攻撃の成功を許してしまいます。

攻撃サイトの実装

では、この脆弱性を突く攻撃サイトはどのような仕組みになっているでしょうか

// src/vulnerable/trap.ts (攻撃コード部分)
function attack1() {
  console.log('攻撃1を実行します');
  
  const form = document.createElement('form');
  form.method = 'POST';
  form.action = 'http://localhost:3000/update-profile';
  form.style.display = 'none'; // ユーザーに見えないよう隠す
  
  const emailInput = document.createElement('input');
  emailInput.type = 'hidden';
  emailInput.name = 'email';
  emailInput.value = 'hacker@evil.com'; // 攻撃者の指定した値
  
  form.appendChild(emailInput);
  document.body.appendChild(form);
  form.submit(); // フォームを自動送信
}

攻撃成功の仕組み

この攻撃コードがなぜ成功するのか、ステップごとに整理してみましょう:

  1. ユーザーが標的サイトでログイン:セッションCookieがブラウザに保存
  2. 攻撃サイトにアクセス:一見無害なページに見える
  3. 隠れたフォーム送信:JavaScript でPOSTリクエストを生成
  4. Cookie自動送信:ブラウザが標的サイトのCookieを自動的に含める
  5. サーバーが処理実行:正規リクエストと誤認して操作を実行

実際に攻撃を実行すると、コンソールに以下のようなログが出力されます:

userのメールアドレスをhacker@evil.comに更新しました
userがコメントしました: このサイトはハッキングされました!攻撃者からのメッセージです。

これで、認証済みのユーザーの意図しない操作が実行されてしまいました。次に、このような攻撃を防ぐための対策を実装していきます。

CSRF対策の実装

攻撃の仕組みを理解したところで、効果的な対策を実装してみましょう。今回は教育目的に特化した簡易実装として、理解しやすさを重視したCSRFトークンによる防御を実装します。

注意: この実装は基本的な対策のデモンストレーションです。本番環境では、トークンの暗号化、有効期限管理、フレームワークの標準機能の活用など、より堅牢な実装が必要です。これらの改善点については後述します。

CSRFトークンによる防御

対策済み版では、CSRFトークンを実装しています:

// src/secure/index.ts (対策部分)
import crypto from 'crypto';

// CSRFトークンを生成
function generateCSRFToken() {
  return crypto.randomBytes(32).toString('hex');
}

// CSRFトークンをセッションに保存
app.use((req, res, next) => {
  if (!req.session.csrfToken) {
    req.session.csrfToken = generateCSRFToken();
  }
  next();
});

// CSRFトークンを検証するミドルウェア
function verifyCSRFToken(req: express.Request, res: express.Response, next: express.NextFunction) {
  console.log(req.session.csrfToken, req.body.csrfToken);
  if (req.session.csrfToken !== req.body.csrfToken) {
    res.status(403).send('CSRFトークンが無効です');
  } else {
    next();
  }
}

フォームへのトークン埋め込み

// 対策済みのフォーム
res.send(`
  <form action="/update-profile" method="post">
    <!-- CSRFトークンを隠しフィールドに追加 -->
    <input type="hidden" name="csrfToken" value="${req.session.csrfToken}" />
    <input type="text" name="email" placeholder="メールアドレス" required />
    <button type="submit">プロフィールを更新</button>
  </form>
`);

保護されたエンドポイント

// プロフィール更新処理(CSRFトークン検証あり)
app.post('/update-profile', requireAuth, verifyCSRFToken, (req, res) => {
  const { email } = req.body;
  req.session.email = email;
  console.log(`${req.session.user}のメールアドレスを${email}に更新しました`);
  res.send(`プロフィールが更新されました。<a href="/profile">プロフィール画面に戻る</a>`);
});

なぜCSRFトークンで防御できるのか

CSRFトークンが有効な理由:

1. 攻撃者はトークンを取得できない

// 攻撃者が試みること(失敗する)
const response = await fetch('http://localhost:3000/profile');
const html = await response.text(); // Same-Origin Policyでブロック
const token = extractTokenFromHTML(html); // 実行されない

2. トークンの推測は現実的に不可能

crypto.randomBytes(32).toString('hex');
// 出力例: "a7f9d2e8b1c4f6e9a2d5c8b1f4e7a0d3c6b9f2e5a8d1c4f7b0e3a6d9c2f5e8b1"
// 2^256の組み合わせがあり、推測は不可能

3. サーバーサイドでの厳密な検証

function verifyCSRFToken(req, res, next) {
  const sessionToken = req.session.csrfToken;    // サーバーが保持
  const requestToken = req.body.csrfToken;       // リクエストに含まれる
  
  if (!sessionToken || sessionToken !== requestToken) {
    return res.status(403).send('CSRFトークンが無効です');
  }
  next();
}

対策後の攻撃失敗

対策済み版で同じ攻撃を実行すると

CSRFトークンが無効です

攻撃者は正しいCSRFトークンを取得できないため、すべてのリクエストが403エラーで拒否されます。

これで基本的なCSRFトークンによる防御が機能していることが確認できました。しかし、CSRFトークン以外にも有効な対策手法が存在します。

その他の対策手法

CSRFトークンが最も一般的で確実な対策ですが、環境や要件によっては他の手法も併用または代替として使用できます。

SameSite Cookie属性

app.use(session({
  // 他の設定...
  cookie: { 
    secure: process.env.NODE_ENV === 'production',
    httpOnly: true,
    sameSite: 'strict'  // CSRF攻撃を軽減
  }
}));

SameSite属性の比較

設定値 動作 CSRF対策効果
strict 同一サイトからのリクエストのみCookie送信 最高
lax 同一サイト + トップレベルナビゲーション
none 全てのリクエストでCookie送信 なし

Refererヘッダーチェック

function verifyReferer(req: express.Request, res: express.Response, next: express.NextFunction) {
  const referer = req.get('Referer');
  const allowedOrigins = ['http://localhost:3000'];
  
  if (referer && allowedOrigins.some(origin => referer.startsWith(origin))) {
    next();
  } else {
    res.status(403).send('Invalid referer');
  }
}

制限事項

  • Refererヘッダーは簡単に偽装可能
  • プライバシー設定でRefererが送信されない場合がある
  • 補助的な対策としてのみ有効

これらの対策手法を理解したところで、実際の開発現場でどのような点に注意すべきかを見ていきましょう。

今回の実装の注意点と改善点

今回のハンズオン実装を通じて、基本的なCSRF対策の仕組みは理解できましたが、今回の実装は理解を優先した簡易版です。

本格的な実装では以下の改善が必要となるでしょう。

セキュリティの向上

1. トークンの暗号化

// 現在:平文でトークンを生成
const token = crypto.randomBytes(32).toString('hex');

// 改善案:HMAC ベースのトークン生成
const hmac = crypto.createHmac('sha256', secretKey);
hmac.update(sessionId + timestamp);
const token = hmac.digest('hex');

2. トークンの有効期限

interface CSRFToken {
  value: string;
  createdAt: number;
  expiresAt: number;
}

function isTokenExpired(token: CSRFToken): boolean {
  return Date.now() > token.expiresAt;
}

3. トークンのローテーション

// リクエスト処理後に新しいトークンを生成
app.post('/protected-endpoint', verifyCSRF, (req, res) => {
  // 処理実行
  req.session.csrfToken = generateCSRFToken(); // 新しいトークンに更新
  res.json({ newToken: req.session.csrfToken });
});

パフォーマンスの最適化

1. トークンキャッシュ

const tokenCache = new Map<string, string>();

function getCachedToken(sessionId: string): string | undefined {
  return tokenCache.get(sessionId);
}

2. バッチ検証

async function verifyMultipleTokens(requests: Request[]): Promise<boolean[]> {
  return Promise.all(requests.map(req => verifyCSRFToken(req)));
}

これらの改善点を踏まえると、今回の実装は「CSRF攻撃と対策の仕組みを理解するためのデモ」として位置づけるべきです。本番環境では、フレームワークの標準機能や専用ライブラリの活用を検討しましょう。

まとめ

実際にCSRF攻撃を再現してみることで、以下の重要なポイントを体験できました

技術的な理解が深まったポイント

  • ブラウザのCookie自動送信がセキュリティホールとなる仕組み
  • Same-Origin Policyの制限範囲とCSRF攻撃の関係
  • CSRFトークンによる防御メカニズムの有効性

実装面で学んだこと

  • セッション認証だけでは不十分であること
  • すべての状態変更操作にCSRF対策が必要であること
  • フレームワークの機能を活用することの重要性

今後の学習課題

  • より高度なCSRF攻撃手法(JSON HijackingやFlash-based攻撃など)
  • Content Security Policy(CSP)との組み合わせ
  • マイクロサービスアーキテクチャでのCSRF対策

CSRF攻撃は一見地味ですが、ユーザーの意図しない操作を実行させる深刻な脅威です。

概念的な理解だけでなく、実際に手を動かして攻撃と対策を体験することで、セキュリティ対策の必要性と実装方法を確実に身につけていきましょう。

22
31
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
22
31

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?