Node.js 的“原型污染”(Prototype Pollution)本质上是对 JavaScript 基于原型的继承机制的一种滥用。攻击者通过在应用程序的合并、克隆或路径设置操作中注入特殊的键值,能够修改 Object.prototype 或其它内置对象的原型。一旦成功,攻击者就相当于在整个应用程序的作用域内植入了幽灵般的属性,这直接导致了包括远程代码执行、拒绝服务以及权限绕过在内的严重注入风险。这种攻击的隐蔽性极高,因为它不依赖传统的漏洞模式,而是利用了语言核心特性的缺陷。
原型污染的底层逻辑与触发点要理解这种注入风险,必须先摒弃“类”的思维定式。JavaScript 中没有真正的类,只有对象。每个对象都有一个指向其原型的内部链接,当访问对象上不存在的属性时,引擎会沿着原型链向上查找。污染的核心在于,如果我们能修改 Object.prototype 上的属性,那么所有普通对象({})都会“继承”这个被污染的属性。
最常见的漏洞触发点是递归合并函数,这类函数常见于处理配置文件、HTTP 请求体解析或深度克隆场景。请看下面这个存在漏洞的合并逻辑:
const merge = (target, source) => {
for (const 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];
}
}
return target;
};
// 攻击者构造的恶意载荷
const maliciousPayload = JSON.parse('{"__proto__": {"isAdmin": true}}');
const config = {};
merge(config, maliciousPayload);
// 此时,一个全新的空对象已经拥有了被污染的属性
const newObj = {};
console.log(newObj.isAdmin); // 输出 true,权限体系被彻底绕过
在上述代码中,__proto__ 属性被当作普通键值处理,但它直接访问了对象的内部原型。同样危险的还有 constructor.prototype,攻击者可以通过构造类似于 {"constructor": {"prototype": {"shell": "/bin/sh"}}} 的载荷来绕过那些只屏蔽了 __proto__ 的防御机制。
从属性注入到远程代码执行(RCE)的路径单纯的属性注入本身通常只是逻辑漏洞,但 Node.js 生态中常见的模板引擎和命令执行库将其转化为了致命的代码执行风险。以 EJS 模板引擎为例,其内部在渲染模板时会动态拼接代码字符串。如果攻击者污染了 Object.prototype 上的特定属性,如 outputFunctionName 或 escapeFunction,EJS 在编译模板时就会将这些污染值直接拼接到即将被 eval 或 Function 构造函数执行的代码中。
具体的攻击链路如下:攻击者首先通过合并函数污染 Object.prototype,注入一个包含恶意代码片段的 outputFunctionName 属性。随后,当服务器端渲染任何 EJS 模板时,即使该模板内容完全静态,EJS 引擎也会读取到被污染的配置项,将恶意字符串拼接到渲染函数的参数中,最终导致服务器执行任意系统命令。这种攻击不需要上传恶意文件,也不需要修改现有模板,仅靠一次 HTTP 请求的 body 就能完成。
绕过防御的进阶污染技巧随着安全意识的提升,很多开发者开始使用 Object.freeze(Object.prototype) 或使用 Map 代替普通对象来存储数据。然而,攻击者依然能找到破绽。当 Object.prototype 被冻结时,攻击者会将目标转向其他内置对象的原型,例如 Array.prototype 或 Function.prototype。污染 Array.prototype 上的方法或属性,可能导致某些依赖数组索引遍历的库出现逻辑混乱。
另一种更具迷惑性的手段是利用不安全的依赖。npm 包的嵌套依赖往往深不可测,一个看似只处理字符串的微型库,可能在底层使用了存在漏洞的 extend 或 defaults-deep 函数。攻击者不需要直接攻击主应用,只需针对这些间接依赖进行供应链污染。此外,某些库在处理查询字符串或 multipart/form-data 解析时,会将形如 a[b][c]=value 的格式转换为嵌套对象,如果解析器没有严格过滤键名,就会直接产生原型污染。
针对 Node.js 服务器的具体注入场景分析除了通用的合并函数,特定框架的配置机制也是重灾区。例如,某些 API 网关或代理服务器会根据传入的 JSON 配置动态调整路由规则。如果攻击者发送了包含 __proto__ 键的配置更新请求,且后端使用递归合并来应用新配置,攻击者就可以全局修改 CORS 设置、日志级别或认证白名单。想象一下,攻击者通过污染将 unsafe-eval 注入到 Content-Security-Policy 头部的生成逻辑中,这直接解除了浏览器端的脚本限制。
在数据库操作层面,ORM(对象关系映射)库也是潜在受害者。以 Mongoose 为例,早期版本在处理查询条件时,如果允许用户传入未经净化的对象作为查询参数,攻击者可以构造特殊的查询操作符。虽然这属于 NoSQL 注入范畴,但结合原型污染,攻击者可以进一步污染 Mongoose 的内部 Schema 定义,使得后续所有数据库查询都绕过验证,或者强制返回敏感字段。例如,通过污染将某个查询的 password 字段设为始终选中,即使代码逻辑中明确排除了该字段。
深度防御:从代码层到架构层的修复策略修复原型污染不能仅靠封禁 __proto__ 关键字。最彻底的手段是放弃易受攻击的递归合并模式,采用安全的对象创建方式。使用 Object.create(null) 创建无原型对象作为字典使用,这样即使键名为 __proto__ 也不会向上追溯原型链。在必须合并对象的场景下,优先使用原生支持的安全方法,如 Object.assign 虽然不能深度合并,但它不会触发原型污染;或者使用 lodash 的 _.merge 时确保版本是最新的,因为 lodash 从 4.17.11 版本开始已针对此类攻击做了修复。
对于无法立即重构的遗留系统,引入严格的输入验证机制是必要的止损措施。可以在应用入口处注册一个全局拦截器,对请求体中的键名进行递归检查。如果键名中包含 __proto__、prototype 或 constructor,直接拒绝该请求。但要注意,这种黑名单机制容易被大小写变形或 Unicode 编码绕过,因此更推荐使用白名单,即只允许符合特定模式的键名通过。
更优雅的方案是在 Node.js 进程启动时,通过 --disable-proto=delete 参数运行。这个 V8 引擎的标志位会直接禁用对 __proto__ 的写操作,将其视为只读属性,任何试图通过赋值修改原型的操作都会静默失败或抛出异常。这虽然可能破坏极少数依赖动态修改原型的库,但对于绝大多数现代应用来说是最低成本的加固手段。
利用安全工具进行自动化检测在 CI/CD 流水线中集成专门的检测工具能有效防止漏洞上线。可以使用 npm audit 扫描依赖树中的已知原型污染漏洞,但不要完全依赖它,因为自定义的合并函数无法被通用扫描器识别。建议引入 CodeQL 或 ESLint 的 no-prototype-builtins 规则,并结合自定义规则扫描代码中是否存在 obj[a][b] = c 这种危险的赋值模式。
对于运行时防护,可以考虑使用 @datadog/pprof 或类似的内存快照工具定期分析原型链状态。一旦监测到 Object.prototype 上出现了非预期的属性,立即触发告警并强制重启服务。这种动态检测手段能捕捉到那些绕过了静态分析的零日攻击。
原型污染在微服务架构中的横向移动风险在微服务架构中,Node.js 服务往往作为 BFF(面向前端的后端)层,负责聚合数据。如果一个服务实例被原型污染,且该服务使用了共享内存或 Redis 缓存来传递序列化后的对象,污染可能会扩散。例如,一个被污染的 isAdmin 属性如果随着 JSON 序列化写入 Redis,另一个从 Redis 读取该数据的 Python 或 Go 服务虽然不会直接受原型链影响,但如果该数据被用来构建新的对象或进行逻辑判断,就会造成跨语言、跨服务的逻辑污染。因此,在服务间通信时,对序列化前的对象进行“净化”至关重要,即只提取必要的白名单字段,丢弃原型链上的额外属性。
原型污染攻击之所以危险,是因为它击穿了语言设计层面的信任基础。防御它需要的不仅是一行代码的修补,更是对 JavaScript 对象模型深刻理解后的架构级敬畏。在编写每一行合并逻辑时,都应假设输入数据是恶意的,并坚持使用不可变数据模式,这才能从根本上杜绝此类注入风险。
