原型污染(Prototype Pollution)并不是一种独立于 JavaScript 运行机制之外的新型攻击,而是直接利用对象原型链继承特性的一种注入方式。当一段代码无意中把用户可控的数据当作普通对象键值进行递归合并、复制或赋值时,攻击者可以通过传入 __proto__、constructor、prototype 等特殊键,改写全局对象 Object.prototype 上的属性。这种修改会立即影响当前 JavaScript 运行环境中的所有普通对象,哪怕这些对象没有显式定义对应属性,访问时也会从原型链上读取到被污染的值。理解原型污染需要从 __proto__ 与 prototype 的区别开始,并重点观察那些看似无害的对象合并操作。

一、原型链机制与污染成因
在 JavaScript 中,除了通过 Object.create(null) 创建的对象外,几乎每个对象都有一个内部原型。访问对象属性时,如果自身没有该属性,解释器会沿着原型链向上查找,最终可能到达 Object.prototype。这意味着一旦 Object.prototype 被添加了一个属性,所有普通对象在访问同名属性时都会命中这个值。正常开发中,我们通过对象字面量或 Object.defineProperty 添加属性,不会意外写进原型链,但递归合并函数却可能打开这个口子。
典型的不安全合并逻辑如下:
function unsafeMerge(target, source) {
for (let key in source) {
if (typeof source[key] === 'object' && source[key] !== null) {
if (!target[key]) target[key] = {};
unsafeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
这段代码的问题在于,当 key 的值是 __proto__ 时,target[key] 并不会返回一个普通属性,而是返回目标对象的原型,也就是 Object.prototype。由于 Object.prototype 是真值,if (!target[key]) 判断不会创建新对象,而是直接递归调用 unsafeMerge(Object.prototype, source[key])。递归进入第二层后,普通键值被写入 Object.prototype,污染就此发生。攻击者只需要发送一段带有 __proto__ 键的 JSON,就能影响所有对象。
这里需要特别区分 __proto__ 和 prototype。__proto__ 是对象上的访问器属性,指向该对象的原型;prototype 则是构造函数上的属性,用来描述通过该构造函数创建的对象所共享的原型。两者虽然方向不同,但在污染链路中经常配合出现。如果只过滤 __proto__,攻击者还可以通过 constructor.prototype 绕到同一个目标。
二、攻击者如何利用原型污染
实际的攻击载荷并不复杂。很多应用会接收 JSON 格式的用户输入,然后把它合并到默认配置或应用对象中。攻击者可以提交如下 JSON:
let body = JSON.parse('{"constructor": {"prototype": {"isAdmin": true}}}');
unsafeMerge({}, body);
console.log({}.isAdmin); // true
这里 constructor 是普通对象从原型链上继承的属性,指向 Object 构造函数,而 Object 构造函数又拥有 prototype 属性。递归合并时,target['constructor'] 拿到 Object 函数,下一层拿到 Object.prototype,再下一层就把 isAdmin 写进了全局原型。这个例子说明仅靠检查 __proto__ 无法覆盖所有攻击路径。
一旦污染发生,影响面非常广。未显式定义的 isAdmin 属性会在所有普通对象上显示为 true,如果后端权限判断依赖类似 if (user.isAdmin) 的逻辑,攻击者就可能绕过权限校验。同理,攻击者可以覆盖 toString、hasOwnProperty 等原生方法,导致应用逻辑异常甚至拒绝服务。在前端场景中,原型污染还可能被用来干扰 DOM 过滤库、模板引擎或第三方脚本的行为,形成组合攻击。
三、常见漏洞场景与检测方法
历史上,lodash 的 merge、defaultsDeep,以及 jQuery 的 extend 等函数都曾经出现过原型污染相关漏洞。Node.js 应用中凡是使用深度合并、深拷贝、路径赋值等操作的模块,都可能成为入口。尤其是当合并函数的 source 参数直接来自 req.body、req.query 或外部 API 响应时,攻击者就拥有了控制键名的能力。
检测原型污染的方式之一是观察全局原型是否被意外修改。可以在应用启动后、处理请求前后执行如下检查:
function checkPollution(candidateKeys) {
const obj = {};
return candidateKeys.some(function (key) {
return key in obj;
});
}
console.log(checkPollution(['isAdmin', 'polluted'])); // true after pollution
更实用的做法是在开发阶段对关键函数做输入审计。看到 merge、extend、clone、deepClone、setPath 等名称时,重点确认它们是否使用了 for...in 遍历用户数据,是否对 __proto__、constructor、prototype 做了过滤。运行时也可以记录 Object.prototype 的键列表快照,在每次请求后对比是否有新增,虽然有一定性能开销,但在安全测试环境中很有价值。
静态扫描工具不一定能直接标记原型污染,但可以通过搜索危险合并函数和用户输入流向缩小范围。需要注意,漏洞不一定只存在于后端,浏览器端的深度复制库、URL 参数解析、消息订阅系统也可能受影响。只要 JavaScript 对象与攻击者可控的键名发生合并,就需要纳入检查范围。
四、防御原型污染的实践方案
防御的第一原则是不信任任何外部输入的键名。安全合并函数应当使用 Object.keys 获取自有属性,并显式屏蔽危险键:
function safeMerge(target, source) {
if (typeof target !== 'object' || typeof source !== 'object') return target;
const forbidden = new Set(['__proto__', 'constructor', 'prototype']);
for (let key of Object.keys(source)) {
if (forbidden.has(key)) continue;
if (typeof source[key] === 'object' && source[key] !== null) {
if (!Object.prototype.hasOwnProperty.call(target, key)) target[key] = {};
safeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
这段代码同时做了几件事:使用 Object.keys 避免遍历原型链上的属性;使用 hasOwnProperty.call 避免继承属性被误用;用 Set 预先拦截 __proto__、constructor、prototype。如果业务场景允许,还可以在合并前递归清洗 JSON 对象,删除所有危险键,或者直接使用 Object.create(null) 创建无原型对象作为配置容器。
另一个有效的加固手段是冻结 Object.prototype。在应用启动阶段执行 Object.freeze(Object.prototype) 可以阻止后续对全局原型的修改。不过这种方式可能影响某些依赖原型扩展的旧代码,因此需要在测试环境充分验证。更推荐的做法是使用 Map 替代普通对象存储动态键值,因为 Map 的键不受原型链影响,天然免疫原型污染。
最后,升级到修复了已知漏洞的库版本也非常重要。例如 lodash 在较高版本中已经针对原型污染做了防护,但前提是不要在业务代码中自行实现不安全的 merge。开发团队还应当通过安全测试用例覆盖这些边界,例如用包含 __proto__、constructor.prototype 的 payload 对合并函数进行回归测试。原型污染的攻击成本很低,但修复和排查成本很高,所以在每次对象合并、克隆和赋值时都保持警惕,才能从根上降低风险。
原型污染Prototype PollutionJavaScript安全修改时间:2026-09-30 02:32:12