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条件
- ユーザーが標的サイトにログイン中(Cookie あり)
- 状態変更操作が、Cookie 認証のみで実行できる(追加トークン等の検証なし)
- 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_pageをfetchしてトークンを読もうとしても、同一オリジンポリシーで読めない。一方、被害者本人が
bank.comを開いている時はトークンが HTML に埋まっている。「ブラウザが Cookie を自動送信する」性質と「クロスオリジンのレスポンスを読めない」性質を組み合わせた防御。
5. 防御3: Double Submit 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 を実際に再現してみる学習
- ローカルに脆弱な「送金アプリ」(
http://localhost:3000) を立てる(CSRF トークンなし、SameSite なし)- 別の HTML ファイル (
attack.html) をhttp://localhost:4000で開く- その HTML 内の
<form>がhttp://localhost:3000/transferに POST するように作る- localhost:3000 でログイン状態 → attack.html を開く → 送金が実行される
- 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
- 流れ:
- 攻撃者は WAF が代理リクエストを送る機能を悪用し、
169.254.169.254を叩かせる- EC2 のメタデータから IAM 認証情報を取得
- その認証情報で S3 バケットにアクセス
- 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 リバインディング:
- 攻撃者は
evil.comを自前 DNS で運用- 1回目の DNS クエリで
evil.com → 8.8.8.8(公開 IP、チェック通過)- アプリが URL チェック通過
- アプリが実際に HTTP 通信する時に再度 DNS 解決 → 今度は
evil.com → 127.0.0.1- ローカルホスト宛にリクエストが飛ぶ
対策:
- 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 1IMDSv2 (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.254を2852039166(10進)で表記して回避127.0.0.1を127.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- CSRF トークン付きの簡易フォーム送信サーバーを Go で実装
- トークンなしの POST が 403 になることを
curlで確認 - 別ポートで attack.html を立てて CSRF を試し、SameSite=Lax の効果を観察
- 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(ドメイン偽装の例)
http://example.comが許可されることを確認- リダイレクト先が内部 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秒セルフテスト
- CSRF と SSRF の 主語 はそれぞれ誰?
- SameSite Cookie だけで CSRF は完璧に防げる? → No の理由
- Double Submit Cookie パターンを1文で
- SSRF 防御の4つの軸(allowlist / deny-list / リダイレクト禁止 / DNS固定)の DNS固定 はなぜ必要?
- AWS の IMDSv2 が IMDSv1 より SSRF に強い理由
詰まったら → セッション①の CSRF トークン実装、セッション②の SSRF 防御4軸を再読。
次のレッスン
2-4 シークレット管理 で、ここで重要性を確認した「シークレット」をどう管理するかを詰める。SSRF で漏れた IAM トークン が、もし最初から KMS で守られていれば被害は最小化された——という流れで本章 → 次章がつながる。
間隔反復ポイント
CSRF と SSRF は 名前で混同しがち。1ヶ月後にこの章の冒頭の対比表だけ眺めると、即座に区別できる体になる。