1. 背景与原理
1.1 原型链速览
JavaScript 是基于原型的语言。每个对象(null 除外)都有一个内部属性 [[Prototype]],指向它的原型对象。当访问一个对象的属性时,如果对象自身没有该属性,引擎会沿 [[Prototype]] 链向上查找,直到找到该属性或到达链的终点 Object.prototype(其 [[Prototype]] 为 null)。
在代码中,[[Prototype]] 通常通过 __proto__ 访问器暴露(非标准但被广泛支持),也可以通过 Object.getPrototypeOf() 获取。构造函数 Fn 的 prototype 属性是供 new Fn() 创建的实例使用的原型对象,而 fn.__proto__ 与 Fn.prototype 指向同一对象。
1.2 关键访问路径的关系
三者都能从不同角度触及同一个目标:Object.prototype。
| 表达式 | 含义 | 关键等价关系 |
|---|---|---|
obj.__proto__ |
实例的原型引用 | a.__proto__ === A.prototype |
A.prototype |
构造函数的原型对象 | A.prototype.__proto__ === Object.prototype |
A.prototype.constructor |
指回构造函数本身 | A.prototype.constructor === A |
攻击者之所以能通过 __proto__、constructor、prototype 三个键名发起污染,正是因为它们构成了从任意对象"爬升"到 Object.prototype 的路径。
1.3 原型污染的本质
原型污染发生在应用使用攻击者可控的输入,通过递归合并、克隆或路径赋值,将属性写入 Object.prototype 的场景中。由于 Object.prototype 是几乎所有对象原型链的终点,一旦它被写入属性,该运行时中的每一个普通对象都会"继承"这个属性,即便这些对象与污染源毫无关系。
关键区分:污染本身不是漏洞终点 。单独写入一个无害属性(如 {}.foo = "bar")通常不直接造成危害。真正的风险在于应用其他位置的代码(gadget)读取了该属性,并将其用于敏感操作。
1.4 最小可复现示例(扩展)
以下是一个典型的不安全递归合并函数:
javascript
function merge(target, source) {
for (const key in source) {
if (typeof source[key] === 'object' && typeof target[key] === 'object') {
merge(target[key], source[key]); // 递归进入嵌套对象
} else {
target[key] = source[key]; // 直接赋值
}
}
return target;
}
攻击输入:
javascript
const malicious = JSON.parse('{"__proto__": {"polluted": "yes"}}');
merge({}, malicious);
逐行分析:
-
for...in遍历malicious的键,第一个键是"__proto__"。 -
source["__proto__"]的值是{"polluted": "yes"},类型为object;target["__proto__"]是什么?对于{}(空对象字面量),target.__proto__指向Object.prototype,类型也是object。 -
两个条件都满足,递归调用
merge(target.__proto__, source.__proto__)。注意:target.__proto__就是Object.prototype本身。 -
在递归调用中,
key为"polluted",target(即Object.prototype)上不存在polluted属性,于是执行Object.prototype.polluted = "yes"。 -
污染完成。
验证:
javascript
({}).polluted; // "yes" —— 一个全新的空对象也“拥有”了 polluted 属性
这个示例揭示了原型污染的核心机制:递归合并函数将 __proto__ 当作普通键处理,没有阻止赋值路径穿透到 Object.prototype。
2. 常见攻击面与攻击方式(扩展)
2.1 典型入口(source)
不安全的合并/克隆函数是最主要的入口类别。已知受影响的具体组件包括:
-
lodash < 4.17.5 :
defaultsDeep、merge、mergeWith均存在原型污染,对应 CVE-2018-3721。需结合具体版本验证。 -
multiparty ≤ 4.2.3:处理 multipart 表单时,字段名若与原型属性冲突,可导致 DoS(CVE-2026-8161)。
-
content-security-policy-parser ≤ 0.5.0 :解析 CSP 指令时,策略名若为
__proto__可污染原型。
其他入口包括:查询参数解析(如 ?__proto__[key]=value)、JSON 请求体反序列化后合并、配置注入、路径式赋值(如 set(obj, "a.b.c", value))等。
2.2 客户端与服务端的差异
| 维度 | 客户端(浏览器) | 服务端(Node.js) |
|---|---|---|
| 污染源可见性 | URL fragment/query、postMessage、JSON 输入 |
请求体、查询参数、配置文件 |
| 典型 gadget | DOM XSS(innerHTML、script.src) |
DoS、逻辑绕过、RCE(通过模板引擎等) |
| 检测难度 | 可在控制台直接验证 ({}).prop |
需通过间接行为变化推断 |
| 持久性 | 单页面生命周期 | 进程级,影响所有后续请求 |
客户端示例:PortSwigger 实验展示了通过 ?__proto__[transport_url]=data:,alert(1); 污染 config.transport_url,使页面动态创建的 <script> 的 src 指向 data: URL,触发 XSS。
服务端 DoS 示例:multiparty 在解析字段名 __proto__ 时,尝试对继承的原型值调用 .push(),因该值不是数组而抛出未捕获的 TypeError,导致 Node.js 进程崩溃。
2.3 攻击链:source → property → sink
能污染不等于能利用。一个完整的原型污染攻击需要三个要素同时成立:
-
Source(污染源):攻击者可控的输入进入了一个不安全的合并/赋值操作。
-
Property(被污染属性) :写入
Object.prototype的键名恰好是下游代码会读取的属性。 -
Gadget(利用链) :应用(或依赖库)中存在一段代码,它读取该属性,并将其用于危险操作(如
innerHTML赋值、eval、模板渲染、权限判断)。
如果缺少 gadget,即使成功污染,攻击也可能毫无实际影响。
3. 攻击可能造成的危害
除前文列出的分类外,以下是具有明确记录的影响实例:
-
DoS(进程崩溃):multiparty ≤ 4.2.3 中,单个精心构造的 multipart 请求即可使 Node.js 进程抛出未捕获异常并终止,无需认证。NocoDB 的连接测试端点也存在类似问题,污染后所有数据库写操作失败直至重启。
-
RCE(通过 gadget 链) :学术研究在 Node.js 核心 API 中发现了 11 个"universal gadgets",并在 NPM CLI、Parse Server、Rocket.Chat 等应用中手动利用了 8 个 RCE 漏洞。Kibana 的 CVE-2019-7609 是公开的著名案例。
-
客户端 XSS :通过污染
transport_url、innerHTML、html等属性,攻击者可在受害者浏览器中执行脚本,且 payload 可完全存在于 URL fragment 中,服务端日志和 WAF 均无法看到。
需注意 :RCE 结论高度依赖具体版本和运行时环境,需结合具体版本验证。
4. 检测与防御(OWASP 官方建议)
OWASP 原型污染防御速查表给出的核心措施:
优先使用 Map / Set :如果数据结构本质是键值对或集合,用 new Map() / new Set() 替代普通对象,从根本上消除原型链继承带来的风险。
必须使用对象时 :使用 Object.create(null) 创建无原型对象。这是最直接的隔离手段。
冻结原型 :Object.freeze(Object.prototype) 可阻止向原型添加新属性。但需注意兼容性:部分库会修改内置原型,冻结可能导致这些库异常。且冻结是浅层的,已有属性的值若为对象仍可变。
Node.js 运行时防护 :--disable-proto=delete 或 --disable-proto=throw 可移除或禁用 __proto__ 访问器。这是纵深防御措施,不能替代代码层修复 ,因为通过 constructor.prototype 路径的污染仍然可能。
静态检测思路:CodeQL/Semgrep 规则应建模 source(用户输入进入合并函数)到 sink(原型属性赋值)的污点传播路径。上述学术研究正是基于 CodeQL 实现了多阶段静态分析来发现 Node.js 库中的原型污染和通用 gadget。
5. 示例与练习(扩展)
5.1 客户端 DOM XSS 示例(教学用)
前置条件 :页面从 URL query 或 fragment 解析配置对象,并使用了不安全的合并逻辑;页面某处读取 config.transport_url 来设置 <script> 的 src。
污染 payload 构造思路 :?__proto__[transport_url]=data:,alert(document.domain)。
触发点 :页面代码中存在类似 script.src = config.transport_url || '/default.js' 的逻辑。由于 config 自身没有 transport_url,属性查找沿原型链到达被污染的 Object.prototype,读取到 data: URL,浏览器将其作为同源脚本执行。
注意:此示例仅用于理解 source→gadget→sink 链,实际利用需要目标页面具备对应 gadget。
5.2 服务端 DoS 示例(教学用)
前置条件:Node.js 服务使用 multiparty ≤ 4.2.3 处理文件上传或表单提交。
payload 构造思路 :发送一个 multipart/form-data 请求,包含一个字段名为 __proto__ 的字段。
触发点 :multiparty 在解析时对继承的 __proto__ 属性调用 .push(),因该属性不是数组而抛出 TypeError,未被捕获,进程崩溃。需结合具体版本验证是否仍受影响。
6. 常见误区
"只有 __proto__ 能污染" :错误。constructor.prototype 同样可以到达 Object.prototype。Node.js 的 --disable-proto 只移除了 __proto__,但通过 constructor 的路径仍然存在。
"原型污染必然导致 RCE":错误。RCE 需要存在可用的 gadget 链,且依赖运行时环境和版本。大多数原型污染的可利用性止步于逻辑绕过或 DoS,甚至完全没有实际影响。
"用了 Object.assign 就一定安全/不安全" :Object.assign 本身只做浅拷贝,不会递归。危险的是递归合并函数。但浅拷贝后若下游代码对嵌套对象做深度操作,仍需评估。
"冻结了 Object.prototype 就万事大吉":冻结确实能阻止添加新属性,但无法阻止修改已有属性的值(如果该值本身是可变对象)。且兼容性风险意味着生产环境需谨慎测试。
"客户端原型污染不重要,因为只影响自己" :错误。URL fragment 中的污染 payload 不经过服务端,可构造针对特定用户的 XSS,且日志和 WAF 完全不可见,检测难度极高。
7. 小结
原型污染的危险性不在于"写入了原型"这一动作本身,而在于污染属性恰好被下游代码信任并用于敏感操作 。防御的重心应放在:消除不安全的递归合并模式、使用无原型对象或 Map/Set、以及对 __proto__/constructor/prototype 键名的显式拦截。检测方面,静态污点分析在 Node.js 生态中已被证明有效,而客户端场景则需要结合运行时探针与代码审计。