
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 漏洞类型UAFUse-After-Free深度解析题目关键词和网络热词都指向了“UAF”。这是堆heap上一种经典且危害极大的漏洞。要利用它我们必须对堆内存管理有清晰的认识。堆与内存管理器的角色程序运行时需要动态申请内存比如在C中用mallocC中用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 利用脚本框架使用pwntoolsfrom 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 fPOST / 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 btrigger 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的位置下断点检查寄存器状态和栈状态。确认$rdi64位第一个参数寄存器是否指向我们伪造的结构体以及该结构体开头是否是/bin/sh字符串地址。单步步入看是否成功跳转到system。5.3 常见问题与调优堆布局不稳定每次分配的大小、顺序稍有变化就可能导致利用失败。解决方案在脚本中增加更多的“填充”操作主动分配和释放一些块来稳定堆的状态。仔细计算大小确保攻击请求分配的大小与目标堆块完全一致包括chunk头开销。泄露的地址不对可能是偏移计算错误或者读到了错误的数据。解决方案在调试器中直接查看泄露点的内存手动计算偏移。使用vmmap命令查看libc的加载基址进行验证。跳转后崩溃可能是参数不对或者跳转地址不可执行。解决方案检查调用约定。64位下system地址应放在callback的位置而/bin/sh地址应作为RequestObj指针即$rdi所指向内存的开头或某个固定偏移。确认跳转地址是否正确以及该内存页是否具有执行权限但通常我们跳转到libc的system它是可执行的。6. 完整利用脚本与最终攻击将上述所有步骤整合并经过反复调试后我们得到最终的利用脚本。这个脚本必须具备健壮性。6.1 脚本的健壮性处理错误处理与重连网络交互可能不稳定在关键步骤后检查连接状态必要时重建连接。偏移量参数化将libc版本相关的偏移如main_arena偏移、system偏移、/bin/sh偏移作为变量或从外部获取方便适配不同环境。交互节奏控制在发送请求后适当使用io.recv(timeout1)或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, size0x80) # 触发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(fLibc base: {hex(libc.address)}) log.success(fsystem {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安全与二进制安全两个领域。它告诉我们安全是一个整体攻击面可能出现在任何一层而优秀的攻击者需要具备穿透层层抽象、直击底层本质的能力。