前言

这个是前几天buu的九月赛的一道web题,赛后根据官方出的wp也是复现了一下,当时打的时候没有看附件,以为就是一个逻辑漏洞,之后才知道是node.js的代码审计和原型链污染,原型链污染我也了解过,只不过一直没有练习对应的题目,所以说这一题对我来说也是有必要记录一下的。

题目复现

打开题目是一个购买商店,登录普通用户有十点钱数。

但是flag要十一点,很明显买不起的。以为跟逻辑漏洞有关,其实给了附件,附件中有源码,审计js代码。

const fs = require('fs');
const express = require('express');
const session = require('express-session');
const bodyParse = require('body-parser');
const app = express();
const PORT = process.env.PORT || 80;
const SECRET = process.env.SECRET || "cybershop_challenge_secret"
const adminUser = {
username: "admin",
password: "😀admin😀",
money: 9999
};
app.use(bodyParse.urlencoded({extended: false}));
app.use(express.json());
app.use(session({
secret: SECRET,
saveUninitialized: false,
resave: false,
cookie: { maxAge: 3600 * 1000 }
}));
app.use(express.static("static"));

app.get('/isLogin', function(req, res) {
if(req.session.username) {
return res.json({
code: 2,
username: req.session.username,
money: req.session.money
});
}else{
return res.json({code: 0, msg: 'Please login!'});
}
});
app.post('/login', function(req, res) {
let username = req.body.username;
let password = req.body.password;
if (typeof username !== 'string' || username === '' || typeof password !== 'string' || password === '') {
return res.json({code: 4, msg: 'illegal username or password!'})
}

if(username === adminUser.username && password === adminUser.password.substring(1,6)) {//only admin need password
req.session.username = username;
req.session.money = adminUser.money;
return res.json({
code: 1,
username: username,
money: req.session.money,
msg: 'admin login success!'
});
}
req.session.username = username;
req.session.money = 10;
return res.json({
code: 1,
username: username,
money: req.session.money,
msg: `${username} login success!`
});
});
app.post('/changeUsername', function(req, res) {
if(!req.session.username) {
return res.json({
code: 0,
msg: 'please login!'
});
}
let username = req.body.username;
if (typeof username !== 'string' || username === '') {
return res.json({code: 4, msg: 'illegal username!'})
}
req.session.username = username;
return res.json({
code: 2,
username: username,
money: req.session.money,
msg: 'Username change success'
});
});
//购买商品的接口
function buyApi(user, product) {
let order = {};
if(!order[user.username]) {
order[user.username] = {};
}
Object.assign(order[user.username], product);

if(product.id === 1) { //buy fakeFlag
if(user.money >= 10) {
user.money -= 10;
Object.assign(order, { msg: fs.readFileSync('/fakeFlag').toString() });
}else{
Object.assign(order,{ msg: "you don't have enough money!" });
}
}else if(product.id === 2) { //buy flag
if(user.money >= 11 && user.token) { //do u have token?
if(JSON.stringify(product).includes("flag")) {
Object.assign(order,{ msg: "hint: go to 'readFileSync'!!!!" });
}else{
user.money -= 11;
Object.assign(order,{ msg: fs.readFileSync(product.name).toString() });
}
}else {
Object.assign(order,{ msg: "nononono!" });
}
}else {
Object.assign(order,{ code: 0, msg: "no such product!" });
}
Object.assign(order, { username: user.username, code: 3, money: user.money });
return order;
}
app.post('/buy', function(req, res) {
if(!req.session.username) {
return res.json({
code: 0,
msg: 'please login!'
});
}
var user = {
username: req.session.username,
money: req.session.money
};
var order = buyApi(user, req.body);
req.session.money = user.money;
res.json(order);
});
app.get('/logout', function(req, res) {
req.session.destroy();
return res.json({
code: 0,
msg: 'logout success!'
});
});
app.listen(PORT, () => {console.log(`APP RUN IN ${PORT}`)});

代码很长,但是仔细读代码也不是很难懂。对js代码进行审计,在这里我们可以看到admin的登录密码,只要我们登录为admin,就有9999点钱数了,还怕买不到flag?

const adminUser = {
username: "admin",
password: "😀admin😀",
money: 9999
};

往下读代码,会对password进行截取处理。

if(username === adminUser.username && password === adminUser.password.substring(1,6))

我们直接可以在游览器上运行js代码。

成功登录为admin,获得9999钱数,但是还是买不了flag。这是为什么?继续审计源代码。我们主要分析buyApi这一个购买函数。

这里还有一个user.token的验证,而这个token属性不能在请求体直接加的,因为user类里的属性都被限定死了。

这里就要利用到node.js的原型链污染漏洞了。首先,原型链污染的利用条件是copy函数,那么在源代码中是否有类似复制功能的函数,仔细看,还真的有。

let order = {};
if(!order[user.username]) {
order[user.username] = {};
}
Object.assign(order[user.username], product);

百度查看assign函数的作用。

有了利用条件那么就好办了。order字典是空的,最后将product对象中的属性复制到order字典中user.username属性里,这里的user.username就是我们的用户名admin,继续看一下product的由来,

var order = buyApi(user, req.body);

就是请求体的内容了,也是我们可控的,user.username和product我们都可控,那么思路就有了,我们可以将admin改名为proto,然后然后通过这个copy函数将token属性copy到原型上,那么原型就有了这个tokan属性了,当需要验证user.token时,本对象没有该属性,会向自己的原型上寻找,那么也就可以成功验证了。

可以看出验证通过了。继续审计代码。

它会判断请求体里有没有flag字段,如果有的话就会给你返回这个提示,反之对name进行文件读取,我们呢来测试一下是否真的会任意文件读取。

目前的一个问题就是flag字段被变相过滤了,我们该怎么绕过限制呢?分析一下fs.readFileSync函数。

路径可以是url类型,可以将flag进行url编码来绕过。用fl%61g来绕。例如用官方wp的例子来说,

const fs = require('fs');
flag = fs.readFileSync(new URL('file:///fl%61g')).toString();
console.log(flag);
console.log(new URL('file:///fl%61g'));

所以说我们要传url对象,根据上面代码打印出URL实例。

运行上面的js代码,本地根目录的flag也被成功读出,说明是没问题的。当然也可以去fs.readFileSync函数的源码里看看,调试跟进代码。这里贴上师傅博客: corCTF2022 部分Web - Pysnow’s Blog

把name字段赋值这些属性,发包读flag。

成功得出flag了。

结语

原型链污染的利用条件很苛刻,在代码中找到类似于copy功能的函数的话,根据js代码环境,就要考虑到是否存在原型链污染漏洞了。

官方wp:DASCTF X CBCTF 2022| 九月挑战赛官方Write Up | CTF导航