nodejs(原型链污染)深入

本来想将p神的JavaScript污染链文章并在我写的基础浅入里,但是感觉还是得分开学习记录,p神干货还得和我水的文章分开,浅入就讲概念理解学习吧,深入就涉及深入认识污染了

prototype__proto__分别是什么

这里引用p神的文章,加上部分自己的理解

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

理解

在JavaScript中,我们如果要定义一个类,需要以定义“构造函数”的方式定义:

1
2
3
4
5
function Foo() {
this.bar = 1
}

new Foo()

image-20230901135415750

其中Foo函数就是Foo类的构造函数,其中的this.barFoo类中的一个属性。

image-20230901154849363

也就是说利用function定义类的同时,构造函数也被定义好了

在ES6中引入了class的概念,两者是有区别,但是不是很大

class本质还是基于原型prototype的实现方式进一步的封装,class本质还是函数function

但是使用class定义类比function使得代码更加直观,也更加灵活,因为class定义类时,可以直接使用constructor()方法来定义构造函数,并且可以使用关键字extends来继承父类

1
2
3
4
5
class Foo {
constructor() {
this.bar = 1
}
}

image-20230901155300768

可以看到用class定义类的时候,构造函数也是被定义好了

并且function和class两者的构造函数的名字也是一样的

image-20230901160722382


在一个类必然有一些方法,类似属性this.bar,我们也可以将方法定义在构造函数的内部

1
2
3
4
5
6
7
8
function Foo() {
this.bar = 1
this.show = function() {
console.log(this.bar)
}
}

(new Foo()).show()

代码中我们可以看到,show的定义利用了function,但是并没有被定义为show类,也就是说这个show方法并不是和上面一样是所谓show类的构造函数,而是Foo类Foo这个构造函数的一个方法

如下图结果可以理解,在新建实例化Foo对象,执行构造函数时,show方法也会被执行可知,show只是构造函数中的一个方法,是绑定在对象中的吗,而不是绑定在类

image-20230901150808861

image-20230901170534691

但由此出现了一个新问题,那如果每次新建一个实例化对象就执行一次show方法,有时在实际生产中并不需要,可能大多数时候只想在创建类后执行一次即可

那肯定就不能把show方法再写到function Foo{}中去了,

这里就需要使用原型prototype

1
2
3
4
5
6
7
8
9
10
function Foo() {
this.bar = 1
}

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

let foo = new Foo()
foo.show()

这段代码其实也好理解,我们将show方法单独提出来当作一个函数,并加到Foo的原型当作一个单独的函数,prototype可以当作Foo类自带的一个属性,所有的创建的Foo对象都将拥有Foo类中所有内容,包括变量和方法。

image-20230901170427183

可以看到和在构造函数中定义show不同的是,show作为和构造函数一样的显示在原型内容中,属于是新创建的Foo类对象可以利用的一部分。

image-20230901171115585

这里将Foo类实例化后给foofoo也完全可以直接调用show函数的内容,foo自身创建开始就具有Foo中的所有变量方法可以调用。


关键点

我们可以通过Foo.prototype来访问Foo类的原型,但是Foo类实例化出来的对象,如上的foo,是不能直接调用prototype进行访问原型的

image-20230901172350796

所以这里就需要提到__proto__,类实例化后的对象可以通过__proto__来直接访问类的原型。

也就是

1
foo.__proto__ == Foo.prototype

image-20230901172719167

进一步思考,也就是实例化后的对象【[比如foo]可以像上面例子一样,通过__proto__访问原型定义一个新的函数,如下例子,利用foo.__proto__定义了一个新函数hacker

并且能成功调用执行,打印’i get shell

1
2
3
foo.__proto__.hacker = function hacker() {
console.log("i get shell")
}

image-20230901182724283

image-20230901182756972

总结

  1. prototype是一个类的属性,所有类对象在实例化的时候将会拥有prototype中的属性和方法
  2. 一个对象的__proto__属性,指向这个对象所在的类的prototype属性

JavaScript原型链继承

继承在java中很常见,在JavaScript中作用也差不多,只不过概念被替换成原型了

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

比如,

1
2
3
4
5
6
7
8
9
10
11
12
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}`)

这里的,

1
Son.prototype = new Father()

可以理解为Son的原型为实例化的Father类,Son类就会继承Father类得得last_name属性,但是继承不会覆盖自己原有的属性,所以first_name还是Son类自己的

image-20230905094854794

然后再将Son实例化给son,那么son就具有Son类Father类的所有属性

1
let son = new Son()

image-20230905095519407

可以在这里的层次就可以看出,sonfirst_name以Son类为主的,而由于自身没有last_name,就会从自己原型Father类继承last_name然后获取值

于是当打印时,最后输出的是Name: Melania Trump

1
console.log(`Name: ${son.first_name} ${son.last_name}`)

image-20230905095812582

总结

以上述的Son类和Father类为例,我们可以看到son的层次中,是有两个Prototype,这就是原型链的最简单的格式。最后一个Object就是null,他在原型Father类的后面,这也是原型链默认的最后一个原型。

我们可以考虑一个问题,当我在打印${son.last_name},如果在Father类中也没有这个属性会怎么样呢,那son就会不断的顺着原型链一直找下去,直到null

就会执行,

1
son.__proto__.__proto__.__proto__.__proto__

image-20230905100719829

JavaScript这种查找机制,被用在面向对象的继承中,被称作prototype继承链。

以上是最基础的JavaScript面向对象编程,我们并不深入研究更细节的内容,只要牢记下面几点即可:

  1. 每个构造函数都有一个原型对象
  2. 对象的__proto__属性,指向类的原型对象prototype
  3. JavaScript使用prototype链实现继承

原型链污染是什么

在最开始写过foo.__proto__指向Foo类的prototype,我们也试过通过foo.__proto__新增一个新函数,也可以加到原型中去,并且可以成功调用与构造函数同一层次,那么如果修改foo.__proto__中的一些值,同理也可以修改Foo类中的一些东西

我们简单试一下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// foo是一个简单的JavaScript对象
let foo = {bar: 1}

// foo.bar 此时为1
console.log(foo.bar)

// 修改foo的原型(即Object)
foo.__proto__.bar = 2

// 由于查找顺序的原因,foo.bar仍然是1
console.log(foo.bar)

// 此时再用Object创建一个空的zoo对象
let zoo = {}

// 查看zoo.bar
console.log(zoo.bar)

首先我们先构造一个简单对象foo,

1
let foo = {bar: 1}

由于foo并不是某个具体类的实例,但是所有对象都有一个实例存在,那就Object

然后我们先看看,

1
console.log(foo.bar)

image-20230905101905435

同样可以看到foo确实存在一个原型,并且为Object

那么如果我们通过foo.__proto__修改原型中的某些值呢?

1
foo.__proto__.bar = 2

这里我们通过foo.__proto__bar值修改为2,

然后打印看看,

image-20230905102214488

突然发现,打印的结果是1,而不是我们刚才修改的结果2

这是为什么呢?

我们再次看看foo就知道了

image-20230905102456014

我们可以看到,bar: 1是foo自身的属性,而原型Object中的bar: 2,层次要低于foo自身的属性,所以相同的属性,还是以自身优先,不存在或者不完善,才从原型中继承,这和上面讲的Son类和Father类是一样的。

所以这里打印结果还是1,就是这个原因。

但是,如果我们再新建一个对象,让它和foo一样,原型也是只有Object,但是没有bar属性,再次打印会怎么样呢

1
2
let zoo = {}
console.log(zoo.bar)

image-20230905102953845

可以看到打印结果为2,因为这里zoo对象没有bar属性,所以这里zoo就直接继承Object中bar属性,而Object本身并没有bar属性,是foo通过foo.__proto__来新增的一个属性,却能达到影响zoo对象的作用。

那么,这种在一个应用中,攻击者控制并修改了一个对象的原型,那么将可能影响所有和这个对象来自同一个类。父祖类的对象。这种攻击方式就是原型链攻击

哪些情况下原型链会被污染呢?

根据上述内容,我们发现关键在于能够顺利调用__proto__并设置其值即可,那么如何成功呢?

关键在于找到能够控制数字【对象】的“键名”的操作即可:

  • 对象merge
  • 对象clone(其实内核就是将待操作的对象merge到一个空对象中)

以对象merge为例,我们想象一个简单的merge函数:

1
2
3
4
5
6
7
8
9
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]
}
}
}

这个函数是一个递归函数,它的目的是将 source 对象中的属性合并到 target 对象中。它通过遍历 source 对象中的每一个键值对,如果 target 对象中也存在这个键,那么就递归调用 merge 函数将两个对象中对应键的值进行合并;否则,就直接将 source 对象中的键值对复制到 target 对象中。最终,target 对象将包含原来的属性以及 source 对象中的所有属性。这个函数通常用于合并两个或多个对象,以便于在一个对象中访问所有属性。

我们看到,在合并时存在复制操作target[key] = source[key],那么如果这个key是__proto__,是否就可以成功造成污染呢?

试一下,

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

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

这里可以看到o1和o2确实是合并成功了,但是o2.__proto__ 的b却没有合并进去,

image-20240531103907131

但是我们的__proto__不见了,取而代之发现多了个Object原型,也就是说我们的__proto__被当作o2的原型,也就是其自身的Object,但是并没有影响最后一个每个对象共有Object

这里的 __proto__ 实际上不是一个普通的属性,而是将 o2 的原型设置为 {b: 2}。所以 o2 的结构如下:

  • o2.a 是自身的属性,值为 1
  • o2 的原型有一个属性 b,值为 2

而merge合并只合并自身属性,所以这种污染是无效的。

此时遍历o2的所有键名,拿到的只有[a,b],__proto__并不是一个key,自然也不无法修改Object的原型。

image-20230905110442713

所以当我们想用o3测试是否污染时,发现是没有的

image-20230905111044306

image-20230905111219362

那么如何将__proto__被当作是一个键名呢,只要加一个解析就行

1
2
3
4
5
6
7
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)

再看看o2的数据,

image-20230905111651534

发现这次__proto__,没有被当作o2的原型了,而是一个键值

此时再次打印o3,发现成功污染Object

image-20230905111841511

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

可以是为什么能实现将属性b以及其值污染到Object中呢?

其实很简单,

我们实际修改原型时用的语法是

1
o2.__proto__.b = 2

而读取数组中键值也是如此语法,所以当键值为__proto__时,调用__proto__其后的数据因为语法,会被当作修改原型的值,从而实现原型链污染的作用

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


最后可以做一下p神的

Code-Breaking 2018 Thejs

https://github.com/phith0n/code-breaking/blob/master/2018/thejs/web/server.js