宁波市第九届网络安全大赛初赛WriteUp

admin 2026-09-02 05:24:42 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文为宁波市第九届网络安全大赛初赛WriteUp,覆盖Web、Pwn、Misc、Crypto、Reverse五大方向共11道题。核心考点包括bcrypt72字节截断SQL注入、Shiro正则绕过、HouseofApple2堆利用、VM逃逸、USB流量分析、Acropalypse漏洞、Franklin-Reiter攻击及Unity逆向等,每题附详细利用链与踩坑提示,对CTF学习具有较高参考价值。 综合评分: 85 文章分类: CTF,WEB安全,二进制安全,逆向分析,漏洞分析


宁波市第九届网络安全大赛初赛 WriteUp

原创

00后反骨崽 00后反骨崽

00后反骨崽

2026年8月15日 16:28 北京

在小说阅读器读本章

去阅读

在公众号小说中沉浸阅读

从 bcrypt 截断到 House of Apple 2,从 Xbox 手柄流量到 Unity 二维码矩阵——一场覆盖 Web / Pwn / Misc / Crypto / Reverse 的解题之旅。

写在前面

大家好,我们是京津冀三人组(刘墨涵、李恩旭、刘俊言)。这次 宁波市第九届网络安全大赛初赛 我们一共拿下了 11 道题,涵盖 Web、Pwn、Misc、Crypto、Reverse 五大方向。本文把每道题的核心思路、关键利用点和踩坑记录整理成一份完整 WriteUp,希望对正在学 CTF 的你有所帮助。

先上战绩总览:

| 编号 | 题目 | 方向 | 核心考点 | | — | — | — | — | | 1-1 | crazy_sql | Web | SQL 注入 + bcrypt 72 字节截断 | | 1-2 | xbox | Misc | USB 流量 + 按键状态解码 | | 1-3 | Easy_Bypass | Web | Shiro 正则绕过(%0a) | | 2-1 | Regular_apples | Pwn | glibc heap + House of Apple 2 | | 2-2 | mvmph | Pwn | 自定义 VM 逃逸 + 任意地址读写 | | 3-1 | Happy Adventure | Misc | Acropalypse 漏洞 + 时间戳隐写 | | 4-1 | TWRSA | Crypto | Franklin–Reiter 相关消息攻击 | | 4-2 | Dual Poly | Crypto/Rev | 伴随矩阵 + 多项式 GCD | | 5-1 | QRCodeWorld | Rev | Unity IL2CPP + Frida 动态提取 | | 5-2 | ClickMe | Reverse | .NET Single-File + 固定种子 RC4 | | 6-1 | dataInfoLeak | Misc | pcap 流量取证 + AES-ECB 解密 |

Web 篇

1-1 crazy_sql:bcrypt 截断打出的 Flag Oracle

题目类型:Web / SQL 注入 / bcrypt 截断

Flag:DASCTF{84508743103255892785911686414779}

信息收集

首页是个登录框,HTML 末尾藏了注释 ,访问后拿到完整 PHP 源码。核心逻辑有三个关键点:

SQL 黑名单只过滤了 insert/update/delete/drop 等,没有过滤 UNION、SELECT 和单引号——username 参数存在 UNION 注入。

当 GET 参数 salt 与 $secret 严格相等时,程序会把 Flag 拼接到密码后面:$password .= $flag;

密码使用 crypt($password, $secret) 计算,其中 $secret 是一个 bcrypt 盐。

三步走利用

第一步:泄露 bcrypt 散列

构造 UNION 注入让查询返回一条假记录,使密码比较失败,页面就会回显服务端计算出的散列:

username=x’ UNION SELECT ‘x’,’y’–

password=A

第二步:伪造 admin 登录拿到 secret

把上一步泄露的正确散列放进 UNION 查询,同时令用户名为 admin:

username=x’ UNION SELECT ‘admin’,’$2a$10$77b4…’–

password=A

两个条件同时满足,响应注释中泄露 secret: $2a$10$77b438009c258a4cb0215c98a19cfb61$。

第三步:bcrypt 72 字节截断 Oracle

这是本题最巧妙的地方。bcrypt只处理密码的前 72 字节,超出部分不参与散列。当 salt 正确时,服务端实际计算的是:

crypt(可控密码 || flag, secret)

为了恢复 Flag 的第 n 个字符,提交 72-n 个 A:

“A” * (72 – n) || flag

由于 72 字节截断,参与计算的部分恰好是 A*(72-n) || flag[0:n]。在本地枚举下一个可打印字符,计算 bcrypt 散列并与服务端回显比对,匹配即命中。

逐字符爆破,40 个字符全部恢复:

[01] D [02] DA [03] DAS … [40] DASCTF{84508743103255892785911686414779}

💡踩坑提示:PHP/libcrypt 输出时会规范化盐的尾字符(c→O),Python 脚本直接把服务端返回的完整散列作为bcrypt.hashpw()的 salt 参数即可,不要自己截取盐。

1-3 Easy_Bypass:一个换行符绕了 Shiro

题目类型:Web / Apache Shiro 授权绕过

Flag:DASCTF{anEasy_CVE_Test}

Boot + Apache Shiro 1.9.0,反编译 JAR 后看到:

MyShiroFilterFactoryBean

只对 /permit/.* 路径添加过滤链;

路径匹配使用 RegExPatternMatcher,底层调用 Java 的 Pattern.matches();

DemoController

提供 /permit/{value} 接口,返回 Flag。

关键在于Java 正则的.默认不匹配换行符。把 URL 中的路径段末尾编码为 %0a(换行):

GET /permit/abc%0a HTTP/1.1

Shiro 的正则 /permit/.* 匹配失败 → 过滤链不生效;

Spring MVC 仍然能路由到 /permit/{value} → 直接返回 Flag。

一行 requests 搞定:

importrequests

url =”http://TARGET:PORT/permit/abc%0a”

r = requests.get(url, timeout=10)

print(r.text.strip())

💡 这类 CVE 的本质是框架间路径规范化不一致——Shiro 和 Spring 对同一段 URL 的理解不同,过滤层和业务层之间出现了缝隙。

Pwn 篇

2-1 Regular_apples:House of Apple 2 打 Full RELRO

题目类型:Pwn / glibc heap + FSOP

保护:Full RELRO + Canary + NX + PIE + SHSTK + IBT

Flag:DASCTF{CongRaTulat1ons_ON_Get1ng_The_R1ght_App1e}

漏洞点

菜单堆题,维护 13 个 note。三个问题叠加:

Delete

调用 free() 后没有清空指针;

Show

仍可从已释放 chunk 输出 8 字节 →UAF 读;

Edit

仍可向已释放 chunk 写入原长度 →UAF 写;

Add 大小限制在 0x500~0x520,适合 unsorted bin / largebin 攻击。

利用链

① 泄露 libc 和 heap

申请 4 个 0x500 chunk,释放两个不相邻的:

第一个释放块的 fd 指向 unsorted-bin arena → 泄露 libc;

第二个释放块的 fd 指向前一个 chunk header → 泄露 heap。

libc_base = unsorted_leak -0x21ace0

heap_base = heap_leak -0x2a0

② Largebin attack 改写 _IO_list_all

构造 0x520 largebin chunk,再把 0x510 chunk 放入 unsorted bin。通过 UAF 修改 largebin chunk 的 bk_nextsize = _IO_list_all – 0x20,再次申请 0x520 触发 largebin 插入,_IO_list_all 被写成堆地址 H。

③ House of Apple 2

Largebin attack 写入的是 chunk header 地址 H,可控用户区从 H+0x10 开始。因此在 H 放一个惰性 FILE 头,令其 _chain 指向完整可控的第二个 FILE:

H -> inert FILE

H+0x10 -> fake FILE F

第二个 FILE 使用_IO_wfile_jumps,伪造_wide_data和 wide vtable,退出刷新时调用链:

_IO_wfile_overflow

-> _IO_wdoallocbuf

-> fake_wide_vtable->doallocate

-> setcontext+61

此时 rdx 正好指向伪造的 wide-data 区,作为 setcontext 的上下文对象。

④ setcontext ROP 执行 ORW

setcontext+61 恢复 rsp/rip/rdi/rsi/rdx,转入堆上 ROP:

open(“/flag”, O_RDONLY,0) =3

read(3, heap_buf,0x100)

write(1, heap_buf,0x100)

exit(0)

远端进程初始只占用 stdin/stdout/stderr,新打开的 /flag 稳定返回 fd 3。

💡 Full RELRO 下无法改 GOT,FSOP + setcontext 是 glibc 2.35+ 的主流打法。House of Apple 2 的核心就是利用 _IO_wfile_jumps 的 wide data 路径控制 rdx。

2-2 mvmph:自定义 VM 的高低 32 位拼接漏洞

题目类型:Pwn / VM 逃逸

Flag:DASCTF{25575043809145309027060155134241}

目标程序 vvmm 是 x86-64 PIE ELF,解释执行自定义 VM 字节码。VM 上下文在 PIE+0x5080,有 R0~R6 七个寄存器,其中 R6 初始值是完整的 heap 指针。

核心缺陷

宿主 syscall 分发器返回前执行了:

ctx->r0 = (ctx->r0 &0xffffffff00000000ULL)

| ((uintptr_t)&ctx->r0 &0xffffffff);

只覆盖 R0 的低 32 位,高 32 位保持不变。利用方式:

把 R6(完整 heap 指针)复制到 R0;

调用一次无副作用的非法 command,触发分发器尾部写回;

R0 高 32 位仍是 heap 的高 32 位,低 32 位变成了 &ctx->r0 的低 32 位;

通常 heap 与 PIE 高 32 位相同(跨 4GiB 边界时差 1),探测两个候选即可。

完整利用链

重建上下文地址 → 探测高 32 位候选

泄露 PIE/libc → 读取 puts@GOT

定位栈 → 读取 libc environ

定位活动返回地址 → 搜索 PIE+0x195b

覆盖返回地址 → ROP: system(“/bin/sh”)

cat /flag

💡 这道题最精彩的地方在于分发器的”半写回”——它本意是把宿主指针传回 VM,但只写了低 32 位,反而把 VM 寄存器变成了一个高低位拼接的原语。

Misc 篇

1-2 xbox:手柄按键里藏着的 320 bit

题目类型:Misc / USB 流量分析

Flag:DASCTF{67f69ab190f061c8b475406e9a9c874e}

附件 XBOX.pcapng,Wireshark 打开确认是 Xbox 手柄的 USB Interrupt IN 流量。

观察输入报文,第 5 个字节(payload[4])只有三种值:

| 值 | 含义 | | — | — | | 0x00 | 按键松开 | | 0x10 | 按键状态 A → 二进制 0 | | 0x20 | 按键状态 B → 二进制 1 |

手柄为了稳定接收,每个状态会重复上报 3 次,随后 3 次 0x00。所以不能直接逐个数据包取值,需要:

压缩连续相同状态;

删除 0x00 松开状态;

得到 320 个有效按键符号;

按 0x10→0、0x20→1 映射为二进制;

每 8 bit 一组转 ASCII。

前 8 次操作 10 20 10 10 10 20 10 10 → 01000100 → 0x44 → “D”,后续依次得到 A、S、C、T、F、{,映射方向正确。

320 bit → 40 字节 ASCII:

DASCTF{67f69ab190f061c8b475406e9a9c874e}

3-1 Happy Adventure:七层套娃的综合杂项

题目类型:Misc / 多层隐写

Flag:flag{W0w!_Y0u_F1nD_M4!!!}

这道题是一条完整的解谜链,层层递进:

First Challenge.png

↓ Acropalypse 原图恢复(CVE-2023-28303)

密码 = CVE-2023-28303 → 解开 Adventure.zip

wow.png(二维码 + PNG 尾随数据)

↓ 污染标记 Zss010C3

清理 Base64 → 解码 whatsthis.txt

↓ TimeKey = 2144483000

解开 Secret time.zip → 日志.rar

↓ 21 个日志文件,创建时间均在 2037 年

时间戳 – TimeKey = ASCII

{W0w!_Y0u_F1nD_M4!!!}

几个关键点:

Acropalypse(CVE-2023-28303)

:Windows 截图工具裁剪覆盖保存后,原图尾部数据不截断。PNG 中出现两个 IEND 标记就是信号。

PNG 尾随数据

:wow.png 的 IEND 后还有 42 字节,提示 Zss010C3 is the obstacle。

Base64 污染

:whatsthis.txt 开头缺了 iVBORw0K 前缀,且被插入了 8 次 Zss010C3,清理后才能正常解码。

时间戳隐写

:TimeKey 2144483000 本身就是 Unix 时间戳(2037-12-15 17:43:20 UTC+8)。每个日志文件的创建时间减去它,差值就是 ASCII 码。

自动提取核心代码:

importos, re

frompathlibimportPath

TIME_KEY =2144483000

files =sorted(Path(“.”).glob(“日志*.txt”),

key=lambdap:int(re.search(r”(\d+)$”, p.stem).group(1)))

result =””.join(chr(round(os.path.getctime(p)) – TIME_KEY)forpinfiles)

print(result)

6-1 dataInfoLeak:5331 条 HTTP 响应里拼出 1950 人

题目类型:Misc / 流量取证

Flag:DASCTF{00077775769626134066864783869729}

攻击者通过内网 HTTP 接口分批窃取员工信息,pcapng 里全是明文 HTTP:

GET /api/profile?pass=BnJVnu.QCHhovMoo HTTP/1.1

Host: 10.10.10.100

响应正文是 Base64 字符串,URL 参数 pass 长度 16 字节 → 正好做 AES-128 密钥。解密方式:

Base64 解码响应体;

AES-128-ECB 解密(key = pass 参数);

去除 PKCS#7填充;

得到 JSON 明文。

接口名称包括 basic、profile、identity、contact、info,每次返回字段不固定。按 userid 合并多次泄露片段,六个字段(userid/username/name/phone/idcard/email)全部齐全才保留。

最终解析 5331 条响应,涉及 3000 个 userid,完整记录 1950 条,上传校验平台 score = 100.000%。

Crypto 篇

4-1 TWRSA:不用分解 n 的 Related Message Attack

题目类型:Crypto / RSA

Flag:DASCTF{P0lyn0m1al_GCD_1s_Th3_K3y_T0_RSA}

题目用同一组 n 和 e=17 加密了两个线性相关的明文:

m1 = m

m2 = (a*m + b) mod n

c1 = m1^e mod n

c2 = m2^e mod n

不需要分解 n。在多项式环(Z/nZ)[x]中构造:

f(x) = x^e – c1

g(x) = (a*x + b)^e – c2

真实明文 m 同时是 f 和 g 的根。计算两个多项式的 GCD,正常情况下得到一次多项式 x – m,直接取根即可——这就是经典的Franklin–Reiter Related Message Attack。

SageMath 核心代码:

R. =PolynomialRing(Zmod(n))

f = x^e – c1

g = (a*x + b)^e – c2

defpolynomial_gcd(f, g):

whileg:

f, g = g, f % g

returnf.monic()

h =polynomial_gcd(f, g)

m = -h[0]

4-2 Dual Poly:伴随矩阵 + 多项式 GCD 求公共根

题目类型:Crypto/Reverse / RSA 变体

Flag:DASCTF{J@st_U53_C0mpan1on_M@TRiX_T0_G3t_m-r00t_Poly}

这道题表面是 Reverse,实质是硬核代数。核心逻辑:

c1 =sum([i *pow(pow(m, d1, n), i, n)foriinrange(64)]) % n

c2 =sum([i *pow(pow(m, d2, n), i, n)foriinrange(64)]) % n

定义 63 次多项式 f(x) = Σ i·x^i,则 c1 = f(m^d1),c2 = f(m^d2)。

解题路线

① 建立多项式模型

F1(X) = f(X) – c1   (x1 = m^d1 是其根)

F2(X) = f(X) – c2   (x2 = m^d2 是其根)

② 商环中降幂

e1、e2 上千位,不能直接展开。在商环 (Z/nZ)[X]/(Fj) 中用快速幂计算:

H1(X) = X^e1 mod F1(X)

H2(X) = X^e2 mod F2(X)

因为 x1^e1 = (m^d1)^e1 = m,所以 H1(x1) = m,同理 H2(x2) = m。

③ 伴随矩阵与特征多项式

在商环中,”乘以 Hj(X)”是 63 维线性映射,构造 63×63 矩阵 Mj。计算其特征多项式:

Pj(T) = det(T·I – Mj)

Fj 的每个根 α 都贡献值 Hj(α),这些值正是 Pj 的根。因此:

P1(m) = 0 mod n

P2(m) = 0 mod n

④ GCD 取公共根

G(T) = gcd(P1(T), P2(T)) mod n = T – m

m = -G(0) mod n

💡 这道题把 RSA、多项式商环、伴随矩阵、结式和 GCD 串在一起,是本次比赛数学味最浓的一题。Flag 本身就是提示:”Just use companion matrix to get m-root, Poly”。

Reverse 篇

5-1 QRCodeWorld:从 Unity 内存里抠出二维码

题目类型:Reverse / Unity IL2CPP

Flag:DASCTF{e45e144f-fe2a-4d49-9f3f-1af4f9df0244}

Unity 游戏,第一人称角色脚下有一片黑白立方体组成的二维码(题目描述”Flag 就在你脚下”)。直接截图因为透视和光照无法扫描,需要从内存中提取原始矩阵。

分析流程

用 AssetStudio / il2cppdumper 枚举 IL2CPP 类型,找到 QRCodeGenerator 类;

分析 GenerateQRCode 方法,发现二维码数据存在实例偏移 +0x48 处,类型是 System.Int32[,](29×29 二维数组);

启动游戏(-batchmode -nographics),用 Frida 附加进程,通过 IL2CPP API 创建实例并调用构造函数;

从 instance + 0x48 读取数组。

IL2CPP 数组布局:

| 偏移 | 内容 | | — | — | | +0x00 | 数组类指针 | | +0x08 | Monitor | | +0x10 | Bounds 指针 | | +0x18 | 元素总数 | | +0x20 | 数组数据起始 |

Frida 核心读取逻辑:

constinstance =il2cppObjectNew(qrClass);

il2cppRuntimeObjectInit(instance);

constarray = instance.add(0x48).readPointer();

constbounds = array.add(0x10).readPointer();

consttotal =Number(array.add(0x18).readULong());

constdata = array.add(0x20);

for(leti =0; i < total; i++) {

values.push(data.add(i *4).readU32());

}

⚠️ Int32[,] 每个元素占 4 字节,如果按单字节连续读取会得到稀疏错位的矩阵。

Python 补上四格静区,最近邻插值放大到 740×740,OpenCV 解码:

matrix = np.array(values, dtype=np.uint8).reshape(29,29)

pixels = (1- np.pad(matrix,4, constant_values=0)) *255

image = Image.fromarray(pixels).resize((740,740), Image.NEAREST)

text, _, _ = cv2.QRCodeDetector().detectAndDecode(np.array(image))

5-2 ClickMe:固定种子的”伪 RC4″与 .NET Single-File 提取

题目类型:Reverse / .NET

Flag:DASCTF{QDxz77WHtRIZxVbVPIriyvoT4xWCs1esZaEddSGldPjn5b4Wt6LMpt2t}

ClickMe.exe 是 .NET 6 single-file bundle,外层是原生 apphost,C

程序集嵌入内部。用 dotsider 查看 bundle manifest,按 offset/size 切出 ClickMe.dll,ILSpy 反编译。

程序行为

父进程检查:不是 explorer 启动就退出(不影响静态分析);

界面两个按钮,小按钮第二次点击要求输入 Flag;

加密算法本质是简化的流密码:Random(2024) 生成固定 S 盒,用密钥初始化另一个 256 字节数组,明文字节 XOR 对应位置的值。

密钥推导

加密式:

c[i] = p[i] XOR key[r[i] mod4]

r[i] =Random(2024).Next(256)

已知 Flag 以DASCTF{开头,前 7 字节明文已知。Random(2024) 的前几个值为:

29, 178, 133, 145, 3, 83, 197, 217, …

由r[i] mod 4确定每个明文字节用的是 key 的哪个位置,直接列方程:

key[1] = c[0] XOR p[0] =0x00XOR0x44=0x44=’D’

key[2] = c[1] XOR p[1] =0x39XOR0x41=0x78=’x’

key[3] = c[4] XOR p[4] =0x2eXOR0x54=0x7a=’z’

再利用 key 同时也是p[7:11](即{后面的 4 个字符),由第 7 项的随机数 217 mod 4 = 1,解出:

key[0] = c[7] XOR key[1] =0x15XOR0x44=0x51=’Q’

完整密钥QDxz,对全部 64 字节做 XOR 解密即得 Flag。脚本最后重新加密明文并逐字节比对_ciphertext,验证完全一致。

总结

这次比赛最大的感受是知识点之间的交叉越来越多:

crazy_sql 把 Web 注入和密码学(bcrypt 截断)结合;

Regular_apples 把堆利用和 FSOP 串成完整链;

Dual Poly 表面是 Reverse,实际需要多项式代数和线性代数;

QRCodeWorld 需要同时理解 Unity IL2CPP 内存布局和二维码编码规范。

每道题的核心都不是”记住某个 payload”,而是理解底层机制后找到逻辑缝隙。希望这份 WriteUp 能给你和你的agent带来一些启发,欢迎交流指正 🙌

WP由京津冀三人组|刘墨涵 · 李恩旭 · 刘俊言整理

感谢大家能看完这篇文章,我先叠个甲,以下所有发言纯属个人观点与其他队友无关,没有任何攻击和诋毁的想法,单纯发表一下一个小登对当前网络安全类比赛的一些看法,欢迎大家一起交流讨论

作为一个马上要升入大三的网络安全专业学生,这几年在圈子里摸爬滚打,确实积攒了不少真实感受。

比赛打得多了,接触到的师傅和团队也越来越广,而Agent技术的爆发,几乎重塑了每一场比赛的底层逻辑。对我们这些在校生来说,奖项和曝光机会是绕不开的现实需求——但如今线上赛场上,无论主办方是否明令禁止AI,都很难完全杜绝有人借助它来解题。在规则明确允许使用AI的比赛中,我合规地调用工具、借助AI拿到名次,这本身并没什么可指责的。

可回过头来,我会忍不住问自己:如果这道题的突破、那次排名的提升,很大程度上是AI代劳的,那这份奖项背后,我自己真正增长的到底是什么?是解决问题的思路,还是仅仅学会了如何更精准地提问?

我慢慢意识到,AI时代已经不可能把网络安全和AI完全切割开,但也不能走向另一个极端——把AI当成万能解题器,从而放弃了对底层原理的深挖和手感的磨练。真正有价值的,或许不是奖状本身,而是我能否在借助AI的同时,保留自己的判断力、调试能力和攻防直觉。这份平衡,才是我现阶段最想找到的东西。


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:00后反骨崽 00后反骨崽 00后反骨崽《宁波市第九届网络安全大赛初赛 WriteUp》

评论:0   参与:  0