Skip to content

送れることを使う攻撃(CSRF)

実装: crypto/csrf/ / 実行: go test ./crypto/csrf/

応答が読めなくても、実行できれば足りる用件がある。送金も退会も、返事は要らない。止め方は合言葉とクッキーの印の2系統で、塞ぐ場所が違う。守り6通りを攻撃3件と正規2件に当てて測ると、攻撃を3件とも止めたうえで正規を2件とも通したものは、1つも無かった。自分のページで動くスクリプトには、どちらの守りも効かない。

この章で作るもの

別のサイトから勝手に送られる要求と、その止め方を実装して、どの守りが何を止めて何を止めないかを測る。

送れるのに読めないで見たとおり、別の生成元へ要求は届く。クッキーも自動で付く。止まるのは応答の読み取りだけだ。

ここから1つの帰結が出る。応答が読めなくても困らない用件は、そのまま通ってしまう。

  あなたが bank.example にログイン済みのまま
  evil.test のページを開く

    evil.test のページ
      <form action="https://bank.example/transfer" method=POST>
        <input name=to    value="攻撃者">
        <input name=amount value="1000000">
      </form>
      <script>form.submit()</script>

                    │ 送信は通る。bank.example のクッキーが付く

    bank.example    ログイン済みの本人からの送金依頼に見える

                    │ 応答

    evil.test       ✗ 読めない ── だが、もう送金は済んでいる
攻撃者は応答を読めない。だが送金の実行に応答は要らない。銀行から見れば、ログイン済みの本人からの要求と区別がつかない

これが CSRF(cross-site request forgery、別サイトからの要求の偽装)だ。名前が長いので、この章では「別サイトから勝手に送られる要求」と読み替えて進める。

順に見ていく。

  1. 合言葉は「送れるが読めない」を裏返して使う: 攻撃者は値を知りようがない
  2. クッキーの印は、送信そのものを止める: 線は「書き換えないはずの移動」で引かれている
  3. GET で状態を変える設計は、両方の守りの外に出る: メソッドを変えるだけで結果が変わる
  4. 単独で足りる守りは無かった: 6通り測って、攻撃を全部止めて正規を全部通したものは0

① 合言葉は「送れるが読めない」を裏返して使う

いちばん古い守りは、サーバが出す合言葉(トークン)を確かめることだ。

go

// 擬似乱数の定数。実時間も乱数源も使わないので、何度走らせても同じ値になる。
const (
	lcgMul = 6364136223846793005
	lcgAdd = 1442695040888963407
)

// Issue はセッションに紐づく合言葉を出す。
//
// 実物は署名や乱数で作るが、ここでは決定的に導出する。要点は値の作り方ではなく、
// **攻撃者がこの値を知りようがない**ことのほうにある。
// 出すのはサーバで、載るのはページの中。そして別の生成元からはページを読めない。
func Issue(session string) string {
	var s uint64 = 1469598103934665603
	for _, c := range []byte(session) {
		s = s*lcgMul + lcgAdd + uint64(c)
	}
	return strconv.FormatUint(s>>16, 36)
}

// Valid は送られてきた合言葉が、そのセッションのものか。
func Valid(session, token string) bool { return token != "" && Issue(session) == token }

値の作り方は本題ではない。攻撃者がこの値を知りようがないところが本題になる。合言葉はページの中に埋めて返す。そして前の章で測ったとおり、別の生成元からはページを読めない。送れるが読めない、という非対称を、そのまま守りに使っている。

テストで、自分の合言葉が通ること、他のセッションの合言葉が通らないこと、空や当てずっぽうが通らないことを固定した。

1つ注意がある。合言葉を求めるのは、状態を変える要求だけにする。読むだけの GET にも求めると、他サイトからのリンクで飛んできた人が全員弾かれる。検索結果から来た読者がエラー画面を見ることになる。テストで、合言葉を持たない GET が通ることも固定してある。

この線引きが、③で効いてくる。

② クッキーの印は、送信そのものを止める

もう1つの系統は、ブラウザ側でクッキーを付けるのをやめさせることだ。クッキーに SameSite という印を付けると、別サイトから出た要求では付かなくなる。

go

// SameSite はクッキーに付ける印。ブラウザが「別サイトからのとき付けるか」を決める。
type SameSite int

const (
	// None は昔からの振る舞い。別サイトからでも付く。
	None SameSite = iota
	// Lax は既定。書き換えない移動(リンクを踏む等)でだけ付く。
	Lax
	// Strict は別サイトからは一切付けない。
	Strict
)

func (s SameSite) String() string {
	switch s {
	case Lax:
		return "Lax"
	case Strict:
		return "Strict"
	default:
		return "None"
	}
}

// Nav はブラウザから見た要求の出かた。
type Nav struct {
	// CrossSite は別サイトから出た要求か。
	CrossSite bool
	// TopLevel はアドレス欄が変わる移動か。リンクやフォームの送信が該当する。
	// 画像の読み込みや fetch は該当しない。
	TopLevel bool
	// Method は HTTP のメソッド。
	Method string
}

// Sends は、その印のクッキーがこの要求に付くか。
//
// Lax の線は「**書き換えないはずの移動**」で引かれている。リンクを踏んだ GET は
// 付き、フォームの POST は付かない。検索から飛んできたときにログイン状態が
// 消えていると困る、という都合と、送信を止めたい都合の折り合いになる。
func Sends(s SameSite, n Nav) bool {
	if !n.CrossSite {
		return true
	}
	switch s {
	case Strict:
		return false
	case Lax:
		return n.TopLevel && safeMethod(n.Method)
	default:
		return true
	}
}

// safeMethod は、決まりの上で「状態を変えない」ことになっているメソッドか。
func safeMethod(m string) bool {
	switch strings.ToUpper(m) {
	case "GET", "HEAD", "OPTIONS", "TRACE":
		return true
	}
	return false
}

3つの値で、要求の出かたごとに測った。

要求の出かたNoneLaxStrict
同一サイトの POST付く付く付く
他サイトからリンク(GET)付く付く付かない
他サイトから隠しフォーム(POST)付く付かない付かない
他サイトの <img>(GET)付く付かない付かない
他サイトからの fetch(GET)付く付かない付かない

読みどころは Lax の列で、リンクは通り、フォームは通らない。同じ「他サイトから来た」でも扱いが違う。

線は2つの条件で引かれている。アドレス欄が変わる移動であること、そして状態を変えないことになっているメソッドであること。リンクを踏むのは両方を満たすが、隠しフォームの POST は後者を満たさない。<img> は前者を満たさない。

なぜこの線かというと、折り合いだ。Strict にすれば全部止まるが、検索結果や他サイトのリンクから飛んできたときに、ログイン状態が消えて見える。それでは困るので、「見るだけの移動」は通す位置に線を引いた。

テストで、Lax がリンクを通してフォームと <img> を止めること、Strict がリンクまで止めること、同一サイトならどの印でも付くことを固定した。

③ GET で状態を変える設計は、両方の守りの外に出る

①と②の線は、どちらも同じ前提の上に立っている。GET は状態を変えない、という前提だ。

  • 合言葉は、状態を変える要求だけに求める(①)
  • Lax は、状態を変えないメソッドを通す(②)

だから GET で状態を変える口があると、両方の守りをすり抜ける

go

// Defense はサーバ側に置いた守り。
type Defense struct {
	// Token は合言葉を確かめるか。
	Token bool
	// Cookie に付ける印。
	SameSite SameSite
	// Origin は Origin ヘッダが自分のものか確かめるか。
	Origin bool
}

// Attempt は1回の要求。
type Attempt struct {
	Name string
	Nav  Nav
	// Origin は要求元の生成元。
	Origin string
	// KnowsToken は送り手が合言葉を知っているか。
	// 正規の画面は知っている。攻撃者のページは読めないので知らない。
	// ただし XSS が刺さると読めてしまう。
	KnowsToken bool
}

// Result は判定。
type Result struct {
	// CookieSent はクッキーが付いたか。付かなければ、そもそも本人と見なされない。
	CookieSent bool
	// Accepted は要求が受け付けられたか。
	Accepted bool
	// Reason は止めた側の理由。
	Reason string
}

// Check は、その守りでその要求が通るかを判定する。
//
// 順番に意味がある。クッキーが付かなければ、合言葉を見るまでもなく他人の要求だ。
func Check(d Defense, session string, a Attempt) Result {
	if !Sends(d.SameSite, a.Nav) {
		return Result{Reason: "SameSite=" + d.SameSite.String() + " でクッキーが付かない"}
	}
	r := Result{CookieSent: true}
	if d.Origin && a.Nav.CrossSite {
		r.Reason = "Origin が自分のものでない"
		return r
	}
	// 合言葉を確かめるのは、状態を変える要求だけにする。読むだけの GET にも
	// 求めると、他サイトからのリンク流入がすべて弾かれてしまう。
	// ここが後で効いてくる。**GET で状態を変える設計だと、この守りの外に出る**。
	if d.Token && !safeMethod(a.Nav.Method) {
		tok := ""
		if a.KnowsToken {
			tok = Issue(session)
		}
		if !Valid(session, tok) {
			r.Reason = "合言葉が無いか、合っていない"
			return r
		}
	}
	r.Accepted = true
	r.Reason = "通った"
	return r
}

測るとこうなった。守りは SameSite=Lax だけ、相手は攻撃者のページからのリンク。

要求クッキー受理
他サイトからリンクで GET付く通る
同じ相手・同じ経路で POST付かない止まる

相手も経路も同じで、メソッドを変えただけで結果が逆になる。テストで両方を固定した。

つまり /logout?/delete?id=3GET で受けていると、<img src> やリンク1つで踏まれる。守りを足したつもりでも、その守りが見ている条件の外にいる。

メソッドの決まりは、守りの前提になっている。「GET は取得だけ」という約束を破ると、その約束に乗っている仕組みが全部ずれる。

④ 単独で足りる守りは無かった

守り6通りを、筋書き5件(攻撃3件・正規2件)に当てた。守りは攻撃を止めるだけでなく、正規を通す必要がある。片方だけ見ると、全部止める守りがいちばん強く見えてしまう。

go

// Attempts は測るときの筋書き。攻撃と正規の両方を含める。
//
// 守りは「攻撃を止める」だけでなく「正規を通す」必要がある。
// 片方だけ見ると、全部止める守りがいちばん強く見えてしまう。
func Attempts() []Attempt {
	const me, evil = "https://bank.example", "https://evil.test"
	return []Attempt{
		{Name: "正規の画面から POST", Nav: Nav{Method: "POST"}, Origin: me, KnowsToken: true},
		{Name: "他サイトからリンクで流入", Nav: Nav{CrossSite: true, TopLevel: true, Method: "GET"}, Origin: evil},
		{Name: "攻撃: 隠しフォームで POST", Nav: Nav{CrossSite: true, TopLevel: true, Method: "POST"}, Origin: evil},
		{Name: "攻撃: img で GET", Nav: Nav{CrossSite: true, Method: "GET"}, Origin: evil},
		{Name: "攻撃: XSS で合言葉を読んで POST", Nav: Nav{Method: "POST"}, Origin: me, KnowsToken: true},
	}
}

// IsAttack はその筋書きが攻撃側か。
func IsAttack(a Attempt) bool { return strings.HasPrefix(a.Name, "攻撃") }

// Defenses は測るときの守り。
func Defenses() []struct {
	Name string
	D    Defense
} {
	return []struct {
		Name string
		D    Defense
	}{
		{"何もしない", Defense{SameSite: None}},
		{"合言葉だけ", Defense{Token: true, SameSite: None}},
		{"SameSite=Lax だけ", Defense{SameSite: Lax}},
		{"SameSite=Strict だけ", Defense{SameSite: Strict}},
		{"合言葉 + Lax", Defense{Token: true, SameSite: Lax}},
		{"Origin 検査 + Lax", Defense{Origin: true, SameSite: Lax}},
	}
}

// Score は、その守りで攻撃を何件止め、正規を何件通したか。
func Score(d Defense, session string) (blocked, attacks, passed, legit int) {
	for _, a := range Attempts() {
		r := Check(d, session, a)
		if IsAttack(a) {
			attacks++
			if !r.Accepted {
				blocked++
			}
			continue
		}
		legit++
		if r.Accepted {
			passed++
		}
	}
	return
}
守り攻撃を止めた正規を通した
何もしない0 / 32 / 2
合言葉だけ1 / 32 / 2
SameSite=Lax だけ2 / 32 / 2
SameSite=Strict だけ2 / 31 / 2
合言葉 + Lax2 / 32 / 2
Origin 検査 + Lax2 / 31 / 2

テストで、攻撃を3件とも止めて正規を2件とも通した守りが1つも無いことを固定した。

3つ読める。

StrictOrigin 検査は、正規を落とす。どちらも他サイトからの流入を切るので、リンクで飛んできた読者がログアウト状態に見える。止めた数だけを見ると強く見えるが、通すべきものを通していない

合言葉だけでは足りない。止まったのは隠しフォームの POST だけで、③で見た GET の口は素通りする。

そして、どれも止められない攻撃が1件残る

残る1件

止まらなかったのは「XSS で合言葉を読んで POST」だ。

自分のページに攻撃者のスクリプトが入り込むと、そのスクリプトは同一生成元で動く。だから合言葉を読める。クッキーも同一サイト扱いで付く。①と②が立っていた前提は、どちらも「攻撃者は外側にいる」ことだった。内側に入られた時点で、両方とも成立しない

合言葉SameSite
外からのフォーム送信止まる止まる
自分のページで動くスクリプト効かない効かない

テストで、外からの攻撃が止まり、同じ守りで XSS 経由が通ることを固定した。

だからこの章の守りは、XSS が無いことを前提にしている。順番があるということで、出力の変換が先に立っていないと、この章の話は土台ごと無くなる。

動かす

下のデモは、守りと筋書きを選んで、どこで止まるかを見る。

デモどの守りが何を止めて、何を止めないか攻撃 2/3 阻止 ・ 正規 2/2 通過
何もしない合言葉だけSameSite=Lax だけSameSite=Strict だけ合言葉 + LaxOrigin 検査 + Lax

合言葉 = 24fp38ty3y(セッション sess-42 から決定的に導出)

正規正規の画面から POSTクッキー付く通った通った
正規他サイトからリンクで流入クッキー付く通った通った
攻撃隠しフォームで POSTクッキー無し止まったSameSite=Lax でクッキーが付かない
攻撃img で GETクッキー無し止まったSameSite=Lax でクッキーが付かない
攻撃XSS で合言葉を読んで POSTクッキー付く通った通った
合言葉 + Lax: 止められない攻撃が 1 件(XSS で合言葉を読んで POST)。正規はすべて通る

守りは攻撃を止めるだけでなく、正規を通す必要がある。Strict と Origin 検査は他サイトからの流入を切るので、 止めた数だけを見ると強く見えるが、リンクで来た読者がログアウト状態に見える。 そして最後の1件は、どの守りでも止まらない。自分のページで動くスクリプトは合言葉を読めるからだ。

設計の観点

  • 応答が読めない = 安全、ではない: 実行が目的なら応答は要らない
  • GET を約束どおりに使う: 状態を変えないという約束が、複数の守りの前提になっている
  • 止めた数と通した数を両方数える: 止めるだけなら全部止めればいい。正規を落としていないかを同時に見る
  • Strict は流入を切ることを承知で選ぶ: ログイン状態が消えて見えるのは、仕様どおりの動作になる
  • 合言葉は状態を変える要求だけに求める: 読むだけの GET に求めると、外からのリンクが全部死ぬ
  • 重ねる理由を言えるようにする: 2つ足すなら、片方が落とす筋書きをもう片方が拾っている、と説明できること
  • XSS を先に片づける: 内側に入られると、この章の守りはどちらも成立しない(「エスケープした」では足りない)

対照と実例

守り効く場所止め方落とすもの内側からの攻撃
合言葉サーバ値を知らないと通さない状態変更の GET は見ない効かない
SameSite=Laxブラウザクッキーを付けない状態変更の GET は通る効かない
SameSite=Strictブラウザ他サイト発を全部切る正規のリンク流入効かない
Origin 検査サーバ出所を見る正規のリンク流入効かない

裏どり:

  • SameSite の線: Lax はトップレベルの移動で、かつ安全なメソッドのときだけクッキーを付ける。<img>fetch はトップレベルの移動ではないので付かない。この章の実装はこの2条件をそのまま書いたもの
  • 安全なメソッド: GET / HEAD / OPTIONS / TRACE は、決まりの上で「状態を変えない」ことになっている。守りがこの約束に乗っているというのが③の中身になる
  • XSS が上位にあること: 同一生成元で動くスクリプトは、そのページが読めるものを全部読める。合言葉もそこに含まれる。この章の守りは、外側からの要求だけを相手にしている
  • 数字はすべて手元の測定: 6通り × 5筋書きの表も、LaxGET/POST の逆転も、この章の実装をテストで固定したもの
  • 実物の合言葉の作り方: 署名つきか、セッションに紐づけた乱数を使う。この章は決定的に導出しているので、そのまま使えるものではない

簡略化したこと

  • 合言葉が署名になっていない: 決定的な導出で、鍵も期限も入っていない。守りの形を見せるための模型になる
  • 二重送信クッキーを扱っていない: 合言葉をクッキーにも入れて突き合わせる形がある。サブドメインからクッキーを書ける状況で破れることがあるが、実装していない
  • SameSite の既定と移行を扱っていない: 印を書かなかったときにブラウザが何を既定とするかは版で動いてきた。ここでは明示した3つだけを見る
  • サイトと生成元の違いを潰している: SameSite が見るのは「サイト」で、前の章の「生成元」とは範囲が違う(サブドメインは同じサイト)。ここでは同じものとして扱った
  • Referer を見ていない: Origin が無い場合に Referer で代用する形は入れていない
  • XSS の側を実装していない: 「入り込んだ」を真偽値で置いただけで、どう入り込むか、どう防ぐかは扱っていない

参考資料