【NewStarCTF2023】OtenkiGirl nodejs 原型链污染

题目

进去之后,是一个类似于 blog 的页面,你输入对应信息后加入购物车就会把东西张贴出来:

后台抓包发现是post json. 题目给了源码,打开源码文件后有 hint 文件,提示我们去 routes 文件夹里找,然后在 submit.js 文件里看到了 merge 函数.

merge函数是一个经典的 nodejs 污染函数,接下来浅析一下什么是 nodejs 原型链污染。

nodejs 原型链污染

JavaScript 中,几乎所有对象都是 Object 的实例,对象原型包含了可以被继承的属性和方法。攻击者通过污染对象原型添加或修改属性,能够影响所有继承自该原型的对象。

在一个对象生成后,会生成一个关于该对象的原型,如:

1
let cat = {};

上述代码构造了一个对象 cat, 首先会生成该 cat 的原型,即 cat.prototype. 生成 prototype 后,会生成一个隐式的构造函数 cat(), 这里构造函数 cat() 的原型 (protoype)就是 cat.prototype, 而对于构造函数构造出来的对象,也会有一个属性指向他的原型,即 cat['__proto__'].

同时由于 cat 对象是继承于 Object 对象,所以 cat 对象的原型 cat.prototype 也有原型,他的原型就是 Object 的原型 Object.prototype.

1
cat.prototype['__proto__'] =  Object.prototype

由于 Object 没有父类,所以 Object 的原型就是 null.

所以在 cat 这个对象就有一条原型链:

1
cat(实例对象) -> cat.prototype(实例原型) -> Object['__proto__'] -> null

给一张网图方便理解:

对于下面代码,我们调用了一个 cat 中不存在的属性:

1
console.log(cat.toString);

由于 cat 中没有 toString 属性, 他会沿着原型链向上搜索(先搜索 cat 自己的 prototype,再搜索 Object 的,最后到 null 返回)。也就是说在这里他会去尝试调用 Object.prototype 的 toString 属性

如果攻击者能够篡改 Object.prototype.toString, 那么上面这句代码就会打印攻击者所指定的内容。

这里由于 Object 是几乎所有属性的类, 所以只要代码中存在

1
obj1[a] = obj2[a]

其中如果 obj1 和 a 我们可以指定且obj1[a]的值我们可以指定,那么就可以触发原型链污染.

接下来分析为什么merge函数会触发原型链污染:

1
2
3
4
5
6
7
8
9
10
11
const merge = (dst, src) => {
if (typeof dst !== "object" || typeof src !== "object") return dst;
for (let key in src) {
if (key in dst && key in src) {
dst[key] = merge(dst[key], src[key]);
} else {
dst[key] = src[key];
}
}
return dst;
}

这里传入的 dst 和 src 都是 Object 类,这里首先会遍历 src 里的所有键 key, 然后将判断 key 是否在源实体和目标实体都存在,如果存在就递归 merge(这里递归是为了保证结构一致),不存在就直接赋值.

在这里如果 src 中存在我们人为构建的键名 __proto__, 如构造了

1
{ '__proto__':{'a':'1'} }

那么在

1
if (key in dst && key in src)

这行代码就会通过,因为对于 dst , __proto__ 是一定存在的, 之后就会进入下一轮递归, 在下一轮递归中, 此时的 dst 就是的原来的 dst['__proto__'], 然后就会 for 循环遍历src['__proto__']的键名,此时的 key = a, 进入到判断语句,此时判断语句不会通过, 因为dst['__proto__']中不存在属性 a, 然后就会进入到赋值语句:

1
dst[key] = src[key];

此时上面这行代码就等效于

1
dst['__proto__']['a'] = src['__proto__']['a'] = 1

就完成了污染,对于每一个 Object 类 ,我们在新建的之后调用 a 属性,都会沿着原型链找到 Object['__proto__']里的 a 属性,其中 a 属性被赋值成了 1.

这里有一点要注意,要确保 merge 操作前, src 经过 json 解析, 如果不经过解析,那么__proto__会被认定是原型,for 遍历时就会从他开始遍历,即这是的 key 就变成 a了。json 解析后 __proto__ 才会被严格解析成一个键名, 而非是原型,即经过下列操作:

1
let dst = JSON.parse({ '__proto__':{'a':'1'} });

解题

题目代码的提交逻辑为:

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
router.post("/submit", async (ctx) => {
if (ctx.header["content-type"] !== "application/json")
return ctx.body = {
status: "error",
msg: "Content-Type must be application/json"
}

const jsonText = ctx.request.rawBody || "{}"
try {
const data = JSON.parse(jsonText);

if (typeof data["contact"] !== "string" || typeof data["reason"] !== "string")
return ctx.body = {
status: "error",
msg: "Invalid parameter"
}
if (data["contact"].length <= 0 || data["reason"].length <= 0)
return ctx.body = {
status: "error",
msg: "Parameters contact and reason cannot be empty"
}

const DEFAULT = {
date: "unknown",
place: "unknown"
}
const result = await insert2db(merge(DEFAULT, data));
ctx.body = {
status: "success",
data: result
};
} catch (e) {
console.error(e);
ctx.body = {
status: "error",
msg: "Internal Server Error"
}
}
})

可以看到这里将我们传入的数据做了 json 解析然后放到 merge 函数里, 这里就能够触发原型污染了. 主要是怎么通过原型污染去获取到 flag 呢, 我们看其他文件, 发现info.js中存在代码:

1
2
3
4
5
6
7
8
async function getInfo(timestamp) {
timestamp = typeof timestamp === "number" ? timestamp : Date.now();
// Remove test data from before the movie was released
let minTimestamp = new Date(CONFIG.min_public_time || DEFAULT_CONFIG.min_public_time).getTime();
timestamp = Math.max(timestamp, minTimestamp);
const data = await sql.all(`SELECT wishid, date, place, contact, reason, timestamp FROM wishes WHERE timestamp >= ?`, [timestamp]).catch(e => { throw e });
return data;
}

这里的逻辑就是你传入一个时间戳,然后和他设置的最小时间戳做对比,把大的那个时间戳作为 SQL 的查询匹配条件. 这里的思路就很明显了,我们要把它所有的时间戳的记录全部调出来,就能拿到 flag.

注意这一行代码:

1
let minTimestamp = new Date(CONFIG.min_public_time || DEFAULT_CONFIG.min_public_time).getTime();

这一行代码会判断 minTimestamp 是否为空如果是空就换成他设置的默认值, 这里就能触发 prototype 调用了, 如果我们在先前的参数中传入 minTimestamp ,给他赋一个极小的值. 之后在判断是否为空的时候,就会沿着原型链往上寻找,找到我们设置的值并赋上,然后后面的 SQL 语句就能全部调出来了.

最后构造 payload 为:

构造完 payload 后, 重进一次主页就能看到 flag 了: