文章总结: 本文为宁波市第九届网络安全大赛初赛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》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。











评论