JavaScript原型链污染
决定我们成为什么样的人,不是我们的能力,而是选择。
这个内容在没入门的时候,就听说过,当时只是草草看了下,知道了这个概念;当我入了门,走了几步后,整好碰到了这样的题目,当时又是了解不深入,只是懂了一丁点,会用payload;现在是第三次了,不说深入地探究透彻,只求能够弄清晰一点。当别人问我这什么东西时,能够说上一两句。
在写之前再说两句,JavaScript和Java并没有关系,语法和使用也可以说是完全不同的。但它之所以叫JavaScript,是因为发明者所在公司的老板极度推崇Java,为了攀上当时如日中天的Java,那老板就把这个浏览器所使用的解释型语言命名为JavaScript。u1s1,这个称呼在之前搞得我晕头转向,直到最近一段时间,了解了一下历史才明白了。
原型与原型链
JavaScript 是一门面向对象的语言,但是在ES6规范之前,JavaScript中没有class语法。虽然ES6中引入了class关键字,但那只是语法糖,JavaScript 仍然是基于原型的。
原型
原型的定义:原型是JavaScript中继承的基础,JavaScript的继承就是基于原型的继承。
- 所有引用类型(函数,数组,对象)都拥有__proto__属性(隐式原型)
- 所有函数拥有prototype属性(显式原型)
JavaScript中的每个函数都有一个prototype属性,它指向通过该构造函数创建的实例对象的原型。同时,每个实例对象也都有一个__proto__属性用来指向实例对象的原型。实例对象的 __proto__ 与创建该实例对象的构造函数的prototype是相等的。
用图来理解的话,就是下面这样。
而且,每个原型对象都有一个constructor属性,指向相关联的构造函数,所以构造函数和构造函数的prototype是可以相互指向的。实例对象也可以访问constructor属性指向其构造函数。
原型链
在JavaScript中,如果想访问某个属性,首先会在实例对象(test)的内部寻找,如果没找到,就会在该对象的原型(test.__proto__,即 Test.prototype)上找。我们知道,对象的原型也是对象,它也有原型,如果在对象的原型上也没有找到目标属性,则会在对象的原型的原型(Test.prototype.__proto__)上寻找。以此类推,直到找到这个属性或者到达了最顶层(null),根据定义,null没有原型,是原型链最后一个环节。在原型上一层一层寻找,这就是原型链。
实例对象原型的原型是Object.prototype,而它的原型是null,null 没有原型,所以 Object.prototype 就是原型链的最顶端。
JavaScript中的所有对象都来自Object,Object 位于原型链的最顶端,几乎所有 JavaScript 的实例对象都是基于 Object。
原型链污染
在JavaScript中,访问一个对象的属性可以用a.b.c或者a[“b”][“c”]来访问。因为对象是无序的,当使用第二种方式访问对象,只能使用指明下标的方式去访问。所以,可以通过a[“__proto__“]去访问其原型对象。
在一个js应用中,如果攻击者控制并修改了一个对象的原型,那么将会影响所有和这个对象来自同一个类、父祖类的对象。跟水资源污染有点一样,源头被污染了,其下面的河流都将被污染,难怪用污染这个修饰词。
1 | function Father(){ |
在上面代码中,有两个函数,Son的原型为Father,可以看作是Son继承自Father。因为Son函数有name这个属性,所以son.name有输出;Son函数在定义的时候,没有money这个属性,但son.money仍有输出,是因为我们给Father的原型增加了一个money属性。当输出son.money时,js发现Son没有该属性,就去Father中寻找,还找不到,就去Father原型中找,这里找到了添加的money属性,就会输出。
利用手段
存在可控的对象键值时,代码中有以下几种情况:
- merge等对象递归合并操作
- 对象克隆
- 路径查找属性然后修改属性
merge常见代码如下,可以说是代码审计的一个标志。
1 | function merge(target, source) { |
在合并的过程中,存在赋值的操作target[key] = source[key]。那么,这个key如果是__proto__,是不是就可以原型链污染?
用下面的代码进行实验:
1 | let o1 = {} |
我们可以看到结果,虽然合并成功了,但原型并没有被污染。
这是因为用JavaScript创建o2的过程(let o2 = {a: 1, “__proto__“: {b: 2}})中,__proto__已经代表o2的原型了,是o2的一个属性。此时遍历o2的所有键名,拿到的是[a, b]。这里的__proto__不是一个key,自然也不会修改Object的原型。
要想让__proto__被认为是一个键名,需要使用JSON.parse函数。
1 | let o1 = {} |
可以看到,新建的o3对象,也存在b属性,说明Object已经被污染。
这是因为,JSON解析的情况下,__proto__会被认为是一个真正的“键名”,而不代表“原型”,所以在遍历o2的时候会存在这个键。
merge操作是最常见的可能控制键名的操作,也最能被原型链攻击,很多常见的库都存在这个问题。
例题
这里是选择p神出的thejs。
先下源码,需要用git clone https://github.com/phith0n/code-breaking
将整个codebreaking下载,不然权限有问题。下载好,然后docker部署环境。
前端那个页面只是一个提交编程语言和ctf类别,其他什么都得不到。
这是一个代码审计题目,这里默认是可以看源码的。
1 | const fs = require('fs') |
整个应用逻辑为,将用户提交的信息,用merge方法合并到session里,多次提交,session里最终保存你提交的所有信息。
我们可以看到比较敏感的merge函数,还存在可控点req.body。但req.body仅仅是提交数据的一个点,我们还要知道要怎样去构造数据。
我们在提交post数据之后,app.engine会在请求之后进行渲染。进行渲染那段代码中,它调用了lodash.template这个函数。
跟进lodash/template.js。
可以看到options是一个对象,sourceURL取到了其options.sourceURL属性。sourceURL是通过下面的语句赋值的,options默认没有sourceURL属性,所以sourceURL默认也是为空。
1 | var sourceURL = 'sourceURL' in options ? '//# sourceURL=' + options.sourceURL + '\n' : ''; |
如果我们能够给options的原型对象加一个sourceURL属性,那么我们就可以控制sourceURL的值。
继续往下面看,最后sourceURL传递到了Function函数的第二个参数当中。紧接着渲染模版,就可以成功执行代码,造成任意代码执行漏洞。
1 | var result = attempt(function() { |
下面是p神的payload:{"__proto__":{"sourceURL":"\nreturn e=> {for (var a in {}) {delete Object.prototype[a];} return global.process.mainModule.constructor._load('child_process').execSync('id')}\n//"}}
- 因为在渲染时会将sourceURL直接拼接到页面中,但前面有个注释,通过\n就可以bypass掉。
- child_process是一个子进程模块,可以用来执行命令并返回结果。global.process.mainModule.constructor._load是为了加载这个模块。
- 前面的return e … delete …,看别人说是用完之后删除原型键值对,但删除完,payload就不能用了…,也就不管了。
在实际post传值的时候,要注意把Content-Type改为json形式的,来让__proto__作为键名,而非属性。
参考
[1] 继承与原型链
[3] JavaScript 原型链污染
[4] 深入理解JavaScript Prototype污染攻击
[5] JavaScript学习小结