1. 项目概述:一次从Web到二进制的“跨界”渗透
看到“ciscn 华东北分区赛 pwn cgi”这个标题,很多刚接触安全竞赛的朋友可能会有点懵。这标题里混合了两种看似不搭界的东西:“pwn”和“cgi”。“pwn”通常指二进制漏洞利用,是直接和程序内存、CPU指令打交道的底层攻防;而“cgi”是通用网关接口,一个古老的Web服务器与外部程序交互的标准,属于Web应用的范畴。这道赛题的精妙之处,恰恰就在于它打破了这种认知壁垒,将Web请求的处理流程与底层的二进制漏洞串联了起来。它模拟了一个真实的场景:攻击者通过发送精心构造的HTTP请求,最终触发后端CGI程序中的内存破坏漏洞,从而夺取服务器控制权。这不仅仅是CTF比赛里的一道题,更是对现代渗透测试中“攻击链”思维的绝佳演练——漏洞的入口可能在应用层,但真正的杀伤链往往需要深入到系统底层。
这道题适合有一定二进制基础,想了解Web与二进制漏洞如何结合的安全爱好者,也适合Web安全方向想向底层拓展的研究者。通过复现和分析这道题,你不仅能巩固堆漏洞利用技巧,更能建立起一种“全栈”视角的安全观:一个HTTP数据包,是如何穿越网络栈、Web服务器、解析逻辑,最终在某个进程的内存空间中掀起波澜的。接下来,我们就一层层剥开这道题的外壳,看看它到底设计了哪些“坑”,以及我们该如何系统地填平它们。
2. 核心漏洞原理与场景构建
要理解这道题,我们得先把它运行的“战场”搭建起来。题目通常会给一个Docker环境或者一个可执行文件加一个启动脚本。核心是一个用C/C++编写的、存在漏洞的CGI程序,以及一个用于启动它的简易Web服务器(比如用socat或python http.server模拟)。你的攻击目标就是这个CGI程序。
2.1 CGI程序的工作机制与本题设定
CGI是一个标准,它定义了Web服务器(如Nginx、Apache)如何将HTTP请求转发给外部程序处理,并如何将外部程序的输出返回给客户端。一个典型的流程是:
- 用户浏览器访问
http://target/cgi-bin/vuln_program。 - Web服务器接收到请求,发现
/cgi-bin/路径指向CGI程序。 - 服务器创建新的进程,设置一系列环境变量(如
REQUEST_METHOD,CONTENT_LENGTH,QUERY_STRING),并将HTTP请求体(如果有)通过标准输入stdin传递给这个新进程。 - CGI程序从环境变量和
stdin读取数据,进行处理,然后将结果通过标准输出stdout打印出来。 - 服务器捕获这些输出,加上合适的HTTP头,返回给浏览器。
在这道题中,这个vuln_program就是存在漏洞的二进制文件。题目可能通过一个脚本这样启动它:
socat TCP-LISTEN:8080,reuseaddr,fork EXEC:./cgi_program,env这条命令监听8080端口,每当有连接进来,就fork一个新进程,并执行./cgi_program,同时将当前环境变量传递过去。这样,我们就绕过了一个完整的Web服务器,直接模拟了CGI的执行环境。你的攻击payload就通过向127.0.0.1:8080发送HTTP请求来传递。
2.2 漏洞类型:UAF(Use-After-Free)深度解析
题目关键词和网络热词都指向了“UAF”。这是堆(heap)上一种经典且危害极大的漏洞。要利用它,我们必须对堆内存管理有清晰的认识。
堆与内存管理器的角色:程序运行时,需要动态申请内存(比如在C中用malloc,C++中用new)。这些内存就从一块叫做“堆”的区域中分配。为了高效管理这些大小不一、频繁申请释放的请求,glibc库提供了内存管理器(如ptmalloc2)。它负责切割大块内存(称为“堆块”或“chunk”)分配给程序,并在程序调用free释放内存后,回收这些堆块,以备后续重用。
UAF的发生过程:
- Free(释放):程序通过
free(ptr)释放了一块堆内存。此时,内存管理器标记该块为“空闲”,并将其链接到相应的管理链表(如fastbin, unsorted bin等)中。关键点:free操作通常不会清空内存中的数据,也不会将指针ptr置空。ptr仍然指向那块已经被释放的内存地址,成了一个“悬空指针”。 - Use(使用):在后续的代码中,程序再次通过这个悬空指针
ptr来读写数据。这就是“Use-After-Free”。 - 漏洞窗口:在
free之后、再次use之前,如果攻击者能够以某种方式“污染”这块已被释放的内存,那么当程序再次使用ptr时,实际上操作的就是攻击者控制的数据。这可以导致任意地址读、写,最终实现代码执行。
为什么UAF危险?因为攻击者可以控制被释放堆块的内容。内存管理器为了复用内存,会在其中存储一些管理数据(如指向其他堆块的指针)。如果攻击者能修改这些数据,就能欺骗malloc返回一个指向任意地址的指针,从而实现向任意地址读写(Arbitrary Write / Read)。
注意:现代
glibc和操作系统(如ASLR, DEP)增加了利用难度。但在CTF的竞赛环境中,这些保护可能被部分禁用,我们的目标就是学习在相对理想化的环境下,理解漏洞原理和利用链的构造。
2.3 本题漏洞点推测与交互分析
结合“cgi”和“pwn”,我们可以合理推测漏洞触发流程:
- HTTP请求解析:CGI程序会从环境变量
CONTENT_LENGTH读取请求体长度,然后从stdin读取对应大小的数据。 - 数据结构与操作:程序内部很可能定义了一个结构体,用来管理请求中的某些数据(例如,解析出的参数、上传的文件片段等)。这个结构体可能包含指针、长度、状态等信息,并被分配在堆上。
- 触发UAF的操作序列:
- 程序接收第一个请求,
malloc一块堆内存A,创建结构体实例,用指针p指向它。 - 处理完第一个请求后,由于某种逻辑错误(比如没有正确维护引用计数,或对同一资源有多个指针但释放不同步),程序
free了堆内存A,但指针p未被置空或后续仍被访问。 - 此时,攻击者发送第二个请求。这个请求的数据会被
malloc分配。由于堆管理器的分配策略(如fastbin的LIFO),新请求的数据很可能恰好被分配到刚刚释放的、p仍然指向的内存块A中。 - 攻击者可以在第二个请求中精心构造数据,完全控制这块内存的内容,也就是伪造了一个结构体。
- 当程序(可能由于处理第二个请求的某个分支,或因为第一个请求的后续回调)再次使用悬空指针
p时,它会将攻击者伪造的数据当作合法的结构体来解析。如果结构体中有函数指针,就可能被篡改;如果有数据指针,就可能实现任意地址读写。
- 程序接收第一个请求,
交互方式:你需要使用工具(如pwntools的remote,或curl、Python的requests库)模拟HTTP客户端,与目标端口建立连接,发送符合格式的恶意请求数据包,来完成上述攻击链。
3. 逆向工程与漏洞定位实操
拿到二进制文件cgi_program,第一步不是盲目运行,而是静态分析。推荐使用Ghidra(免费且强大)或IDA Pro。
3.1 初始分析步骤
文件信息检查:
file cgi_program checksec cgi_programchecksec结果会告诉我们关键信息:是否开启NX(堆栈不可执行)、PIE(地址随机化)、RELRO(重定位保护)等。这对后续利用方式有决定性影响。例如,如果PIE关闭,那么代码和全局变量的地址是固定的,我们计算偏移量会简单很多。字符串检索:在逆向工具中搜索字符串,寻找关键线索。关注如
malloc、free、Content-Length、GET、POST等。找到处理输入的主函数。主函数与逻辑梳理:找到
main函数或主要的处理函数。分析其流程:- 如何获取
CONTENT_LENGTH? - 如何从
stdin读取数据?(常用read、fgets等) - 读取的数据存放在哪里?(堆、栈、全局变量?)
- 核心的数据结构是什么样子?(在Ghidra中,可以依据对内存的访问模式来推断结构体成员)
- 如何获取
3.2 定位UAF的关键代码模式
在逆向过程中,警惕以下模式:
- 双重释放(Double Free):对同一个指针调用了两次
free。这通常会导致程序崩溃,但也是UAF的一种前兆。 - 释放后未置空:
free(ptr)之后,没有ptr = NULL,且后续存在对*ptr的访问。 - 生命周期管理混乱:有两个指针
p1和p2指向同一块堆内存。通过p1释放了内存,但后续代码路径仍在使用p2。 - 在循环或条件分支中释放:释放操作发生在某个分支中,但指针在分支外仍被使用。
实操技巧:在Ghidra中,可以高亮所有对malloc和free的调用,然后沿着数据流向上追溯指针的来源,向下跟踪指针的用途。重点关注那些在free之后,该指针还被解引用(如MOV到寄存器再进行操作)的地方。
3.3 还原核心数据结构
假设我们通过逆向,发现了一个类似如下的结构体(这是基于常见模式的一种合理推测):
struct RequestObj { int type; char *data_buffer; size_t data_len; void (*callback)(struct RequestObj*); // ... 其他字段 };data_buffer:指向存储HTTP请求体数据的堆指针。callback:一个函数指针,可能在处理完成后被调用。
漏洞可能出现在:
- 程序在释放
RequestObj本身时,没有释放data_buffer(导致内存泄漏),或者反过来。 - 程序先释放了
RequestObj,但在某个全局链表或数组中还保留着它的指针,后续遍历链表时又访问了这个已被释放的对象。 - 在释放
RequestObj后,由于堆布局被攻击者控制,下一个malloc请求(如新的请求数据)占用了同一块内存,并伪造了callback函数指针。当原代码路径触发调用obj->callback(obj)时,就跳转到了攻击者控制的地址。
4. 利用链设计与堆风水实战
找到漏洞点后,我们需要构思如何将一次简单的内存释放,转变为一次成功的远程代码执行。这需要精心设计堆的布局,也就是常说的“堆风水”(Heap Feng Shui)。
4.1 利用目标与前提条件
我们的最终目标通常是调用system("/bin/sh")来获取shell。为此,我们需要:
- 泄露地址:如果开启了PIE和ASLR,我们需要先泄露一个已知的地址(如libc中的某个函数地址),来计算libc基址,进而得到
system函数的真实地址。 - 控制流劫持:修改某个函数指针(如
callback)或关键数据(如free的hook指针__free_hook),使其指向我们想要执行的代码或system。 - 传递参数:确保在跳转时,能正确传递参数(如
"/bin/sh"字符串的地址)。
4.2 利用步骤分解
假设我们通过逆向,确认了UAF漏洞可以让我们在释放一个RequestObj后,还能通过某个残留指针修改其callback成员。
步骤一:信息泄露(如果需绕过ASLR)
- 触发漏洞,但先不急于覆盖函数指针。而是利用UAF的“读”能力,让程序在释放后打印出
RequestObj的内容。 - 在
RequestObj被释放后,它会被放入某个bin(如unsorted bin)。此时,其fd和bk指针会指向libc中的main_arena区域。如果我们能安排程序读取RequestObj的data_buffer(这个指针本身可能还在对象里),而data_buffer在释放后被我们伪造指向了RequestObj自身的某个位置,我们可能就能读到这些libc指针。 - 计算偏移:
泄露的地址 - libc中已知符号的偏移 = libc基址。有了libc基址,就能算出system、__free_hook等的地址。
步骤二:堆布局与内存篡改
- 清空堆状态:有时需要先发送一些请求来“整理”堆,确保后续分配在预期的位置。这可能包括分配和释放一些特定大小的块。
- 触发UAF并占位: a. 发送请求A,导致程序分配
RequestObj(记为obj1)及其data_buffer。 b. 通过特定操作(如发送结束包或触发错误)使程序free(obj1),但保留一个指向它的悬空指针p。 c. 立即发送请求B。请求B的数据部分需要精心设计大小,使其恰好被分配到obj1原先所在的内存。在请求B的数据中,我们伪造一个RequestObj结构: * 将callback指针的值覆盖为system的地址(或__free_hook的地址,如果我们能劫持它)。 * 将data_buffer指针指向一个我们可控的、存储着"/bin/sh"字符串的内存地址。 - 触发回调执行:通过悬空指针
p,或者触发某个会让程序调用p->callback(p)的代码路径。此时,程序会跳转到system,并且参数p(即伪造的RequestObj地址)会被当作第一个参数。我们需要确保这个地址(或它偏移某个位置)的内容是"/bin/sh"。
4.3 利用脚本框架(使用pwntools)
from pwn import * import sys context.binary = './cgi_program' context.log_level = 'debug' # 1. 连接目标 # 如果是远程,用 remote('靶机IP', 端口) # 如果是本地用socat启动的,也可以用 process 或者 remote('127.0.0.1', 8080) io = remote('127.0.0.1', 8080) # 2. 辅助函数:发送HTTP请求 def send_request(data): # 构造一个最简单的HTTP POST请求 request = f"""POST / HTTP/1.1 Host: 127.0.0.1:8080 Content-Type: application/octet-stream Content-Length: {len(data)} """ request = request.encode() + data io.send(request) # 可能需要接收一下响应,避免缓冲区阻塞 # sleep(0.1) # 3. 步骤一:泄露libc地址 (假设需要) log.info("Step 1: Heap feng shui and leak libc address") # ... 这里是一系列堆布局操作,发送特定大小的请求,触发UAF读 # send_request(payload1) # 从返回信息中解析出地址 # leaked_addr = u64(io.recvuntil(...)[x:y].ljust(8, b'\x00')) # libc_base = leaked_addr - libc.sym['main_arena'] - 0x10 # 举例 # system_addr = libc_base + libc.sym['system'] # binsh_addr = libc_base + next(libc.search(b'/bin/sh\x00')) # 4. 步骤二:篡改函数指针,准备getshell log.info("Step 2: Exploiting UAF to hijack control flow") # 构造伪造的结构体数据 fake_obj = b'' fake_obj += p32(1) # 假设的 type 字段 fake_obj += p64(binsh_addr) # 伪造的 data_buffer 指针,指向 "/bin/sh" fake_obj += p64(0x100) # 伪造的 data_len fake_obj += p64(system_addr) # 覆盖 callback 指针为 system # ... 可能还有其他字段需要填充 send_request(fake_obj) # 这个请求的数据会占用被释放的obj1空间 # 5. 步骤三:触发漏洞利用 log.info("Step 3: Triggering the callback") # 可能需要发送一个特殊的请求,或者复用之前的连接,触发程序使用悬空指针调用callback trigger_payload = b'trigger' send_request(trigger_payload) # 6. 获取shell io.interactive()5. 动态调试与利用链验证
静态分析规划好了利用链,但实际堆布局往往充满变数,必须动态调试。
5.1 调试环境搭建
使用socat附加调试:不要直接运行
socat EXEC:./cgi_program,而是让它等待调试器连接。socat TCP-LISTEN:8080,reuseaddr,fork EXEC:"gdbserver :9999 ./cgi_program"这样,每次有HTTP连接,都会启动一个
gdbserver进程监听9999端口。GDB连接调试:
gdb ./cgi_program (gdb) target remote :9999现在你就可以像调试普通程序一样下断点、观察内存了。关键在于在
malloc、free以及疑似UAF的代码处下断点。
5.2 关键调试技巧
- 观察堆块状态:使用
pwndbg或gef等GDB增强插件。它们提供了强大的堆命令:heap bins:查看所有bins中的堆块情况。heap chunks:查看所有堆块。vis_heap_chunks:图形化查看堆布局。
- 追踪指针:在释放操作
free(ptr)后,使用watch *ptr设置硬件观察点。当这块内存被再次写入(即被我们的攻击请求占用并伪造数据)时,GDB会中断,让我们可以检查写入的内容。 - 验证利用链:在即将调用
callback的位置下断点,检查寄存器状态和栈状态。确认$rdi(64位第一个参数寄存器)是否指向我们伪造的结构体,以及该结构体开头是否是"/bin/sh"字符串地址。单步步入,看是否成功跳转到system。
5.3 常见问题与调优
- 堆布局不稳定:每次分配的大小、顺序稍有变化,就可能导致利用失败。解决方案:
- 在脚本中增加更多的“填充”操作,主动分配和释放一些块来稳定堆的状态。
- 仔细计算大小,确保攻击请求分配的大小与目标堆块完全一致(包括chunk头开销)。
- 泄露的地址不对:可能是偏移计算错误,或者读到了错误的数据。解决方案:
- 在调试器中直接查看泄露点的内存,手动计算偏移。
- 使用
vmmap命令查看libc的加载基址,进行验证。
- 跳转后崩溃:可能是参数不对,或者跳转地址不可执行。解决方案:
- 检查调用约定。64位下
system地址应放在callback的位置,而"/bin/sh"地址应作为RequestObj指针(即$rdi)所指向内存的开头(或某个固定偏移)。 - 确认跳转地址是否正确,以及该内存页是否具有执行权限(但通常我们跳转到libc的
system,它是可执行的)。
- 检查调用约定。64位下
6. 完整利用脚本与最终攻击
将上述所有步骤整合,并经过反复调试后,我们得到最终的利用脚本。这个脚本必须具备健壮性。
6.1 脚本的健壮性处理
- 错误处理与重连:网络交互可能不稳定,在关键步骤后检查连接状态,必要时重建连接。
- 偏移量参数化:将libc版本相关的偏移(如
main_arena偏移、system偏移、/bin/sh偏移)作为变量或从外部获取,方便适配不同环境。 - 交互节奏控制:在发送请求后,适当使用
io.recv(timeout=1)或sleep来等待处理,避免发送过快导致数据混乱。
6.2 最终攻击演示
假设最终脚本如下(部分细节用伪代码表示):
#!/usr/bin/env python3 from pwn import * import time elf = context.binary = './cgi_program' libc = ELF('./libc.so.6') # 题目提供的或本地对应的libc def exp(): io = remote('192.168.1.100', 8080) # 替换为目标地址 # 1. 堆初始化与地址泄露 # 发送多个请求塑造堆布局 for i in range(4): send_dummy_request(io, size=0x80) # 触发UAF泄露libc地址的特定序列 leak_payload = craft_leak_payload() send_request(io, leak_payload) # 解析响应,获取泄露的指针 leak = extract_leak(io.recvuntil('some marker')) libc.address = leak - 0x3ebca0 # 示例偏移,需根据实际libc版本调整 log.success(f"Libc base: {hex(libc.address)}") log.success(f"system @ {hex(libc.sym.system)}") log.success(f"__free_hook @ {hex(libc.sym.__free_hook)}") log.success(f"/bin/sh @ {hex(next(libc.search(b'/bin/sh')))}") # 2. 准备伪造对象和触发数据 fake_obj = p64(0xdeadbeef) # 一些填充 fake_obj += p64(next(libc.search(b'/bin/sh'))) # data_buffer -> "/bin/sh" fake_obj += p64(0x100) fake_obj += p64(libc.sym.system) # callback -> system # 3. 触发UAF并覆盖 # 先释放目标对象 trigger_free(io) # 立即发送伪造对象占位 send_request(io, fake_obj) time.sleep(0.5) # 4. 触发回调调用 trigger_use(io) # 5. Enjoy shell io.interactive() if __name__ == '__main__': exp()运行这个脚本,如果一切顺利,你将在终端中看到一个远程的shell提示符,标志着利用成功。
7. 总结与延伸思考
回顾这道“cgi pwn”题目,它成功地将Web层面的输入传递与二进制底层的堆漏洞结合了起来。解决它的过程,是一次完整的漏洞利用链实践:
- 信息收集:分析二进制文件,理解其协议、数据结构。
- 漏洞识别:通过静态分析和动态调试,定位UAF的具体代码路径。
- 利用开发:设计堆布局,构思如何将漏洞转化为信息泄露和代码执行。
- 调试优化:在动态环境中验证每一步,调整偏移和布局,确保稳定。
- 武器化:编写健壮的自动化利用脚本。
延伸思考:
- 现代缓解措施:在实际的现代系统中,有ASLR、DEP、堆栈保护、Safe Linking、FULL RELRO等众多保护。这道题的环境相对简单。在更复杂的环境下,你可能需要结合其他漏洞(如信息泄露)先绕过ASLR,或者利用更复杂的堆技巧(如Tcache Poisoning、House of系列)来达成利用。
- 从CTF到实战:真实的CGI程序可能更复杂,但原理相通。审计此类程序时,要特别关注其生命周期管理:对象何时创建、何时释放、指针如何传递。资源管理不善是滋生UAF的温床。
- 工具链的熟练度:熟练掌握
pwntools、GDB(配合pwndbg/gef)、Ghidra/IDA是完成这类题目的基础。更重要的是培养一种“数据流”的思维:用户输入从哪里进,经过哪些处理,最终在哪里以何种形式被使用,又在何时被释放。
这道题就像一座桥梁,连接了Web安全与二进制安全两个领域。它告诉我们,安全是一个整体,攻击面可能出现在任何一层,而优秀的攻击者需要具备穿透层层抽象、直击底层本质的能力。