JavaScript 原型链与原型链污染:从 new 机制到 Web 安全攻击面解析

原创2026-10-12 10:17:221,460 阅读
文章标签:网络安全应用安全渗透测试教程文档

JavaScript 原型链与原型链污染:从 new 机制到 Web 安全攻击面解析

导读

原型链(Prototype Chain)是 JavaScript 实现继承与属性共享的核心机制,也是理解 new 调用过程、对象属性查找顺序以及一类高危 Web 漏洞——原型链污染(Prototype Pollution)——的根基。本篇以 Web 安全学习笔记项目 source/language/javascript/prototype.rst 为骨架,结合仓库中 JavaScript 专题的 vm 沙箱逃逸、深拷贝与 Sink 等章节,系统讲解显式原型与隐式原型的区别、new 的三步执行过程、属性查找链路的形成,并深入剖析原型链污染的原理、典型攻击路径与防御思路,帮助读者同时掌握前端原型机制与相关安全利用手法。

一、显式原型与隐式原型

JavaScript 的对象系统与传统的"类 + 继承"模型不同,它采用基于原型的继承(prototype-based inheritance)。围绕原型机制,语言层面存在两个容易混淆的概念:显式原型(explicit prototype property)与隐式原型(implicit prototype link)。

1.1 显式原型:prototype

显式原型指 prototype,它是函数(Function)的一个属性,这个属性是一个指针,指向一个对象,用于显式地设置"以该函数作为构造函数所创建的对象"的原型。需要注意:

  • 只有函数才拥有 prototype 属性,普通对象没有;
  • 通过 prototype 可以显式地向原型对象添加属性与方法,实现基于原型的继承与属性的共享;
  • 修改 prototype 上定义的内容,会影响所有经由该构造函数创建出来的实例。

1.2 隐式原型:__proto__ 与 [[Prototype]]

隐式原型指 JavaScript 中任意对象都拥有的内置属性(规范中记作 [[Prototype]])。在 ES5 之前,没有标准的方法访问这个内置属性,但大多数浏览器都支持通过 __proto__ 来访问;ES5 起则提供了标准化的 Get 方法 Object.getPrototypeOf(),与之配套的还有 Object.setPrototypeOf() 用于设置原型。

隐式原型与显式原型的关系可以概括为:

  • __proto__ 指向的是"当前对象的原型对象";
  • prototype 指向的是"以当前函数作为构造函数构造出来的对象"的原型对象;
  • 隐式原型指向创建这个对象的函数(constructor)的 prototype。

也就是说,对于构造函数 Person 与其实例 p,存在恒等式:

p.__proto__ === Person.prototype

用一张关系表区分两者:

维度 显式原型 prototype 隐式原型 __proto__ / [[Prototype]]
归属 仅函数拥有 任意对象都有
指向 该函数作为构造函数所创建对象的原型对象 当前对象自己的原型对象
标准访问方式 直接访问 fn.prototype Object.getPrototypeOf(obj)(ES5 标准方法)
主要作用 实现基于原型的继承与属性共享 构成原型链,同样用于实现基于原型的继承

1.3 属性查找与原型链的构成

显式原型的作用是实现基于原型的继承与属性的共享;隐式原型的作用则是构成原型链。当我们访问对象 obj 中的 x 属性时,如果 obj 自身找不到,就会沿着 __proto__ 依次向上一层层查找,直到找到该属性或到达原型链顶端:

obj → obj.__proto__(构造函数的 prototype)→ … → Object.prototype

特别地,原型链存在一个例外:

Note: Object.prototype 这个对象是个例外,它的 __proto__ 值为 null。

正是这个 null 终结了原型链,防止查找无限循环。这一机制与仓库中 作用域与闭包 所讲的作用域链在"逐层向上查找"的思想上是一致的,只是作用域链查找的是变量,原型链查找的是属性。

二、new 的过程:三步拆解与验证

2.1 从 new Person() 说起

JavaScript 中通过构造函数创建对象的标准写法:

var Person = function(){};
var p = new Person();

new 的过程可以拆解为以下三步:

  1. var p = {}; —— 初始化一个对象 p;
  2. p.__proto__ = Person.prototype; —— 将 p 的隐式原型指向构造函数的显式原型;
  3. Person.call(p); —— 以 p 为 this 调用构造函数,构造(初始化)p。

关键在于第二步:隐式原型与显式原型的绑定发生在对象创建之时。可以用下面的代码证明:

var Person = function(){};
var p = new Person();
alert(p.__proto__ === Person.prototype);

这段代码会返回 true,说明第二步的绑定关系成立。也就是说,new 的本质是"创建一个空对象 → 把它的原型链挂到构造函数的 prototype 上 → 以该对象为 this 执行构造函数体内的初始化逻辑"。

从规范实现的角度看,new 还隐式处理了返回值:如果构造函数显式返回了一个对象,则该对象会取代 p 成为 new 的最终结果;如果返回的是原始值或未返回,则仍返回步骤 1 创建的对象。

2.2 属性查找示例

下面演示原型链在实际属性访问中的作用:

var Person = function(){};
Person.prototype.sayName = function() {
    alert("My Name is Jacky");
};

Person.prototype.age = 27;
var p = new Person();
p.sayName();

p 是一个引用指向 Person 创建的对象。我们在 Person.prototype 上定义了 sayName 方法和 age 属性,当执行 p.age 时,会先在 this 的内部查找(也就是实例自身,或者说构造函数内部初始化的属性),如果没有找到,再沿着原型链向上追溯。

这里的"向上追溯"如何实现?正是通过 __proto__ 属性将实例链接到原型(即 Person.prototype)进行查找,最终在原型上找到了 age 属性。同理,p.sayName() 也是沿这条链路找到并执行方法——这也就是为何所有由 Person 创建的实例都能"共享"原型上定义的方法与属性。

三、原型链污染(Prototype Pollution)

3.1 漏洞成因:动态继承与用户可控键值

如前文所述,JavaScript 是动态继承,通过 __proto__ 修改对象自身时会影响到拥有相同原型的其他对象。因此当键值对是用户可控的情况下,就可能出现原型链污染。

原型链污染的本质是:攻击者构造形如 {"__proto__": {...}} 的嵌套对象,利用某些存在缺陷的递归赋值/合并逻辑,把 __proto__ 键当作普通属性写入,从而篡改 Object.prototype 或某个构造函数的 prototype。一旦全局原型被污染,所有后续创建的对象都会"继承"被注入的属性或方法,进而可能导致:

  • 属性覆盖(如覆盖 toString、constructor 等内置方法,破坏程序逻辑);
  • 属性注入(为所有对象注入攻击者控制的属性,影响鉴权、配置读取等逻辑);
  • 在 Node.js 场景下与 常见 Sink(如 child_process、eval、exec、execSync)组合,进一步演化为任意代码执行。

3.2 典型攻击路径:递归合并与拷贝

原型链污染通常出现在以下场景中,而这些场景在仓库的 其他 章节中同样有对应讨论(对象拷贝)与风险提示(Sink):

  1. 递归合并(merge / deep extend):程序把用户提交的 JSON 与默认配置递归合并,但没有过滤 __proto__ / constructor.prototype 等键。例如:

    function merge(target, source) {
        for (var key in source) {
            if (typeof source[key] === 'object' && source[key] !== null) {
                merge(target[key], source[key]);
            } else {
                target[key] = source[key];
            }
        }
    }
    

    当输入包含 {"__proto__": {"polluted": true}} 时,merge 会走到 target["__proto__"] 分支——target["__proto__"] 正是 Object.prototype——从而把 polluted 写入全局原型,导致任意 {}.polluted === true。

  2. 深拷贝(deep copy):对象拷贝 一节指出,for-in、Object.assign()、扩展运算符 ... 等浅拷贝只对第一层做深拷贝,复杂的嵌套结构需要用递归实现。递归拷贝与递归合并一样,都存在未过滤 __proto__ 键而被污染的隐患。

  3. JSON 解析与反序列化:用户可控的 JSON 经 JSON.parse 后进入合并/拷贝逻辑;而仓库 反序列化 章节还提示,node-serialize、serialize-to-js 等库支持对任意对象(尤其是函数)的反序列化,配合 child_process 可直接构造 RCE——原型链污染则常常作为这类链路的"第一步"。此外,JSON 安全风险 一节也点出了重复 key、特殊 key 值(如 \x00、\ud800、")等可作为绕过手段的边界情况。

3.3 与沙箱逃逸的结合

在 沙箱逃逸 章节中,仓库提到前端沙箱会通过删除 eval、重写 Function.prototype.constructor / GeneratorFunction / AsyncFunction 等方式完成隔离。这些手段恰好与原型机制深度相关:

  • Function.prototype.constructor 被重写后,即可劫持通过函数构造获得的能力;
  • 服务端原生 vm 模块隔离不彻底时,可通过 this.constructor.constructor 一路沿着原型链逃逸到 Function,再借 Function("return process")() 拿到 process 与 require,最终执行 require("fs")。

可见,无论是"污染原型"还是"沿着原型链逃逸",攻击面都集中在"原型链可被外部数据或外部代码触及"这一核心事实上,两者常在同一套攻击链中配合使用。

3.4 防御思路

结合原型链的机制,可以从以下几个层面防御原型链污染:

  1. 过滤危险键:在合并/拷贝/反序列化入口处显式拒绝 __proto__、prototype、constructor 等键(例如以 Object.hasOwnProperty 校验后再处理);
  2. 使用安全的合并实现:采用不递归处理 __proto__ 的库,或对递归逻辑增加白名单校验;
  3. 冻结原型:在生产环境对 Object.prototype 等调用 Object.freeze(),使其不可被篡改;
  4. 输入校验:严格校验用户可控 JSON 的结构与 key,参考 JSON 中的风险点(重复 key、特殊 key 值)做好规范化;
  5. 运行时约束:在沙箱场景(参见 vm.rst)中避免暴露全局构造器,必要时升级到隔离更完善的方案并跟进版本更新。

四、关联阅读与扩展

原型链是 JavaScript 专题的基石,仓库 JavaScript 内容索引 中以下章节与本文形成完整的学习闭环:

  • 作用域与闭包:作用域链与原型链同为"逐层向上查找"的链路模型,理解两者差异有助于建立完整的运行时模型;
  • 反序列化:基于函数序列化构造 RCE 的实战手法,常与原型污染串联利用;
  • 沙箱逃逸:利用 constructor 沿原型链逃逸原生 vm 的完整示例;
  • 其他:浅拷贝/深拷贝的边界与常见 Sink 清单,是判断污染触发面的直接依据;
  • 参考链接:其中收录了 NorthSec 2018 关于 Prototype Pollution 的公开议题资料,可作为进一步研读的起点。

小结

本篇围绕原型链这条主线,完整覆盖了显式原型与隐式原型的区别、new 的三步执行过程、属性查找机制与原型链污染漏洞的成因与防御。掌握 p.__proto__ === Person.prototype 这一核心恒等式,理解"任何对象沿 __proto__ 向上查找、直至 Object.prototype.__proto__ === null"的链路模型,就同时掌握了 JavaScript 继承机制的钥匙与相关安全分析的地图。

登录后查看全文
Learn-Web-Hacking