JavaScript语言的继承不通过class,而是通过原型对象(prototype)实现。

一、构造函数

JavaScript通过构造函数来生成新对象,构造函数相当于模板的作用。实例对象的属性和方法定义在构造函数中。

1
2
3
4
5
6
7
8
9
10
11
12
13
function student(name, ID)
{
this.name = name;
this.ID = ID;
}

var student1 = new student("cyt", "20231")
var student2 = new student("zpc", "20232")

console.log(student1.name)
console.log(student1.ID)
console.log(student2.name)
console.log(student2.ID)

这样通过构造函数给实例对象定义属性和方法是比较方便的。

但是这样会使得同一个构造函数的多个实例对象无法共享属性,那么在每一次生成新的实例对象时,这些对象之间本可以共享的属性都会重新进行一次内存分配并存储,造成资源的浪费。

1
2
3
4
5
6
7
8
9
10
11
12
13
function student(name, ID)
{
this.name = name;
this.ID = ID;
this.saysex = function(){
console.log("I'm Gay.")
}
}

var student1 = new student("cyt", "20231")
var student2 = new student("zpc", "20232")

console.log(student1.saysex === student2.saysex)

可以看到这里输出的false,因为进行比较的时候是比较这两个对象的内存地址,内存不一样说明两次分配内存是独立的,造成了资源的浪费,因为每一个实例对象的操作都是一样的。

二、prototype-原型对象

JavaScript通常被描述为一种基于原型的语言,即每一个对象都有一个原型对象。JavaScript的继承机制是原型对象的所有属性与方法,都被实例对象共享。原型是一块共享内存区域,存放各实例的共享元素。

JavaScript规定,每一个函数对象都有一个prototype属性,指向一个对象。对于普通函数几乎没用,对于构造函数,在生成新的实例对象时,该属性会自动成为实例对象的原型。

这里注意几点:

  1. 原型对象的属性不是实例对象的属性,修改原型对象的属性,所有的实例对象也会变动。
  2. 如果实例对象有某个属性或者方法,则不会再去原型对象寻找。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function student(name, ID)
{
this.name = name;
this.ID = ID;
}

student.prototype.weight = "Big fat"

student.prototype.saysex = function(){
console.log(this.name + " is Gay.")
}

var student1 = new student("cyt", "20231")
var student2 = new student("zpc", "20232")

console.log(student1.weight)
console.log(student2.weight)
student1.saysex()
student2.saysex()

student1.weight = "thin and thin"

console.log(student1.weight)
console.log(student2.weight)

student.prototype.weight = "not fat"

console.log(student1.weight)
console.log(student2.weight)

三、原型链

1.原型链基本概念

每个对象都有自己的原型对象,原型对象也是一个对象,也有自己的原型对象,这会形成一个原型链。

  1. 当试图访问对象的某个属性时,会先在对象内部查找,没有就往原型查找,没有就接着往上,直到找到或者找到原型链的末尾,还是找不到返回undefined
  2. 一层一层的往上查找,找到最后会找到Object.prototype,也就是Object的prototype属性,这也是为什么所有的对象都有valueOf方法和toString方法的原因。
  3. Object.prototype的原型对象是null,null没有属性和方法,没有自己的原型对象,也是原型链的末尾。
1
2
3
4
5
6
7
8
9
10
11
12
13
function student(name, ID)
{
this.name = name;
this.ID = ID;
}

student.prototype = new Array();

var studentgroup = new student();
studentgroup.push("one", "two", "three")
console.log(studentgroup.length)
console.log(studentgroup instanceof Array)
console.log(Object.getPrototypeOf(Object.prototype))

2.constructor属性

prototype对象都有一个constructor属性,指向所在的构造函数。

  1. 这个属性可以知道某个实例对象是由哪个构造函数生成的,从而能够通过实例对象新建另一个实例。
  2. constructor属性表示的是原型对象和构造函数的关系,如果修改原型函数的指向时,需要一起修改constructor属性,防止引用出错。所以一般不直接修改原型函数指向,采用添加方法的形式操作。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function student(name, ID)
{
this.name = name;
this.ID = ID;
}
console.log(student.prototype.constructor)

var cyt = new student();
console.log(cyt.constructor);
console.log(cyt.hasOwnProperty("constructor"));//实例对象没有这个属性,是继承于原型链

var zpc = new cyt.constructor();
console.log(zpc.constructor)

student.prototype = new Array();
console.log(student.prototype.constructor)
student.prototype = {
method: function(){}
}
console.log(student.prototype.constructor)
student.prototype.constructor = student;
console.log(student.prototype.constructor)

console.log(cyt.constructor.name)//输出构造函数名

3.ptorotype和__proto__

对于构造函数而言,访问原型对象是通过prototype属性,但是对于构造函数创建的实例对象而言不是这么做的,而是通过__proto__属性。

特性 prototype (显式原型) proto (隐式原型)
谁拥有它? 函数 (构造函数/类) 对象 (实例)
它的作用 存放公共的方法和属性(相当于“公共库”)。 一个指针,指向构造函数的 prototype,帮对象找到它的“根”。
形象比喻 工厂的模具/设计图纸 产品身上贴的标签:“产自该模具”
1
2
3
4
5
6
7
8
9
10
function student(name, ID)
{
this.name = name;
this.ID = ID;
}
console.log(typeof student.prototype)
var cyt = new student();
console.log(typeof cyt.prototype)
console.log(typeof cyt.__proto__)
console.log(typeof cyt.__proto__.__proto__.__proto__.__proto__)

其实cyt.__proto__.__proto__.__proto__的值为null,因为历史原因(类型标签的定义),类型为Object,此时访问cyt.__proto__.__proto__.__proto__.__proto__也就是null.__proto__就会报错。

四、原型链污染

原型链污染故名思及就是通过实例进行对其构造函数的原型对象的属性进行修改,从而污染后续构造函数新建的每一个实例。前提是这个实例没有自定义这个属性,即可实现覆盖。

1
2
3
4
5
6
let father = {name: "father", age: 45};
console.log(father.name)
father.__proto__.name = "son";
let son = {}
console.log(son.name)
console.log(father.name)

可以看到已经实现了污染,新建的对象son的name值为son,而father对象因为自定义了这个属性所以没有被污染。

哪些情况会出现原型链污染呢?要找能控制对象的键名的操作,像merge()、clone()等危险函数

要注意:JavaScript 引擎对于对象字面量 和JSON.parse处理 __proto__ 键的方式完全不同

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function merge(target, source) {
for (let key in source) {
if (key in source && key in target) {
merge(target[key], source[key])
} else {
target[key] = source[key]
}
}
}
let o1 = {}
let o2 = {a: 1, "__proto__": {b: 2}}//对象字面量
merge(o1, o2)
console.log(o1.a, o1.b)
o3 = {}
console.log(o3.b)
console.log(o1.__proto__)
console.log(o2.__proto__)
console.log(o3.__proto__)

这里可以看到,o1的b属性被修改,a属性也被修改,理论上来说我们应该实现了原型链污染,但是从输出信息来看,只有o2的原型对象被修改,o1和o3的原型链并未被污染,这又是何意味?

当使用对象字面量 let o2 = {a: 1, “__proto__“: {b: 2}} 时,JavaScript 引擎进行了特殊处理:

  • o2 的自有属性是 a: 1
  • o2 的内部原型([[Prototype]])被设置为 {b: 2}

for…in 的行为:

  1. 循环首先找到 o2 的自有属性 "a",将其复制到 o1
  2. 循环接着向上查找 o2 的原型链。它在 o2 的原型对象 {b: 2} 上找到了属性 "b"
  3. merge 函数执行 target[key] = source[key],即 o1["b"] = o2["b"]
    • o2["b"] 通过原型查找得到了值 2
    • 这个值 2 被赋值给了 o1,作为 o1自有属性

原型链污染的发生,必须满足一个核心条件:攻击者成功地修改了所有对象共享的Object.prototype

  1. o2 是用对象字面量创建的,"__proto__" 没有成为 o2 的自有属性,因此 for...in 循环根本没有遍历到 "__proto__" 这个键。
  2. merge 函数没有机会执行类似 o1["__proto__"] = ... 的赋值操作。
  3. 因此,全局的 Object.prototype 保持干净,没有被添加 b 属性。

那应该怎么操作呢?重点就是需要让__proto__被认为是一个键名,需要用到JSON解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function merge(target, source) {
for (let key in source) {
if (key in source && key in target) {
merge(target[key], source[key])
} else {
target[key] = source[key]
}
}
}
let o1 = {}
let o2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}')//JSON解析
merge(o1, o2)
console.log(o1.a, o1.b)
o3 = {}
console.log(o3.b)
console.log(o1.__proto__)
console.log(o2.__proto__)
console.log(o3.__proto__)

此时原型链真正被污染了,为什么用JSON解析就可以呢?

JSON 是一种数据交换格式,它不包含 JavaScript 的复杂逻辑(如原型链、函数等)。在 JSON 标准中,"__proto__" 没有任何特殊含义,它就是一个普通的字符串,就像 "name""age" 一样。

  • JSON.parse 解析字符串并创建一个对象。它将 "__proto__" 视为一个普通的自有属性。
  • o2 这个对象拥有一个名为 "__proto__" 的属性,其值是 {b: 2}

当你调用 merge(o1, o2) 时:

  1. 遍历循环检测到了 o2 有一个名为 "__proto__" 的属性。

  2. merge 函数试图执行类似这样的操作:

    1
    o1["__proto__"]["b"] = o2["__proto__"]["b"];
  3. 在 JavaScript 中,访问 o1["__proto__"] 实际上访问的是所有对象共享的 Object.prototype

  4. 代码成功地在 Object.prototype 上添加了属性 b

  5. 之后创建的任何对象都会通过原型链访问到这个 b

merge操作是最常见可能控制键名的操作,也最能被原型链攻击,很多常见的库都存在这个问题