【NewStarCTF2023】OtenkiGirl nodejs 原型链污染
【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 | const merge = (dst, src) => { |
这里传入的 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 | router.post("/submit", async (ctx) => { |
可以看到这里将我们传入的数据做了 json 解析然后放到 merge 函数里, 这里就能够触发原型污染了. 主要是怎么通过原型污染去获取到 flag 呢, 我们看其他文件, 发现info.js
中存在代码:
1 | async function getInfo(timestamp) { |
这里的逻辑就是你传入一个时间戳,然后和他设置的最小时间戳做对比,把大的那个时间戳作为 SQL 的查询匹配条件. 这里的思路就很明显了,我们要把它所有的时间戳的记录全部调出来,就能拿到 flag.
注意这一行代码:
1 | let minTimestamp = new Date(CONFIG.min_public_time || DEFAULT_CONFIG.min_public_time).getTime(); |
这一行代码会判断 minTimestamp
是否为空如果是空就换成他设置的默认值, 这里就能触发 prototype 调用了, 如果我们在先前的参数中传入 minTimestamp
,给他赋一个极小的值. 之后在判断是否为空的时候,就会沿着原型链往上寻找,找到我们设置的值并赋上,然后后面的 SQL 语句就能全部调出来了.
最后构造 payload 为:
构造完 payload 后, 重进一次主页就能看到 flag 了: