趁着放假继续学点一直没学的,js相关的一些安全问题也是越来越常见了,先看下最出名的原型链污染。感想就是js也是最好的语言有力竞争者,面向对象的用法离谱。
所谓原型链其实就是js面向对象编程中的一个特性,所以先介绍下js的面向对象。虽然ES6之后也有class、extends之类看上去很正常的写法了,但那些也都是语法糖,还是要了解下一言难尽的ES5写法。
首先,类的定义关键字是function(惊不惊喜意不意外),写法就是正常类的写法,但叫做构造函数
1 | function A(name,age){ |
不过这样就导致了个问题,当创建多个实例的时候,实际上会开辟多个内存空间,而这个函数其实是相同的,这样就会浪费内存空间。所以每个对象(函数/类)都有一个prototype属性,指向该构造函数的原型对象。这个原型对象的所有属性和方法,都会被构造函数的实例继承。所以可以这么写:
1 | A.prototype.kind = "human" |
那么这里a变量作为一个实例,成功调用了A类中的goodbye方法,说明它们之间是有一定联系的。这个联系就是每个实例都有一个__proto__属性(可以自己console.log看),内容是类的原型,也就是说实例的__proto__和类的prototype相当于两个指向同一个地址的指针,默认情况a.__proto__ === A.prototype。所以实例可以访问类里面的属性和方法。查找顺序是先查看对象自己的方法,没有再找构造函数的prototype中的方法。
每个对象都有__proto__属性指向它的原型,构造函数A的原型对象的__proto__指向Object函数的原型,也就是A.prototype.__proto__ === Object.prototype,感觉和python中的object差不多,所有类的基类。
这就是JavaScript中的原型链,a -> A.prototype -> Object.prototype -> null
这条链是靠__proto__/prototype属性来连接的,所以修改实例的__proto__中的属性就可能修改原型对象的属性,比如:
1 | a.__proto__.foo = "test" //和A.prototype.foo = "test"等价 |
网上的例子一般是用一个简单对象做例子,但这样的话它的构造函数就是Object,Object的原型(好像)是只能添加值不能修改的,所以可能会把人弄懵
1 | var o1 = {} //等价于 var o1 = new Object() |
所以这个漏洞可能出现的场景就是在可以修改实例对象属性的时候修改实例的__proto__属性。比如merge、clone、copy等操作。先来一个简单点的版本:
1 | var pro = '__proto__' //利用字符串 |
因为本质是只需要一个叫做”__proto__“的字符串,所以字符串和数组都很正常,但是当使用对象类型的时候就会出现问题
1 | let o1 = {a: 1, "__proto__": {b: 2}} |
也就是说”__proto__“这个键在实例化的时候就自动解析了(参考https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/Object_initializer),把o1的\_\_proto\_\_属性指向了{b: 2}这个对象。这里其实o1只有a一个属性,只是用for语句遍历的时候会把原型链上的b也打印出来,用Object.keys()可以忽略原型链中的属性。
所以为了使对象具有真正的__proto__属性,不能直接用字面量创建对象,一般选择用JSON.parse(‘{“a”: 1, “__proto__“: {“b”: 2}}’)创建对象。还可以用var obj3 = { a : 1 , [“__prot” + “o__“]: {k : 123} };这种。不过还是json解析对象的更常见。
要验证这个漏洞,最简单的验证方法就是尝试修改Object类的原型,直白的想法就这么写
1 | var o3 = {} // o.__proto__ === Object.prototype |
注意不能直接写o3[key] = obj3[key]这种,因为这样的话就相当于o3[‘__proto__‘] = {k:123},这只是修改了o3这个对象的__proto__属性不再指向Object.prototype了,而不是修改了Object.prototype。必须要再深入一层来给Object.prototype添加属性才能实现原型污染。
当然上边的只是便于理解的验证写法,实际上有个很接近的函数就是merge,它会递归复制属性,正好实现了上边的两层for循环:
1 | function merge(target, source) { |
这也就是实际中lodash的漏洞出现的位置。
另外原型对象中还有一个constructor属性可以利用,它的值就是构造函数本身,也就是A.prototype.constructor === A。
所以有
1 | o = {} |
注意到最后一种写法没有利用__proto__字段就获取到了Object的原型,可以绕过一些过滤。实际上o是没有constructor这个属性的,本质上还是利用了o.__proto__.constructor。所以在递归遍历属性的时候可以用{“constructor”: {“prototype”: {“a0”: true}}}这种payload
至于攻击例子看参考链接里的吧,都挺详细的。
如何预防
冻结原型-使用Object.freeze (Object.prototype)。
对JSON输入进行模式验证。
避免使用不安全的递归合并功能。
使用没有原型的对象例如Object.create(null)。
使用Map而不是Object。
参考链接
https://www.leavesongs.com/PENETRATION/javascript-prototype-pollution-attack.html
https://www.mi1k7ea.com/2019/10/20/%E6%B5%85%E6%9E%90JavaScript%E5%8E%9F%E5%9E%8B%E9%93%BE%E6%B1%A1%E6%9F%93%E6%94%BB%E5%87%BB/
https://nikoeurus.github.io/2019/11/30/JavaScript%E5%8E%9F%E5%9E%8B%E9%93%BE%E6%B1%A1%E6%9F%93
https://hu3sky.github.io/2019/05/07/proto2/