CyberFix Note
脆弱性・CVE解説

XSS(クロスサイトスクリプティング)の仕組みと対策。反射型・格納型・DOM型を原理から整理

対象の目安: Webアプリ開発者 / 実務レベル

ソウ攻撃・脆弱性リサーチ担当
・ 約21分で読めます
XSS(クロスサイトスクリプティング)の仕組みと対策。反射型・格納型・DOM型を原理から整理

XSS(クロスサイトスクリプティング)は、SQLインジェクションと並んで古くから知られながら、いまも繰り返し作り込まれ続けているWeb脆弱性です。攻撃が成立すると、被害者のブラウザ上で攻撃者の用意したスクリプトが実行され、セッションの乗っ取り、入力内容の盗み取り、偽の画面の表示、利用者になりすました操作などが起こり得ます。サーバー側のデータベースを直接狙うわけではないため軽く見られがちですが、実際には利用者の信頼とアカウントを直接おびやかす、影響の小さくない脆弱性です。

やっかいなのは、ひとことでXSSと言っても、攻撃が混入する場所と実行されるタイミングによって反射型・格納型・DOM型という性質の異なる型があり、それぞれ防ぎ方の勘どころが少しずつ違う点です。この記事では、なぜXSSが起きるのかという原理から、3つの型の違い、想定される影響、そして根本対策である出力時のコンテキスト別エスケープと、それを補強するCSP(Content Security Policy)までを、順を追って整理します。

なお、攻撃手法の理解はあくまで自分が管理するシステムを守るためのものです。検証は必ず自分が管理する環境、または明示的に許可を得た対象に対してのみ行ってください。

XSSの3つの型 早見表

まず全体像をつかむため、3つの型を一覧で整理します。それぞれの詳細は後続のセクションで掘り下げます。

混入経路実行される場所典型例主な対策の軸
反射型(Reflected)リクエストに含めた入力がそのまま応答に反映されるサーバーが生成するHTML検索キーワードやエラーメッセージの表示サーバー側の出力エスケープ
格納型(Stored)入力がDB等に保存され、後で別の利用者に配信されるサーバーが生成するHTML掲示板・コメント・プロフィール欄サーバー側の出力エスケープ
DOM型(DOM Based)入力がブラウザ内のJavaScriptでDOMに書き込まれるブラウザ(クライアント)側URLのフラグメントをinnerHTMLに渡す等安全なDOM API・クライアント側の処理見直し

反射型と格納型はサーバー側でHTMLを組み立てる際に混入する問題で、DOM型はサーバーを経由せずブラウザ内のスクリプトで混入する問題、という点が最大の分かれ目です。

OWASPは、反射型・格納型をサーバー側、DOM型をクライアント側の問題として整理し、いずれも「どこで混入するか」が異なるだけで被害そのものは共通だと説明しています。DOM型は2005年にAmit Kleinによって整理された比較的新しい分類です。

なぜXSSは起きるのか

問題の本質は、SQLインジェクションとよく似ています。すなわち、利用者が入力した「データ」を、HTMLやJavaScriptという「コード」の中にそのまま文字列として混ぜ込んでしまう点にあります。

たとえば、検索キーワードを画面にそのまま表示するページがあるとします。サーバーが次のようなHTMLを組み立てているとしましょう。

<p>「入力値」の検索結果</p>

ここで利用者が検索キーワードとして <script>alert(document.cookie)</script> を入力すると、組み上がるHTMLは次のようになります。

<p>「<script>alert(document.cookie)</script>」の検索結果</p>

ブラウザから見れば、これは「正しいHTML」です。<script> タグはスクリプトとして実行され、document.cookie を読み取る処理が動いてしまいます。ブラウザには、どこまでが開発者が意図したマークアップで、どこからが利用者の入力なのかを区別する手段がありません。コードとデータの境界が、文字列連結という操作によって壊されてしまったのです。これがXSSの核心です。

重要なのは、これが「<script> という危険な単語を通してしまった」という表層的な問題ではない点です。<img src=x onerror=...> のようにスクリプトタグを使わない混入もあれば、属性値の中、JavaScriptの文字列の中、URL文脈の中など、出力される「場所」によって危険な文字も書き方も変わります。だからこそ、後述するように出力する場所(コンテキスト)に応じた処理が必要になります。

反射型XSS(Reflected)

反射型は、リクエストに含めた入力が、そのままその場の応答HTMLに反映されることで起きます。検索結果やエラーメッセージ、「お探しのページは見つかりませんでした: 入力されたURL」のような表示が典型です。

特徴は、攻撃が永続的に保存されない点です。攻撃者は、悪意あるスクリプトを仕込んだURLを用意し、それを被害者にクリックさせる必要があります。メールやSNS、広告などを通じて「このリンクを踏ませる」というひと手間がかかるため、格納型に比べると攻撃の難度は一段上がります。ただし、フィッシングと組み合わせれば十分に現実的な脅威です。

https://example.com/search?q=<script>...悪意あるコード...</script>

このURLを踏んだ被害者のブラウザ上で、example.com のオリジンの権限でスクリプトが動いてしまう、というのが反射型の流れです。被害者から見れば正規サイトのドメインなので、警戒しにくいのがやっかいな点です。

格納型XSS(Stored)

格納型は、入力がデータベースなどに保存され、後から別の利用者の画面に配信されるときに実行される型です。掲示板の投稿、商品レビュー、プロフィール欄、問い合わせの管理画面など、「誰かの入力を、別の誰かが後で見る」あらゆる場所が候補になります。

格納型が厄介なのは、攻撃者がリンクを踏ませる必要がない点です。一度危険な投稿を保存できれば、そのページを閲覧したすべての利用者のブラウザでスクリプトが実行されます。被害が広範囲かつ自動的に広がるため、一般に反射型より影響が大きいと評価されます。SNS型のサービスで自己増殖する「XSSワーム」が問題になった歴史的事例もあります。

特に見落とされやすいのが、利用者向けの画面ではなく管理者向けの画面に混入するケースです。問い合わせフォームに仕込まれたスクリプトが、それを確認する管理者のブラウザで実行されれば、強い権限を持つアカウントが乗っ取られかねません。入力を受ける画面と、それを表示する画面が別の権限レベルにある場合は、特に注意が必要です。

利用者に見える表示部分はエスケープを徹底していたのに、社内の管理画面で問い合わせ内容を生のHTMLとして表示していて、そこが盲点になっていた。「外向き」だけでなく「内向き」の表示も同じ脅威にさらされていると後から気づいた、という話はよく聞きます。

あるWebアプリ運用担当の声

DOM型XSS(DOM Based)

DOM型は、これまでの2つと性質が異なります。反射型・格納型がサーバー側でHTMLを組み立てる過程で混入するのに対し、DOM型はサーバーを経由せず、ブラウザ内で動くJavaScriptが入力をDOMに書き込む過程で混入します。

たとえば、URLのフラグメント(# 以降)やクエリ文字列を読み取って、そのまま画面に差し込むコードを考えます。

// 危険な例: 入力をそのままHTMLとして書き込んでいる
const q = location.hash.substring(1);
document.getElementById("out").innerHTML = q;

innerHTML に渡された文字列は、ブラウザによってHTMLとして解釈されます。フラグメントに <img src=x onerror=alert(1)> のような値が入れば、スクリプトが実行されます。注目すべきは、この処理が完全にブラウザ内で完結しており、危険な値がサーバーに送られない場合があることです。# 以降はサーバーに送信されないため、サーバー側のログにもWAFにも痕跡が残らず、サーバー側のエスケープでは防げません。

DOM型は、入力が読み込まれる箇所(ソース)と、それがコードとして実行され得る箇所(シンク)の組み合わせで生じます。innerHTMLdocument.writeevalsetTimeout への文字列渡しなどがシンクの代表です。対策はサーバー側ではなく、クライアントのJavaScriptそのものを見直すことになります。

注意

DOM型は、サーバー側のテンプレートエンジンが自動エスケープしていても、フロントエンドのコードで innerHTML などに直接代入していれば成立します。「フレームワークが守ってくれているはず」という思い込みは、クライアント側の生のDOM操作には及びません。

想定される影響

XSSはサーバーのDBを直接壊すわけではありませんが、被害者のブラウザ上で、そのサイトのオリジンの権限で任意のスクリプトを実行できてしまいます。これは実務上、非常に強力です。

影響内容
セッションの乗っ取りCookieやトークンを盗み、利用者になりすましてログイン状態を奪う
入力値の窃取フォームに入力されたID・パスワード・カード番号などをリアルタイムに送信する
なりすまし操作利用者の権限で送金・投稿・設定変更などのリクエストを勝手に発行する
画面の改ざん偽のログインフォームを差し込み、認証情報を直接盗む(フィッシングの足場)
マルウェア配布・横展開別の脆弱性や偽ダウンロードへ誘導し、被害を拡大する

特に、セッションCookieが盗まれると、攻撃者はパスワードを知らなくてもログイン状態を再現できてしまいます。なお、Cookieに HttpOnly 属性を付けるとJavaScriptからの読み取りを防げますが、これはCookie窃取という一経路を塞ぐ緩和策であり、XSSそのものを防ぐものではない点に注意が必要です。スクリプトが動いてしまえば、Cookieを読まなくても利用者の権限で操作を行えます。

根本対策: 出力時のコンテキスト別エスケープ

XSSの根本対策は、利用者の入力を画面に出力するその瞬間に、出力先の文脈(コンテキスト)に応じて適切にエスケープ(エンコード)することです。「データを表示するときは、それがコードとして解釈されないように無害な文字列へ変換してから出す」という発想です。

ここで決定的に重要なのは、出力する場所によって正しいエスケープ方式が変わるという点です。OWASPのCross Site Scripting Prevention Cheat Sheetは、コンテキストごとに異なるエンコードを行うことを基本ルールとして挙げています。

出力先コンテキスト主な対処
HTML本文< > & " ' などをHTMLエンティティに変換する
HTML属性値属性を必ず引用符で囲み、属性用のエンコードを行う
JavaScript内の文字列\uXXXX 形式のUnicodeエスケープを使う(生の値を文字列に埋めない)
URL(href等のパラメータ)パーセントエンコードし、スキームを http/https に限定する

たとえば、HTML本文に出すなら <&lt; に変換すれば <script> はタグとして解釈されません。しかし、同じ値をJavaScriptの文字列リテラルの中に埋め込む場面では、HTMLエンティティ化は意味をなさず、別のエスケープが必要になります。コンテキストを取り違えたエスケープは、効いているように見えて実は穴が残る、という事故につながります。

なお、JavaScript文脈について補足すると、OWASPは「利用者由来の値をJavaScriptコードに置いてよい安全な場所は、引用符で囲んだデータ値の中だけであり、それ以外のJavaScript文脈は安全ではない」としています。つまり、onclick などのイベントハンドラ属性や eval・文字列を渡す setTimeout の中などは、JavaScriptエンコードを施しても安全にはならず、そもそも利用者由来の値を埋め込まないのが原則です。

OWASPのCheat Sheetは、出力コンテキストに応じたエンコードを第一の防御とし、利用者がHTMLを書ける場合はDOMPurifyのようなサニタイザでの無害化を、さらにCSPを多層防御として重ねることを推奨しています。

モダンなフレームワークの自動エスケープ

ReactやVue、サーバーサイドのテンプレートエンジンの多くは、変数を画面に出すとき既定でHTMLエスケープを行うため、通常の使い方をしている限り安全側に倒れます。これは大きな前進です。ただし、各フレームワークには「自動エスケープを意図的に外す」抜け道があり、そこが事故の温床になります。

// React: 通常の {value} は自動エスケープされる(安全)
<p>{value}</p>

// 危険: dangerouslySetInnerHTML は自動エスケープを外す
<p dangerouslySetInnerHTML={{ __html: value }} />

Reactの dangerouslySetInnerHTML、Vueの v-html、Angularの bypassSecurityTrust* などは、いずれも「自動エスケープを外す」操作です。名前のとおり危険であり、利用者由来の値を渡してはいけません。どうしてもHTMLを表示したい場合は、次のサニタイズを挟みます。

利用者にHTMLを書かせる場合のサニタイズ

リッチテキストエディタなどで、利用者にある程度のHTML(太字やリンク等)を許可したい場面では、単純なエスケープでは要件を満たせません。この場合は、許可するタグ・属性だけを残して危険な要素を取り除く「サニタイズ」を行います。クライアント側ではDOMPurifyが事実上の標準的なライブラリです。

ポイントは、許可リスト方式で「安全と分かっているものだけを通す」ことです。危険なものを列挙して弾くブラックリスト方式は、新しい回避手法に追従しきれず破られます。サニタイズは自前で正規表現を書かず、実績あるライブラリに任せるのが鉄則です。

XSSもSQLインジェクションも「コードとデータを混ぜない」という同じ原理に根ざしています。あわせて

も読むと、両者に共通する設計の勘どころが見えてきます。

多層防御: CSP(Content Security Policy)

出力エスケープを徹底したうえで、万一の取りこぼしに備える保険がCSP(Content Security Policy)です。CSPは、ブラウザに対して「このページではどこから読み込んだスクリプトを実行してよいか」をHTTPヘッダ(またはmetaタグ)で宣言する仕組みです。仮にXSSでスクリプトが混入しても、CSPの許可条件を満たさなければブラウザが実行を拒否するため、被害を抑えられます。

ここで強調したいのは、CSPはXSSの根本対策ではなく、あくまで多層防御だという点です。CSPだけでXSSを完全に防げるわけではありません。出力エスケープという根本対策の上に重ねる、二段目の防壁と位置づけてください。

旧来のドメイン許可リストの限界

script-src https://example.com のように、信頼するドメインを並べる書き方は直感的ですが、許可したドメインの中に攻撃に悪用できるスクリプト(JSONPエンドポイントや古いライブラリなど)があると回避され得ます。このため、近年はドメインの列挙ではなく、nonceやハッシュを使う「Strict CSP」が推奨されています。

nonce方式とstrict-dynamic

nonce方式は、サーバーがHTTPレスポンスごとにランダムな使い捨ての値(nonce)を生成し、正規の <script> タグとCSPヘッダの両方に同じ値を付けます。攻撃者が注入したスクリプトには正しいnonceが付かないため、ブラウザが実行を拒否します。

Content-Security-Policy: script-src 'nonce-r4nd0m' 'strict-dynamic'
<script nonce="r4nd0m" src="/app.js"></script>

strict-dynamic を併用すると、nonceで信頼されたスクリプトが動的に読み込む別スクリプトも信頼されるため、すべての要素に個別にnonceを付ける手間を減らせます。OWASPのCSP Cheat Sheetは、nonceを「レスポンスごとに生成する一度きりのランダム値」とし、すべてのscriptタグへ機械的にnonceを差し込むミドルウェアは攻撃者の注入スクリプトにもnonceを付けてしまうため避けるよう注意しています。

メモ

ハッシュ方式(script-src 'sha256-...')はインラインスクリプトの中身からハッシュを計算して許可する方式ですが、空白や整形を含めスクリプトを少しでも変えるとハッシュが変わり動かなくなります。OWASPもこの扱いの難しさに触れています。nonce方式はレスポンスごとに一意な値を生成してHTMLとヘッダの両方へ差し込む必要があるため、サーバー側でHTMLを動的に生成できる構成が前提です。静的HTMLを配信するだけの構成ではnonceを毎回差し替えられないため、その場合は外部JS化やhash方式、配信層でのnonce注入を検討します。

unsafe-inlineを避ける

script-src(または default-src)を設定したCSPでは、'unsafe-inline' やnonce/hashで明示的に許可しない限り、インラインスクリプト(HTML内に直接書いた <script>...</script> やHTML属性の onclick=...)は実行されません。この保護を 'unsafe-inline' で解除してしまうと、CSPのXSS防御効果はほぼ失われます。OWASPは、インラインのコードは別ファイルに移し、onclick などのインラインイベントハンドラは addEventListener に置き換えることを推奨しています。

OWASPのCSP Cheat Sheetは、ドメイン許可リストより nonce / hash を用いたStrict CSPを推奨し、'unsafe-inline' の使用を避けるよう述べています。strict-dynamicはnonceまたはhashと組み合わせて使う前提です。

よくある誤解と、やってはいけない対策

XSS対策にも、効果が限定的だったり、かえって危険だったりする「やりがちな対処」があります。

  • 入力時に <script> という単語を弾けば安全: 入力フィルタは多層防御の一つにはなりますが、<img onerror=...> のようにscriptタグを使わない手法、大文字小文字やエンコードによる回避があり、根本対策にはなりません。根本は「出力時のエスケープ」です。
  • 入力バリデーションだけで防ぐ: 入力値の検証(型・長さ・形式)は重要ですが、自由記述のコメントのように「あらゆる文字を受け付ける必要がある」入力では、検証だけではXSSを防げません。出力エスケープと役割が違います。
  • どこでも同じエスケープ関数を使えばよい: HTML用のエスケープをJavaScript文脈やURL文脈に流用すると穴が残ります。コンテキストごとに正しい方式を使う必要があります。
  • CSPを入れたからエスケープは不要: CSPは保険であって根本対策ではありません。両方が必要です。

つまり、対策の良し悪しは「危険な入力を頑張って取り除いているか」ではなく、「出力する瞬間に、その文脈で安全な形に変換されているか」で判断するのが正しい見方です。

よくある質問

入力時にスクリプトタグを弾けばXSSは防げますか?
防ぎきれません。scriptタグを使わない混入(onerror属性など)や、エンコード・大文字小文字による回避があります。入力フィルタは多層防御の一つにとどめ、根本対策は出力時のコンテキスト別エスケープに置いてください。
反射型・格納型・DOM型のうち、どれが一番危険ですか?
一概には言えませんが、格納型は被害者がリンクを踏まなくても閲覧者全員に影響が及ぶため、影響範囲が広くなりがちです。DOM型はサーバー側のエスケープやWAFで防げず痕跡も残りにくい点が厄介です。型ごとに対策の軸が違うので、すべてを想定する必要があります。
ReactやVueを使っていればXSSは起きませんか?
通常の変数表示は自動エスケープされ安全側に倒れますが、dangerouslySetInnerHTMLやv-htmlで自動エスケープを外すと脆弱になります。また、innerHTMLなど生のDOM操作によるDOM型XSSはフレームワークの自動エスケープの範囲外です。
CSPを設定すればエスケープは不要になりますか?
なりません。CSPは万一スクリプトが混入しても実行を拒否させる多層防御の保険であり、根本対策は出力エスケープです。両方を組み合わせてください。
Cookieに HttpOnly を付ければXSS対策になりますか?
HttpOnlyはJavaScriptからのCookie読み取りを防ぐ緩和策で、Cookie窃取という一経路を塞ぎます。ただしXSS自体は防げず、スクリプトが動けば利用者の権限でなりすまし操作などが可能です。あくまで補助策です。
DOM型XSSはサーバー側で対策できますか?
基本的にできません。DOM型はブラウザ内のJavaScriptで混入し、危険な値がサーバーに届かない場合もあります。innerHTMLやevalなどへの値の渡し方を見直し、textContentなど安全なAPIに置き換えるクライアント側の対処が必要です。

まとめ

XSS対策チェックリスト

  • 利用者由来の値は、出力する文脈(HTML本文/属性/JavaScript/URL)ごとに正しくエスケープしているか
  • dangerouslySetInnerHTMLやv-html、innerHTMLなど自動エスケープを外す箇所を洗い出したか
  • 利用者にHTMLを許可する箇所は、DOMPurify等のサニタイザを許可リスト方式で使っているか
  • 管理画面など内部向けの表示にもエスケープを適用しているか(格納型の盲点)
  • DOM型の観点で、innerHTML/eval/document.writeへの値の渡し方を確認したか
  • 多層防御としてStrict CSP(nonceまたはhash)を導入し、unsafe-inlineを避けているか
  • Cookieに HttpOnly / Secure を付けているか(あくまで緩和策として)

仕組みを理解すれば、XSSは「コードとデータを混ぜず、出力する瞬間にその文脈で無害化する」という一点に集約されることが見えてきます。SQLインジェクションが「命令とデータの分離」だったのと同じ構造です。個別の危険な文字を頑張って取り除くのではなく、そもそも安全な形で出力する設計にする。これがすべての出発点です。Web脆弱性の全体像とXSSの位置づけは

でも整理しているので、あわせてご覧ください。

出典・参考

この記事をシェア

関連する記事