JavaScript面向对象机制

JavaScript是通过一个构造函数来新建对象的

function Foo() {
    this.bar = 1;
    this.show = function () {
        console.log(this.bar);
    }
}
let foo = new Foo();
foo.show();

但是这样的做法是存在问题的

function Foo() {
    this.bar = 1;
    this.show = function () {
        console.log(this.bar);
    }
}

let foo1 = new Foo();
let foo2 = new Foo();

console.log(foo1.show === foo2.show);

两个对象的show方法是不同的,也就是占了两片内存,这样每次新建一个对象,都会为show函数新开辟一段内存,浪费空间。

为了让两个对象上的show方法是同一个,我们可以利用函数的prototype 属性,这是每个函数都具有的属性。

function Foo() {
    this.bar = 1;
}

Foo.prototype.show = function () {
    console.log(this.bar);
}
let foo1 = new Foo();
let foo2 = new Foo();

foo1.show();

console.log(foo1.show === foo2.show);

这样每次新new出来的对象,它们的show方法都是指向同一片内存。

但是foo1foo2本身是没有show方法的,它们都只有一个bar属性

foo1打印出来看看
![1](./images/1.png)
foo1有个bar属性,还有一个__proto__
这是 new 这个对象的时候加上去的,看看 new 这个关键字背后到底做了什么

new 会依次执行下面的步骤
![2](./images/2.png)
总结如下:
1.函数使用new关键字的时候,它就成为了构造函数,任何一个函数都可以是构造函数,任何一个函数都具有 prototype 属性。
2.任何一个对象都有 __proto__ 属性,我们称为对象的原型。

JavaScript原型链继承

所有类对象在实例化的时候将会拥有prototype中的属性和方法,这个特性被用来实现JavaScript中的继承机制。
比如:

function Father() {
    this.first_name = 'Donald'
    this.last_name = 'Trump'
}

function Son() {
    this.first_name = 'Melania'
}

Son.prototype = new Father()

let son = new Son()
console.log(`Name: ${son.first_name} ${son.last_name}`)

Son类继承了Father类的last_name属性,最后输出的是Name: Melania Trump

总结一下,对于对象son,在调用son.last_name的时候,实际上JavaScript引擎会进行如下操作:

1.在对象son中寻找last_name
2.如果找不到,则在son.__proto__中寻找last_name
3.如果仍然找不到,则继续在son.__proto__.__proto__中寻找last_name
4.依次寻找,直到找到null结束。比如,Object.prototype__proto__就是null
![3](./images/3.png)
JavaScript的这个查找的机制,被运用在面向对象的继承中,被称作prototype继承链。

再举个例子

function Animal(name) {
    this.name = name;
}
Animal.prototype.color = 'white';

var cat1 = new Animal('大毛');
var cat2 = new Animal('二毛');

console.log(cat1.color)
console.log(cat2.color)

color 的属性查找过程如下:
![4](./images/4.png)
1.在对象 cat1 中寻找 color 属性
2.如果找不到,在 cat1.__proto__(也就是 Animal.prototype ) 中寻找 color
3.如果仍然找不到,继续在 cat1.__proto__.__proto__ 中寻找 color
4.这样一层层上溯,最终到达 Object.prototype,而 Object.prototype.__proto__null
![5](./images/5.png)

原型链污染

上面第一个例子说到,foo.__proto__指向的是Foo类的prototype。那么,如果我们修改了foo.__proto__中的值,是不是就可以修改Foo类呢
![6](./images/6.png)
发现新建的foo2也拥有good属性
更进一步,我们是否可以直接修改 Object.prototype
![7](./images/7.png)
通过 foo1.__proto__.__proto__ 访问到 Object.prototype ,再给其添加上属性,新建的对象foo2也被加上了一个good属性。

原型链污染实际应用

在实际应用中,哪些情况下可能存在原型链能被攻击者修改的情况呢?

我们思考一下,哪些情况下我们可以设置__proto__的值呢?其实找找能够控制数组(对象)的“键名”的操作即可:

1.对象merge
2.对象clone(其实内核就是将待操作的对象merge到一个空对象中)
以对象merge为例,我们想象一个简单的merge函数:

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]
        }
    }
}

在合并的过程中,存在赋值的操作target[key] = source[key],那么,这个key如果是__proto__,是不是就可以原型链污染呢
我们用如下代码实验一下:

let o1 = {}
let o2 = {a: 1, "__proto__": {b: 2}}
merge(o1, o2)
console.log(o1.a, o1.b)

o3 = {}
console.log(o3.b)

结果是,合并虽然成功了,但原型链没有被污染
![8](./images/8_1.png)
这是因为,我们用JavaScript创建o2的过程(let o2 = {a: 1, "__proto__": {b: 2}})中,__proto__已经代表o2的原型了,此时遍历o2的所有键名,你拿到的是[a, b]__proto__并不是一个key,自然也不会修改Object的原型。

那么,如何让__proto__被认为是一个键名呢?

我们将代码改成如下:

let o1 = {}
let o2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}')
merge(o1, o2)
console.log(o1.a, o1.b)

o3 = {}
console.log(o3.b)

可见,新建的o3对象,也存在b属性,说明Object已经被污染:
![9](./images/9.png)
这是因为,JSON解析的情况下,__proto__会被认为是一个真正的“键名”,而不代表“原型”,所以在遍历o2的时候会存在这个键。

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

CVE-2019-10744 lodash 原型链污染

可以参看P牛的Code-Breaking 2018 Thejs分析
https://www.leavesongs.com/PENETRATION/javascript-prototype-pollution-attack.html#0x05-code-breaking-2018-thejs

参考链接

https://www.leavesongs.com/PENETRATION/javascript-prototype-pollution-attack.html
https://xz.aliyun.com/t/7182