📚 网络安全 Kali 学习系列
模块 0 基础阶段 ✅ 已完成
01-18 Linux系统 → 计算机网络 → 密码学 → Python/Bash/PHP/JS → Docker
模块 1 信息收集与侦察 ✅ 已完成
19 nmap → 20 masscan → 21 指纹识别 → 22 漏洞扫描与 Web 基础
模块 2 Web 应用渗透 ✅ 已完成
23 sqlmap → 24 XSS → 25 CSRF/上传 → 26 命令执行 → 27 文件包含/SSRF → 28 反序列化 → 29 XXE → 30 越权 → 31 WAF
模块 3 代码审计(白盒分析阶段)
✓ 32 代码审计入门:从黑盒到白盒的思维转换
33 Node.js 原型链污染:原理与利用(当前篇)
34 PHP 代码审计实战(预告)
模块 4-5 后续持续更新
导读
上一篇我们建立了代码审计的通用方法论:污点分析、Source → Sink 追踪、多语言危险函数清单。但 JavaScript/Node.js 有一类其他语言完全不存在的漏洞——原型链污染(Prototype Pollution)。它不像 SQL 注入那样显眼,却能从一个看似无害的 merge() 函数出发,最终实现远程代码执行。
读完本篇你将能:理解 JavaScript 原型链机制为什么天然存在污染风险、通过 __proto__ 和 constructor.prototype 两种路径实现污染、审计代码中常见的 merge/clone/deepCopy 注入点、从污染属性到 RCE/权限绕过/DoS 的完整利用链、用 Semgrep 编写 Node.js 原型链污染检测规则。
目录
1. 原型链基础:JavaScript 的"遗传"机制
2. 原型链污染原理:__proto__ 与 constructor 两条路径
3. 常见注入点:merge、clone、deepCopy 函数审计
4. 利用链分析:从污染属性到 RCE / 权限绕过 / DoS
5. Node.js 其他审计要点:SSRF 与命令注入
6. 自动化检测:Semgrep 自定义规则实战
7. 分级练习
1. 原型链基础:JavaScript 的"遗传"机制
在 PHP、Python、Java 中,类和对象的关系是"模具 → 铸件"——类定义了属性和方法,对象是类的实例。JavaScript 不同,它没有真正的类(ES6 的 class 只是语法糖),对象之间通过"原型链"实现继承:每个对象都有一个隐藏的 __proto__ 属性,指向它的"原型对象",当访问一个对象没有的属性时,JavaScript 会顺着原型链一层层往上找。
JavaScript · 原型链与属性查找
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// 构造函数相当于"类"的角色
function User(name) {
this.name = name;
}
// 原型对象上的方法,所有实例共享
User.prototype.greet = function() {
return `Hello, ${this.name}`;
};
const alice = new User('Alice');
console.log(alice.name); // "Alice" — 自身属性
console.log(alice.greet()); // "Hello, Alice" — 原型链查找
console.log(alice.__proto__ === User.prototype); // true
|
第 14 行的 alice.greet() 调用时,JavaScript 先在 alice 自身找 greet,没找到,就顺着 alice.__proto__ 找到 User.prototype,在那里找到了 greet。这就是原型链查找。
💡 小贴士
三个概念必须分清:prototype 是函数的属性(构造函数才有),__proto__ 是对象的属性(每个对象都有),constructor 是原型对象上的属性,指回构造函数。三者关系:alice.__proto__ === User.prototype,User.prototype.constructor === User。
原型链的顶端是 Object.prototype——所有对象最终都会继承自它。这意味着如果能修改 Object.prototype,就等于给所有对象"注射"了一个新属性。这就是原型链污染攻击的核心思路。
2. 原型链污染原理:两条污染路径
原型链污染的本质是:攻击者控制了某个对象的属性名,并且这个属性名是 __proto__ 或 constructor.prototype,而代码又用这个属性名去赋值,结果就修改了 Object.prototype。以下是两条核心污染路径。
路径一:__proto__ 直接污染
最直接的方式。当代码递归合并对象时,如果键名是 __proto__,JavaScript 不会把它当作普通属性名,而是会访问对象的原型。
JavaScript · __proto__ 污染演示
|
1
2
3
4
5
6
7
8
9
10
11
12
|
// 一个普通的空对象
const obj = {};
// 污染:通过 __proto__ 修改 Object.prototype
obj['__proto__'].polluted = 'yes';
// 验证:所有对象都"继承"了 polluted 属性
const victim = {};
console.log(victim.polluted); // "yes" — 被污染了!
// 等价于直接修改 Object.prototype
console.log(Object.prototype.polluted); // "yes"
|
第 5 行用方括号语法给 obj['__proto__'] 的 polluted 属性赋值。因为 __proto__ 不是普通键名,它指向 Object.prototype,所以赋值操作实际上修改了原型链顶端——结果是所有对象都能读到 polluted 属性。
路径二:constructor.prototype 绕过
有些框架会过滤 __proto__ 关键字,但忘记过滤另一条等价路径:constructor.prototype。因为 obj.constructor 指向创建该对象的构造函数(即 Object),而 Object.prototype 就是原型链顶端。
JavaScript · constructor.prototype 绕过
// 绕过 __proto__ 过滤的替代路径
const obj = {};
obj['constructor']['prototype'].polluted = 'yes';
const victim = {};
console.log(victim.polluted); // "yes" — 同样被污染
⚠️ 常见错误
只过滤 __proto__ 就以为安全了
正确做法:必须同时过滤 __proto__ 和 constructor,或者在合并对象时使用 Object.prototype.hasOwnProperty.call(target, key) 严格检查属性是否为自身属性,禁止沿原型链查找。
在代码审计中,只要发现"用户可控的键名"被用于"递归对象赋值",就要警觉原型链污染风险。下一章我们具体看哪些函数模式最容易出问题。
3. 常见注入点:merge、clone、deepCopy 函数审计
知道了污染原理,接下来要在代码里找哪里会触发。原型链污染的必要条件有两个:一是键名可控(用户输入能决定对象的属性名),二是递归赋值(代码会逐层深入嵌套对象并赋值)。以下三类函数是最高危的注入点。
注入点一:对象合并(merge / extend)
对象合并函数把源对象的属性递归复制到目标对象上。如果源对象来自用户输入(如 JSON 请求体),且合并函数没有过滤 __proto__,就会产生污染。以下是一个典型的有漏洞 merge 函数:
JavaScript · 有漏洞的 deepMerge 函数
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
function deepMerge(target, source) {
for (const key in source) {
// ⚠️ 没有过滤 __proto__ 和 constructor
if (
typeof source[key] === 'object' &&
source[key] !== null &&
!Array.isArray(source[key])
) {
// 递归合并嵌套对象
target[key] = target[key] || {};
deepMerge(target[key], source[key]);
} else {
// 直接赋值基本类型
target[key] = source[key];
}
}
}
|
审计要点在第 3 行的注释处:for...in 遍历会遍历原型链上的属性,而且代码没有检查 key 是否是 __proto__ 或 constructor。当用户提交的 JSON 中包含 {"__proto__": {"isAdmin": true}} 时,第 10 行的 target[key] = target[key] || {} 会创建一个新对象并赋给原型,第 11 行递归就会往 Object.prototype 上写属性。
注入点二:深拷贝(deepClone / cloneDeep)
深拷贝函数的原理和 merge 一样——递归遍历对象的所有属性并复制到新对象中。如果源对象包含 __proto__ 键,复制过程就会污染新对象的原型链。著名的 lodash _.cloneDeep 在 4.17.5 之前的版本就存在此漏洞(CVE-2018-16487)。
注入点三:路径赋值(setPath / set)
有些工具库提供"用点路径设置属性"的函数,如 set(obj, 'a.b.c', value)。如果路径字符串来自用户输入,攻击者可以构造 __proto__.polluted 作为路径来实现污染。
| 函数类型 |
常见名称 |
风险等级 |
审计要点 |
| 对象合并 |
merge / extend / assignDeep |
高危 |
是否过滤 __proto__ / constructor |
| 深拷贝 |
cloneDeep / deepClone / copy |
高危 |
递归复制时是否跳过原型属性 |
| 路径赋值 |
set / setPath / resolve |
中危 |
路径分段后是否检查每段键名 |
| 配置解析 |
parseConfig / loadOptions |
中危 |
用户输入是否参与配置合并 |
💡 小贴士
审计时的快速搜索关键词:grep -rn "merge\|deepMerge\|extend\|cloneDeep\|deepClone" .。找到这些函数后,追踪它们的参数来源——如果源数据来自 req.body、req.query、JSON.parse() 等用户可控输入,就标记为可疑点。
4. 利用链分析:从污染到 RCE / 权限绕过 / DoS
污染原型链本身只是第一步——往 Object.prototype 上写了一个属性,能造成什么危害取决于应用代码如何使用这个被污染的属性。以下是三种典型的利用链,按利用难度从低到高排列。
利用一:权限绕过(最常见)
如果代码中用 obj.isAdmin 或 user.role 这种方式判断权限,而对象本身没有这个属性,就会沿原型链查找。污染 Object.prototype.isAdmin = true 就能让所有对象"看起来都是管理员"。
JavaScript · 权限绕过利用链
|
1
2
3
4
5
6
7
8
9
10
11
12
|
// 服务端权限检查代码
function checkAdmin(user) {
// ⚠️ 直接访问属性,未用 hasOwnProperty 检查
if (user.isAdmin) {
return 'admin panel';
}
return 'access denied';
}
// 攻击者通过原型污染注入 isAdmin 属性
const normalUser = { name: 'guest' };
console.log(checkAdmin(normalUser)); // "admin panel" — 已绕过!
|
第 4 行的 user.isAdmin 判断方式很常见,但如果 user 对象自身没有 isAdmin,JavaScript 会沿原型链往上找。攻击者只需通过 merge 函数污染 Object.prototype.isAdmin = true,就能让所有普通用户都通过权限检查。
利用二:远程代码执行(RCE)
RCE 利用链更复杂,需要找到"代码中调用了某个函数/方法,而函数名或参数可以通过原型链污染控制"的场景。常见的 RCE gadget 包括:
原型链污染
Object.prototype.shellOptions = {}
↓
影响 options 对象
child_process.exec 的 options 参数被污染
↓
远程代码执行
通过 shell 选项注入恶意命令
经典案例是 child_process.spawn 的 env 选项,或者模板引擎(如 ejs、handlebars)的渲染选项。当代码用 options = Object.assign({}, userInput) 的方式合并参数时,原型链上的污染属性会被带入 options 对象,进而影响危险函数的行为。
利用三:拒绝服务(DoS)
即使找不到 RCE 链,原型链污染也能造成拒绝服务。通过污染 Object.prototype.toString 或 Object.prototype.valueOf 为抛出异常的函数,可以让所有对象在类型转换时崩溃,导致服务瘫痪。
JavaScript · DoS 利用:toString 污染
// 污染原型链上的 toString
Object.prototype.toString = function() {
throw new Error('DoS by prototype pollution');
};
// 任何对象的字符串转换都会崩溃
const obj = {};
String(obj); // 抛出异常,进程崩溃
`obj = ${obj}`; // 同样崩溃
5. Node.js 其他审计要点:SSRF 与命令注入
原型链污染是 Node.js 独有的漏洞类型,但代码审计不能只关注它。Node.js 生态还有两类高频漏洞需要审计:SSRF(服务端请求伪造)和命令注入。它们和 PHP/Python 中的同类漏洞原理相似,但有 Node.js 特有的 API 风险点。
SSRF 审计:关注 http.request 与 axios
Node.js 中发起 HTTP 请求的常见方式有原生 http.request、https.get,以及第三方库 axios、node-fetch、request(已废弃但仍大量存在于旧项目中)。
JavaScript · SSRF 漏洞:用户可控 URL
// Express 路由:用户传入 url 参数,服务端请求它
app.get('/fetch', (req, res) => {
// ⚠️ url 完全由用户控制,无白名单校验
const url = req.query.url;
https.get(url, (response) => {
response.pipe(res); // 将响应返回给用户
});
});
审计要点:搜索所有调用 HTTP 请求 API 的地方,追踪 URL 参数是否来自用户输入。如果是,检查是否有域名白名单、是否禁止了内网 IP 段(127.0.0.1、10.x.x.x、192.168.x.x、169.254.169.254 等)。特别注意 request 库的 followRedirect 默认开启,可能导致跳转绕过 SSRF 防护。
命令注入审计:child_process 全家桶
Node.js 的 child_process 模块提供了四个执行命令的函数,其中 exec 和 execSync 因为走 shell 解析,是命令注入的重灾区。
| 函数 |
是否走 shell |
注入风险 |
审计建议 |
| exec / execSync |
是 |
极高 |
优先用 execFile 替代 |
| execFile / execFileSync |
否(默认) |
低 |
参数用数组传递,不拼接字符串 |
| spawn / spawnSync |
否(默认) |
低 |
注意 shell:true 选项会开启注入 |
6. 自动化检测:Semgrep 自定义规则实战
手动审计大型项目效率太低,用上一篇介绍的 Semgrep 编写自定义规则可以批量扫描原型链污染漏洞。以下是两个实用的检测规则,可以直接放入你的 .semgrep.yml 中使用。
规则一:检测不安全的对象合并
规则思路:匹配 for...in 循环中对目标对象的属性赋值,但缺少 hasOwnProperty 检查和 __proto__ 过滤。
YAML · Semgrep 规则:检测不安全 merge
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
rules:
- id: js-prototype-pollution-merge
languages: [javascript, typescript]
severity: ERROR
message: "Unsafe merge: potential prototype pollution"
patterns:
- pattern: |
for (... $KEY in $SOURCE) {
... $TARGET[$KEY] = ... ;
}
- pattern-not: "hasOwnProperty"
- pattern-not: "__proto__"
- pattern-not: "constructor"
- pattern-not: "Object.keys"
|
规则二:检测 child_process 命令注入
规则思路:匹配 child_process.exec 的参数来自表达式拼接(而非固定字符串)的场景,标记为潜在命令注入风险。
YAML · Semgrep 规则:检测 exec 命令注入
rules:
- id: js-child-exec-injection
languages: [javascript, typescript]
severity: ERROR
message: "Command injection via child_process.exec"
patterns:
- pattern-either:
- pattern: require('child_process').exec($CMD, ...)
- pattern: require('child_process').execSync($CMD, ...)
- pattern-not: const $CMD = "...";
运行 semgrep --config .semgrep.yml src/ 即可批量扫描。注意 Semgrep 规则会有误报,需要人工确认每一个命中点的参数是否真的来自用户输入。
7. 分级练习
🟢 基础验证 · 第 1 题
在 Node.js 环境中运行以下代码,验证原型链污染效果:创建一个空对象 obj,通过 obj['__proto__'].testPollution = 'yes' 污染原型,然后新建另一个空对象 victim,打印 victim.testPollution 观察结果。同时用 constructor.prototype 路径再试一次。
预期结果:两种路径都能让 victim 对象读到污染的属性,输出均为 "yes"。
🟡 组合应用 · 第 1 题
编写一个有漏洞的 deepMerge(target, source) 函数(递归合并对象,不做安全检查),然后构造一个 JSON payload,通过调用 deepMerge({}, JSON.parse(payload)) 实现原型链污染,使所有新创建的对象都拥有 isAdmin: true 属性。然后修复这个 deepMerge 函数,使其能防御原型链污染。
提示:修复时在 for...in 循环开头添加判断:如果 key 是 '__proto__' 或 'constructor',跳过当前迭代。
🔴 开放挑战 · 第 1 题
在 GitHub 上搜索一个使用 Express + lodash 4.17.4 版本的真实开源项目(或使用 Vulhub 的 Node.js 原型链污染靶场),复现一次完整的原型链污染攻击链:从找到 merge/clone 注入点,到构造 Payload,再到实现权限绕过或其他实际影响。记录完整步骤并分析为什么最新版 lodash 不存在此问题。
参考方向:搜索 "CVE-2018-16487 lodash prototype pollution" 了解漏洞细节和修复方式。务必在本地或授权靶场中实验,禁止对未授权系统测试。
📝 知识回顾
原型链机制
__proto__ 污染
constructor.prototype
deepMerge 注入点
cloneDeep 风险
权限绕过利用
RCE 利用链
DoS 拒绝服务
child_process 审计
SSRF 服务端请求
Semgrep 自定义规则
hasOwnProperty 防御
📖 下一篇预告
第 34 篇我们将进入PHP 代码审计实战,带你从一个真实开源 PHP 项目出发,从危险函数搜索到污点追踪,从 SQL 注入发现到文件上传绕过,完整走一遍 PHP 代码审计的实操流程。点赞、关注,持续更新。