前言
这个是前几天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)) { 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) { 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) { if(user.money >= 11 && user.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导航