JavaScript原型、原型链及原型链污染

0x00 前言

因为在CTF中时常也会考察原型链污染的问题,以前也一直让我捉襟见肘,一直没有系统的学习了解过JS原型的这些相关概念,因此写下本文,通过不断总结大佬的文章,写出自己对于此部分内容的理解。同时建议学习本文前要有对面向对象部分知识的一定理解(无论哪种语言)。

JavaScript没有”子类”和”父类”的概念,也没有”类”(class)和”实例(instance)的区分,全靠一种很奇特的”原型链”(prototype chain)模式,来实现继承。 在javascript中一切皆对象,因为所有的变量,函数,数组,对象 都始于object的原型即object.prototype。

0x01 JavaScript原型

一、对象和函数

在学习原型和原型链之前,首先一定要搞清楚对象和函数到底有什么区别和联系:

“对象是由函数创建的,而函数又是一种对象。”这样一句话要深刻记忆。

我们都知道JavaScript可以在浏览器中使用“F12”打开的控制台中输入JavaScript代码进行执行,但也要知道其实在浏览器中已经内置了几个全局函数可供随时调用,如:Number()、String()、Boolean()、Object()等 。

在JavaScript中声明一种数据类型的变量时其实有以下两种方式,而第一种可以更直观的体现对象和函数之间的关系,但第二种在各种语言中都较为常用。

// 声明一个字符串类型的变量
var str = new String('hello');  // 使用String()函数来创建一个string对象
// 声明一个数值类型的变量
var num = new Number(123);     // 使用Number()函数来创建一个number对象
// 声明一个布尔类型的变量
var b = new Boolean(true);     // 使用Boolean()函数来创建一个boolean对象
// 声明一个字符串类型的变量
var str = 'hello';          // 用字符串字面量形式创建string对象
// 声明一个数值类型的变量
var num = 123;              // 用数值字面量形式创建number对象
// 声明一个布尔类型的变量
var b = true;               // 用布尔字面量形式创建boolean对象

​但第二种虽然我们是用赋值形式创建的,但在JavaScript的内部,则仍然是通过调用函数来创建对象的。也就是说他们是一样的。

而对于“函数又是一种对象”这句话,也可以使用 instanceof 关键字来验证:

instanceof 的作用是判断一个对象是不是一个函数的实例。
比如 obj instanceof fn
实际上是判断fn的prototype是不是在obj的原型链上。
比如:
obj.__proto__ === fn.prototype
obj. __proto__.__proto__=== fn.prototype
obj. __proto__ … __proto__ === fn.prototype
以上只要一个成立即可。

以上这个内容如果现在看不懂,不要着急后面会解释什么是原型、原型链和__proto__属性。

二、原型(共有属性):__proto__

首先定义个对象:var str = new String(‘hello’); 输出看看该对象中包含哪些属性:

再创建一个num对象。

可以看到两个不同的对象,但都存在__proto__属性。再看以下例子:

肯定会疑惑valueOf和toString方法是哪里来的呢,其实这两个方法也都是在__proto__属性中带来的,打开__proto__的指向箭头就可以看到

总结:不只是str和num对象,每个对象中都有__proto__属性,JavaScript将这些对象(如:Number(函数也是对象))中的共有属性,拿了出来,全都集中到一个新的对象(num)中。而新对象中,就保存着一个__proto__,指向这个原对象。

而__proto__所指向的这个原对象,也叫做原型对象。后文会继续解释。

而既然存在共有属性,那也一定存在独有属性。string对象有string对象的属性是其他对象没有的;number对象有number对象的属性是其他对象没的;boolean对象有boolean对象的属性是其他对象没有的;以此类推。 那这个“独有对象”又是保存在哪儿的呢?我们来看看什么是prototype。

三、函数的原型(prototype)

上面说到,__proto__是每个对象都有的属性,那么要区别记住的是prototype是函数才有的属性。

再继续了解prototype属性前再补充学习几个知识点:

1-什么是构造函数

Person就是一个构造函数,我们使用 new 创建了一个实例对象 person

2-constructor属性

接着按照刚刚的例子查看Person和person的结构输出。

可以看到person的构造函数Person存在的原型包含一个constructor属性。接下来记住一句话:“每个原型(prototype)都有一个 constructor 属性指向关联的构造函数,实例原型指向构造函数 ”。再看person的结果中__proto__属性所指的constructor属性也是与之关联的构造函数,而对于该例中,它的构造函数就是function Person()函数,因此整个结构图如下图所示。

所以以下代码是成立的

function Person() {
}
var person = new Person();
console.log(person.__proto__ == Person.prototype) // true
console.log(Person.prototype.constructor == Person) // true

0x02 JavaScript原型链

其实当认真理解完上面的内容,原型链的概念就基本清楚了,以下总结出几点:

1-从上面的代码中可以看到,创建person对象虽然使用的是由构造函数Person创建,但是对象创建出来之后,这个person对象其实已经与Person构造函数没有任何关系了,person对象的__proto__属性指向的是Person构造函数的原型对象(Person.prototype)。
2-如果使用new Person()创建多个对象person1、person2、person3,则多个对象都会同时指向Person构造函数的原型对象。
3-我们可以手动给这个原型对象添加属性和方法,那么person1、person2、person3这些对象就会共享这些在构造函数的原型对象中添加的属性和方法。
4-如果我们访问person中的一个属性name,如果在person对象中找到,则直接返回。如果person对象中没有找到,则直接去person对象的__proto__属性指向的原型对象中查找,如果查找到则返回。(如果原型中也没有找到,则继续向上找原型的原型—原型链),直到最高级Object的__proto__为Null为止。
5-如果通过person对象添加了一个属性name,则通过person访问name时,就相当于屏蔽了原型中的属性name,输出的是person对象中的name值
6-通过person对象只能读取构造函数的原型中的属性name值,而不能修改原型中的属性name值。 person.name = “purplet”; 并不是修改了原型中的值,而是在person对象中给添加了一个属性name。

下面可以把原型、原型链的关系当作一个公式一般去记忆:

var 对象 = new 函数()     
对象.__proto__ === 对象的构造函数.prototype

// 推论
var str = new String()      // 前面的str是对象,后面的String是函数
str.__proto__ === String.prototype()
String.__proto__ === Function.prototype   // 因为 String 是 Function 的实例

var number = new Number()    // 前面的number是对象,后面的Number是函数
number.__proto__ === Number.prototype
Number.__proto__ === Function.prototype // 因为 Number 是 Function 的实例

var object = new Object()     // 前面的object是对象,后面的Object是函数
object.__proto__ === Object.prototype
Object.__proto__ === Function.prototype // 因为 Object 是 Function 的实例

var function = new Function()     // 前面的function是对象,后面的Function是函数
function.__proto__ === Function.prototype
Function.__proto__ === Function.prototye // 因为 Function 是 Function 的实例!

由于__proto__是任何对象都有的属性,而JavaScript里万物皆对象,所以会形成一条__proto__连起来的链条,但递归访问__proto__必须最终到头,其终点是Null
当JavaScript引擎查找对象的属性时,先查找对象本身是否存在该属性,如果不存在,会在原型链上查找,但不会查找自身的prototype,如图所示。

0x03 JavaScript原型链污染

在看懂原型链的那几点内容后,其实就应该可以理解什么是原型链污染了,就是修改其构造函数的原型中的属性值,使其他通过该构造函数实例出的对象也具有该属性值。

可以看到我们修改成功了,新生成的 foo2 对象也具有hacker 属性,如果给foo1再往上加一个__proto__就可以修改(添加)Object的属性了。

那么在哪些情况下原型链会存在污染

这里我引用郁离歌师傅的博客内容了。

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

  • 对象merge
  • 对象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)

结果是,合并虽然成功了,但原型链没有被污染:

这是因为,我们用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已经被污染:

这是因为,JSON解析的情况下,__proto__会被认为是一个真正的“键名”,而不代表“原型”,所以在遍历o2的时候会存在这个键。

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

0x04 文章参考

https://www.zhihu.com/tardis/sogou/art/44035916

https://www.jianshu.com/p/be7c95714586

https://www.leavesongs.com/PENETRATION/javascript-prototype-pollution-attack.html#0x04

发表评论

电子邮件地址不会被公开。 必填项已用*标注