「エスケープした」では足りない(XSS)
実装:
crypto/xss// 実行:go test ./crypto/xss/
外から来た文字列をページに出すとき、正しい変換は出す場所ごとに違う。HTML エスケープを5か所すべてに当てて測ると、攻撃を止めて値も無事だったのは2か所だけだった。1か所は、どの変換を当てても守れない。空白を潰せば止まるが、普通の値も一緒に壊れる。そこは変換で直すところではなく、引用符を書くところになる。
この章で作るもの
外から来た文字列をページに出す。そのときに当てる変換を5通り、出す場所を5通り作って、どの組み合わせで何が起きるかを総当たりで測る。
XSS(cross-site scripting)は、そこに攻撃者の書いた script が混ざり、あなたのページの一部として動いてしまうことを指す。前の章までとの関係でいうと、ここが土台になる。送れることを使う攻撃で見たとおり、内側でスクリプトが動いてしまうと、合言葉も SameSite も効かなくなる。
対策は「エスケープする」だと言われるが、それだけでは何も言っていないのと同じだ。
外から来た値 ──┬──▶ <div>ここ</div> < で外に出られる
│
├──▶ <img alt="ここ"> " で外に出られる
│
├──▶ <img alt=ここ> 空白 1 つで外に出られる
│
├──▶ <script>var s="ここ"</script> " でも </script> でも出られる
│
└──▶ <a href="ここ"> javascript: で実行される
「エスケープした」だけでは足りない。
**どこへ出すために、何を変換したのか**まで言えて初めて意味を持つ順に見ていく。
- 1つの変換では、5か所すべてを守れない: HTML エスケープで成立するのは2か所
- 引用符を書かないと、HTML エスケープでも防げない: 空白は変換されない
- スクリプトの中は、文字列を閉じなくても出られる:
</script>で外に出る - リンク先は、文字を変換するのではなく行き先を選ぶ: 許可制にする
① 1つの変換では、5か所すべてを守れない
まず出す場所を5通り用意する。
// Place は文字列を出す場所。危ない文字がここで決まる。
type Place int
const (
// HTMLText は本文。<div>ここ</div>
HTMLText Place = iota
// QuotedAttr は引用符で囲んだ属性値。<img alt="ここ">
QuotedAttr
// BareAttr は引用符を書かなかった属性値。<img alt=ここ>
BareAttr
// JSString はスクリプトの中の文字列。<script>var s = "ここ"</script>
JSString
// URLValue はリンク先。<a href="ここ">
URLValue
)
func (p Place) String() string {
switch p {
case QuotedAttr:
return "属性値(引用符あり)"
case BareAttr:
return "属性値(引用符なし)"
case JSString:
return "スクリプトの中の文字列"
case URLValue:
return "リンク先"
default:
return "本文"
}
}
// Places は測る対象の一覧。
func Places() []Place { return []Place{HTMLText, QuotedAttr, BareAttr, JSString, URLValue} }
// Render は、その場所へ値を埋めた結果の断片を返す。
func Render(p Place, v string) string {
switch p {
case QuotedAttr:
return `<img alt="` + v + `">`
case BareAttr:
return `<img alt=` + v + `>`
case JSString:
return `<script>var s = "` + v + `";</script>`
case URLValue:
return `<a href="` + v + `">link</a>`
default:
return `<div>` + v + `</div>`
}
}そこへ HTML エスケープ(& < > " ' を実体参照にする、いちばん広く使われるもの)だけを当てて測った。
| 出す場所 | 止めた | 値も無事 | |
|---|---|---|---|
| 本文 | 1 / 1 | 1 / 1 | |
| 属性値(引用符あり) | 1 / 1 | 1 / 1 | |
| 属性値(引用符なし) | 0 / 1 | 0 / 1 | 止まらない |
| スクリプトの中の文字列 | 2 / 2 | 0 / 2 | 止まるが値が壊れる |
| リンク先 | 0 / 1 | 0 / 1 | 止まらない |
攻撃を止めて、しかも値が無事だったのは5か所のうち2か所だった。テストで固定してある。
ここで「値も無事」を分けて数えているのが要点になる。止めることと、出したいものが出ることは別だ。スクリプトの中に HTML エスケープを当てると、攻撃は止まる。実体参照はスクリプトの中で展開されないので、" は文字列を閉じないからだ。だが同じ理由で、利用者の名前に " がそのまま表示される。無害ではあるが、正しくない。
判定はこう書いた。
// Outcome は、その組み合わせで何が起きたか。
type Outcome int
const (
// Executed は攻撃者のコードが動く。
Executed Outcome = iota
// Neutralized は無害な文字列として出る。
Neutralized
// Mangled は無害だが、まともな値としても壊れている。
Mangled
)
func (o Outcome) String() string {
switch o {
case Executed:
return "動く"
case Mangled:
return "壊れる"
default:
return "無害"
}
}
// Attack は攻撃に使う文字列と、それがどこを狙っているか。
type Attack struct {
Name string
Payload string
// Targets はこの文字列が刺さる場所。
Targets []Place
}
// Attacks は測るときの攻撃。それぞれ狙う場所が違う。
func Attacks() []Attack {
return []Attack{
{"タグを開く", `<script>alert(1)</script>`, []Place{HTMLText}},
{"引用符を閉じて属性を足す", `" onerror="alert(1)`, []Place{QuotedAttr}},
{"空白で属性を足す", `x onerror=alert(1)`, []Place{BareAttr}},
{"文字列を閉じる", `";alert(1);//`, []Place{JSString}},
{"スクリプトを閉じる", `</script><script>alert(1)</script>`, []Place{JSString}},
{"実行するスキーム", `javascript:alert(1)`, []Place{URLValue}},
}
}
// Check は、その場所にその変換を当てたとき、その攻撃がどうなるかを返す。
//
// 判定は素朴に、変換後の文字列に「外へ出るための文字」が残っているかで見る。
// 本物のブラウザの構文解析ではないが、どの変換がどの場所で足りないかは、
// これで十分に分かれる。
func Check(p Place, e Escape, a Attack) Outcome {
v := Apply(e, a.Payload)
// その場所を狙った攻撃でなければ、そもそも刺さらない。
hit := false
for _, t := range a.Targets {
if t == p {
hit = true
}
}
if !hit {
return Neutralized
}
switch p {
case HTMLText:
if strings.Contains(v, "<") {
return Executed
}
case QuotedAttr:
if strings.Contains(v, `"`) {
return Executed
}
case BareAttr:
// 引用符で囲っていないので、空白1つで次の属性になってしまう。
// HTML エスケープは空白を変換しないので、ここでは効かない。
if strings.ContainsAny(v, " \t\n") {
return Executed
}
case JSString:
// 文字列を閉じる " か、スクリプトを閉じる < が残っていれば出られる。
// ここだけは \" のように逃がされた引用符を数えてはいけない。
// 属性値では逆で、HTML に \ の逃がしは無いので " はそのまま閉じる。
if hasBareQuote(v) || strings.Contains(v, "<") {
return Executed
}
case URLValue:
if safeURL(v) {
break
}
return Executed
}
// 無害にはなったが、値として無事とは限らない。
// その場所のための変換でないのに何かを変えたなら、出したいものが壊れている。
// 例えば HTML エスケープをスクリプトの中に当てると、攻撃は止まるが
// 文字列の中に " がそのまま見えることになる。
if v != a.Payload && e != Right(p) {
return Mangled
}
return Neutralized
}
// hasBareQuote は、逃がされていない " が残っているか。
//
// \" は文字列を閉じないので、数えない。\\ は \ そのものなので、
// その次の " は逃がされていないことになる。
func hasBareQuote(v string) bool {
esc := false
for _, c := range v {
switch {
case esc:
esc = false
case c == '\\':
esc = true
case c == '"':
return true
}
}
return false
}変換を全部試しても、5か所すべてで成立するものは1つも無い。
| 変換 | 止めて値も無事だった場所 |
|---|---|
| 何もしない | 0 / 5 |
| HTML エスケープ | 2 / 5 |
| JS 文字列エスケープ | 1 / 5 |
| URL エンコード | 0 / 5 |
| スキーム検査 | 1 / 5 |
URL エンコードが 0 なのは、全部止めるが全部壊すからだ。本文に当てると「こんにちは & さようなら」が %E3%81%93…%26… になる。攻撃は通らないが、読めるものも出ない。
② 引用符を書かないと、HTML エスケープでも防げない
いちばん意外なのがここだった。同じ文字列に同じ変換を当てて、引用符の有無だけで結果が逆になる。
攻撃は x onerror=alert(1)。HTML エスケープを当てる。
| 出力 | 結果 |
|---|---|
<img alt=x onerror=alert(1)> | 動く |
<img alt="x onerror=alert(1)"> | 無害 |
理由は単純で、HTML エスケープはこの文字列を1文字も変えない。& < > " ' のどれも含まれていないからだ。テストで、変換の前後で文字列が一致することを固定した。
引用符が無ければ、属性値は空白で終わる。だから空白1つで次の属性になり、onerror= が属性として解釈される。引用符があれば、値が終わるのは " のところなので、空白は値の一部でしかない。
そして、ここはどの変換でも守れない。
| 引用符なしの属性に当てた変換 | 止めた | 値も無事 |
|---|---|---|
| 何もしない | 0 / 1 | 0 |
| HTML エスケープ | 0 / 1 | 0 |
| JS 文字列エスケープ | 0 / 1 | 0 |
| URL エンコード | 1 / 1 | 0 |
| スキーム検査 | 0 / 1 | 0 |
止まるのは URL エンコードだけで、それは空白を潰すからだ。だが空白を潰すと、名前も住所も題名も全部壊れる。値を壊さずに守れる変換が存在しない。
だからここは変換で直すところではない。引用符を書くところになる。テストでも FixableByEscaping が5か所のうちここだけ偽になることを固定した。
対策を1つ足したくなる場面で、書き方を直すのが唯一の答えという場所がある、というのがこの節の中身になる。
③ スクリプトの中は、文字列を閉じなくても出られる
スクリプトの中に値を埋める場合、危ない文字が2種類ある。
// Escape は変換のやり方。
type Escape int
const (
// NoEscape は何もしない。
NoEscape Escape = iota
// HTMLEscape は & < > " ' を実体参照にする。いちばん広く使われる。
HTMLEscape
// JSEscape はスクリプトの中の文字列として安全にする。
JSEscape
// URLEscape は URL の一部として安全にする。
URLEscape
// SchemeCheck はリンク先のスキームを許可制にする。
SchemeCheck
)
func (e Escape) String() string {
switch e {
case HTMLEscape:
return "HTML エスケープ"
case JSEscape:
return "JS 文字列エスケープ"
case URLEscape:
return "URL エンコード"
case SchemeCheck:
return "スキーム検査"
default:
return "何もしない"
}
}
// Escapes は測る対象の一覧。
func Escapes() []Escape { return []Escape{NoEscape, HTMLEscape, JSEscape, URLEscape, SchemeCheck} }
var htmlRepl = strings.NewReplacer(
"&", "&", "<", "<", ">", ">", `"`, """, "'", "'")
var jsRepl = strings.NewReplacer(
`\`, `\\`, `"`, `\"`, "'", `\'`, "\n", `\n`, "\r", `\r`,
// スクリプトの中では、文字列の中にいても </script> で外に出られてしまう。
// だから < > も逃がす。実体参照ではなく、JS の書き方で書く。
"<", `\u003C`, ">", `\u003E`)
// 許可するスキーム。相対 URL も通す。
var okScheme = map[string]bool{"http": true, "https": true, "mailto": true}
// Apply は変換を当てる。
func Apply(e Escape, v string) string {
switch e {
case HTMLEscape:
return htmlRepl.Replace(v)
case JSEscape:
return jsRepl.Replace(v)
case URLEscape:
return url.QueryEscape(v)
case SchemeCheck:
if safeURL(v) {
return v
}
return "#" // 相対 URL にして無害化する
default:
return v
}
}
// safeURL は、そのリンク先が許可したスキームか。
//
// 実行できるスキーム(javascript: など)を弾くのが目的になる。
func safeURL(v string) bool {
s := strings.TrimSpace(v)
i := strings.IndexByte(s, ':')
if i < 0 {
return true // スキームが無い = 相対 URL
}
// スキームの中に / や ? が来ていたら、それは区切りであってスキームではない。
if j := strings.IndexAny(s, "/?#"); j >= 0 && j < i {
return true
}
return okScheme[strings.ToLower(s[:i])]
}1つは文字列を閉じる引用符だ。";alert(1);// を入れると、文字列が終わって続きが命令になる。これは引用符を逃がせば止まる。
もう1つが厄介で、</script> はスクリプトの中でも効く。文字列の途中だろうと、ブラウザは </script> を見つけた時点でスクリプトを閉じる。つまり </script><script>alert(1)</script> は、文字列を閉じなくても外に出られる。
テストで、引用符だけを逃がす変換ではこの文字列が素通りすることを固定した。だから用意した JS 文字列エスケープは、引用符に加えて < と > も逃がしている。実体参照ではなく < の形で書くのは、スクリプトの中では実体参照が展開されないからだ。
ここで①の表に戻ると、スクリプトの中に HTML エスケープを当てた行が「止まるが値が壊れる」になっていた理由が読める。実体参照が展開されないので攻撃としては止まる。そして同じ理由で文字として残るので、値が壊れる。
動かす
下のデモは、出す場所と変換と攻撃を選んで、出力と結果を見る。
x onerror=alert(1)x onerror=alert(1)<img alt=x onerror=alert(1)>| 出す場所 | 止めた | 値も無事 |
|---|---|---|
| 本文 | 1 / 1 | 1 / 1 |
| 属性値(引用符あり) | 1 / 1 | 1 / 1 |
| 属性値(引用符なし) | 0 / 1 | 0 / 1 |
| スクリプトの中の文字列 | 2 / 2 | 0 / 2 |
| リンク先 | 0 / 1 | 0 / 1 |
表は、選んだ変換を 5 か所すべてに当てたときの成績。止めたかと、値が無事かを分けて数えている。 全部潰せば止まるが、それは対策ではなく破壊になる。引用符なしの属性値だけは、 止めて値も無事になる変換が1つも無い ── そこは引用符を書くところになる。
④ リンク先は、文字を変換するのではなく行き先を選ぶ
<a href="ここ"> に javascript:alert(1) を入れられると、踏んだ時点で実行される。
スキームというのは、URL の先頭で最初のコロンまでに書く、行き先の種類のことだ。https://example.com なら https、mailto:a@example.com なら mailto になる(送れるのに読めないで生成元の3つ組の1つとして出てきたのと同じもの)。この欄に javascript と書けるのが問題で、そのリンクは移動ではなく実行になる。
// Score は、その変換がその場所で攻撃を何件止め、値を壊さなかったか。
func Score(p Place, e Escape) (stopped, total, intact int) {
for _, a := range Attacks() {
hit := false
for _, t := range a.Targets {
if t == p {
hit = true
}
}
if !hit {
continue
}
total++
switch Check(p, e, a) {
case Neutralized:
stopped++
intact++
case Mangled:
stopped++
}
}
return
}
// Right は、その場所で使うべき変換。
//
// 「エスケープする」ではなく「**この場所のための**変換をする」という形にしないと、
// 場所が変わったときに黙って外れる。
func Right(p Place) Escape {
switch p {
case JSString:
return JSEscape
case URLValue:
return SchemeCheck
default:
return HTMLEscape
}
}
// FixableByEscaping は、その場所が変換だけで守れるか。
//
// 1 か所だけ false になる。引用符を書かなかった属性値は、
// **値を壊さずに守れる変換が存在しない**。空白1つで次の属性になってしまうので、
// 空白を潰すしかなく、潰すと普通の値も壊れる。
// ここは変換で直すところではなく、**引用符を書く**ところになる。
func FixableByEscaping(p Place) bool { return p != BareAttr }| 変換 | 出力 | 結果 |
|---|---|---|
| 何もしない | javascript:alert(1) | 動く |
| HTML エスケープ | javascript:alert(1) | 動く |
| URL エンコード | javascript%3Aalert%281%29 | 壊れる |
| スキーム検査 | # | 無害 |
HTML エスケープはこの文字列を1文字も変えない。& < > " ' が1つも入っていないからだ。テストで、変換の前後で一致することを固定した。②と同じ形の落とし穴になる。
URL エンコードは止めるが、まともなリンクも全部壊す。https://example.com/x が https%3A%2F%2F… になっては使えない。
ここで要るのは文字の変換ではなく、行き先の許可制だ。http / https / mailto と相対 URL だけ通し、それ以外は差し替える。テストで、普通のリンク4種が通ること、javascript: が大文字小文字違いでも先頭に空白があっても弾かれること、data:text/html, も弾かれることを固定した。
変換で守る場所と、選別で守る場所がある。文字を置き換える発想のまま来ると、ここで手が止まる。
設計の観点
- 「エスケープした」は情報になっていない: どこへ出すために何を変換したのか、まで言えて初めて意味を持つ
- 止めたかと、値が無事かを分けて数える: 全部潰せば止まる。それは対策ではなく破壊になる
- 出す場所を先に決める: 場所が決まらないと正しい変換が決まらない。入り口で潰す設計がずれるのはこのため
- 属性値には必ず引用符を書く: 変換では直せない唯一の場所になる
- スクリプトの中に値を埋めない: どうしても埋めるなら
</script>まで面倒を見る - リンク先は許可制にする: 文字の変換では
javascript:を止められない - 場所が変わったら変換も見直す: 本文用の変換をそのまま属性やスクリプトへ持っていくと、黙って外れる
対照と実例
| 出す場所 | 外へ出る手 | 使うべき変換 | 変換で守れるか |
|---|---|---|---|
| 本文 | < でタグを開く | HTML エスケープ | 守れる |
| 属性値(引用符あり) | " で属性を閉じる | HTML エスケープ | 守れる |
| 属性値(引用符なし) | 空白で次の属性へ | — | 守れない(引用符を書く) |
| スクリプトの中 | " と </script> | JS 文字列エスケープ | 守れる |
| リンク先 | javascript: | スキーム検査 | 守れる(選別で) |
裏どり:
- 実体参照はスクリプトの中で展開されない: だから HTML エスケープを
<script>の中に当てると、攻撃としては止まるが"が文字として残る。①の表で「止まるが値が壊れる」になっているのはこの性質による </script>は文字列の中でも効く: ブラウザは script の中身を先に切り出してから中身を解釈するので、文字列リテラルの途中かどうかを見ない- 引用符なしの属性値: 属性値は空白で終わるので、値の中の空白がそのまま次の属性の始まりになる。HTML エスケープの対象文字に空白は入っていない
- 数字はすべて手元の測定: 5か所 × 5変換 × 6攻撃の総当たりを、この章の実装でテストに固定した
- 判定は構文解析ではない: 変換後の文字列に「外へ出るための文字」が残っているかで見ている。ブラウザの実装を写したものではないので、どの変換がどの場所で足りないかを見るための模型になる
簡略化したこと
- ブラウザを書いていない: 実際に描画して確かめてはいない。判定は模型で、本物の構文解析とは違う
- 攻撃が6つだけ: 実際にはもっと多い。
<svg onload>、srcdoc、styleの中、iframe、大文字小文字や全角の変種などは入れていない - CSP を扱っていない: 出力の変換とは別に、そもそもスクリプトを走らせない仕組みがある。この章では触れない
- DOM 側の XSS を扱っていない: サーバが出す HTML ではなく、JavaScript が
innerHTMLに値を入れる形は扱わない - HTML の消毒を扱っていない: 利用者に一部のタグを許す(太字だけ通す等)場合は、変換ではなく解析して選別することになる。ここでは全部を文字として出す前提にした
- 文字コードを1つに固定している: 文字コードの取り違えを使う攻撃は扱わない
参考資料
- クロスサイトスクリプティング(MDN) — 成立の条件と種類
- OWASP Cross Site Scripting Prevention Cheat Sheet — 出す場所ごとに規則を分けた一覧
- 関連: 送れるのに読めない / 送れることを使う攻撃
- 実装: crypto/xss