什么是原型污染?

原型污染是一个 JavaScript(nodejs) 漏洞,攻击者可以利用它向全局对象原型添加任意属性,然后用户定义的对象可以继承这些属性。

原型链污染又分为客户端原型链污染和服务端原型链污染;在ctf中基本都是服务端污染导致绕过验证,得到管理员权限或者RCE等等;

JavaScript 中的原型是什么?

JavaScript 中的每个对象都链接到某种类型的另一个对象,称为原型。JavaScript 使用原型继承模型,这与许多其他语言使用的基于类的模型有很大不同。默认情况下,JavaScript 会自动将新对象分配给其内置原型之一。例如,字符串会自动分配内置的String.prototype. 您可以在下面看到这些全局原型的更多示例:

1
2
3
4
5
6
7
8
9
10
11
let myObject = {};
Object.getPrototypeOf(myObject); // Object.prototype

let myString = "";
Object.getPrototypeOf(myString); // String.prototype

let myArray = [];
Object.getPrototypeOf(myArray); // Array.prototype

let myNumber = 1;
Object.getPrototypeOf(myNumber); // Number.prototype

对象会自动继承其指定原型的所有属性,除非它们已经拥有具有相同键的自己的属性。这使得开发人员能够创建可以重用现有对象的属性和方法的新对象。

内置原型提供了用于处理基本数据类型的有用属性和方法。例如,String.prototype对象有一个toLowerCase()方法。因此,所有字符串都会自动有一个现成的方法将它们转换为小写。这使得开发人员不必手动将此行为添加到他们创建的每个新字符串中。

对象继承是如何工作的?

每当您引用对象的属性时,JavaScript 引擎都会首先尝试直接在对象本身上访问该属性。如果对象没有匹配的属性,JavaScript 引擎会在对象的原型上查找它。

原型链

请注意,一个对象的原型只是另一个对象,该对象也应该有自己的原型,依此类推。由于实际上 JavaScript 中的所有内容都是底层的对象,因此这条链最终会回到顶层Object.prototype,其原型很简单null

至关重要的是,对象不仅从其直接原型继承属性,而且从原型链中位于其上方的所有对象继承属性。在上面的示例中,这意味着username对象可以访问String的属性和方法。String.prototype and Object.prototype.

使用 __proto__ 访问对象的原型

每个对象都有一个特殊的属性,您可以使用它来访问其原型。尽管它没有正式的标准化名称,但__proto__它是大多数浏览器使用的事实上的标准。您可以使用它来读取原型及其属性,甚至在必要时重新分配它们。

与任何属性一样,您可以__proto__使用括号或点符号进行访问:

1
2
username.__proto__
username['__proto__']

甚至可以链接引用以__proto__沿着原型链向上工作:

1
2
3
username.__proto__                        // String.prototype
username.__proto__.__proto__ // Object.prototype
username.__proto__.__proto__.__proto__ // null

修改原型

修改原型可以像修改任何其他对象一样修改 JavaScript 的内置原型。这意味着开发人员可以自定义或重写内置方法的行为,甚至添加新方法来执行有用的操作。

例如,现代 JavaScript 提供了trim()字符串方法,使您能够轻松删除任何前导或尾随空格。在引入此内置方法之前,开发人员有时会String.prototype通过执行以下操作将自己的此行为的自定义实现添加到对象中:

1
String.prototype.removeWhitespace = function(){    // remove leading and trailing whitespace }

由于原型继承,所有字符串都可以访问此方法:

1
let searchTerm = "  example "; searchTerm.removeWhitespace();    // "example"

原型污染漏洞是如何产生的?

当JavaScript函数递归地将包含用户可控属性的对象合并到现有对象时,通常会出现原型污染漏洞。这可以允许攻击者注入带有类似__proto__的键的属性,以及任意嵌套的属性。

由于__proto__在JavaScript上下文中的特殊含义,合并操作可能会将嵌套属性分配给对象的原型,而不是目标对象本身。因此,攻击者可以用包含恶意值的属性污染原型,这些属性随后可能被应用程序以危险的方式使用。

更多参考

prototype , __proto__

prototype__proto__ 是 JavaScript 中与原型(prototype)相关的两个重要概念,但它们有不同的用途和含义。

  1. prototype:

    • prototype 是函数对象特有的属性。每个函数都有一个 prototype 属性,这个prototype属性是一个指向原型对象的指针,它包含了可以由该函数的实例继承的属性和方法。
    • 当你创建一个新的函数时,JavaScript 自动为该函数创建一个 prototype 对象,并赋值给 prototype 属性。
    • 通过 prototype 对象,你可以定义函数的共享属性和方法,它将被该函数的所有实例所共享。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // 定义构造函数
    function Person(name, age) {
    // 实例属性
    this.name = name;
    this.age = age;
    }

    // 在构造函数的 prototype 上定义共享方法
    Person.prototype.sayHello = function() {
    console.log(`Hello, my name is ${this.name} and I am ${this.age} years old.`);
    };

    // 创建实例
    const person1 = new Person("Alice", 25);
    const person2 = new Person("Bob", 30);

    // 调用共享方法
    person1.sayHello(); // 输出: Hello, my name is Alice and I am 25 years old.
    person2.sayHello(); // 输出: Hello, my name is Bob and I am 30 years old.
  2. proto:

    • __proto__ 是每个对象都具有的属性,用于指向其构造函数的原型。它是一个指向该对象的原型链上一层的链接。
    • __proto__ 属性是非标准的,尽管大多数现代浏览器都支持它,但它已经被 ECMAScript 6 标准中的 Object.getPrototypeOf 方法所替代。
    • __proto__ 主要用于获取和设置对象的原型。
    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
    const  myObject= {};
    const myObjectPrototype = Object.getPrototypeOf(myObject);
    console.log(myObjectPrototype === Object.prototype); //true
    console.log(myObject.__proto__ === Object.prototype); //true

    const myString = "";
    const myStringPrototype = Object.getPrototypeOf(myString);
    console.log(myStringPrototype.__proto__ === Object.prototype); //true

    const myArray = [];
    const myArrayPrototype = Object.getPrototypeOf(myArray);
    console.log(myArrayPrototype.__proto__ === Object.prototype); //true

    const myNumber = 1;
    const myNumberPrototype = Object.getPrototypeOf(myNumber);
    console.log(myNumberPrototype.__proto__ === Object.prototype); //true

    //======================================================================
    console.log(Object.getPrototypeOf(myString)) //{}
    console.log(Object.getPrototypeOf(myNumber)) //{}
    console.log(Object.getPrototypeOf(myString) ==Object.getPrototypeOf(myNumber)) //false
    console.log(Object.getPrototypeOf(myString).__proto__ ==Object.getPrototypeOf(myNumber).__proto__) //true

    //下面解释为什么是 false?
    //在 JavaScript 中,Object.getPrototypeOf 用于获取对象的原型。在代码中,myString 是一个空字符串,而 myNumber 是一个数字。当调用 Object.getPrototypeOf(myString) 时,它返回的是字符串的原型对象 String.prototype。同样,Object.getPrototypeOf(myNumber) 返回的是数字的原型对象 Number.prototype。虽然输出结果都是 {} 对象,但它们是不同的对象。这是因为 {} 是表示一个空对象字面量的方式,而不是真正的空对象。在 JavaScript 中,每次调用 Object.getPrototypeOf 都会返回一个新的对象,即使它们有着相同的结构和原型链。

总的来说,prototype 用于构造函数,帮助定义共享属性和方法,而 __proto__ 是每个对象都有的属性,用于指向其构造函数的原型。Object.getPrototypeOf 方法是更推荐使用的获取原型的方式,而 __proto__ 在现代 JavaScript 中已经不再建议使用。

一个简单的案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const myObject = { a: 1, b: 2 };

Object.prototype.c = 3;
myObject.__proto__.d = 4;

for(const propertyKey in myObject){
console.log(propertyKey);
}

//输出:
//a
//b
//c
//d

这也适用于数组,其中for...in循环首先迭代每个索引(本质上只是底层的数字属性键),然后再继续处理任何继承的属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const myArray = ['a','b'];

Object.prototype.c = 3;
myArray.__proto__.d = 4;

for(const arrayKey in myArray){
console.log(arrayKey);
}

//输出
//0
//1
//d
//c

//下面解释为什么键“c”输出在“d”的后面?
//在这个例子中,给 Object.prototype 添加了一个属性 c,并给 myArray.__proto__ 添加了一个属性 d。当您使用 for...in 循环迭代数组时,它会遍历对象的所有可枚举属性,包括原型链上的属性。在JavaScript中,Object.prototype 是所有对象的原型链的顶部,因此它的属性会在其他原型上的属性之前被遍历。在这里,c 是添加到所有对象的原型链上的属性,而 d 是添加到 myArray.__proto__ 上的属性。因此,在 for...in 循环中,d 的输出在 c 之前,因为 myArray.__proto__ 的属性会先被遍历。

通过 JSON 输入造成原型污染

JSON.parse()

在ctf中最常见的莫过于JSON.parse()函数了;

用户可控的对象通常是使用该JSON.parse()方法从 JSON 字符串派生的。有趣的是,JSON.parse()还将 JSON 对象中的任何键视为任意字符串,包括__proto__. 这为原型污染提供了另一个潜在载体。

假设攻击者通过网络消息注入以下恶意 JSON:

1
2
3
4
5
{
"__proto__": {
"evilProperty": "payload"
}
}

如果通过该方法将其转换为 JavaScript 对象JSON.parse(),则生成的对象实际上将具有一个带有 key 的属性__proto__

1
2
3
4
5
const objectLiteral = {__proto__: {evilProperty: 'payload'}};
const objectFromJson = JSON.parse('{"__proto__": {"evilProperty": "payload"}}');

console.log(objectLiteral.hasOwnProperty('__proto__')); // false
console.log(objectFromJson.hasOwnProperty('__proto__')); // true

注意:在Node.js中,hasOwnProperty函数是JavaScript中的一个内置函数,用于检查对象自身是否包含指定的属性(即不包括从原型链继承的属性)。这个函数返回一个布尔值,如果对象包含指定的属性,则返回true,否则返回false。

例如:

1
2
3
4
let obj = {a: 1, b: 2};

console.log(obj.hasOwnProperty('a')); // true
console.log(obj.hasOwnProperty('c')); // false

Example

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
32
33
34
35
36
37
function merge(target, source) { 
for (let key in source) {
if (key in source && key in target) {
// console.log(key)
// console.log(target[key])
// console.log(source[key])
merge(target[key], source[key])
} else {
target[key] = source[key]
}
}}
let object1 = {}
let object2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}')
merge(object1, object2)
console.log(object1.a, object1.b) //1 2
object3 = {}
console.log(object3.b) //2

let object4 = ""
console.log(object4.b) //2

// console.log(object2.hasOwnProperty('a'));
// console.log(object2.hasOwnProperty('b'));
// console.log(object2.hasOwnProperty('__proto__'));
// console.log(object1.__proto__);
// console.log(object2.__proto__);
// console.log(object3.__proto__);

//我将大致解释一下这段代码是如何通过merge函数污染到object3的属性的(这样说也许不太对,应该是污染到了原型的属性,所以新建一个对象,都会拥有b属性)
//JSON.parse在上面已经分析过了;这里主要看merge函数;
//第一次循环:"a": 1
//单纯的将object2的a属性赋值给object1
//第二次循环:"__proto__": {"b": 2}
//首先将__proto__赋值给key;
//if (key in source && key in target)判断为真,因为target也就是object2有__proto__属性,这一点可以用hasOwnProperty函数更能看明白
//如果判断为真,递归merge函数,这里注意 传递的第一个参数是object1.__proto__和第二个参数object2.__proto__ == ({"b": 2})
//接着会向object1.__proto__赋值b属性,而object1.__proto__就是原型,所以整个原型污染流程结束

服务器端原型污染

记录一下portswigger上的实验;漏洞点都是在一个修改个人形象的功能点,后端是基于 Node.js 和 Express 框架构建。它很容易受到服务器端原型污染的影响,因为它将用户可控的输入不安全地合并到服务器端 JavaScript 对象中。

污染进行权限升级

用bp抓包;往请求包中用__proto__尝试原型污染;然后发送请求。可以看到响应包中出现了"hacker":"zIxyd"

1
"__proto__":{"hacker":"zIxyd"}

确认存在原型链污染,发送恶意数据,

1
"__proto__":{"isAdmin":true}

检测服务器端原型污染

大多数时候,即使您成功污染了服务器端原型对象,您也不会在响应中看到受影响的属性。也就是说你在一个点上无法判断是否存在原型污染;那该怎么办呢?

一种方法是尝试注入与服务器的潜在配置选项相匹配的属性。然后,您可以比较注入前后服务器的行为,以查看此配置更改是否已生效。如果是这样,这强烈表明您已成功发现服务器端原型污染漏洞。

状态码覆盖

Express 等服务器端 JavaScript 框架允许开发人员设置自定义 HTTP 响应状态。如果出现错误,JavaScript 服务器可能会发出通用 HTTP 响应,但在正文中包含 JSON 格式的错误对象。这是提供有关错误发生原因的附加详细信息的一种方法,这从默认 HTTP 状态中可能并不明显。

Node 的http-errors模块包含以下用于生成此类错误响应的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
function createError () {
//...
if (type === 'object' && arg instanceof Error) {
err = arg
//注意下面这一行
status = err.status || err.statusCode || status
} else if (type === 'number' && i === 0) {
//...
if (typeof status !== 'number' ||
(!statuses.message[status] && (status > 400 || status >= 600))) {
status = 500
}
//...

“ //注意下面这一行”显示的下一行试图通过读取传递给函数的对象的status或statusCode属性来分配状态变量。如果网站的开发人员没有明确地为错误设置状态属性,你可以使用它来探测原型污染;

注意:根据代码分析,我们要修改的状态码必须在400和600之间,否则为状态码为500

再次声明,实验环境用的是:portswigger

多加了一个双引号,使得json格式不对,返回报错状态码为400

尝试污染status的值

1
2
3
"__proto__": {
"status":555
}

再次使得json格式错乱,查看报错状态码为555,说明已经污染成功了

JSON 空格覆盖

Express 框架提供了一个json spaces选项,使您能够配置用于缩进响应中任何 JSON 数据的空格数。在许多情况下,开发人员不会定义此属性,因为他们对默认值感到满意,这使其容易受到原型链的污染。

如果您可以访问任何类型的 JSON 响应,则可以尝试使用自己的json spaces属性污染原型,然后重新发出相关请求以查看 JSON 中的缩进是否相应增加。您可以执行相同的步骤来删除缩进以确认漏洞。

这是一项特别有用的技术,因为它不依赖于所反映的特定属性。它也非常安全,因为您只需将属性重置为与默认值相同的值即可有效地打开和关闭污染。

尽管 Express 4.17.4 中已经修复了原型污染问题,但未升级的网站可能仍然容易受到攻击。

1
2
3
"__proto__":{
"json spaces":10
}

除了上面介绍的俩种;还有字符集覆盖方法等等

绕过过滤造成原型污染

有时会过滤__proto__,但是这种关键的过滤方法并不是一个强大的长期解决方案,因为有多种方法可能会绕过它。

__proto__节点应用程序还可以分别使用命令行标志--disable-proto=delete或来完全 删除或禁用--disable-proto=throw。然而,这也可以通过使用构造函数技术来绕过。

实验

多次发送json spaces请求,但是响应包没有反应

每个构造函数(constructor)都有一个原型对象(prototype); 上面已经解释的很清楚了

constructor.prototype === __proto__

1
2
3
4
5
6
7
8
9
10
11
12
13
let myObject = {}

myObject.constructor.prototype.b = 'zIxyd'

for(const propertyKey in myObject){
console.log(propertyKey); //b
}

let myString = ""
//zIxyd
console.log(myString.b)
console.log(myObject.__proto__) //[Object: null prototype] { b: 'zIxyd' }
console.log(myObject.constructor.prototype === myObject.__proto__) //true

尝试使用constructor

1
2
3
4
5
"constructor": {
"prototype": {
"json spaces":10
}
}

Tools

Server-Side Prototype Pollution Scanner

https://portswigger.net/research/server-side-prototype-pollution