前言
这个是前几天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导航