WHUCTF2026校赛
PWN
ZombieSurvival Server
题目描述:某款 Unity 联机游戏的服务端出现了奇怪的崩溃。运维发现只要发送一个超长的存档数据包,服务就会异常退出。
注:服务端使用 IL2CPP 后端编译,存档格式直接沿用了 Il2CppString 的二进制内存布局。
本题涉及网络协议嵌套IL2CPP协议
第一层网络协议逆向:
1 | size_t handle_client() |
可知网络协议为:32位魔法字符(0x5252494D)+ 16位类型数 + 16位数据长度 + 数据
第二层IL2CPPString协议:
1 | ssize_t __fastcall handle_load_savedata(unsigned int *a1, __int64 a2, unsigned __int16 a3) |
可知IL2CPPString协议为:20字节padding + 32位数据长度 + 数据
同时逆向Il2CppString_Deserialize函数:
1 | _BYTE *__fastcall Il2CppString_Deserialize(__int64 a1, __int64 a2, unsigned __int16 a3, int a4) |
可知读入数据的方式为每输入两字节读入一字节
解决了协议问题,本题的核心部分就已经结束,剩下的就是先用type=1的数据包进行握手,然后用type=2的数据包进行利用handle_load_savedata函数的栈溢出漏洞直接ret2win
exp:
1 | #!/usr/bin/env python3 |
ZombieSurvival Server Hard
题目描述:安全团队对 ZombieSurvival 游戏服务端进行渗透测试。服务端基于 Mirror Networking v67,使用 Unity IL2CPP 后端编译,启用了全套内存保护。测试人员注意到握手阶段的响应有些奇怪……存档上传接口的解析逻辑也值得究。
该题的网络协议与IL2CPPString与ZombieSurvival Server相同,唯一的区别在于开启了canary和pie,处理方法为利用handle_get_stats函数中的格式化字符串漏洞泄露canary和text段基址,需注意远程的栈与本地的栈有不同需要微调
1 | unsigned __int64 __fastcall handle_get_stats(const char *a1) |
然后再ret2win即可
exp:
1 | #!/usr/bin/env python3 |
无能的PWN手
题目描述:你是伊地知虹夏,名义上是乐队的队长,但实际上却是乐队里最菜的那个。看着乐队里那三个天赋怪,一种自卑感油然而生。为了不拖累你的乐队伙伴,你决定偷偷加练。
但那个晚上,你无论如何也练不出想要的效果,你抬头望向鼓谱,看到那个190BPM十六分音符的重音移位,内心的怒气慢慢升腾。你红温了,拿起鼓棒重重砸向面前的军鼓。鼓棒反弹起来。你的手被震得生疼,但你却从鼓棒小小的反弹中得到了灵感。你终于成功练出效果了!你很兴奋,想着明天在Starry的演出上一定要大展拳脚,让结束乐队名扬下北泽。
事与愿违。在演出那天,你望着台下稀疏的观众手心直冒汗。你的信心被抽干了。你那pwnpwn的鼓点开始发乱……当你不知所措的时候,吉他英雄出现了。吉他英雄劲爆的solo留住了观众,也留住了你的信心。你开始发力,和乐队的伙伴们完美地完成了这次演出。
演出结束后,你和吉他英雄互诉衷肠。你们聊了很多。关于乐队、关于未来、关于梦想。最后的最后,你再次向吉他英雄表示感谢。吉他英雄没多说什么,她只是微微抬头,向你轻轻说了一句:
“啊啊,没关系的,”
“毕竟,我们是一个乐队的伙伴嘛。”
该题表面上无附件,开启容器后尝试nc按enter发现回显:

说明应该发送http数据包,因此尝试curl:

获得回显,提示加上?filepath=xxx,因此猜测要通过这个方法来获得二进制文件信息,故尝试curl http://127.0.0.1:35239/?filepath=pwn :

可知,直接返回了二进制文件的内容,可用--output <FILE>参数接收到文件中,故用curl --output pwn http://127.0.0.1:35239/?filepath=pwn命令将二进制文件内容保存到pwn文件中,随后可用ida进行分析
主要逻辑:
1 | int __fastcall handle_client(unsigned int a1) |
HTTP 服务器对 filepath 参数做了 URL 解码后传入build_path_from_decoded
1 | FILE *__fastcall build_path_from_decoded(const void *a1, size_t a2) |
该函数将解码结果 memcpy 进 256 字节栈缓冲区但未做长度限制,造成栈溢出。由于无任何保护机制,直接覆盖返回地址实现 stack pivot,注入的 shellcode 获取 shell,但是由于这是一个远程题,通过 execve(“/bin/sh”) 获取的shell继承了进程的fd,就导致无法与我们连入的socket交互,因此我们要用dup2将当前的fd覆盖为socket的fd,这样就可以在本地终端与服务器上的shell交互
exp:
1 | #!/usr/bin/env python3 |
ezProtobuf
根据题目名提示,本题涉及protobuf协议,因此需要还原出二进制文件中的proto结构,可以借助pbtk工具来分析
运行命令pbtk-from-binary chall chall.proto即可获得该题目中的proto结构:

然后使用protoc --python_out=. chall.proto生成chall_pb2.py,就可以调用其中的函数来把要发送的数据自动打包成符合protobuf协议结构的编码
chall_pb2.py:
1 | # -*- coding: utf-8 -*- |
再看ida,分析主要函数,可发现漏洞为del_chunk函数中释放chunk后没有置空指针,即存在UAF
1 | void __cdecl del_chunk(const chall::Request *req) |
利用该漏洞,可以用show_chunk函数show bin中的chunk来获取堆基址以及libc基址,随后通过tcache poison实现任意地址写来打house of apple2
exp:
1 | #!/usr/bin/env python3 |
vmpp
一道 C++ 实现的自定义虚拟机题目。VM 支持栈操作、数据存储、寄存器运算和 syscall
首先看主要逻辑:
1 | int __fastcall main(int argc, const char **argv, const char **envp) |
第一个重点在于VM::updateFunc函数:
1 | void __cdecl VM::updateFunc(VM *const this) |
该函数会且仅会进行一次函数替换,将五个执行函数替换为新版的更安全的执行函数
用ida看 VM 对象的具体结构
1 | 00000000 struct __cppobj __attribute__((aligned(4))) VM // sizeof=0x1160 |
查看真实内存中堆上的 VM 结构:
执行VM::updateFunc前:

执行VM::updateFunc后:

可发现std::function均由VM::resetFunc的函数替换为VM::updateFunc的函数
新版执行函数的主要差别在于:
- vm_syscall2函数不可执行
- vm_data_op2函数没有负索引越界
但是好消息是vm_exit2函数仍存在std::cin在向定长缓冲区输入时溢出的洞:
1 | void __cdecl VM::vm_exit2(VM *const this) |
exit_code_buffer 是 VM 对象的一个字段,std::istream >> 读取字符串不限长度。通过发送足够长的数据可以覆盖 VM 对象上的内容,同时由于在解析opcode的时候是通过调用 VM 对象中的std::function中记录的函数地址来执行函数
1 | void __cdecl VM::vm_operator(VM *const this) |
因此通过覆盖std::function字段即可控制执行流
那么先发送单字节0x05,触发vm_exit2然后输入0xac字节padding(注意需要输入满足stoi要求的字符) + main函数地址即可在执行exit_func时跳回main而此时不再updateFunc,因此我们就能调用旧版函数
此时看到可执行的vm_syscall函数:
1 | void __cdecl VM::vm_syscall(VM *const this) |
可发现该函数仅能执行一次,因此我们需要其他的方式将 /bin/sh\x00 字符串放到可知的地址处
此时看到另一漏洞:vm_data_op 负索引越界
1 | void __cdecl VM::vm_data_op(VM *const this) |
data_idx 声明为 signed __int8,允许 -128 到 127 的值。当传入负数时,vm_data[-4] 实际访问的是 VM 对象中 vm_data vector 之前的字段——即std::function 字段,而该字段中是残留着堆地址信息的,因此可以先把 /bin/sh\x00 放到 vm_data 段内 ,再通过执行 optype==33将堆地址放到reg[1]内,然后通过reg计算使reg[1]指向 /bin/sh\x00 ,最后设置reg[0] == 59,reg[2] == 0,reg[3] == 0,执行vm_syscall即可getshell。
exp:
1 | #!/usr/bin/env python3 |
题呢
该题的服务的所有 I/O 用汉字编码:
呢= bit 0,题= bit 1,MSB first,每字节 8 个汉字- 输入输出均为 UTF-8 编码的呢/题字符流
TineBuf 帧格式(解码后)
1 | Magic(2=0x5449) Cmd(1) Seq(2) BodyLen(2) Body CRC8(1) |
Body 内字段:
1 | FID(1) FTYPE(1) FLEN(2,BE) DATA(FLEN) |
CRC-8 仅对 Body 计算(poly=0x31, init=0xFF),不含帧头。
命令集
| CMD | 功能 | 必要字段 |
|---|---|---|
| 0x01 | SET | FID=1(KEY) + FID=2(BUFHINT,ftype=0x03,4字节u32) + FID=3(VALUE) |
| 0x02 | GET | FID=1(KEY) |
| 0x03 | DEL | FID=1(KEY) |
| 0x04 | 读 flag | 需要权限 |
| 主要漏洞为 | ||
SET 命令的 BUFHINT 字段告诉服务器分配多大的缓冲区,但实际写入的是 VALUE 的完整内容。当 len(VALUE) > BUFHINT 时发生堆溢出。 |
利用过程
- 用 hint=1 分配极小缓冲区
- 发送 257 字节的 payload:前 256 字节为
\x00,第 257 字节为\x01 - 溢出覆盖堆上偏移 256 处的连接级别权限标志位(auth flag)为 1
- 此时 CMD 0x04 不再返回 “permission denied”,而是返回 flag
- 返回数据用 0xFF 逐字节 XOR 加密,解密即得 flag