XSSこわい
若頭: おいお前ら、なにかおもしろい遊びをしねえか。こんなにみんなで集まる機会もそうねえだろう
エンジニア佐藤: そうですねえ、こんなのはどうでしょうか。人間誰しも怖いものが1つはありますから、それをみんなで教えあってみましょうよ
若頭: そりゃあおもしれえな。そうだなあ、おれはヘビが怖いね。ありゃ気味が悪くてしょうがねえ
エンジニア山田: 自分はカエルを見ると縮み上がってしまいます、テカテカしていてどうにも苦手で。佐藤さんは何が怖いんですか
エンジニア佐藤: 私は、XSSがこわいです
エンジニア八島: あはは!何言ってんですか佐藤さん。XSSなんてこわいことないですよ
エンジニア佐藤: ひいい、名前を聞くのも怖いです
エンジニア山田: XSSなんて、フレームワークさえ使っていればきょうび起こらないですからねえ。佐藤さんは臆病だなあ
その晩、エンジニア佐藤を目の敵にしている町のエンジニアたちは、こぞって彼のPCにXSSのペイロードを投げつけたそうで。エンジニア佐藤は大量のペイロードを手に入れて、XSSのスペシャリストになったそうな。
これは何の記事なのか?
こんにちは、GMO Flatt Security でセキュリティエンジニアとして働いているcanalun(@i_am_canalun)です👶
Still X.S.S.と銘打った本記事のテーマはずばり「なぜいまでもXSSが起きるのか」です。この「なぜいまでも」という言葉は、開発フレームワークがビルトインで備えているXSS対策を念頭に置いたものです。
Web開発の現場は秒進月歩であり、特に開発フレームワークの進化速度はさながらアウトバーンです。そして、そこでは開発効率の向上だけでなくセキュリティの強化も行なわれています。
特に、XSSに対する防衛機構は充実の一途をたどっています。多くのフレームワークがHTMLコンテンツや属性値のエスケープを自動で行なったり、危険なinnerHTML
の使用につながるインターフェースにはそれが伝わる名前を付けたり(LitのunsafeHTML
やReactのdangerouslySetInnerHTML
など)。直近ではReact v19でjavascript:
スキームのURLを無効化できるようになり*1、また1つXSSを仕掛けるための道具が封じ込められました。
いまや「フレームワークを使っていればXSSなんて怖くないよね」と考える方もいるのではないでしょうか。先ほどの小話でも「フレームワークさえ使っていればきょうび起こらない」と言っているエンジニアが登場していました。
しかし、実際には全くそうではないのです。これだけフレームワークが充実した現代でも、XSSがいまだに深刻度および発生頻度ともにトップクラスであることを裏付けるデータは多くあります。Web開発に携わる私たちにとってはやっぱりXSS、Still X.S.S.なのです。XSSに注意しすぎて損することはない、いまだにそんな時代なのです。これは決して、冒頭の小話に出てきたエンジニア佐藤のように怖がっているふりをしているわけではありません!
……はい、たぶん信じられない方が多いと思います。ぜひ、この記事を読んでいってください!そんな方のために一生懸命書いた記事です。もちろん、ここまでですでに信じてくださったお人柄の良いあなたもぜひ!
それでは巧妙なスクリプトインジェクションの世界への旅を始めましょう👶レッツゴー
- XSSこわい
- これは何の記事なのか?
- XSSは業種問わず、5年間継続的に、深刻さを伴って発見され続けている
- なぜいまだにXSSが起きてしまうのか?
- じゃあどうしたらええんや!?
- おわりに
XSSは業種問わず、5年間継続的に、深刻さを伴って発見され続けている
先ほど「XSSがいまだに深刻度および発生頻度ともにトップクラスであることを裏付けるデータが多くあるのです」と書きました。まずはそこから確認してみます。
CWE Top 25 Archive
最初に「CWE Top 25 Archive」です。
どのような世界にもとても骨が折れることをしてくださっている方々がいるもので、セキュリティの世界も例外ではありません。米国の非営利団体MITRE Corporation(通称MITRE、マイター)は、日々発生する世界中の脆弱性をCVEという仕組みで管理しており、昼夜問わず様々な脆弱性がMITREのもとに集まります。MITREは脆弱性の分類も行いますが、その際に使用されるラベルのようなものがCWEです。たとえばSQLインジェクションはCWE-89、XSSはCWE-79です。そして「CWE Top 25 Archive」とは、毎年発見された脆弱性をMITREが分析して、深刻さと出現頻度の両方を加味したスコアでCWEをソートした年次アーカイブのことです。
長々と書きましたが、要するに「CWE Top 25 Archive」とは「今年ヤバかった脆弱性ジャンルTop 25」です。さっそく直近5年の1〜3位を見てみましょう。
2020年*2 | 2021年*3 | 2022年*4 | 2023年*5 | 2024年*6 | |
---|---|---|---|---|---|
1位 | XSS | Out-of-bounds Write | Out-of-bounds Write | Out-of-bounds Write | XSS |
2位 | Out-of-bounds Write | XSS | XSS | XSS | Out-of-bounds Write |
3位 | Improper Input Validation | Out-of-bounds Read | SQLi | SQLi | SQLi |
はい、驚くべきことにXSSは5年間、1位か2位に留まり続けています。XSSはいまだ、まったく無視できないどころかむしろ最も注意を向けてもよいくらいの脆弱性なのです👶キャー!
Annual Hacker-Powered Security Reportにおける産業種別ランキング
せっかくなので産業種別のデータも確認します。LY CorporationやGitHubなど世界中の企業が利用するバグバウンティプラットフォームHackerOneが公開している"8th Annual Hacker-Powered Security Report 2024/2025"を参照してみましょう*7。おや、2023年から2024年にかけて報告された脆弱性のうちTOP10のものに注目した産業種別統計がありますね……
ええ、XSSがCryptoを除く全産業で20%前後を占めているんですか!(知ってた)
これじゃあXSSは政府系からEコマースまで、私たちがどのような産業に携わっていても挨拶をくれるWeb界隈の顔利きみたいなもんじゃないですか。渋谷のクラブにも顔パスで入れますよ。
GMO Flatt Security Top 10
最後に弊社のデータ*8も見てみましょう。
2023年に見つかった脆弱性を分類し件数でソートすると、まずは認証認可の脆弱性、そしてロジックの脆弱性ときて、3番目にXSSが現れます。
このデータは上で紹介した2つのデータと異なり、母集団が弊社の脆弱性診断対象の多くを占めるBtoB SaaSに偏っていると思われます。そのため認証認可の脆弱性が多くなってはいますが、それでもある程度普遍的な脆弱性の中ではCSRFやセキュリティヘッダーの不備を差し置いて、XSSが抜きん出ています。やっぱりXSS、Still X.S.S.。
さて、3つのデータを紹介しました。どうでしょうか。XSSは今も昔も技術スタックの新旧によらず、それなりの深刻性を伴ったうえでコンスタントに見つかることが示唆されていると思います。やはりXSSは、Web開発者にとって今でもまったく他人事ではないのです。
なぜいまだにXSSが起きてしまうのか?
大変お待たせいたしました、ようやく本題に入っていきます。ここからは先ほどまで眺めてきた恐ろしい数字の裏側に思いを馳せます。つまり「なぜいまだにXSSが起きてしまうのか」を見ていきます。
XSSの発生原因を考えることは、現代における正しいXSS対策に辿り着くための最短経路でもあるはずです。さっそく参りましょう!
前提1: XSSのsinkは極めて多様である
XSSを考える上で最も抑えておくべき点の1つに、そのsinkが極めて多様であるという事実があります。
「source」および「sink」とは脆弱性を分析するうえで使用される概念です。sourceとは攻撃者が操作できるデータの入り口(e.g. フォーム、ファイル名)を指し、sinkはそのデータがプログラムの流れにのって辿り着く地点(e.g. HTMLへの埋め込み、JavaScriptとしての実行)を指します。
たとえばSQLインジェクションのsinkと言えば、db.query("SELECT * FROM users WHERE name = '" + userInput + "'");
のような生クエリの生成箇所です。パストラバーサルならreadFile("/user/uploads/" + userInput);
のような箇所でしょう。
XSSのsinkは本当にさまざまで、いろいろな発生の仕方をしてしまいます。下記にXSSの代表的なsinkのパターンを挙げます。
- HTML: 信頼できないデータをHTMLとして挿入するケース。
- 例:
document.getElementById('output').innerHTML = userInput;
はuserInputが"<img src onerror=alert(document.domain)>"
だった場合にXSSが発火
- 例:
- HTML属性(非URL): 信頼できないデータをHTML属性値として挿入するケース
- 例:
<button type="text" data-value="[userInput]">
はuserInputが"><script>alert(document.domain)<p "
だった場合にXSSが発火
- 例:
- HTML属性(URL): 信頼できないデータをHTML属性値でURLとして使用するケース
- 例:
<a href=[userInput]>
はuserInputが"javascript:alert(document.domain)"
だった場合にXSSが発火
- 例:
- JavaScript(URL): 信頼できないデータをJavaScriptのコード内でURLとして使用するケース
- 例:
window.location.href = userInput;
はuserInputが"javascript:alert(document.domain)"
だった場合にXSSが発火
- 例:
- JavaScript(関数構築): 信頼できないデータをJavaScriptの関数の構築に使用するケース
- 例:
eval('var data = "' + userInput + '";');
はuserInputが";alert(document.domain);//
だった場合にXSSが発火
- 例:
ちなみにこれを見て「おいおいなんか最近そういうコード書いたぞ」という方は、この記事を読むのをいったんやめて、職場や家のソースコードを読みに戻っても大丈夫です。
すこし待ちますね……
大丈夫でしたか?では、これをふまえて見ていってみましょう!
前提2: フレームワークが対処できるsinkは限定的である
フレームワークはXSSの多様なsinkのすべてに対応しているわけではありません。ほとんどの場合、主に注意を払われるのはHTMLの出力におけるエスケープ処理やtextContentの使用です。つまりHTML内で文字列として解釈されるべきデータが、意図せずHTMLやJavaScriptとして解釈されるのを防ぐための処理です。
こういった機構が管轄するのは先ほどのsinkで言うところの「HTML」と「HTML属性(非URL)」です。それ以外の「HTML属性(URL)」やJavaScript関連のsinkはフレームワークのHTMLエスケープ処理ではカバーしきれません。私たちが最初に思い浮かべるXSSの防衛機構は、決してすべてのsinkをカバーしているわけではないのです。
さて、以上の前提を踏まえ、ここからはsinkの種類によって話を2つに分けます。
まずは「なぜフレームワークのHTMLエスケープ機構があっても、HTMLやHTML属性(非URL)によるXSSを防げないのか」です。そうなんです、HTMLエスケープ処理で守られる「HTML」と「HTML属性(非URL)」において、フレームワークを使っていれば100%安全かというと全くそんなことはなく、まずはその話をさせて下さい。
もう一つは「どのようなときに、HTML属性(URL)やJavaScript関連のsinkによるXSSが発生するのか」です。「URLを設定したり、スクリプトを構成したり、そんな実装いつすんねん」という方はぜひ見ていってください!
いずれも理解を助けるための実例を使いながら話していきます。具体的には、HackerOneで2024年に報告されたレポートのうちXSSのCWEが付与されたものから興味深いケースを取り上げていきます!*9
なお、ここから先は少し長くなってしまったので簡単な要約を記載しておきます!なんというホスピタリティでしょうか。現代において発生するXSSのパターンは要するにこういうことだと考えています。
- 安全だと思っていた値が安全ではなかった
- 自前のサニタイザーを使っていたがバイパスされてしまった。もしくはOSSのサニタイザーを使っていたが、使用法が間違っていた
- フレームワークが用意した防衛機構を正しく使っていなかった、もしくはそもそも使わないで済ませていた
- フレームワークが守ってくれない、かつ、あまり知られていないsinkが埋め込まれていた
- ライブラリの仕様を正しく理解せずに使っていた
👶👶👶それでは「なぜいまだにXSSが起きてしまうのか?」を具体的に見ていきましょう👶👶👶
HTML関連のXSSは、フレームワークのHTMLエスケープ機構があったとしても防ぎきれない
まずは「なぜフレームワークのHTMLエスケープ機構があっても、HTMLやHTML属性(非URL)によるXSSを防げないのか」です。要因を大きく2つに分けていきます。
A. そもそもフレームワークのエスケープ機構が使えない場所がある
ある程度複雑なシステムや、様々なモジュールを繋ぎ込むシステムを開発していると、フレームワークのエスケープ機構を使わずにHTMLを直接セットした方が圧倒的に良い、もしくはセットする必要があるケースに出会います。例えば、フレームワークの外側にあるコードではフレームワークのエスケープ機構は使えません。はたまた、渡されたHTMLを自分でパースしてJSXにはめていけばエスケープできるものの、開発工数を考えると現実的ではないケース。
こういった「そもそもフレームワークのエスケープ機構が使えない場所がある」というのが1つめの要因です。
このような事態は、例えばWYSIWYGエディターやマークダウンパーサーのようにHTMLが出力されるモジュールをシステムに組み込むと生じやすいです。もしくは信頼できると考えられているCMSを使っている場合は、そこから取得したHTMLをそのまま入れる実装もありうるでしょう。あるいはframe間通信のようなフレームワークで扱いづらい機構もありえます。
さて、ここで皆さんおそらくこう言うと思います。
「HTMLを直接入れる実装が危ないことなんてわかってる!!どうしてもやらないといけないときは、入力が安全なことを確かめるかサニタイザーをかませるかするから!!」
そうなんです。本要因の論点はまさにそこです。入力が安全であると確かめるとか、サニタイズをするとかで本当になんとかなるのかを考えてみましょう。
A-1. ある値が安全な値であるかの判断を間違える
さて、まずは入力が安全であることを確かめるという行いについてです。これについては、GoogleのエンジニアChristoph Kernが著した「Securing the tangled web」という論文*10の中で、まさにその確認行為のミスがXSSの主要因の1つとして挙げられています。
この論文では著者がGoogle VRP*11で報告されたXSS脆弱性、つまりリアルなケースをもとにXSSの原因を考察しています。そして、Webアプリケーションにおける値の安全性の確認は非常に難しいのだと主張しています。理由としては主に以下の点が挙げられています*12。
- さまざまな場所から取得したユーザー入力やデータが、複雑な条件分岐を経て組み合わされたうえで利用されるため
- 時間経過によってソースコードが常に変化するため
また、興味深いトピックとして責任所在の話も挙げられています。すなわちフロントエンドかバックエンドかどちらが値の安全性に責任を持つかが正しく認識されないことで発生する類のバグもあるということです。
This bug could arise in practice from a misunderstanding between front-end and back-end developers regarding responsibilities for data validation and sanitization.
ここで、一見安全そうに思える値がXSSを引き起こしている事例としてHackerOneのCVE-2021-20323を見てみましょう。これは米国国防総省のプログラムで報告されたXSSであり、実際のペイロードは下記です。
{"<img onerror=confirm('xss_poc_unexpectedbufferc0n') src/>":1}
レポートからは、JSONのフィールド名がXSSを引き起こしたように読み取れます。JSONのフィールド名が画面上に描かれる、しかもHTMLとして描かれる実装は珍しく感じますが、フィールド名なら確かに描いても安全である気がしてしまうのは分からなくありません。
A-2. 自前のサニタイザーがバイパスされる
さて、ここでサニタイザーに話を移します。直接入れるのは避けられない、かつ、入力される値も信じられないとするなら、サニタイズをするのはどうでしょうか。
まず自作のサニタイザーについて言えば、セキュリティエンジニア100人にそれを勧めるか聞いてみれば、100人がやめておけと答えるのではないでしょうか。なぜならHTMLのサニタイザーには十中八九ミスが埋め込まれてしまうからです。
実際の事例を見てみましょう。HackerOneのレポートID1675516では、まさにサニタイザーの不備が突かれています。具体的には、攻撃者は閉じられていない複数のpタグに、スラッシュをタグ名や属性名の区切り文字として使ったaudioタグを後続させることでサニタイザーをバイパスすることに成功しています。下記が実際のペイロードです。
<p><p><p><p><p><p><p><p><audio/src/onerror=alert(document.domain)>.
このペイロードからは、適度に寛容でありつつ正しく無害化を行うHTMLサニタイザーを作ることの難しさが伺えます。
そもそも、このペイロードは明らかに私たちが想像する「正しい」HTMLではありません。pタグは閉じられていないし、audioタグも属性名とタグ名の間にスペースが入っていません。しかしそれでも、ブラウザは上記の値がHTMLとして与えられるとそれを「よしなに」解釈して、p要素8個とaudio要素1個を作ったうえでポップアップを出してくれます*13。こんなふうに「間違っている」HTMLでもブラウザが「よしなに」対処してくれる例は枚挙にいとまがありません。TaliとPaulによる有名なポスト「How browsers work」にはこのような一節があります*14。
You never get an "Invalid Syntax" error on an HTML page. Browsers fix any invalid content and go on.
また、このような明らかに「正しくない」HTMLを除外したとしても、サニタイズは難しいものです。例えば下記は先ほどのGoogleの論文で、サニタイズが如何に繊細な問題であるかを説明するために引かれた例です。使われているのはClosureですが、やっていることはHTMLエスケープとJS用文字列エスケープです。そして、このサニタイズ実装は間違っていますが、自分が仮にコードレビュアーだったとして気づくことができるでしょうか*15👀
var escapedCat = goog.string.htmlEscape(category); var jsEscapedCat = goog.string.escapeString(escapedCat); catElem.innerHTML = `<a onclick="createCategoryList('` + jsEscapedCat + `')">` + escapedCat + `</a>`;
このような状況があるため、自力のサニタイザーで柔軟性や高機能性を追い求める行為は大変危険です。極めて単純な実装にして、許可するHTMLの形式も極限まで絞り込んだ方が良いです。しかし、そのような姿勢で臨んだとしても間隙を突かれてバイパスされることはあるでしょう。例えば許可するタグをdivだけにしたとしても、属性が付与できた時点でおしまいです。攻撃者はdivタグにonmouseenter
属性として悪意あるスクリプトを設定します😢
もちろん、タグや属性をもっともっと絞り込めば理論上は安全になる可能性があります。ただ、そうなった場合には、もはやHTMLを受け入れる実装が本当に必要なのかを再考してもいいのではないかと感じます。
A-3. OSSのサニタイザーを使っているが使用法が間違っている
さて、ここまで述べたように自前のサニタイザーは明らかに危険です。サニタイズをするなら、広く使われていて信頼ができるOSSのサニタイザーを使うべきです。サニタイザーはいくつかありますが、OWASPはDOMPurifyを推奨しています*16。
OWASP recommends DOMPurify for HTML Sanitization.
ただし、ここでも注意すべきことがあります。それはサニタイザーを正しく使うということです。どんなに優れたサニタイザーでも間違った使い方をしてしまえば効果はなくなります。
特に、サニタイズした結果に手を加えることでサニタイズ効果を無効化する実装パターンはよく見られ、desanizationと呼ばれることがあります*17。desanitizationは、Webセキュリティの大本営であるOWASPも注意喚起を行っており、相当程度にメジャーなパターンであることが伺われます*18。
If you sanitize content and then modify it afterwards, you can easily void your security efforts.
If you sanitize content and then send it to a library for use, check that it doesn’t mutate that string somehow. Otherwise, again, your security efforts are void.
実際のdesanitizationの例に興味がある方は、Sonar社の分析によくまとまっているので参照すると良いです*19。ここでは下記の事例をかいつまんで紹介します。
// (1) sanitize data = DOMPurify.sanitize(userInput); // (2) modify data = data.replace(/class=".*?"/, 'class="custom-class" '); // (3) use document.body.innerHTML = data;
さて、これのどこが脆弱なのでしょうか。DOMPurifyを通した時点でHTMLは安全になっているはずです。
しかし、Sonar社も記事中で言う通り「サニタイズすればデータは安全になり、そこに機能実装や意図したデータ変更を行っても問題はない」という認識こそが間違っているのです。その証拠に、このコードはuserInputとしてclass=" <div id="<img src onerror=alert(1)>">
という文字列が与えられるとXSSを実現されてしまいます。うぅ😭
userInput = 'class=" <div id="<img src onerror=alert(1)>">'; // (1) sanitize data = DOMPurify.sanitize(userInput); // class=" <div id="<img src onerror=alert(1)>"></div> // (2) modify data = data.replace(/class=".*?"/, 'class="custom-class" '); // class="custom-class"<img src onerror=alert(1)>"></div> // (3) use document.body.innerHTML = data; // triggers alert(1)
なお、直接手を加えることはしない方でも、サニタイズしたHTMLを別のHTMLの中に埋め込んで使っている方がいるかもしれません。それもXSS脆弱性につながるケースがあります*20。
B. フレームワークのエスケープ機構が使えるのに使われないケースがある
さて、前半では「そもそもフレームワークのエスケープ機構が使えない場所がある」というケースを見てきました。では、エスケープ機構がちゃんと使える場面なら大丈夫かというと、残念ながらそうでもありません。例えば、保護機能が限定的だったり、開発者が適切な使い方を誤ってしまったりするケースがあります。
前提として、XSSのsinkは非常に多様でしたよね。実はそれぞれのsinkに対して施すべきエスケープ処理は異なります*21。例えばURLにはURL向けのエスケープ、HTML属性には別のエスケープが必要です。Reactのように適した場所で適切な処理を自動で行ってくれるのならよいのですが、開発者が適切なエスケープ処理を選択・適用する必要がある場合、これは簡単なことではありません。
また、Reactのようにフレームワークの構文の中にエスケープ機構が組み込まれている({}
に入れるだけでよい)なら忘れる心配はありませんが、そうでないとミスがありえます。例えばjQueryはHTMLをtextとして扱う際にtext()
メソッドを使う必要がありますが、HackerOneのID243363のレポートなんかはその呼び忘れ、もしくは異なるエスケープ方法を選んでしまっていたように見えます。
// 筆者注: msgパラメーターを通じてXSSが可能 $(document).ready(function () { const msg = window.location.search.match(/[&?]msg=([^&]+)/); const msgText = msg ? decodeURIComponent(msg[1]) : "No data collected for this metric, cannot generate analytics."; $(document.body).html(Utils.infoMessage(msgText)); });
さらに、たとえフレームワークの機能が使いやすくても、実装の手間や時間の制約から、意図的にエスケープ処理が省略されたり、「まあ大丈夫だろう」と承認されたりすることがあります。例えば、複雑なJSONを加工しなければJSXの中に適切にはめこめないときに、その煩雑な処理を省略してしまうケースが考えられます。複雑な動的フォームのレンダリング処理などが典型例かもしれません。
このような省略や承認の背景には、「この入力なら安全だろう」という判断があるわけですが、これも先ほどGoogleの論文でも触れたように、その判断が間違っているケースが残念ながら非常に多いのです。詳細は明かせませんが、このようなケースが弊社の診断において実際に発見されたこともあります。
まとめると、フレームワークの防衛機構を適切に使えない、あるいは意図的に使わないというミスや判断が存在します。これは、新しい技術への移行期でフレームワークに不慣れな場合、開発初期の急ぎたい時期、あるいは時間がない中で追い込まれた開発状況など、様々なシチュエーションで発生し得る問題と言えるでしょう。
フレームワークの守備範囲外にもsinkは潜んでいる
さて、ここまではフレームワークの防衛機構が有効なはずのsinkがなぜ怖いのかという話でした。ここからは「守備範囲外」のsinkに目を向けます。これらのsinkは開発の現場で意外と登場する機会が多い割に、HTMLエスケープほどsinkとして広く知られていないという恐ろしさがあります。「XSSになっちゃいそうだなあ」という直感が働かないまま実装されて脆弱性と化してしまうのです。
では、そんな隠れsink(別に本人たちは隠れているつもりがないと思う)を実装場面とともに見てみましょう。
HTML属性(URL)
信頼できないデータをHTML属性値でURLとして使用するケースです。例えば<a href="${userInput}">Link</a>
はわかりやすく、userInputにjavascript:
スキームのURLを入れられるとaタグクリックでスクリプトが発火します。
このような実装が生まれやすいのは、例えば下記のような状況です。
- postMessageやライブラリ謹製の通信機構で受け取った値をもとに、aタグのhrefやimgタグのsrcを設定したいことがあります。例えばポップアップの中で選択された画像をもとのタブで使いたいなどでしょうか。下記はHackerOneのレポートID1379400で実際に報告された脆弱なコードです。
Meteor.startup(function() { MessageTypes.registerType({ id: 'message_snippeted', system: true, message: 'Snippeted_a_message', data(message) { const snippetLink = `<a href="/snippet/${ message.snippetId }/${ encodeURIComponent(message.snippetName) }">${ escapeHTML(message.snippetName) }</a>`; return { snippetLink }; }, }); });
- 稀かもしれませんが、標準仕様がurlを属性値に設定するよう定めていることがあります。HackerOneのレポートID2515808は、"OAuth2 form post response mode"という仕様が定める、formタグのaction属性にURLを設定するという流れを実装するなかで脆弱性が生まれており、とても興味深いです。XSSというよりはHTMLインジェクションですが!*22
JavaScript(URL)
信頼できないURLをJavaScriptのコードを通じて使用するケースです。例えばwindow.open()
やlocation.href
などにjavascript:
スキームのURLを入れられるとスクリプトが発火します。
このような実装が生まれやすいのは、例えば下記のような状況です。
- リダイレクト処理(
window.location.href
など)。特にログイン後の自動リダイレクトは認証済みのセッションで行われるため、悪用されると危険です。HackerOneのレポートID2611305が該当するでしょう - ポップアップウィンドウを開きたい場合もURLをJS経由で設定することが多いです。HackerOneのレポートID728001が該当するでしょう
JavaScript(関数の構築)
信頼できないデータをもとにスクリプトを動的に構成して実行するケースです。例えばeval(userInput);
です。
「そんなパターンいまどきある?」と思われるかもしれませんが、実例が存在します。HackerOneのID2670521はフォームから'};alert('XSS');var x={y:'
という値が送信されるとXSSが発火した事例です。詳細は明かされていませんが、おそらくユーザーが入力した検索クエリ文字列からスクリプトを生成して実行していたのではないかと思われます。たしかにうっかり作ってしまいそうなコードです。
補足: URLを検査してくれるフレームワークもあるっちゃある
さて、フレームワークが見てくれない傾向にあるsinkも、「よくある」機能の実装で登場することが分かって頂けたでしょうか。これもまた、フレームワークが普及した現代でXSSがまだ続く理由の1つだと考えられます。
ただ、フレームワークがこれらのsinkをガン無視しているかというとそれも違います。Laravelなどで使われる標準的なバリデーターであるisUrl
関数はjavascript:
スキームをはじきます*23。また、React v19ではhref属性におけるjavascriptプロトコルの無効化がようやく入りました*24。
それでもやはり前述のHTML関連のsinkに比べると手が薄いことが多いです。やはり注意は必要だと感じます。
ライブラリの誤った使い方がXSSを招くケースもある
さらにもう一つ見落とされがちなのが、ライブラリの誤った使い方に起因するXSSです。これはDependabotが教えてくれるようなライブラリそのものの脆弱性の話ではありません。利用しているライブラリの仕様を正しく理解せず、使い方を誤ることでXSSが生まれるという観点です。
例えば、ツールチップを表示するライブラリでcontent
として設定した値がHTMLとして解釈されるパターンがあります。また、markdownライブラリが出すHTMLも要注意です。
ただし、この論点については比較的新しいライブラリであれば誤用を防ぐ工夫を設けてくれていることが多いです。例えば"Tippy.js"というツールチップライブラリはallowHTML
というオプションでコントロールするようにしていますし*25、markdownパーサー"marked"もとても目立つ注意書きをREADMEにしてくれています*26。
それでも、危険なインターフェースがさりげなく出ているライブラリがあってもおかしくないなとは感じますし、EJS記法の<%=
(HTMLエスケープあり)と<%-
(HTMLエスケープなし)のように視認性が低いパターンも気をつけた方がよいと思っています。
XSSの多層防御は有効なのか
さて、ここまではかなり個別具体的な発生原因を見てきました。ここで少し毛色の違う話をさせてください!CSPやWAFをはじめとした、XSSの多層防御の有効性についてです。
結論から言うと、これらを追加のセキュリティレイヤーとして活用することは素晴らしいことなのですが、ここまで見てきたような事態に対する根本的なアプローチをしないままにCSPやWAFがあるからひとまず安心だよねと考えるのは危険です。
最初にCSPについて見てみましょう。これは、皆さんもご存知だと思いますが、正しく設定することがかなり難しいと言われています。どのくらい難しいかというと、CSP Level 3の仕様に"Deployment of an effective CSP against XSS is a challenge"と書かれている*27程度には難しいです。事実、特定のホストを許可するようなCSPのやり方は"script gadget"と呼ばれる抜け道の研究*28を含め、これまで非常に多くのバイパスが発見されています。例えばHackerOneのID2279346のレポートでは、CSPでhttps://www.google.com/recaptcha/
のスクリプトが許可されていたことでreCAPTCHAが含むAngularの機構を利用したCSPバイパスが成功しています。加えてID2246576では詳細こそ不明ですが、GitHub Enterprise ServerでのCSPバイパスが行われています*29。
さて、そこで唱えられたのがStrict CSPです。要するに「CSPってバイパスされがちだけど、こう設定すれば大丈夫なのでは?」という設定方法です。CSP Level 3の仕様に定められており、またweb.devでも導入方法が案内されています。Strcit CSPの考え方は、特定のホストを許可するのではなくて動的なランダム値やスクリプトのハッシュ値で制御をしようというものです。この設定はたしかにXSSリスクをかなり低減できるでしょう。ただ一方で、やはり導入コストが高いという問題があります。事実、HTTP Archiveにより公開されている2024 Web Almanacによれば、CSPを使用しているデスクトップ向けサイトの91%およびモバイル向けサイトの92%が、依然としてscript-src
ディレクティブにunsafe-inline
を含めているのです*30。
こういった導入の難しさや実際の状況をふまえると、CSPだけでXSSを防ごうとするのはあまり有効ではないように思えます。
では、Trusted Typesはどうでしょうか。XSSのsinkになりうるAPIへの入力値に対して、そのページの開発者が定義したvalidationを通過済みであることを強制する仕組みです。
これはまずChromiumをベースとしたブラウザでしか使用できない、つまりFirefoxやSafariは対応していないことが短所として挙げられます。加えて、やはりこれもバイパスが研究されています。リポジトリ"Cursed Types"にはTrusted Typesをすり抜けてしまうXSSのsinkが集められており、例えば下記のパターンはわかりやすいです。XSSのsinkは多様ですねえ👶
let attackerControlledString = '<img src=x onerror=alert(origin)>'; const blob = new Blob([attackerControlledString], {type: 'text/html'}); const url = URL.createObjectURL(blob); location.href = url;
ではWAFは?はい、事情は同じでバイパスがたくさんあります。あのOWASPも警鐘をならしているほどです*31。
WAF’s are unreliable and new bypass techniques are being discovered regularly. WAFs also don’t address the root cause of an XSS vulnerability. In addition, WAFs also miss a class of XSS vulnerabilities that operate exclusively client-side. WAFs are not recommended for preventing XSS, especially DOM-Based XSS.
さて、ここまでCSPやTrusted Types、そしてWAFについて見てきました。いずれもやはりそれだけでXSSを防げるものではなさそうです。
大事なことは、XSSの多層防御の策はどれもXSSのリスク低減にしかならないということです。つまり、XSSにつながるソースコードを根本的になくすわけではなく、そのようなソースコードがあったときに攻撃者がXSSを成功させる難易度を高くするだけなのです。
ではあなたがXSSのチャンスを見つけた攻撃者だとして、難易度が高いというだけで諦めるでしょうか。おそらく諦めません、必ずバイパスを探すはずです。そりゃそうです、XSSを成功させればいくらでもお金が手に入る悪用法があるんです!だから生半可なCSPは蹴散らすし、Trusted TypesやWAFなら世界中で公開されているバイパスを片っ端から試すはずです。攻撃者はそのくらいするわけです👀
もちろん、難易度を高くしていった結果として、本当にXSSが成立しないようにできているケースもあります。しかしそれは結果論であって、多層防御があるから大丈夫という認識は持たない方が良いです。最後にあらためて、OWASPの言葉を引用しておきましょう*32。
One final note: If deploying interceptors / filters as an XSS defense was a useful approach against XSS attacks, don't you think that it would be incorporated into all commercial Web Application Firewalls (WAFs) and be an approach that OWASP recommends in this cheat sheet?
じゃあどうしたらええんや!?
すみません、ここまで長々と書いてしまいました。最後に、Still X.S.S.(Dr. Xss)な現代において私たちがどのようにXSSと向き合えば良いのかを考えてみます👶
ここまでの話をまとめると、現代において発生するXSSのパターンは要するにこういうことでした。あらためて記載します。
- 安全だと思っていた値が安全ではなかった
- 自前のサニタイザーを使っていたがバイパスされてしまった。もしくはOSSのサニタイザーを使っていたが、使用法が間違っていた
- フレームワークが用意した防衛機構を正しく使っていなかった、もしくはそもそも使わないで済ませていた
- フレームワークが守ってくれない、かつ、あまり知られていないsinkが埋め込まれていた
- ライブラリの仕様を正しく理解せずに使っていた
パターン踏まえると、採るべき対策が自ずと見えてきますネ。ということで、そもそも現代においてXSS脆弱性を作らないために大事なことは下記のようになると考えています。
- すべての値を疑う。プログラムは常に変化し、複雑さも増していきます。攻撃者がどの値をどのくらいコントロールできるのかを正確に分析することは不可能に近いと思った方がよいです。安全な値なんてないというくらいの心構えで良いと思います
- フレームワークの外で開発する場合、HTMLを扱うときは必ず、広く使われているOSSのサニタイザーを使う。DOMPurifyはフロントエンドでもバックエンドでも使用可能で、OWASPにも推奨されています。おすすめです。また、何を使うにせよ、サニタイズ結果には一切手は加えないでください!
- フレームワークの中で開発する場合、提供されている防衛策を「正しく」理解して、「必ず」使う。まずはフレームワークのドキュメントなどを参考に、提供されているセキュリティ機能を正しく理解すべきです。そのうえで、使える場所では何としてでもそれを使ってください。可能ならコードレビューだけでなく、リンターなどによる自動チェックを導入できると良いです
- URLを扱う場合はスキームのチェックをする。
javascript:
といったURLが入らないようにしてください - 入力をもとにスクリプトを動的に生成しない。特にJavaScriptはプロトタイプチェーンへの介入など、言語そのものの特性として多くのことができるようになっています。ユーザー入力をもとにスクリプトを生成して実行するような機構はリスキーです
- ライブラリの挙動を理解する。最終的なHTMLおよびJSの生成に関わるライブラリについて、そのドキュメントをしっかり読んでおきましょう。特に、セキュリティに関する事項は「warning」「security」「caution」「deprecated」「preview」といったキーワードで検索すると見つかることがあります*33。ぜひやってみてください!
そして、これらの方法を実践したうえでさらにやるとすれば次のことでしょう。
- 静的解析ツールでsinkの有無をチェックする。XSSのsinkは多様である一方である程度は定型化されているため、CodeQLやSemgrepのような静的解析ツールによる検査が有効です。CI/CDに組み込んでプルリクエストのマージ条件にするのが理想です。もし可能であれば、ライブラリがバンドルされたJavaScriptに対しても静的解析をかけることでライブラリに潜むsinkも発見しやすくなります。このとき、Semgrepのように差分のみを対象に検査できるツールであれば、実行時間も多少は抑えられます。しかし、静的解析も万能ではありません。公式のルールが十分でない場合もあるため、これに頼り切るのは避けた方が懸命でしょう
- 怪しいモジュールはiframeに入れてsandboxする。例えばWYSIWYGエディターでの編集結果を表示する部分だけをiframeに入れて、iframeができることをsandbox属性やallow属性で制限することは有効です
- そのうえでCSPやWAFを利用する。根本的な対策をしたうえで、リスクを低減することは非常に有効です
おわりに
ということで、ここまでいまだにXSSがこわい理由を眺めてみました👶<コワイヨー
フレームワークやサニタイザーがどれだけ進化を重ねても、ちょっとしたほころびからXSSが生まれてしまう背景をお分かり頂けたなら幸いです。
なお、GMO Flatt Securityの脆弱性診断・ペネトレーションテストでは、XSSに留まらない幅広い観点で脆弱性をお探しします。みなさんの気が向きましたら、いつでもなんなりとご相談ください!
人間の代わりにコードレビューをしてくれるAIエージェント・Takumiも提供中です、ぜひ!
ではでは👋
*1:https://github.com/facebook/react/pull/26507
*2:https://cwe.mitre.org/top25/archive/2020/2020_cwe_top25.html#cwe_top_25
*3:https://cwe.mitre.org/top25/archive/2021/2021_cwe_top25.html#cwe_top_25
*4:https://cwe.mitre.org/top25/archive/2022/2022_cwe_top25.html
*5:https://cwe.mitre.org/top25/archive/2023/2023_top25_list.html
*6:https://cwe.mitre.org/top25/archive/2024/2024_cwe_top25.html
*7:https://www.hackerone.com/resources/reporting/8th-hacker-powered-security-report
*8:https://blog.flatt.tech/entry/flatt_top10_2025#2025%E5%B9%B4%E7%89%88-Top-10
*9:個人的に、昨年のXSSレポートのsinkを分類してまとめた結果をレポジトリにあげています。ベストエフォートですが、興味がある方はみてみてくださいネ👶 https://github.com/canalun/h1-2024-xss-categorization
*10:この論文はTrusted Typesの元になった考え方を紹介しており、当該仕様のexplainerでも言及されています: https://dl.acm.org/doi/pdf/10.1145/2643134
*11:平たく言えば、主にGoogleのプロダクトを対象にしたバグバウンティプログラムのこと
*12:興味のある方はぜひご自身でも読んでみてください!ちなみに本記事で言及したのは"It is noteworthy that the persistent storage contains both trustworthy and untrustworthy data in different entities of the same schema—no blanket assumptions can be made about the provenance of stored data."や、"In reality, a large, nontrivial Web application will have hundreds if not thousands of branching and merging data flows into injection-prone sinks. Each such flow can potentially result in an XSS bug if a developer makes a mistake related to validation or escaping. Exploring all these data flows and asserting absence of XSS is a monumental task for a security reviewer, especially considering an ever-changing code base of a project under active development. "といった部分のつもりです
*13:興味のある方はDevToolsでHTMLに差し込んでみてください。alertが実行され、ポップアップが表示されるはずです
*14:https://web.dev/articles/howbrowserswork?hl=ja#browsers_error_tolerance
*15:答えはhttps://dl.acm.org/doi/pdf/10.1145/2643134を参照
*16:https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html#html-sanitization
*17:https://www.sonarsource.com/blog/pitfalls-of-desanitization-leaking-customer-data-from-osticket/
*18:https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html#html-sanitization
*19:https://www.sonarsource.com/blog/pitfalls-of-desanitization-leaking-customer-data-from-osticket/
*20:現実の脆弱性ではなくCTFの問題になりますが、弊社の企画"Flatt Security XSS Challenge"の第一問はまさにサニタイズ結果を埋め込むことの危険性を取り扱っています。textareaタグにDOMPurifyの結果を入れ込むという問題設定で、現実世界にもありそうです👶
*21:https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html
*22:このレポートは面白いです。ぜひ読んでみてください👀
*23:https://github.com/illuminate/support/blob/19062b37bbdb1ef7a94f003adb2fd1cc775b47ae/Str.php#L565C1-L566C1
*24:https://github.com/facebook/react/releases/tag/v19.0.0#:~:text=Javascript%20URLs%20are%20replaced%20with%20functions%20that%20throw%20errors%20(%2326507%2C%20%2329808%20by%20%40sebmarkbage%20and%20%40kassens)))。なんとobjectタグ経由でのXSSに対する対応も入っています((https://github.com/facebook/react/pull/29808
*25:https://atomiks.github.io/tippyjs/#html-content
*26:https://github.com/markedjs/marked?tab=readme-ov-file#warning--marked-does-not-sanitize-the-output-html-please-use-a-sanitize-library-like-dompurify-recommended-sanitize-html-or-insane-on-the-output-html-
*27:https://www.w3.org/TR/CSP3/#strict-csp
*28:https://www.blackhat.com/docs/us-17/thursday/us-17-Lekies-Dont-Trust-The-DOM-Bypassing-XSS-Mitigations-Via-Script-Gadgets.pdf
*29:https://hackerone.com/reports/2246576
*30:https://almanac.httparchive.org/en/2024/security#keywords-for-script-src
*31:https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html
*32:https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html
*33:https://speakerdeck.com/hamayanhamayan/ctfnowebniokeru-nan-yi-du-wen-ti-nituite?slide=26