参考文章

JavaScript原型链污染原理及相关CVE漏洞剖析 - FreeBuf网络安全行业门户

Node.js原型链污染的利用 - FreeBuf网络安全行业门户

用前端原型链漏洞污染拿下了服务器 - 腾讯云开发者社区-腾讯云 (tencent.com)

原型

  • 三个名字
    1. 隐式原型:所有引用类型(函数、数组、对象)都有 __proto__ 属性,例如arr.__proto__
    2. 显式原型:所有函数拥有prototype属性,例如:func.prototype
    3. 原型对象:拥有prototype属性的对象,在定义函数时被创建

JavaScript 中每个对象都拥有一个原型对象,对象以其原型为模板,从原型继承方法和属性。原型对象也可能拥有原型,并从中继承方法和属性,一层一层、以此类推。这种关系常被称为原型链(prototype chain)

示例:

  1. 数组原型链:arr → Array.prototype → Object.prototype
1
2
3
4
5
6
7
8
9
10
11
12
13
var arr = ['a', 'b', 'c']
// 修改forEach方法
Array.prototype.forEach = function() {
var result = "result : ";
for (let i = 0, n = this.length; i < n; ++i) {
result += this[i];
result += " ";
}
console.log(result);
}
arr.forEach()
// result : a b c
// arr中没有forEach()方法,向其原型寻找,此时其原型中的forEach方法已被我们修改,就会返回被修改后的结果。修改其父类,也就是其原型,达到攻击的手段
  1. 对象原型链:object → class.prototype → Object.prototype
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
class Person {
constructor(name, age, gender) {
this.name = name;
this.age = age;
this.gender = gender;
}
}
var person = new Person('zishuQ', 19, 'male');

console.log(person);
// Person {name: 'zishuQ', age: 19, gender: 'male'}

// 修改Object的原型,添加introduce()方法
Object.prototype.introduce = function() {
console.log("my name is " + this.name + "!!");
}

person.introduce();
// my name is zishuQ!!

// 修改Person的原型,添加introduce()方法
Person.prototype.introduce = function() {
console.log("This is Person prototype");
}

person.introduce();
// This is Person prototype

// 从这个例子可以看到,当Obejct和Person的原型都被修改时,会先找Person的原型

person是一个Person类的示例,有四个属性:nameagegenderproto。前三个是在构建函数中定义的,第4个属性proto就是Person.prototype

Untitled

这个机制的作用是什么呢?当我们访问person的一个属性时,浏览器首先查找person是否有这个属性.如果没有,然后浏览器就会在personproto中查找这个属性(也就是Person.prototype)。如果Person.prototype有这个属性,那么这个属性就会被使用。否则,如果Person.prototype没有这个属性,浏览器就会去查找Person.prototypeproto,看它是否有这个属性,依次类推。默认情况下,所有类的原型属性的proto都是Object.prototype

JavaScript的对象在访问自身没有的变量或者方法时,会向其父类(也就是原型)寻找,没找到再向其父类寻找,对于本例来说就是:

person.introduce();

person 自身没有introduce()方法,向Person类寻找,Person类找到则返回,没找到继续向Object类寻找。

  1. 攻击的例子
1
2
3
4
5
6
7
var a = { 1 : "a", 2 : "b"};
a.__proto__.role = function() {
alert("Its polluted!");
}
var b = {};
b.role();
// Its polluted!

原型式继承

由于JavaScript没有类似Java中的Class概念,继承都是由原型链来实现的。实现继承的方法很多,例:

1
2
3
4
5
6
7
8
9
10
11
12
function Person(name, age, gender) {
this.name = name;
this.age = age;
this.gender = gender;
}

function Teacher(name, age, gender, subject) {
Person.call(this, name, age, gender);
this.subject = subject;
}

Teacher.prototype = new Person();

创建一个构造函数Teacher,其中调用Person.call运行Person构造函数。Person.call函数的第一个参数指明了在运行这个函数时想对“this”指定的值,此处是Teacherthis,即Person构造函数里对this.name赋值,实际上是对Teacherthis赋值。接着用Object.create()方法创建一个新对象proto,这个对象使用Person.prototype来作为它的proto。然后将Teacherproto相互“关联”,proto的构造函数是TeacherTeacher的原型是proto。这样就实现了TeacherPerson的继承,此时Teacher.prototype如下图所示:

Untitled

Teacher.prototype的proto属性是Person.prototype,原型链也就多了“一层”。

Teacher → Teacher.prototype → Person.prototype → Object.prototype

Untitled

原型链污染

在 JavaScript发展历史上,很少有真正的私有属性,类的所有属性都允许被公开的访问和修改,包括 proto,构造函数和原型。攻击者可以通过注入其他值来覆盖或污染这些 proto,构造函数和原型属性。然后,所有继承了被污染原型的对象都会受到影响。原型链污染通常会导致拒绝服务、篡改程序执行流程、导致远程执行代码等漏洞。

原型链污染的发生主要有两种场景:不安全的对象递归合并和按路径定义属性。

其他语言,例如 Java,可以通过 public(default)promotedprivate的访问限制符避免此类问题。对应着 Java来看,JavaScript里面的所有属性都是 public属性

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
30
31
class Student {
private String name;
private int age;
// true is male, false is female
private boolean gender;

Student(String name, int age, boolean gender) {
this.name = name;
this.age = age;
this.gender = gender;
}
// use get/set method to change parameter
void setName(String name) {
this.name = name;
}
void setAge(int age) {
this.age = age;
}
void setGender(boolean gender) {
this.gender = gender;
}
String getName() {
return name;
}
int getAge() {
return age;
}
boolean getGender() {
return gender;
}
}

不安全的对象递归合并

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const merge = (target, source) => {
// Iterate through `source` properties and if an `Object` set property to merge of `target` and `source` properties
for (const key of Object.keys(source)) {
if (source[key] instanceof Object) {
Object.assign(source[key], merge(target[key], source[key]));
}
}
// Join `target` and modified `source`
Object.assign(target || {}, source)
return target
}
function Person(name, age, gender) {
this.name = name;
this.age = age;
this.gender = gender;
}
let newperson = new Person("test1", 22, "male");
let job = JSON.parse('{"title":"Security Engineer","country":"China","__proto__":{"x":1}}');
merge(newperson, job);
console.log(newperson);

(说明:JavaScript自带的Object.assign只能深拷贝第一层,第一层以后还是浅拷贝。因此很多开发者都会自定义一些递归合并函数。)

如果有这样一个场景:job对象是由用户输入的,并且用户可以输入任意对象。那么我们输入一个含有“proto”属性的对象,那合并的时候就可以把person的原型给修改了。 此时如果我们新建一个Person对象,可以发现,新建的对象的原型已被修改。

需要注意的是,只有不安全的递归合并函数才会导致原型链污染,非递归的算法是不会导致原型链污染的,例如JavaScript自带的Object.assign。

示例:

1
2
3
4
5
6
7
8
9
function Person(name,age,gender) {
this.name=name;
this.age=age;
this.gender=gender;
}
let person1 = new Person("test1",22,"male");
let job = JSON.parse('{"title":"Security Engineer","country":"China","__proto__":{"x":1}}');
Object.assign(person1,job);
console.log(Person.prototype);

此时再新建一个Person对象,可以发现,原型未被污染。

这是因为Object.assign在合并时,对于简单类型的属性值得到的是深拷贝,如stringnumber。如果属性值是对象或其他引用类型,则是浅拷贝。上面例子中的proto就是一个浅拷贝,合并后person的原型只是指向了一个新对象,即{“x”: 1},Person.prototype没有受到影响。