JavaScript原型链污染:原理、利用与防御实战指南

发布时间:2026/8/2 8:25:47

JavaScript原型链污染:原理、利用与防御实战指南 1. 从一次意外的属性覆盖说起那天下午我正在调试一个后台管理系统。一个普通的功能用户可以通过表单提交一些配置项比如主题颜色、页面标题之类的。代码逻辑很简单前端把用户输入的JSON对象通过Object.assign合并到默认配置对象上然后传给后端。测试时我随手在表单的__proto__字段里填了点东西提交后刷新页面整个站点的默认配置全变了。不是当前用户的配置变了是所有未登录用户的默认配置都变成了我提交的那个值。那一瞬间我后背有点发凉意识到自己可能无意中触发了JavaScript里一个古老但威力不小的安全机制——原型链污染。如果你写过一段时间JavaScript肯定对prototype和__proto__不陌生。但大多数时候我们只把它们当作实现继承的工具很少会去想如果这个继承链条能被外部数据篡改会发生什么。原型链污染简单说就是攻击者能够向一个对象的原型prototype中注入或修改属性从而影响所有继承自该原型的对象。这听起来有点抽象但它的影响可以非常具体和危险从篡改全局配置、绕过输入验证到在特定条件下导致远程代码执行。网上关于原型链污染的文章不少但很多要么停留在__proto__.polluted polluted的演示层面要么直接跳到复杂的CTF题解。作为一个踩过坑的一线开发者我更想记录下的是在实际的代码场景里它到底是怎么发生的哪些常见的API和操作是高风险点我们该如何系统地发现和修复它这篇文章就是我结合自身踩坑经历和后续研究的学习记录希望能帮你把“原型链污染”从一个模糊的安全概念变成代码审查时一个具体的检查项。2. 原型链污染的核心原理属性访问的“向上查找”机制要理解污染必须先彻底搞懂JavaScript中对象属性的访问机制。这不仅仅是“原型链”三个字那么简单它涉及到引擎底层的行为逻辑。2.1 重温原型、原型链与__proto__在JavaScript中几乎所有的对象都有一个内置属性[[Prototype]]我们通常通过__proto__或Object.getPrototypeOf()来访问它。这个属性指向了该对象的“原型”对象。当你尝试访问一个对象的某个属性比如obj.a时JavaScript引擎会执行以下步骤检查实例自身首先在对象obj自身的属性中查找a。沿原型链向上如果没找到引擎就会去obj.__proto__指向的对象里找a。递归查找如果还没找到就继续去obj.__proto__.__proto__里找以此类推直到某个原型对象的__proto__为null通常是Object.prototype的原型。返回或未定义如果找到则返回该值如果直到链条尽头都没找到则返回undefined。这个查找路径就是所谓的“原型链”。它构成了JavaScript基于原型的继承模型的基础。function Person(name) { this.name name; } Person.prototype.sayHello function() { console.log(Hello, ${this.name}); }; const alice new Person(Alice); const bob new Person(Bob); // alice 自身没有 sayHello 方法 console.log(alice.hasOwnProperty(sayHello)); // false // 但通过原型链可以访问到 Person.prototype 上的 sayHello alice.sayHello(); // 输出Hello, Alice bob.sayHello(); // 输出Hello, Bob // 原型链是alice - Person.prototype - Object.prototype - null console.log(alice.__proto__ Person.prototype); // true console.log(Person.prototype.__proto__ Object.prototype); // true console.log(Object.prototype.__proto__); // null2.2 污染是如何发生的对__proto__的赋值污染的关键在于JavaScript允许我们动态地修改对象的__proto__属性或者直接修改原型对象上的属性。如果攻击者能够控制给对象属性赋值的“键名”key并且这个键名恰好是__proto__、constructor、prototype这类有特殊意义的字符串那么赋值操作就可能修改到原型链上游的对象。看一个最经典的例子function merge(target, source) { for (let key in source) { if (source.hasOwnProperty(key)) { target[key] source[key]; // 危险的操作 } } return target; } const obj1 {}; const obj2 JSON.parse({a: 1, __proto__: {polluted: yes}}); merge(obj1, obj2); console.log(obj1.polluted); // undefinedobj1自身没有被污染 console.log({}.polluted); // yes所有普通对象都被污染了这里发生了什么obj2有一个属性__proto__其值是一个新对象{“polluted”: “yes”}。merge函数遍历obj2的属性当key为“__proto__”时执行target[key] source[key]。在JavaScript中target[“__proto__”]这个赋值操作并不是在target对象自身创建一个名为“__proto__”的普通属性而是会直接修改target对象的实际原型[[Prototype]]于是obj1.__proto__即Object.prototype被修改了添加了一个polluted属性。由于几乎所有对象都继承自Object.prototype所以后续新创建的普通对象{}都能访问到polluted属性。注意这个经典例子在现代浏览器Chrome、Firefox的当前版本中可能已经无法直接复现因为引擎对__proto__这个键名做了特殊处理直接赋值可能不会生效或者只会在对象自身创建一个名为“__proto__”的普通属性。但这绝不意味着原型链污染已经过时攻击者会寻找其他路径比如constructor.prototype我们稍后会详细讲。这个例子的价值在于清晰地揭示了污染的核心逻辑。2.3 哪些操作是高风险操作任何能够根据动态的键名key来设置对象属性的函数或操作如果未对键名进行过滤都可能成为原型链污染的入口。在实际开发中要特别警惕以下场景对象合并Object.assign(target, source)、自己实现的merge、extend、deepMerge函数。对象复制/克隆JSON.parse(JSON.stringify(obj))是安全的因为它会丢弃__proto__等特殊属性。但手写的递归深拷贝函数如果不处理特殊属性就非常危险。根据输入动态设置属性obj[userInputKey] userInputValue。使用eval或Function构造函数动态创建对象这本身已是高危操作。一些第三方库的工具函数特别是那些提供“深度合并”、“配置项扩展”功能的工具。理解了这个核心原理我们就可以进入更实际的环节看看在现代JavaScript环境中攻击者通常会如何利用它。3. 从原理到利用常见的污染路径与Payload构造直接对__proto__赋值被限制后攻击者并没有放弃。他们找到了更隐蔽、更有效的路径。理解这些路径是进行有效防御的前提。3.1 路径一通过constructor.prototype进行污染这是目前最常见且有效的污染路径。在JavaScript中每个对象都有constructor属性指向创建它的构造函数而构造函数有prototype属性指向原型对象。const obj {}; // obj.constructor 指向 Object 函数 // Object.prototype 就是所有普通对象继承的原型 console.log(obj.constructor Object); // true console.log(obj.constructor.prototype Object.prototype); // true攻击者可以构造这样的Payloadconst maliciousPayload { “constructor”: { “prototype”: { “polluted”: “yes” } } }; function vulnerableMerge(target, source) { for (let key in source) { // 这里没有对 key 做任何过滤 target[key] source[key]; } } const obj1 {}; vulnerableMerge(obj1, maliciousPayload); console.log({}.polluted); // “yes” - 污染成功过程拆解第一次循环key为“constructor”执行target[“constructor”] source[“constructor”]。这会将obj1.constructor覆盖为一个新对象{“prototype”: {…}}。注意此时obj1自身的constructor属性被改变了但这还不是污染。但这个新对象{“prototype”: {…}}会被合并进target即obj1。vulnerableMerge函数会递归或通过for…in遍历这个新对象的属性。第二次或在下一次合并中key为“prototype”此时target是上一步被覆盖后的constructor对象。执行target[“prototype”] source[“constructor”][“prototype”]。这里的target是obj1.constructor而obj1.constructor原本应该指向Object函数。但经过第一步它已经被覆盖成了一个普通对象。然而在赋值target[“prototype”] …时如果target恰好是某个函数的prototype属性或者这个赋值操作被后续代码以某种方式应用到真正的原型上就可能造成污染。在某些具体的库函数实现逻辑中这个过程会导致Object.prototype被修改。关键点实际的利用过程需要看目标合并函数的具体实现。有些库的深合并逻辑在遇到constructor和prototype属性时会进行特殊的对象递归合并从而最终将属性注入到真正的原型对象中。这需要结合具体代码分析。3.2 路径二污染特定构造函数的原型如果目标应用大量使用了某个自定义构造函数那么污染这个构造函数的原型影响范围可能更精准危害也可能更大。function User(role) { this.role role; } User.prototype.isAdmin false; // 默认不是管理员 // 假设有一个污染点能修改 User.prototype // 攻击者通过某种手段如上述的合并漏洞执行了 // User.prototype.isAdmin true; const normalUser new User(‘user’); console.log(normalUser.isAdmin); // 如果原型被污染这里会输出 true导致权限绕过。3.3 如何构造和测试Payload在安全测试或CTF中我们经常需要手动构造Payload。以下是一些思路基础测试Payload{__proto__: {polluted: test}} {constructor: {prototype: {polluted: test}}}嵌套Payload针对深合并函数。{a: {b: {constructor: {prototype: {polluted: test}}}}}数组形式Payload有些库解析属性路径时用.分隔但也可能支持数组索引形式。{constructor[prototype][polluted]: test}本地测试方法 在怀疑存在漏洞的代码处尝试传入上述Payload然后在污染点之后立即检查全局原型是否被修改// 执行可疑操作后 console.log(Object.prototype.polluted); // 检查 Object.prototype console.log(({}).polluted); // 等价于上一行 console.log(([]).polluted); // 检查 Array.prototype // 检查应用中使用到的自定义构造函数的原型4. 实战场景剖析在常见库与代码模式中寻找漏洞理论说再多不如看几个实际的代码片段。原型链污染往往藏在看似无害的工具函数和业务逻辑里。4.1 场景一不安全的对象合并函数这是最典型的漏洞来源。很多开发者会自己写一个“深度合并”工具。// 一个存在漏洞的深合并示例 function deepMerge(target, source) { for (let key in source) { if (source.hasOwnProperty(key)) { if (typeof source[key] ‘object’ source[key] ! null) { // 如果target没有对应属性则初始化一个空对象 if (!target[key]) { target[key] {}; } // 递归合并 deepMerge(target[key], source[key]); } else { // 直接赋值 - 危险 target[key] source[key]; } } } return target; } const config { db: { host: ‘localhost’ } }; const userInput JSON.parse(‘{“__proto__“: {“polluted“: “yes“}}‘); // 或者 {“constructor“: {“prototype“: {“polluted“: “yes“}}} const mergedConfig deepMerge(config, userInput); console.log({}.polluted); // 很可能输出 “yes”漏洞点分析递归时target[key]和source[key]都被传入下一层。当key为“__proto__”时target[“__proto__”]实际上获取到的是target的原型例如Object.prototype。递归调用就变成了deepMerge(Object.prototype, source[“__proto__”])。在递归的最后一层source[key]是基本值“yes”会走到else分支执行target[key] source[key]此时target是Object.prototypekey是“polluted”从而污染成功。修复建议在合并函数的开头加入对键名的过滤。一个简单但有效的方法是使用Object.prototype.hasOwnProperty.call(target, key)来判断key是否是对象自身的、可枚举的属性但这还不够。更安全的做法是直接禁止合并__proto__、constructor、prototype等特殊属性。function safeDeepMerge(target, source) { for (let key in source) { if (source.hasOwnProperty(key)) { // 关键过滤 if (key ‘__proto__’ || key ‘constructor’ || key ‘prototype’) { continue; // 跳过这些特殊键 } // … 剩下的合并逻辑 } } }更健壮的做法是使用Object.defineProperty或Object.setPrototypeOf的思维但最简单直接的往往是检查键名黑名单。4.2 场景二使用Object.assign进行配置合并Object.assign本身是浅合并且现代JavaScript引擎通常对__proto__等有内部保护直接使用Object.assign合并用户输入相对安全一些。但是如果合并的源头是一个被污染了的对象Object.assign会把污染属性作为自身属性拷贝到目标对象。// 假设原型已被污染 Object.prototype.polluted ‘from prototype’; const userInput { normalKey: ‘normalValue’ }; const config Object.assign({}, userInput); console.log(config.polluted); // ‘from prototype’! console.log(config.hasOwnProperty(‘polluted’)); // true! 它成了config自身的属性。这里Object.assign在遍历userInput的属性时由于polluted存在于原型链上且可枚举它会被拷贝到config对象上成为一个自身的属性。这可能导致后续的逻辑判断出错例如if (config.polluted)会为真。漏洞点分析问题不在于Object.assign造成了污染而在于它传播了污染。如果程序的其他部分依赖于检查对象自身属性hasOwnProperty那么这个被意外添加的自身属性可能会绕过检查。修复建议在使用Object.assign、展开运算符…或遍历对象属性时如果源头对象不可信务必使用Object.hasOwn()ES2022或Object.prototype.hasOwnProperty.call(source, key)来确保只拷贝对象自身的属性。function safeAssign(target, source) { for (const key of Object.keys(source)) { // Object.keys只返回自身可枚举属性 target[key] source[key]; } return target; } // 或者 const safeConfig {}; for (const key in userInput) { if (Object.hasOwn(userInput, key)) { safeConfig[key] userInput[key]; } }4.3 场景三基于eval或Function的动态代码生成这是最危险的一类可能将原型链污染升级为远程代码执行RCE。// 极度危险的示例切勿在生产环境使用此类模式 function dynamicSetter(obj, path, value) { // 意图根据路径字符串 ‘a.b.c‘ 设置 obj.a.b.c value const evalString obj.${path} ${JSON.stringify(value)}; eval(evalString); // 或 new Function(‘obj‘, ‘value‘, obj.${path} value)(obj, value); } const myObj {}; // 如果攻击者能控制 path 参数 dynamicSetter(myObj, ‘__proto__.polluted‘, ‘harmless‘); // 污染 Object.prototype dynamicSetter(myObj, ‘constructor.prototype.polluted‘, ‘harmless‘); // 同上 // 更可怕的如果 value 参数也可控 dynamicSetter(myObj, ‘__proto__.toString‘, (){ console.log(‘RCE!‘); }); // 现在任何对象调用 toString() 都会执行攻击者的代码需满足特定条件漏洞点分析eval或new Function将字符串作为代码执行。如果传入的path包含了__proto__等关键字赋值语句就会直接操作原型链。如果value参数是函数字符串则可能直接注入可执行代码。修复建议绝对不要使用eval或new Function来解析或执行来自用户输入的字符串。如果需要根据路径设置深层属性应该手动解析路径字符串并严格验证每一段键名。function safeSetter(obj, path, value) { const keys path.split(‘.‘); let current obj; for (let i 0; i keys.length - 1; i) { const key keys[i]; // 过滤特殊键名 if (key ‘__proto__‘ || key ‘constructor‘ || key ‘prototype‘) { throw new Error(‘Invalid path segment‘); } if (!(key in current)) { current[key] {}; } current current[key]; } const finalKey keys[keys.length - 1]; // 同样过滤最后的键名 if ([‘__proto__‘, ‘constructor‘, ‘prototype‘].includes(finalKey)) { throw new Error(‘Invalid path segment‘); } current[finalKey] value; }5. 防御策略从编码习惯到安全审计知道了漏洞怎么产生的防御就有了方向。防御原型链污染是一个系统工程需要在编码、代码审查、依赖管理和安全测试多个环节建立习惯。5.1 编码层面的最佳实践使用不可变数据和无原型对象Object.create(null)创建一个没有原型__proto__为null的空对象。用它来存储键值对比如Map的替代品可以彻底免疫原型链污染因为属性查找根本不会走到原型链。const safeDict Object.create(null); safeDict[‘__proto__’] ‘test’; console.log(safeDict.__proto__); // undefined console.log(({}).__proto__); // 仍然是原来的Object.prototype未被污染冻结对象Object.freeze()可以冻结一个对象使其不能被修改、添加或删除属性。冻结Object.prototype理论上可以阻断污染但实践中很少这么做因为可能影响其他库的正常运行。安全地操作对象属性使用Object.keys()、Object.getOwnPropertyNames()、Object.hasOwn()在遍历对象时使用这些只返回自身属性的方法避免遍历到原型链上的可枚举属性。属性存在性检查始终使用Object.hasOwn(obj, key)或Object.prototype.hasOwnProperty.call(obj, key)来检查属性是否为对象自身所有而不是用if (obj[key])或if (key in obj)in操作符会检查原型链。彻底避免危险函数将eval、new Function、setTimeout/setInterval的第一个参数为字符串等用法列入代码审查的红线。除非有极其特殊且安全可控的理由否则一律禁止。5.2 安全的对象合并与克隆实现如果需要深合并建议使用经过安全审计的第三方库如lodash.merge但需注意版本旧版本可能存在漏洞。如果自己实现必须包含严格的键名过滤。一个相对安全的深合并函数框架function safeDeepMerge(target, source, options {}) { const { prototypePollution true } options; // 默认开启原型污染防护 const seen new WeakMap(); // 处理循环引用 function merge(tgt, src) { // 过滤特殊键名 for (const key of Object.keys(src)) { // 只遍历自身属性 if (prototypePollution (key ‘__proto__’ || key ‘constructor’ || key ‘prototype’)) { continue; } const srcVal src[key]; const tgtVal tgt[key]; if (srcVal typeof srcVal ‘object’ !Array.isArray(srcVal)) { // 处理循环引用 if (seen.has(srcVal)) { tgt[key] seen.get(srcVal); continue; } // 准备合并对象 if (tgtVal typeof tgtVal ‘object’ !Array.isArray(tgtVal)) { seen.set(srcVal, tgtVal); merge(tgtVal, srcVal); } else { // 目标不是对象则覆盖可考虑更复杂的策略如不覆盖 const newObj Array.isArray(srcVal) ? [] : {}; seen.set(srcVal, newObj); tgt[key] newObj; merge(newObj, srcVal); } } else { // 基本类型或数组直接赋值 tgt[key] srcVal; } } } merge(target, source); return target; }5.3 依赖管理与安全审计定期更新依赖使用npm audit、yarn audit或 GitHub 的 Dependabot 等工具定期检查项目依赖中已知的安全漏洞。很多原型链污染漏洞是在流行的工具库如 lodash、hoek、jQuery.extend等中被发现并修复的。代码审查时重点关注在团队代码审查中将对用户输入数据的对象合并、动态属性设置、eval类函数的使用作为重点审查项目。建立团队的“安全编码清单”。使用安全工具进行扫描在CI/CD流程中集成静态代码安全扫描工具如SonarQube, Semgrep可以配置规则来检测潜在的原型链污染模式。5.4 漏洞发现与测试对于自己的应用可以主动进行测试模糊测试Fuzzing在接收JSON输入的API端点尝试提交包含各种特殊键名__proto__、constructor、prototype及其各种大小写、编码变形的Payload。代码审计全局搜索代码中的以下模式for (let key in obj)且内部有target[key] …或obj[key] …。Object.assign、{…obj}展开运算符且源对象来自不可信输入。merge、extend、deepCopy、deepMerge等自定义函数。eval、new Function、setTimeout(string)。使用检测工具有一些专门针对Node.js应用的原型链污染扫描工具可以在测试环境中运行。6. 与其他漏洞的联动从污染到更严重的攻击原型链污染很少作为一个独立的漏洞被利用它更像一个“启动器”或“放大器”为其他攻击铺平道路。理解这些联动场景才能充分认识其危害。6.1 污染导致逻辑漏洞这是最常见的联动。通过污染全局配置改变应用程序的行为。案例污染模板引擎配置。许多模板引擎如Pug/Jade旧版本允许通过配置选项来启用/禁用某些功能。如果攻击者能污染到模板引擎的配置对象可能开启危险选项比如关闭输出转义从而为跨站脚本攻击XSS创造条件。案例污染权限校验标志。如前面User.prototype.isAdmin的例子直接将普通用户提升为管理员。6.2 污染触发代码执行Gadget Chains这是最危险的场景。攻击者通过污染控制某些函数或对象的属性使其在特定代码路径下被调用最终执行任意代码。这需要结合应用程序中特定的“小工具”Gadget——即那些会被自动调用、且其行为受污染属性影响的代码片段。一个经典的Node.js例子涉及child_process模块。假设一个应用有以下代码const { exec } require(‘child_process’); // … 某个地方基于某个配置对象运行命令 if (someConfig.debug) { exec(someConfig.debugCommand); // 如果debugCommand可被污染控制… }如果攻击者能污染someConfig的原型使得debug为true并且debugCommand为rm -rf /或其他恶意命令就可能实现命令注入。寻找Gadget的难点这需要攻击者对目标应用的代码库或使用的第三方库有深入的了解知道哪些属性会被哪些函数在什么条件下使用。在CTF中这常常是考察的重点在真实攻击中这通常需要结合源代码泄露或对流行框架的深入研究。6.3 污染加剧其他注入攻击SQL注入如果数据库查询的配置如表名前缀、查询选项是通过对象合并从用户输入中获取的污染可能修改这些默认配置间接影响查询的构建。XSS如前所述通过污染模板配置、污染用于生成HTML的对象的属性如toString方法可以引入XSS向量。7. 调试与排查当怀疑污染发生时如果你在开发或测试中发现一些诡异的、全局性的行为改变怀疑可能是原型链污染可以按以下步骤排查确认现象行为改变是否影响了所有同类对象例如是不是所有新创建的空对象{}都多了一个奇怪的属性用console.log({})和console.log(Object.prototype)快速检查。定位污染点二分法如果代码复杂尝试注释掉最近修改的、涉及对象合并或用户输入处理的部分代码看问题是否消失。日志法在可疑的合并、赋值函数前后打印出目标对象和源对象的结构使用console.dir或JSON.stringify。使用Proxy进行监控在开发环境可以用Proxy对象包装Object.prototype或关键的配置对象监控对其属性的set操作。const originalProto Object.prototype; const handler { set(target, property, value) { console.warn([WARNING] Setting ${property} on Object.prototype to, value, new Error().stack); // 可以选择抛出错误阻止污染 // throw new Error(Attempt to pollute prototype: ${property}); return Reflect.set(target, property, value); } }; Object.prototype new Proxy(originalProto, handler); // 注意这只是一个调试技巧会严重影响性能切勿在生产环境使用。分析调用栈一旦监控到非法设置错误栈new Error().stack会告诉你是在哪一行代码进行的赋值这是定位漏洞代码最直接的方法。修复与验证根据定位到的代码应用前面提到的防御策略过滤键名、使用安全合并等。修复后重新运行测试用例确保污染不再发生并且原有功能正常。原型链污染是一个“知道原理就很简单但不知道就极易忽略”的安全问题。它考验的是开发者对JavaScript语言底层机制的理解深度和安全编码的意识。最好的防御就是在第一次写for…in循环或者Object.assign时就下意识地问自己“这个源对象可信吗它的属性键名安全吗” 把这种审问变成编码习惯才是杜绝此类漏洞的根本。

相关新闻