各位来宾,大家好!
今天,我们将深入探讨一个在JavaScript生态系统中既强大又危险的特性——原型链(Prototype Chain),以及随之而来的一个严重安全漏洞:原型链污染(Prototype Pollution)攻击。作为一名编程专家,我将带领大家从原理、复现到防御策略,尤其是聚焦于 Object.freeze 这一关键防御手段,进行一次全面而深入的剖析。
一、 JavaScript 原型链的深层解析
在理解原型链污染之前,我们必须先对JavaScript的核心机制——原型链有透彻的理解。JavaScript是一种基于原型的语言,这意味着它没有传统的类继承模型(ES6的class语法只是语法糖,底层依然是原型)。对象间的继承是通过原型链来实现的。
1.1 对象与原型:基石
在JavaScript中,每个对象都有一个内部属性 [[Prototype]],它指向该对象的原型(prototype)。当您尝试访问一个对象的某个属性时,如果该对象本身没有这个属性,JavaScript引擎就会沿着 [[Prototype]] 链向上查找,直到找到该属性或到达原型链的末端(null)。
-
__proto__属性:这是访问对象[[Prototype]]内部属性的一个非标准但广泛实现的访问器属性。它允许我们直接获取或设置一个对象的原型。- 例如:
obj.__proto__会返回obj的原型对象。 - 虽然在ES6中引入了
Object.getPrototypeOf()和Object.setPrototypeOf()作为标准方法来操作原型,但__proto__依然因为其简洁性而被广泛使用,也正是其简洁性为原型链污染提供了便利的攻击路径。
- 例如:
-
prototype属性:这是一个函数特有的属性。当一个函数被用作构造函数(通过new关键字调用)时,新创建的实例对象会将其[[Prototype]]链接到构造函数的prototype属性所指向的对象。- 例如:
MyConstructor.prototype。所有由MyConstructor创建的实例,其__proto__都将指向MyConstructor.prototype。 MyConstructor.prototype对象通常包含所有实例共享的方法和属性。
- 例如:
-
Object.prototype:原型链的顶端:- 几乎所有JavaScript对象都直接或间接地继承自
Object.prototype。它是所有对象原型链的最终环节(除了那些通过Object.create(null)创建的“空”对象)。 Object.prototype包含了一些所有对象都可用的通用方法,例如toString(),hasOwnProperty(),valueOf()等。- 关键点:由于几乎所有对象都继承自
Object.prototype,如果攻击者能够向Object.prototype添加或修改属性,那么这些更改将“流布”到所有继承它的对象上,这就是原型链污染的核心。
- 几乎所有JavaScript对象都直接或间接地继承自
1.2 原型链的查找机制
让我们通过一个简单的例子来理解原型链的查找机制:
function Person(name) {
this.name = name;
}
Person.prototype.sayHello = function() {
console.log(`Hello, my name is ${this.name}`);
};
const john = new Person('John');
john.sayHello(); // 输出: Hello, my name is John
// 查找过程:
// 1. john 对象本身没有 sayHello 属性。
// 2. 引擎查找 john 的原型,即 john.__proto__ (也就是 Person.prototype)。
// 3. Person.prototype 上找到了 sayHello 方法。
// 4. 调用 Person.prototype.sayHello()。
console.log(john.hasOwnProperty('name')); // true (john 自身的属性)
console.log(john.hasOwnProperty('sayHello')); // false (sayHello 是原型上的属性)
console.log(Object.prototype.hasOwnProperty.call(john, 'name')); // true (更安全地调用hasOwnProperty)
// john 的原型链:
// john -> Person.prototype -> Object.prototype -> null
console.log(john.__proto__ === Person.prototype); // true
console.log(Person.prototype.__proto__ === Object.prototype); // true
console.log(Object.prototype.__proto__ === null); // true
这个查找机制的便利性在于,它允许我们通过修改原型对象来影响所有实例。然而,正是这种便利性,在被恶意利用时,会成为一个严重的安全漏洞。
二、 原型链污染:原理剖析
原型链污染攻击的核心思想是:攻击者通过某种方式,在应用程序运行期间,向 Object.prototype 这个全局对象添加或修改属性。一旦 Object.prototype 被污染,所有继承自它的对象(几乎所有JavaScript对象)都将受到影响,自动获得或修改了这些恶意属性。
这种攻击之所以危险,在于其隐蔽性和广泛性。攻击者不需要直接访问目标对象,只需要利用应用程序中任何一个不安全的属性赋值操作,就能达到污染全局的目的。
2.1 攻击路径
原型链污染通常通过以下两种主要路径实现:
-
直接通过
__proto__键名:
当应用程序接收用户输入,并将其作为对象的键名进行深层合并或属性设置时,如果用户输入中包含__proto__作为键名,且应用程序没有进行严格的过滤,那么__proto__将被解释为一个普通的对象键。当这个键被赋值时,实际上是直接修改了Object.prototype。
例如:let obj = {}; obj['__proto__']['maliciousProperty'] = true; // 攻击成功!这里
obj['__proto__']实际上就是Object.prototype。 -
通过
constructor.prototype间接路径:
有些库或应用程序在处理属性路径赋值时,可能会递归地遍历对象属性。如果路径中包含constructor,例如obj.constructor.prototype.maliciousProperty,那么:obj.constructor会返回obj的构造函数。obj.constructor.prototype则会指向该构造函数的原型对象。- 对于普通对象,
obj.constructor就是Object函数,那么obj.constructor.prototype就是Object.prototype。
这种方式利用了constructor属性在原型链上的特殊行为。
2.2 常见漏洞场景
原型链污染通常发生在以下几种常见的编程场景中:
-
对象合并/深度合并函数:
许多库(如Lodash的merge,jQuery的extend,或者自定义的深度合并函数)会递归地将源对象的属性合并到目标对象。如果源对象包含__proto__或constructor作为键,并且合并逻辑没有对这些特殊键进行过滤,就可能导致污染。// 假设有一个不安全的合并函数 function unsafeMerge(target, source) { for (let key in source) { if (typeof target[key] === 'object' && target[key] !== null && typeof source[key] === 'object' && source[key] !== null) { unsafeMerge(target[key], source[key]); } else { target[key] = source[key]; } } } let userControlled = JSON.parse('{"__proto__": {"isAdmin": true}}'); let config = {}; unsafeMerge(config, userControlled); // 污染发生 -
对象属性路径赋值函数:
一些库(如lodash.set、dot-prop、set-value)允许通过字符串路径来设置对象的深层属性。如果这些函数没有对路径中的__proto__或constructor进行特殊处理,攻击者可以构造恶意路径来污染原型。// 假设有一个不安全的 set 函数 function unsafeSet(obj, path, value) { let parts = path.split('.'); let current = obj; for (let i = 0; i < parts.length - 1; i++) { let part = parts[i]; if (!(part in current)) { current[part] = {}; } current = current[part]; } current[parts[parts.length - 1]] = value; } let data = {}; unsafeSet(data, '__proto__.isAdmin', true); // 污染发生 -
深度克隆函数:
与合并类似,深度克隆函数也可能在复制对象时,如果遇到__proto__或constructor键,而未进行适当处理,导致原型链污染。 -
数据解析与反序列化:
当应用程序接收并解析JSON等结构化数据时,如果解析后的对象直接被用于上述不安全的合并或赋值操作,用户构造的恶意载荷(例如{"__proto__": {"key": "value"}})就可能被注入。
三、 原型链污染的复现与代码示例
现在,让我们通过具体的代码示例来复现原型链污染,以更直观地理解其发生过程和影响。
3.1 场景一:通过 __proto__ 直接污染
这个场景模拟了一个常见的配置更新或对象合并操作,其中应用程序接收一个包含用户输入的对象,并将其属性合并到现有配置中。如果合并函数不够健壮,就会成为攻击的入口。
// 示例1: 模拟一个易受攻击的配置合并函数
// 这个函数递归地将 source 对象的属性合并到 target 对象。
// 注意:为了演示漏洞,这里故意不进行 __proto__ 和 constructor 的过滤。
function mergeConfig(target, source) {
for (const key in source) {
// 确保是 source 对象的自有属性,避免合并原型链上的属性
if (Object.prototype.hasOwnProperty.call(source, key)) {
// 如果 target 和 source 的当前属性都是对象且不是数组,则进行递归合并
if (typeof target[key] === 'object' && target[key] !== null &&
typeof source[key] === 'object' && source[key] !== null &&
!Array.isArray(target[key]) && !Array.isArray(source[key])) {
mergeConfig(target[key], source[key]);
} else {
// 否则,直接赋值
target[key] = source[key];
}
}
}
return target;
}
console.log("--- 场景一:通过 __proto__ 直接污染 ---");
// 应用程序的默认配置或一个待更新的对象
const appDefaultConfig = {
port: 8080,
host: 'localhost',
features: {
darkMode: true,
notifications: false
}
};
// 假设这是来自用户请求或外部服务的恶意输入
// 攻击载荷:{"__proto__": {"isAdmin": true}}
// JSON.parse 会将字符串解析为 JavaScript 对象。
const maliciousInput = JSON.parse('{"__proto__": {"isAdmin": true, "attackedByProto": "YES"}}');
console.log("n--- 攻击前状态 ---");
let testObjectBeforeAttack = {};
console.log("一个普通对象是否拥有 isAdmin 属性?", testObjectBeforeAttack.isAdmin); // undefined
// 模拟攻击:将恶意输入合并到应用程序的某个对象中
// 在这个示例中,我们将恶意输入合并到一个新的空对象中,
// 但实际情况可能是合并到 appDefaultConfig 或其他用户配置对象。
let userProvidedConfig = {};
mergeConfig(userProvidedConfig, maliciousInput);
console.log("n--- 攻击后验证 ---");
// 此时,Object.prototype 已经被污染。
// 任何新创建或不包含 isAdmin 属性的对象,都会从 Object.prototype 继承该属性。
let newObjectAfterAttack = {};
console.log("一个新的普通对象是否拥有 isAdmin 属性?", newObjectAfterAttack.isAdmin); // 期望输出: true
console.log("一个新的普通对象是否拥有 attackedByProto 属性?", newObjectAfterAttack.attackedByProto); // 期望输出: YES
// 甚至 appDefaultConfig 也可能受到影响,如果它在污染发生后才被创建或访问相关属性
console.log("appDefaultConfig 是否拥有 isAdmin 属性?", appDefaultConfig.isAdmin); // 期望输出: true
// 验证污染是否真的发生在 Object.prototype 上
console.log("Object.prototype.isAdmin:", Object.prototype.isAdmin); // 期望输出: true
console.log("Object.prototype.attackedByProto:", Object.prototype.attackedByProto); // 期望输出: YES
// 清理,以便后续演示不受影响
delete Object.prototype.isAdmin;
delete Object.prototype.attackedByProto;
console.log("n--- 清理污染完成 ---");
解释:
在这个示例中,mergeConfig 函数在处理 maliciousInput 时,没有对 __proto__ 这个特殊的键进行过滤。当 source 中的 key 是 __proto__ 时,target[key](即 userProvidedConfig['__proto__'])实际上就是 Object.prototype。因此,Object.prototype['isAdmin'] 和 Object.prototype['attackedByProto'] 被赋值为 true 和 "YES"。之后,任何新建的普通对象,由于其原型链最终指向 Object.prototype,都会继承这些被污染的属性。
3.2 场景二:通过 constructor.prototype 间接污染
这个场景模拟了一个可以根据路径字符串设置对象属性的函数,例如在处理配置或数据绑定时。如果路径中包含 constructor.prototype,就可能导致原型链污染。
// 示例2: 模拟一个易受攻击的深度属性设置函数 (类似 lodash.set)
// 这个函数通过点分字符串路径来设置对象的深层属性。
// 注意:为了演示漏洞,这里故意不进行 constructor 和 prototype 的过滤。
function setDeepProperty(obj, path, value) {
const parts = path.split('.');
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
// 如果当前路径部分不存在或不是对象,则创建一个新的空对象
if (!(part in current) || typeof current[part] !== 'object' || current[part] === null || Array.isArray(current[part])) {
current[part] = {};
}
current = current[part];
}
// 设置最终属性
current[parts[parts.length - 1]] = value;
return obj;
}
console.log("n--- 场景二:通过 constructor.prototype 间接污染 ---");
// 攻击载荷:构造一个路径,通过 constructor.prototype 修改 Object.prototype
// 'constructor.prototype.isVulnerable'
const attackPath = 'constructor.prototype.isVulnerable';
const attackValue = 'Pwned by Constructor!';
console.log("n--- 攻击前状态 ---");
let initialObjectBeforeAttack = {};
console.log("一个普通对象是否拥有 isVulnerable 属性?", initialObjectBeforeAttack.isVulnerable); // undefined
// 模拟攻击:在一个普通对象上设置恶意路径
let victimObject = {};
setDeepProperty(victimObject, attackPath, attackValue);
console.log("n--- 攻击后验证 ---");
// 此时,Object.prototype 已经被污染。
let anotherObjectAfterAttack = {};
console.log("一个新的普通对象是否拥有 isVulnerable 属性?", anotherObjectAfterAttack.isVulnerable); // 期望输出: Pwned by Constructor!
// 再次验证 Object.prototype
console.log("Object.prototype.isVulnerable:", Object.prototype.isVulnerable); // 期望输出: Pwned by Constructor!
// 清理
delete Object.prototype.isVulnerable;
console.log("n--- 清理污染完成 ---");
解释:
setDeepProperty 函数在遍历路径时,当遇到 constructor 部分时,current 会变成 victimObject.constructor,也就是 Object 函数。接着,下一部分 prototype 会访问 Object.prototype。最终,Object.prototype['isVulnerable'] 被赋值为 'Pwned by Constructor!'。这同样导致了 Object.prototype 的全局污染。
3.3 场景三:实际漏洞案例模拟(以 lodash.merge 旧版本为例)
虽然现代版本的流行库(如Lodash、jQuery)都已修复了原型链污染漏洞,但了解旧版本是如何受影响的,有助于我们理解这种攻击的普遍性以及对依赖管理的重要性。这里我们不再直接使用旧版本库,而是模拟一个具有类似漏洞行为的 customMergeDeep 函数。
// 示例3: 模拟旧版本 lodash.merge 的漏洞行为
// 注意: 现代lodash版本已修复此问题。这里是概念性演示,模拟其不安全行为。
function customMergeDeep(target, source) {
for (const key in source) {
// 关键:这里没有对 'key' 进行特殊检查,如是否是 '__proto__' 或 'constructor'
// 也没有严格检查 source[key] 是否是自有属性,虽然 for...in 遍历通常会跳过不可枚举的原型属性,
// 但对于直接通过 JSON.parse 创建的对象,__proto__ 可能是其自有属性。
if (Object.prototype.hasOwnProperty.call(source, key)) { // 确保是自有属性
if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key])) {
// 如果目标对象中对应属性不是对象,或者为空,则创建一个新对象
if (typeof target[key] !== 'object' || target[key] === null || Array.isArray(target[key])) {
target[key] = {};
}
customMergeDeep(target[key], source[key]); // 递归合并
} else {
target[key] = source[key]; // 直接赋值
}
}
}
return target;
}
console.log("n--- 场景三:模拟旧版本库漏洞 ---");
let appSettings = {
theme: 'dark',
user: {
id: 123
}
};
// 攻击载荷1: 通过 __proto__
const userOverridesProto = JSON.parse('{"__proto__": {"secretFlag": "EXPOSED_VIA_PROTO"}}');
console.log("n--- 攻击前状态 ---");
let configBeforeProtoAttack = {};
console.log("configBeforeProtoAttack.secretFlag:", configBeforeProtoAttack.secretFlag); // undefined
// 模拟攻击
customMergeDeep(appSettings, userOverridesProto);
console.log("n--- 攻击后验证 (Proto) ---");
let newObjectAfterProtoAttack = {};
console.log("newObjectAfterProtoAttack.secretFlag:", newObjectAfterProtoAttack.secretFlag); // 期望输出: EXPOSED_VIA_PROTO
console.log("Object.prototype.secretFlag:", Object.prototype.secretFlag); // 期望输出: EXPOSED_VIA_PROTO
// 清理
delete Object.prototype.secretFlag;
// 攻击载荷2: 通过 constructor.prototype
// 注意:这种攻击对 customMergeDeep 这种简单的递归合并函数可能不如 setDeepProperty 函数有效,
// 因为 customMergeDeep 期望 source[key] 是一个对象才能递归,而 constructor 自身不是普通对象。
// 但在更复杂的库实现中,如果它能处理类似 {"constructor": {"prototype": {"malicious": true}}} 这样的结构,
// 污染仍然可能发生。这里为了演示,假设一个能处理这种结构的版本。
// 我们可以通过一个更直接的构造来模拟:
const userOverridesConstructor = JSON.parse('{"constructor": {"prototype": {"anotherSecretFlag": "EXPOSED_VIA_CONSTRUCTOR"}}}');
console.log("n--- 攻击前状态 (Constructor) ---");
let configBeforeConstructorAttack = {};
console.log("configBeforeConstructorAttack.anotherSecretFlag:", configBeforeConstructorAttack.anotherSecretFlag); // undefined
// 为了让 customMergeDeep 能够处理这种深层结构,需要对其进行一些假设性修改
// 假设 customMergeDeep 在处理 `constructor` 时,如果 `target[key]` 是函数,也能进入递归,
// 并正确地访问其 `prototype` 属性。在真实的漏洞库中,这正是复杂性所在。
// 简单化演示,我们直接污染 Object.prototype 来模拟效果:
// 在实际漏洞中,`customMergeDeep(appSettings, userOverridesConstructor)` 会在内部某个时刻
// 导致 `Object.prototype.anotherSecretFlag = "..."`
// 这里我们直接模拟最终结果,因为 customMergeDeep 这种简单形式难以直接触发 constructor.prototype 路径。
Object.prototype.anotherSecretFlag = userOverridesConstructor.constructor.prototype.anotherSecretFlag;
console.log("n--- 攻击后验证 (Constructor) ---");
let newObjectAfterConstructorAttack = {};
console.log("newObjectAfterConstructorAttack.anotherSecretFlag:", newObjectAfterConstructorAttack.anotherSecretFlag); // 期望输出: EXPOSED_VIA_CONSTRUCTOR
console.log("Object.prototype.anotherSecretFlag:", Object.prototype.anotherSecretFlag); // 期望输出: EXPOSED_VIA_CONSTRUCTOR
// 清理
delete Object.prototype.anotherSecretFlag;
console.log("n--- 所有演示场景清理完成 ---");
解释:
这个示例旨在说明,即便是一个看似无害的对象合并操作,如果其内部逻辑没有充分考虑 __proto__ 和 constructor.prototype 这些特殊属性,就可能成为原型链污染的温床。攻击者可以精心构造JSON输入,利用这些缺陷,从而在全局范围内植入恶意属性。
四、 原型链污染的潜在危害
原型链污染并非一个独立的攻击,它往往是作为跳板,与其他漏洞或应用程序逻辑结合,导致更严重的后果。
-
拒绝服务 (DoS):
攻击者可以污染Object.prototype上的关键方法,例如将其修改为非函数或抛出错误的值,导致应用程序在尝试调用这些方法时崩溃。例如,修改Object.prototype.toString。// 污染 Object.prototype.toString Object.prototype.toString = 'malicious string'; // 此时,任何对象的 toString() 调用都可能失败或导致非预期行为 try { ({}).toString(); // 可能会抛出 TypeError: (intermediate value).toString is not a function } catch (e) { console.error("DoS攻击成功:", e.message); } delete Object.prototype.toString; // 清理 -
远程代码执行 (RCE):
这是最严重的危害之一。在某些使用模板引擎、表达式解析器或动态属性访问的框架中,如果被污染的属性恰好是用于执行代码的钩子(hook),攻击者就可以注入恶意代码。例如,污染Object.prototype上的某个属性,该属性随后被某个库作为回调函数或Shell命令的一部分执行。 -
权限绕过/数据泄露:
攻击者可以在Object.prototype上注入一个属性,如isAdmin: true或isAuthenticated: true。如果应用程序的权限检查逻辑依赖于检查对象的某个属性是否存在或其值是否为真,那么攻击者就能绕过权限限制,获取管理员权限或访问敏感数据。// 假设应用程序有这样的权限检查 function checkAdmin(user) { return user.isAdmin === true; } // 正常用户 const normalUser = { name: 'Guest' }; console.log("正常用户管理员权限:", checkAdmin(normalUser)); // false // 污染 Object.prototype Object.prototype.isAdmin = true; // 再次检查 console.log("污染后正常用户管理员权限:", checkAdmin(normalUser)); // true (权限绕过) delete Object.prototype.isAdmin; // 清理 -
会话劫持:
在某些基于对象存储会话信息的场景中,如果会话对象容易受到原型链污染,攻击者可能篡改会话ID或其他关键信息,从而劫持用户会话。 -
客户端JS代码注入:
在浏览器环境中,如果污染的属性影响到DOM操作、事件处理或数据绑定,可能导致XSS等客户端漏洞。
五、 Object.freeze 防御策略
鉴于原型链污染的巨大危害,我们必须采取有效的防御措施。其中,Object.freeze() 提供了一种强大且相对简单的防御策略。
5.1 Object.freeze() 的基本概念
Object.freeze() 方法可以冻结一个对象。冻结一个对象有以下效果:
- 不可扩展:不能向对象添加新属性。
- 不可删除:不能删除现有属性。
- 不可配置:不能更改现有属性的可枚举性、可配置性或可写性。
- 不可修改值:不能更改现有属性的值(除非属性本身是访问器属性,且其setter未被冻结)。
- 原型链冻结:它还会阻止更改该对象的原型(即不能修改
obj.__proto__)。
简而言之,一个被冻结的对象是不可变的,在它被冻结之后,其所有的属性和原型链都不能再被修改。
5.2 如何防御原型链污染
Object.freeze() 之所以能有效防御原型链污染,关键在于:在应用程序启动时,尽早地冻结 Object.prototype。
一旦 Object.prototype 被冻结,任何试图通过 __proto__ 或 constructor.prototype 向其添加新属性或修改现有属性的操作都将失败。
- 在严格模式下,这种尝试会直接抛出
TypeError错误。 - 在非严格模式下,这种尝试会静默失败,不会抛出错误,但也不会成功修改
Object.prototype。
这就像给所有JavaScript对象的根基打上了一层不可穿透的钢板,无论攻击者如何尝试通过继承链向上渗透,都会被这层钢板阻挡。
5.3 代码示例:使用 Object.freeze 保护 Object.prototype
// 示例4: 使用 Object.freeze 保护 Object.prototype
// 这一行代码至关重要,必须在应用程序启动时尽早执行,
// 确保在任何可能导致原型链污染的代码运行之前。
Object.freeze(Object.prototype);
console.log("n--- 场景四:使用 Object.freeze 保护 Object.prototype ---");
// 重新使用场景一的易受攻击合并函数
function mergeProtected(target, source) {
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
if (typeof target[key] === 'object' && target[key] !== null &&
typeof source[key] === 'object' && source[key] !== null &&
!Array.isArray(target[key]) && !Array.isArray(source[key])) {
mergeProtected(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
return target;
}
// 尝试通过 __proto__ 污染 (严格模式下会抛出错误)
let userConfigProtected = {};
const attackPayloadProtoProtected = JSON.parse('{"__proto__": {"isAdmin": true, "protectedByFreeze": "YES"}}');
console.log("n--- 尝试通过 __proto__ 污染 (受保护) ---");
let innocentObjectBeforeProtoAttack = {};
console.log("攻击前 innocentObjectBeforeProtoAttack.isAdmin:", innocentObjectBeforeProtoAttack.isAdmin); // undefined
try {
// 在严格模式下,这里会抛出 TypeError,因为 Object.prototype 被冻结
// 在非严格模式下,会静默失败
mergeProtected(userConfigProtected, attackPayloadProtoProtected);
console.log("mergeProtected 尝试完成,未抛出错误(非严格模式或合并逻辑未直接修改 Object.prototype)");
} catch (e) {
console.error("尝试通过 __proto__ 污染失败 (在严格模式下捕获到):", e.message); // 期望捕获 TypeError
}
let innocentObjectAfterProtoAttack = {};
console.log("攻击后 innocentObjectAfterProtoAttack.isAdmin:", innocentObjectAfterProtoAttack.isAdmin); // 期望输出: undefined
console.log("攻击后 innocentObjectAfterProtoAttack.protectedByFreeze:", innocentObjectAfterProtoAttack.protectedByFreeze); // 期望输出: undefined
// 验证 Object.prototype 是否真的未被污染
console.log("Object.prototype.isAdmin (受保护):", Object.prototype.isAdmin); // 期望输出: undefined
console.log("Object.prototype.protectedByFreeze (受保护):", Object.prototype.protectedByFreeze); // 期望输出: undefined
// 重新使用场景二的易受攻击深度属性设置函数
function setDeepPropertyProtected(obj, path, value) {
const parts = path.split('.');
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!(part in current) || typeof current[part] !== 'object' || current[part] === null || Array.isArray(current[part])) {
current[part] = {};
}
current = current[part];
}
try {
// 在严格模式下,这里会抛出 TypeError
current[parts[parts.length - 1]] = value;
console.log("setDeepPropertyProtected 尝试完成,未抛出错误(非严格模式或路径未直接修改 Object.prototype)");
} catch (e) {
console.error("尝试通过 constructor.prototype 污染失败 (在严格模式下捕获到):", e.message); // 期望捕获 TypeError
}
return obj;
}
// 尝试通过 constructor.prototype 污染 (严格模式下会抛出错误)
let victimObjectProtected = {};
const attackPathProtected = 'constructor.prototype.isVulnerableProtected';
const attackValueProtected = 'Pwned by Constructor! (But Blocked)';
console.log("n--- 尝试通过 constructor.prototype 污染 (受保护) ---");
let innocentObjectBeforeConstructorAttack = {};
console.log("攻击前 innocentObjectBeforeConstructorAttack.isVulnerableProtected:", innocentObjectBeforeConstructorAttack.isVulnerableProtected); // undefined
setDeepPropertyProtected(victimObjectProtected, attackPathProtected, attackValueProtected);
let innocentObjectAfterConstructorAttack = {};
console.log("攻击后 innocentObjectAfterConstructorAttack.isVulnerableProtected:", innocentObjectAfterConstructorAttack.isVulnerableProtected); // 期望输出: undefined
// 验证 Object.prototype
console.log("Object.prototype.isVulnerableProtected (受保护):", Object.prototype.isVulnerableProtected); // 期望输出: undefined
console.log("n--- Object.prototype 已被冻结,攻击被有效阻止 ---");
// 尝试修改 Object.prototype 的现有属性也会失败
try {
Object.prototype.hasOwnProperty = 'new value';
} catch (e) {
console.error("尝试修改 Object.prototype 现有属性失败:", e.message); // 期望捕获 TypeError
}
运行上述代码时请注意:
为了更好地观察 Object.freeze 的效果,建议在严格模式下运行JavaScript文件。在Node.js中,可以在文件顶部添加 'use strict';。这样,当尝试修改冻结对象时,会抛出 TypeError,而不是静默失败,这有助于调试和理解。
5.4 Object.freeze 的局限性与注意事项
尽管 Object.freeze(Object.prototype) 是一个强大的防御手段,但它并非万能,且在使用时需注意以下几点:
- 执行时机:这是最关键的一点。
Object.freeze(Object.prototype)必须在应用程序启动时尽早执行,在任何可能导致原型链污染的代码(包括第三方库的初始化)运行之前。如果污染已经发生,再冻结就为时已晚。 - 非严格模式下的静默失败:如前所述,在非严格模式下,对冻结对象的修改会静默失败,不会抛出错误。这可能导致难以发现潜在的攻击尝试或应用程序中的逻辑错误。因此,强烈建议在整个应用程序中启用严格模式(
'use strict';)。 - 浅冻结:
Object.freeze是浅冻结。它只冻结对象本身及其直接属性,不冻结嵌套对象。然而,对于Object.prototype的防御而言,这正是我们所需。我们关心的是Object.prototype对象本身不被修改,而不是其属性值如果是对象时,这些子对象是否被冻结。 - 兼容性:
Object.freeze是ES5标准的一部分,现代浏览器和Node.js都完全支持。 - 与其他防御的结合:
Object.freeze(Object.prototype)是一个非常有效的补充防御措施,但它不能替代其他基本的安全编码实践。它应该与输入验证、白名单过滤、使用最新安全的库版本等策略结合使用,形成多层防御体系。 - 对现有代码的影响:如果你的代码或依赖库在启动后尝试修改
Object.prototype(尽管这通常是不推荐的实践),那么冻结Object.prototype将会破坏这些代码。这在极少数情况下可能发生,因此在部署前务必进行充分测试。
六、 其他防御策略
除了 Object.freeze 之外,还有一系列重要的防御策略可以帮助我们抵御原型链污染。
6.1 输入验证和白名单过滤
这是最基础也是最重要的安全实践之一。对所有来自不可信来源(如用户输入、外部API响应)的数据进行严格的验证。
- 白名单:只允许预定义的一组安全属性名通过。如果属性名是
__proto__或constructor,或者任何不在白名单中的属性,则拒绝或清理。 - 递归过滤:在处理嵌套对象时,确保递归应用过滤规则。
// 示例5: 带有白名单过滤的合并函数
function mergeSafe(target, source, allowedKeys = []) {
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
// 1. 过滤特殊键
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
console.warn(`[Security Alert] Attempted to merge restricted key: ${key}`);
continue;
}
// 2. 白名单检查 (如果提供了白名单)
if (allowedKeys.length > 0 && !allowedKeys.includes(key)) {
console.warn(`[Security Alert] Merging disallowed key: ${key}`);
continue;
}
if (typeof target[key] === 'object' && target[key] !== null &&
typeof source[key] === 'object' && source[key] !== null &&
!Array.isArray(target[key]) && !Array.isArray(source[key])) {
mergeSafe(target[key], source[key], allowedKeys); // 递归应用过滤
} else {
target[key] = source[key];
}
}
}
return target;
}
console.log("n--- 场景五:输入验证和白名单过滤 ---");
let safeConfig = {
appName: 'MyApp',
version: '1.0'
};
const maliciousInputProto = JSON.parse('{"__proto__": {"maliciousProto": true}}');
const maliciousInputConstructor = JSON.parse('{"constructor": {"prototype": {"maliciousConstructor": true}}}');
const maliciousInputDisallowed = JSON.parse('{"secretKey": "attack", "allowedKey": "value"}');
// 尝试合并恶意输入
mergeSafe(safeConfig, maliciousInputProto);
mergeSafe(safeConfig, maliciousInputConstructor);
mergeSafe(safeConfig, maliciousInputDisallowed, ['allowedKey', 'version']); // 仅允许 'allowedKey' 和 'version'
let testObjSafe = {};
console.log("testObjSafe.maliciousProto:", testObjSafe.maliciousProto); // undefined
console.log("testObjSafe.maliciousConstructor:", testObjSafe.maliciousConstructor); // undefined
console.log("safeConfig.secretKey:", safeConfig.secretKey); // undefined (被过滤)
console.log("safeConfig.allowedKey:", safeConfig.allowedKey); // value (允许)
console.log("Object.prototype.maliciousProto:", Object.prototype.maliciousProto); // undefined
6.2 禁用 __proto__ 作为键名
在处理从用户输入解析的对象时,明确地检查并过滤掉 __proto__ 和 constructor 作为键名。许多库在修复原型链污染时都采用了这种方式。
// 示例6: 明确过滤 __proto__ 和 constructor
function safeDeepAssign(target, source) {
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
// 明确过滤
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
console.warn(`[Security Alert] Blocked attempt to assign restricted key: ${key}`);
continue;
}
if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key])) {
if (typeof target[key] !== 'object' || target[key] === null || Array.isArray(target[key])) {
target[key] = {};
}
safeDeepAssign(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
return target;
}
console.log("n--- 场景六:明确过滤 __proto__ 和 constructor ---");
let baseObject = {};
const maliciousPayload = JSON.parse('{"__proto__": {"attack1": true}, "constructor": {"prototype": {"attack2": true}}, "normalKey": "value"}');
safeDeepAssign(baseObject, maliciousPayload);
let checkObject = {};
console.log("checkObject.attack1:", checkObject.attack1); // undefined
console.log("checkObject.attack2:", checkObject.attack2); // undefined
console.log("baseObject.normalKey:", baseObject.normalKey); // value
console.log("Object.prototype.attack1:", Object.prototype.attack1); // undefined
6.3 使用安全的库和框架
始终使用最新版本的第三方库和框架。流行的库(如Lodash、jQuery、Express等)通常会迅速修复已知的安全漏洞。定期更新依赖是防止此类攻击的关键。
6.4 Object.create(null)
当您需要一个完全“干净”的对象,不继承任何 Object.prototype 上的属性时,可以使用 Object.create(null)。这种对象没有原型链,因此不会受到 Object.prototype 污染的影响。它特别适用于存储用户输入或其他不确定来源的数据。
// 示例7: 使用 Object.create(null)
let cleanObject = Object.create(null);
console.log("n--- 场景七:使用 Object.create(null) ---");
// cleanObject 不继承 Object.prototype 的任何方法
console.log("cleanObject.toString:", cleanObject.toString); // undefined
console.log("cleanObject.hasOwnProperty:", cleanObject.hasOwnProperty); // undefined
// 即使 Object.prototype 被污染,cleanObject 也不会受影响
Object.prototype.pollutedProperty = 'I am polluted!';
console.log("cleanObject.pollutedProperty:", cleanObject.pollutedProperty); // undefined
console.log("({}).pollutedProperty:", ({}).pollutedProperty); // I am polluted!
// 当然,如果直接将 '__proto__' 作为 cleanObject 的属性赋值,它依然会成为 cleanObject 的自有属性,
// 但这不会导致 Object.prototype 污染。
cleanObject['__proto__'] = { someProp: 'value' };
console.log("cleanObject.__proto__.someProp:", cleanObject.__proto__.someProp); // value
console.log("Object.prototype.someProp:", Object.prototype.someProp); // undefined
delete Object.prototype.pollutedProperty; // 清理
注意:Object.create(null) 创建的对象缺乏标准方法,可能需要手动添加一些常用功能或在操作前进行类型检查。
6.5 严格模式
在代码中启用严格模式('use strict';)可以帮助捕获许多常见的编程错误,并使JavaScript行为更可预测。例如,在严格模式下,对冻结对象的修改会抛出 TypeError,而不是静默失败,这有助于及早发现和修复问题。
七、 总结与展望
原型链污染是JavaScript世界中一个独特且强大的安全威胁。它利用了语言本身灵活的原型继承机制,通过不安全的属性赋值操作,达到全局污染的目的。这种攻击的隐蔽性和广泛性使其成为开发人员必须高度警惕的问题。
深入理解JavaScript原型链的运作原理是防御此攻击的基础。而 Object.freeze(Object.prototype) 作为一种简单而有效的防御策略,能够在应用程序启动的早期阶段,为 Object.prototype 构筑起一道坚不可摧的防线,极大地降低了原型链污染的风险。当然,单一的防御策略不足以应对所有威胁,它必须与严格的输入验证、白名单过滤、禁用特殊键、使用最新安全的库版本以及在严格模式下编写代码等多种安全实践相结合,才能构建一个健壮、多层次的防御体系。
在不断演进的Web安全领域,持续学习和实践安全编码原则,是每个开发者的责任。希望通过今天的讲座,大家能对原型链污染攻击有更深刻的理解,并能在日常开发中,运用这些知识和策略,构建更加安全可靠的应用程序。