2-3. CSRF と SSRF - クロスサイト系攻撃と防御

所要時間: 40-60分(がっつりなら2セッション分) ゴール: CSRF と SSRF の本質的な違いを理解し、Go ミドルウェアで両方を防御できる コミット内容: ~/learn/security/day-csrf-ssrf/ に CSRF トークンと URL 検証の実装


前章とのつながり

このレッスンの位置づけ

  • 2-2 セッション管理SameSite Cookie を学んだ → その効果が最大限発揮される攻撃が CSRF。前章の発展編
  • 1-6 XSS: CSRF と XSS は混同されがち。XSS は「ブラウザで JS が動く」CSRF は「ブラウザが勝手にリクエストを送る」。違いは本章で
  • 次章 2-4 シークレット管理: SSRF で漏れる代表例が クラウドのメタデータ → IAM クレデンシャル。本章の被害が次章の問題へ直結

本章のコツ: CSRF と SSRF は 名前が似てるだけで全くの別物。混同しないよう、主語と標的の表 を頭に焼き付ける。

実害シーン: CSRF / SSRF の実被害

  • CSRF - 2000年代の銀行サイト: <img src="bank.com/transfer?to=attacker"> を被害者が踏むだけで送金完了
  • SSRF - Capital One (2019): WAF の SSRF 脆弱性経由で EC2 メタデータ 169.254.169.254 から IAM トークン取得 → S3 から 1億700万件流出罰金 約1.9億ドル
  • GitHub 等の Webhook 機能: ユーザー入力URLを取れる機能はすべて SSRF の温床。バリデーションを怠ると内部APIが叩き放題

大前提: 名前が似ているが、別物

攻撃主語標的利用するもの
CSRF (Cross-Site Request Forgery)被害者のブラウザ被害者がログイン中のサービス自動送信される Cookie
SSRF (Server-Side Request Forgery)サーバー内部ネットワーク、メタデータエンドポイントサーバーの権限・ネットワーク到達性

両方とも「攻撃者ではない誰か(CSRFはユーザーのブラウザ、SSRFはサーバー自身)にリクエストを出させる」点が共通。だが防御の話は全く別物。

実例で温度感を理解する:

  • CSRF: 2008年頃まで日本のメガバンクでも騒がれた。攻撃者の Web ページに仕掛けられた <img src="https://bank.com/transfer?to=attacker&amount=10000"> をログイン中の被害者が踏むと、銀行サイトに認証 Cookie が自動付与されて送金完了
  • SSRF: 2019年 Capital One 事件。WAF の脆弱性経由で SSRF が成立、攻撃者は EC2 メタデータエンドポイント 169.254.169.254 から IAM 認証情報を取得 → S3 から 1億700万件の個人情報 を流出。同社は約 1.9億ドルの罰金

セッション①: CSRF - クロスサイトリクエストフォージェリ(30分)

0. 検証用脆弱アプリの起動

このレッスンで使う攻撃デモ環境を起動する。Docker が必要。

# 作業ディレクトリ
mkdir -p ~/log ~/learn/security/day-csrf-ssrf
cd ~/learn/security/day-csrf-ssrf
script ~/log/security_day-csrf-ssrf.log
 
# DVWA を起動(CSRF モジュールを使って攻撃を体感する)
docker run --rm -d --name dvwa -p 8080:80 vulnerables/web-dvwa
# → ブラウザで http://localhost:8080 を開く(初期ユーザー: admin / password)
# → Security Level を Low にして CSRF ページへ
 
# 必要に応じて Juice Shop も併用可
# docker run --rm -d --name juiceshop -p 3000:3000 bkimminich/juice-shop

検証環境は隔離する

DVWA / Juice Shop は 本気で脆弱なアプリ。インターネット公開しないで、ローカルか隔離環境で動かすこと。レッスン終わったら必ず停止: docker stop dvwa (または juiceshop)

1. CSRF の古典シナリオ

1. 被害者は bank.com に通常ログイン中(セッション Cookie が保存されている)
2. 別タブで attacker.com を開く
3. attacker.com の HTML に以下が仕込まれている:

<form action="https://bank.com/transfer" method="POST" id="f">
  <input name="to" value="attacker_account">
  <input name="amount" value="1000000">
</form>
<script>document.getElementById('f').submit();</script>

4. ブラウザは bank.com への POST に、保存されている認証 Cookie を自動付与
5. bank.com からすれば「ログインしているユーザーが送金リクエストを出した」ようにしか見えない
6. 送金完了

CSRF が成立する3条件

  1. ユーザーが標的サイトにログイン中(Cookie あり)
  2. 状態変更操作が、Cookie 認証のみで実行できる(追加トークン等の検証なし)
  3. GET / POST が攻撃者から発火可能(form, img, fetch など)

1つでも欠けると成立しない。防御は「2を破壊する」のが基本。

2. なぜ「同一オリジンポリシー」では防げないのか

ブラウザの 同一オリジンポリシー (Same-Origin Policy, SOP) は「異なるオリジンの レスポンス を JS で読めない」というルール。

しかしリクエストを送ること自体は止めない<form><img> は元々クロスオリジンで動く設計だから、SOP は CSRF を防がない。

「読めない」と「送れない」は違う

  • 読めない (SOP): 攻撃者 JS は bank.com のレスポンスボディを取得できない
  • 送れる: 送金 POST 自体は届く → 副作用(送金実行)は発生する

CSRF は「レスポンスを読まなくても、副作用を起こせれば勝ち」というタイプの攻撃。だから SOP では不十分。

3. 防御1: SameSite Cookie(最重要・現代の主防御)

セッション管理 で扱った通り:

Set-Cookie: session_id=abc; SameSite=Lax; HttpOnly; Secure

SameSite=Lax で防げる範囲

  • <form method=POST> をクロスサイトから送信 → Cookie が付かない → 認証されない → 攻撃失敗
  • fetch(..., {credentials: 'include'}) クロスサイト → Cookie 付かない → 攻撃失敗
  • 多くの古典的 CSRF が SameSite=Lax で自動的に死ぬ

2020年の Chrome 80 以降、SameSite を指定しない Cookie もデフォルト Lax 扱い。現代の CSRF 攻撃はかなり成立しにくくなった

ただし:

  • 古いブラウザを使うユーザーは依然脆弱
  • SameSite=None を選んでいるサービスは対策が必要
  • GET でも状態変更している実装は GET リンク経由で攻撃が成立する(後述)

4. 防御2: CSRF トークン(Synchronizer Token)

1. サーバーがランダムなトークンを生成、セッションに紐付けて保存
2. HTML フォームに <input type="hidden" name="csrf_token" value="xyz"> を埋め込む
3. ユーザーが送信時、トークンも一緒に送られる
4. サーバーはセッション側のトークンと比較、一致すれば実行

攻撃者は被害者セッションのトークンを知らないので、偽造リクエストを通せない。

なぜトークンが攻撃者から見えないか

攻撃者の attacker.com から bank.com/edit_pagefetch してトークンを読もうとしても、同一オリジンポリシーで読めない。

一方、被害者本人が bank.com を開いている時はトークンが HTML に埋まっている。「ブラウザが Cookie を自動送信する」性質と「クロスオリジンのレスポンスを読めない」性質を組み合わせた防御

セッションストアにトークンを保存したくない場合の代替:

1. サーバーがランダムトークンを生成、Cookie にセット (httpOnly なしの別 Cookie)
2. ページ側 JS がその Cookie を読み、リクエスト時に X-CSRF-Token ヘッダにも入れる
3. サーバーは Cookie のトークンとヘッダのトークンを比較、一致すればOK

攻撃者は被害者の Cookie を読めない(XSS でない限り)。ヘッダに同じ値を入れることができないので攻撃失敗。

Double Submit の弱点

  • サブドメイン汚染: evil.example.com に攻撃者がコントロールできる箇所があると Cookie をセットされて偽造可能になることがある
  • 読める Cookie(HttpOnly なし)が前提なので、XSS の被害が拡大しやすい

Synchronizer Token 方式の方が安全。Double Submit は「セッションサーバーを軽くしたい」場合に使う。

6. 防御4: Origin / Referer ヘッダ検証

// CSRF っぽい怪しいリクエストを Origin で弾く
allowed := "https://myapp.example.com"
if r.Method == "POST" {
    origin := r.Header.Get("Origin")
    if origin == "" {
        origin = r.Header.Get("Referer")
    }
    if !strings.HasPrefix(origin, allowed) {
        http.Error(w, "CSRF: invalid origin", 403)
        return
    }
}

Origin / Referer の使い所

  • 軽量・追加トークン不要
  • 一部のプロキシや古いブラウザで Referer が落ちる
  • 補助防御として有用(メイン防御は SameSite + CSRF トークン)

7. Go で CSRF ミドルウェアを書く

package main
 
import (
    "crypto/rand"
    "crypto/subtle"
    "encoding/base64"
    "net/http"
)
 
func newCSRFToken() string {
    b := make([]byte, 32)
    _, _ = rand.Read(b)
    return base64.RawURLEncoding.EncodeToString(b)
}
 
// セッションに csrf_token を保存する想定(簡略化のため擬似的に)
func csrfMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // GET / HEAD / OPTIONS は副作用なし、トークン不要
        if r.Method == "GET" || r.Method == "HEAD" || r.Method == "OPTIONS" {
            next.ServeHTTP(w, r)
            return
        }
        sessionToken := getCSRFFromSession(r)        // セッションから
        requestToken := r.Header.Get("X-CSRF-Token") // ヘッダから
        if requestToken == "" {
            requestToken = r.FormValue("csrf_token")
        }
        if sessionToken == "" || requestToken == "" {
            http.Error(w, "CSRF token missing", 403)
            return
        }
        // 定数時間比較(タイミング攻撃対策)
        if subtle.ConstantTimeCompare([]byte(sessionToken), []byte(requestToken)) != 1 {
            http.Error(w, "CSRF token mismatch", 403)
            return
        }
        next.ServeHTTP(w, r)
    })
}
 
func getCSRFFromSession(r *http.Request) string {
    // 実際はセッションストアから引く
    return "TODO"
}

実装のポイント

  • GET 系は素通り(副作用なし前提)。これが破れている設計(GET で状態変更)が CSRF の温床
  • subtle.ConstantTimeCompare で定数時間比較(== だと文字長や位置から推測可能なタイミング攻撃が成立しうる)
  • CSRF トークンも crypto/rand で生成

アンチパターン: GET で状態変更

// NG: GET で削除処理
http.HandleFunc("/users/delete", func(w http.ResponseWriter, r *http.Request) {
    id := r.URL.Query().Get("id")
    deleteUser(id)
})

なぜNGか:

  • <img src="https://example.com/users/delete?id=42"> を踏むだけで削除実行
  • SameSite=Lax でも GET は許される(トップレベルナビゲーション扱い)
  • ブラウザのプリフェッチで誤発火することも

状態変更は必ず POST/PUT/DELETE。HTTP メソッドの意味論を守ることがセキュリティに直結する。

CSRF を実際に再現してみる学習

  1. ローカルに脆弱な「送金アプリ」(http://localhost:3000) を立てる(CSRF トークンなし、SameSite なし)
  2. 別の HTML ファイル (attack.html) を http://localhost:4000 で開く
  3. その HTML 内の <form>http://localhost:3000/transfer に POST するように作る
  4. localhost:3000 でログイン状態 → attack.html を開く → 送金が実行される
  5. SameSite=Lax を付けると攻撃が成立しなくなることを確認

やってみると「自分のコードに CSRF があるとどれだけマズいか」体感できる。


セッション②: SSRF - サーバーサイドリクエストフォージェリ(30分)

8. SSRF の本質

SSRF とは

「サーバーに、攻撃者が指定した URL に対してリクエストを送らせる」攻撃。

典型シナリオ:

  • 「画像をURL で投稿できる機能」で http://169.254.169.254/... を指定
  • 「Webhook 設定」で内部APIのURLを指定
  • 「リンクプレビュー機能」で内部サービスのURLを指定

サーバーは内部ネットワークにいるので、ファイアウォール越しの内部サービスや、クラウドのメタデータエンドポイントにアクセスできる。これを攻撃者が悪用する。

9. 最強の攻撃対象: クラウドメタデータエンドポイント

AWS / GCP / Azure では、EC2 / GCE / VM インスタンスから特定の IP に HTTP リクエストを投げると、そのインスタンスの認証情報や設定情報 が返ってくる:

  • AWS: http://169.254.169.254/latest/meta-data/iam/security-credentials/<role>
  • GCP: http://metadata.google.internal/computeMetadata/v1/...
  • Azure: http://169.254.169.254/metadata/...

これを SSRF で叩くと:

GET http://169.254.169.254/latest/meta-data/iam/security-credentials/web-role

{
  "AccessKeyId": "ASIA...",
  "SecretAccessKey": "...",
  "Token": "..."
}

EC2 インスタンスに付いている IAM ロールの認証情報 がそのまま吐き出される。これで攻撃者は S3 / DynamoDB / その他 AWS API を叩ける。

10. 実例: Capital One 2019

Capital One 事件(2019年7月)

  • 攻撃者: 元 AWS 従業員(Paige Thompson)
  • 入口: Capital One が使っていた WAF(ModSecurity)の設定不備による SSRF
  • 流れ:
    1. 攻撃者は WAF が代理リクエストを送る機能を悪用し、169.254.169.254 を叩かせる
    2. EC2 のメタデータから IAM 認証情報を取得
    3. その認証情報で S3 バケットにアクセス
    4. 1億700万件の個人情報、14万のSSN、8万の銀行口座番号 を流出
  • 制裁金: 米 OCC から 8000万ドル、訴訟和解で 約1.9億ドル

SSRF + IMDSv1 + IAM ロール過剰権限 という典型コンボ。

教訓:

  • SSRF 防御を本気でやる
  • IMDSv2 を強制(AWS の対策版、トークン必須)
  • IAM ロールの権限は最小に
  • WAF など中継サービスを置く時は SSRF 設定を厳格に

11. SSRF の防御

防御1: URL の allowlist

import "net/url"
 
var allowedHosts = map[string]bool{
    "images.example.com":  true,
    "cdn.example.com":     true,
}
 
func validateURL(rawURL string) error {
    u, err := url.Parse(rawURL)
    if err != nil {
        return err
    }
    if u.Scheme != "https" {
        return fmt.Errorf("https only")
    }
    if !allowedHosts[u.Hostname()] {
        return fmt.Errorf("host not allowed")
    }
    return nil
}

allowlist が最強の理由

「これだけ許可する」という発想は、deny-list(これを禁止する)より圧倒的に堅い。

deny-list の弱さ:

  • 127.0.0.1 をブロック → 攻撃者は 2130706433(10進表記の同じIP)で迂回
  • 127.0.0.1 ブロック → 127.1 で迂回(短縮表記)
  • 169.254.169.254 ブロック → DNS リバインディングで迂回(後述)
  • IPv6 ループバック ::1 忘れる

知らない記法・知らない迂回路に常に晒される。allowlist なら「許可リストにないので拒否」で終わる。

防御2: 内部IPアドレス帯のブロック

allowlist が現実的でない場合(ユーザー任意の URL を扱う Webhook 機能など):

import "net"
 
// 内部・予約 IP 帯
var privateNets []*net.IPNet
 
func init() {
    cidrs := []string{
        "127.0.0.0/8",     // ループバック
        "10.0.0.0/8",      // プライベート
        "172.16.0.0/12",   // プライベート
        "192.168.0.0/16",  // プライベート
        "169.254.0.0/16",  // リンクローカル (AWS/GCP メタデータ含む)
        "::1/128",         // IPv6 ループバック
        "fc00::/7",        // IPv6 ユニークローカル
        "fe80::/10",       // IPv6 リンクローカル
    }
    for _, c := range cidrs {
        _, n, _ := net.ParseCIDR(c)
        privateNets = append(privateNets, n)
    }
}
 
func isPrivateIP(ip net.IP) bool {
    for _, n := range privateNets {
        if n.Contains(ip) {
            return true
        }
    }
    return false
}
 
// 解決された IP をチェックしてから fetch
func safeGet(rawURL string) (*http.Response, error) {
    u, err := url.Parse(rawURL)
    if err != nil {
        return nil, err
    }
    ips, err := net.LookupIP(u.Hostname())
    if err != nil {
        return nil, err
    }
    for _, ip := range ips {
        if isPrivateIP(ip) {
            return nil, fmt.Errorf("private IP not allowed: %s", ip)
        }
    }
    // CheckRedirect でリダイレクト先も検査
    client := &http.Client{
        CheckRedirect: func(req *http.Request, via []*http.Request) error {
            return http.ErrUseLastResponse // リダイレクトを追わない
        },
    }
    return client.Get(rawURL)
}

DNS リバインディング攻撃に注意

上の防御にもまだ穴がある。DNS リバインディング:

  1. 攻撃者は evil.com を自前 DNS で運用
  2. 1回目の DNS クエリで evil.com → 8.8.8.8(公開 IP、チェック通過)
  3. アプリが URL チェック通過
  4. アプリが実際に HTTP 通信する時に再度 DNS 解決 → 今度は evil.com → 127.0.0.1
  5. ローカルホスト宛にリクエストが飛ぶ

対策:

  • DNS 解決した IP を保持し、コネクト時にその IP を直接使う(DialContext で固定)
  • allowlist 方式に切り替える
  • 内部サービスを別ネットワークに置く(API Gateway 等)

防御3: リダイレクト追跡禁止

client := &http.Client{
    CheckRedirect: func(req *http.Request, via []*http.Request) error {
        return http.ErrUseLastResponse  // リダイレクトを追わない
    },
    Timeout: 5 * time.Second,
}

なぜリダイレクトが危険か

URL https://safe.example.com/redirect?to=... のように、最初は安全な URL でも、サーバーから Location: http://169.254.169.254/... でリダイレクトされた場合、追跡するとそのまま内部 IP に飛ぶ。

「URL チェック → fetch」の間に攻撃者がリダイレクトを挟むのは典型的迂回路。

12. AWS の場合は IMDSv2 を強制

# EC2 のメタデータサービスを v2 強制に
aws ec2 modify-instance-metadata-options \
  --instance-id i-xxx \
  --http-tokens required \
  --http-put-response-hop-limit 1

IMDSv2 (Instance Metadata Service v2)

AWS が Capital One 事件後にデフォルト推奨にしたメタデータ取得方式:

  • PUT で X-aws-ec2-metadata-token-ttl-seconds を送ってトークンを取得
  • 以後の GET で X-aws-ec2-metadata-token を必須化

単純な GET http://169.254.169.254/... ではアクセス不可になり、多くの SSRF パターンを無効化できる。新規 EC2 はデフォルトで IMDSv2 必須に設定するのが今の常識。

13. アンチパターン総まとめ

ユーザー入力 URL を素で fetch

// NG: 任意の URL を投げてくれと言わんばかり
resp, _ := http.Get(r.FormValue("url"))

なぜNGか:

  • 内部 IP やメタデータエンドポイントへの侵入を許す
  • file:// プロトコルでローカルファイル読み出しを許すライブラリもある
  • リダイレクトで迂回される

deny-list で内部IPを「ブロックしたつもり」

// NG: 抜け穴だらけ
if strings.Contains(url, "169.254.169.254") || strings.Contains(url, "127.0.0.1") {
    return error
}

なぜNGか:

  • 169.254.169.2542852039166(10進)で表記して回避
  • 127.0.0.1127.1 で回避
  • localhost という名前で回避
  • IPv6 表記で回避
  • DNS で 169.254.169.254 を返すドメインで回避

文字列マッチでブロックは絶対にやらない。net.ParseIP してから CIDR 比較

GET で状態変更

(CSRF 編に書いた通り)GET の状態変更は CSRF も SSRF も両方の温床。


練習課題

mkdir -p ~/learn/security/day-csrf-ssrf
cd ~/learn/security/day-csrf-ssrf
  1. CSRF トークン付きの簡易フォーム送信サーバーを Go で実装
  2. トークンなしの POST が 403 になることを curl で確認
  3. 別ポートで attack.html を立てて CSRF を試し、SameSite=Lax の効果を観察
  4. SSRF 防御関数 safeGet を書き、以下の入力をすべて拒否することを確認:
    • http://127.0.0.1/
    • http://localhost/
    • http://169.254.169.254/
    • http://2852039166/ (169.254.169.254 の10進表記)
    • http://[::1]/
    • http://example.com.evil.com (ドメイン偽装の例)
  5. http://example.com が許可されることを確認
  6. リダイレクト先が内部 IP の URL を safeGet に渡し、ブロックされることを確認

締め: git で証跡を残す

cd ~/learn/security/day-csrf-ssrf
git init
git add .
git commit -m "feat(security): CSRF ミドルウェアと SSRF 防御関数を実装"

チェックリスト

  • CSRF と SSRF の違いを1分で説明できる
  • CSRF が成立する3条件を言える
  • SameSite=Lax でなぜ多くの CSRF が防げるか説明できる
  • CSRF トークン(Synchronizer Token)の流れを図示できる
  • subtle.ConstantTimeCompare を使う理由を説明できる
  • SSRF のメタデータエンドポイント攻撃を Capital One 事件と絡めて説明できる
  • allowlist と deny-list で前者を選ぶ理由を、迂回例とともに説明できる
  • DNS リバインディング攻撃の概要を語れる
  • IMDSv2 が何を改善したかを言える

詰まった時のチートシート

やりたいことコード / 設定
CSRF トークン生成crypto/rand で 32バイト → base64
定数時間比較subtle.ConstantTimeCompare(a, b)
SameSite 設定SameSite: http.SameSiteLaxMode
Origin 検証strings.HasPrefix(r.Header.Get("Origin"), allowed)
内部 IP 判定CIDR を net.IPNet.Contains(ip) で判定
リダイレクト無効化CheckRedirect: http.ErrUseLastResponse を返す
IMDSv2 強制aws ec2 modify-instance-metadata-options --http-tokens required

「実務OK」基準

  • 状態変更を絶対に GET で書かない、を心の底から守れる
  • SameSite + CSRF トークンの二重防御を要件に合わせて設計できる
  • SSRF 防御の allowlist / deny-list / リダイレクト禁止 / DNS 固定の4対策を実装できる
  • クラウド上のサーバーから 169.254.169.254 を叩かれないよう IMDSv2 を強制する判断ができる
  • ユーザー入力 URL を扱う機能(プロフィール画像、Webhook、リンクプレビュー)に「SSRF が起きうるか?」と即問える

さらに深掘りするなら

  • OWASP Cheat Sheet: CSRF Prevention - 業界標準
  • OWASP Cheat Sheet: SSRF Prevention - 同上
  • Capital One 事件の DOJ 起訴状(PDF 検索可能)- SSRF の歴史的事例
  • AWS IMDSv2 公式ドキュメント - メタデータサービスの設計思想
  • github.com/gorilla/csrf のソース - Go の CSRF ライブラリ実装

メタ認知: 自分の理解度を診断する

30秒セルフテスト

  1. CSRF と SSRF の 主語 はそれぞれ誰?
  2. SameSite Cookie だけで CSRF は完璧に防げる? → No の理由
  3. Double Submit Cookie パターンを1文で
  4. SSRF 防御の4つの軸(allowlist / deny-list / リダイレクト禁止 / DNS固定)の DNS固定 はなぜ必要?
  5. AWS の IMDSv2 が IMDSv1 より SSRF に強い理由

詰まったら → セッション①の CSRF トークン実装、セッション②の SSRF 防御4軸を再読。

次のレッスン

2-4 シークレット管理 で、ここで重要性を確認した「シークレット」をどう管理するかを詰める。SSRF で漏れた IAM トークン が、もし最初から KMS で守られていれば被害は最小化された——という流れで本章 → 次章がつながる。

間隔反復ポイント

CSRF と SSRF は 名前で混同しがち。1ヶ月後にこの章の冒頭の対比表だけ眺めると、即座に区別できる体になる。