很多刚接触安全的人会问:缓冲区溢出这种上世纪八十年代就公开的技术,现在还有必要学吗?我一开始也是这么想的,直到我照着seedlab软件安全系列实验一步步做下去,才发现自己错得离谱。seedlab是一套开源的安全教学实验环境,它把真实漏洞场景装进一个隔离的Ubuntu虚拟机里,让你亲手复现栈溢出、格式化字符串、返回导向编程、setuid提权等攻击链路,再用这些经验反推现代防御机制。这篇文章就是我的完整实验复盘,记录了从环境搭建到卡壳排错的全过程,适合正在刷seedlab、准备安全入门或想补系统底层知识的同学参考。
1. 先把seedlab软件安全系列的全貌搞清楚:环境、关卡与目标
1.1 实验环境的两个关键选择
seedlab官方提供了预构建的虚拟机镜像,基于Ubuntu,通常是一个32位与64位混合的环境。我使用的是官方文档推荐的Desktop镜像,虚拟机软件建议直接用VirtualBox,配置不需要太高,但硬盘一定要给够,因为后续要装各种依赖、编译不同架构的程序,空间小了容易出现莫名其妙的问题。
第一次做实验前有一件事必须做:给虚拟机做一个干净的快照。整个实验过程中你会反复修改系统配置,比如关掉ASLR、切换root权限、安装不同的库版本。一个实验失败后改了一堆系统设置,想恢复到初始环境,没有快照就只能重新下镜像。这是我一开始忽略的教训,后来吃了几次亏才养成"动手前先快照"的习惯。
实验环境里还有一点常被忽略:官方镜像默认有一个低权限用户和一个root用户,两者之间的切换是实验的一部分。很多setuid相关任务就是利用"普通用户执行高权限程序"这个场景展开的,不要为了方便全程用root登录,否则后面几个实验会完全失去意义。
1.2 四类核心实验的递进关系
seedlab软件安全系列并不是一堆孤立的题目,它的关卡设计有明显递进:
- 栈缓冲区溢出:先学会控制程序计数器,完成shellcode注入;
- 格式化字符串:利用
printf系函数进行内存泄露与内存写,熟悉栈布局和GOT; - 返回导向编程:在NX开启、栈不可执行之后,通过复用程序自带的机器指令拼出攻击链;
- setuid与环境变量:从内存攻击转向"程序如何信任外部输入"的更高层攻击面。
这个顺序很像真实攻击链的演进:最初攻击者直接往栈上撒代码,然后防御方说"栈不可执行",于是攻击者改用现有代码块,再后来防御方发现无论内存怎么防,程序主动调用不可信的外部程序一样会被攻陷。理解了这条主线,seedlab就不再是几个零散的lab,而是一套"攻击-防御-绕过-再防御"的对抗史。
1.3 实验文档与报告提交的思路
seedlab每个实验都附有一份任务清单,里面明确列出需要完成的Task,最后要求提交一份包含证据截图和解释的报告。截图不是随便截的,任务文档会要求你截"攻击前后的内存布局""shell输出""反汇编片段",目的就是逼你把原理说清楚。
我建议每完成一个Task就立刻截图和记录命令行历史,拖到最后再补报告,很多细节会忘得干干净净。尤其是GDB里的地址、寄存器状态、每隔几步的变化,这些只有现场记录才真实可信。还有一个实用技巧:给每个实验单独建一个日志文件,把每次失败的命令和现象都追加进去。后来排查问题时,这些日志比任何教程都管用,因为你能清楚地看到自己是从哪一步开始走偏的。
2. 缓冲区溢出实验:关掉所有防护之后,我为什么还是找不到栈地址
2.1 漏洞代码与编译参数的逐项拆解
缓冲区溢出实验使用的漏洞代码很经典,一般是这样的:
#include <stdio.h> #include <string.h> int bof(char *str) { char buffer[24]; strcpy(buffer, str); return 1; } int main(int argc, char **argv) { bof(argv[1]); return 0; }编译命令大致如下:
sudo sysctl kernel.randomize_va_space=0 gcc -m32 -fno-stack-protector -z execstack -o bof bof.c这里的每个参数都值得掰开讲:
-m32:强制生成32位程序。32位的栈地址范围很小,shellcode编写和地址猜测都简单很多;-fno-stack-protector:关掉栈保护canary。否则strcpy触发溢出前会先撞上canary,程序直接报错退出;-z execstack:把栈标记为可执行,允许注入的shellcode直接在栈上运行;- 关闭ASLR:保证每次运行时栈地址一致,否则你刚算好的返回地址下次启动就变了。
这些参数在真实环境里全是反面教材,但把它们逐个关掉的过程,其实就是逐个认识防御机制的过程。如果你连无防护的栈溢出都调不出来,后面开防护的实验基本无从下手。
2.2 shellcode注入的完整调用链
缓冲区溢出攻击的思路,是把payload分成三部分:NOP sled + shellcode + 覆盖返回地址。NOP sled是一串0x90,目的是即使返回地址略有偏差,CPU也能顺着滑入真正的shellcode。shellcode要选最简单的execve("/bin/sh"),32位版本常见字节序列如下,仅用于seedlab实验环境复现:
\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\xb0\x0b\xcd\x80这段汇编的逻辑并不复杂:将/bin//sh字符串压栈,eax置为11,调用int 0x80触发内核执行execve。教学实验里的shellcode几乎都是这个套路。关键是返回地址必须指向NOP sled的中间位置。计算方法是先用GDB查看buffer的地址,再手工填充一个试探性的payload,看程序崩溃时eip停在哪儿。
第一次做的时候很容易有一个误解:以为返回地址要精确等于buffer的起始地址。其实不需要,只要落在NOP sled内部就行。NOP sled越长,容错空间越大。这也是为什么CTF里经常看到一大片0x90堆在前面,不是在凑字数,而是在给自己留误差余地。
2.3 我卡了快一天的问题:GDB里的地址和真实运行地址不一样
这里是我最想分享的踩坑经历。我在GDB里把buffer地址打印出来是0xffffd1a0,满怀信心地用它构造payload,结果一运行就段错误。后来查了很久才明白:GDB启动程序时会额外设置一些环境变量,导致栈的起始位置和直接运行时相差几十到几百字节。这个问题在刷seedlab的人里非常普遍,并不是我一个人的手误。
解决方案通常有三种:
- 在GDB中执行
unset env LINES和unset env COLUMNS,减少环境变量干扰; - 更常用的做法是写一个临时打印
buffer地址的调试版本,编译运行拿到稳定地址后,再删掉打印语句重编目标程序; - 还有一招是给payload前面塞一个足够长的NOP sled,用"更宽的靶子"抵消地址偏移。
最终我采用的是"NOP sled + 动态调试地址 + 多次微调"的组合。实测下来,只要ASLR是关闭的,一次成功概率非常高。如果你发现攻击失败,不要急着怀疑shellcode,先用单步调试看CPU到底跳到了哪个地址,往往答案就在那里。
2.4 打开防御后的对比实验
seedlab要求在一个攻击成功的程序上逐个开启防御措施:开canary、开NX、开ASLR、开启PIE。每开一个防御,之前的payload就失效一次。这种"由简到繁"的对比特别直观:
| 防护配置 | 攻击结果 | 失败原因 |
|---|---|---|
| 全关 | 成功getshell | 无任何障碍 |
| +Stack Canary | 崩溃退出 | 溢出先破坏了canary值,程序触发__stack_chk_fail |
| +NX | 段错误 | 栈上不可执行,CPU跳入shellcode后立即被拒绝 |
| +ASLR | 段错误 | 返回地址随进程启动随机化,固定地址失效 |
| +PIE | 段错误 | 代码段基址不固定,所有静态地址都不可预测 |
这个表格是理解"纵深防御"的极好材料。你会发现单一防御总有被绕过的方式,但当它们叠加在一起,攻击成本会指数级上升。真正安全的系统从来不是靠某一个神奇开关,而是靠多层防御互相兜底。
3. 格式化字符串实验:printf(buf)比栈溢出更容易被低估
3.1 一个被忽略的错误写法
很多C程序为了省事,会直接写printf(user_input),而没有使用printf("%s", user_input)。这个看似无害的写法就是格式化字符串漏洞。seedlab里专门有一个实验,让你把整个printf行为玩明白。
格式化参数里,%x会从栈上取出一个整数打印,%s会把栈上某个值当作地址去读字符串,%n则会把当前已输出字符数写入一个地址。三个操作组合起来就是:任意读、按需写、甚至写地址。可以理解为printf把栈当成了自己的"参数仓库",你控制的格式串决定了它从仓库哪个位置取货、取多少、往哪个地址放货。
这个漏洞的危险之处在于,它看起来只是"输出个字符串",没有人会想到输出语句还能变成内存读写工具。我在做实验前也一直觉得格式化字符串是入门级漏洞,直到亲手用它改掉了一个内存变量的值,才意识到它的能力边界远比想象中大。
3.2 用偏移量定位自己的输入
第一步要解决"我的输入字符串在栈的哪个位置"。通常做法是把恶意输入构造为很多个地址块,然后用一串%x占据不同的参数位置,观察哪一组输出和输入的地址值重合。比如输入:
AAAA.%08x.%08x.%08x通过观察41414141出现在第几个输出位,就能确定偏移量。seedlab里常见的偏移量是6、7、8这类数,具体取决于程序栈布局。
这一步必须自己动手做,因为不同编译选项和函数调用方式会让偏移量变化。很多同学一上来就照着网上的固定偏移量填,结果读出来全是对不上的乱码。学会手动定位,是你脱离抄作业的第一步。我在做实验时专门写过一个小脚本,循环生成%x的数量,然后自动对比输出中是否出现0x41414141,大大加速了定位过程。
3.3 从泄露到写入:%n的把戏
格式化字符串实验的进阶任务是修改一个变量的值。假设程序中有一个target变量,初始值是某个固定数值,要求把它改成指定值。思路是用%n把已打印的字符数写入target的地址。%hn写入2字节,%n写入4字节。为了减少打印字符数太多带来的低效,通常会拆成两个%hn分别写低位和高位。
这里有个计算细节:如果想让写入的值是0xbeef,那么只需先打印出0xbeef个字符,然后调用%hn。但打印几万个字符会让输出变得巨大,seedlab里通常采用"先打印小值,再通过多个格式化参数组合"来缩短长度,或者调整写入顺序来降低单次打印量。
实际调试时我在输出里经常看到一长串填充字符,这些都是打印计数造成的,属于正常现象。关键是不要慌,看清每次写入的字节数,用十六进制验证结果。这个实验做完之后,我对"printf会不会是漏洞点"的判断标准直接从"会不会崩溃"升级到"格式串是不是用户可控",思维完全换了。
3.4 从格式化字符串联想到的现实防御
做完这个实验,我再看代码时对printf的敏感度明显提升。代码审计时我会专门搜索非常量格式串作为printf、sprintf、snprintf第一参数的调用点。虽然现代编译器的-Wformat-security能拦截大部分情况,但第三方代码和遗留代码里仍然时不时见到这类问题。
seedlab教给你的不是"怎么攻",而是让你形成一种条件反射:任何把用户输入直接拼进语法结构的地方,都可能是一条信任边界。格式化字符串就是最典型的"用户输入变成代码语法"的场景。这样的思考方式,比记住十个CVE细节有用得多。
4. ROP实验:栈不可执行之后,攻击者靠"借刀杀人"照样拿shell
4.1 NX带来的转折
当你做完栈溢出,正觉得"攻击不过如此",seedlab紧接着就给你泼了一盆冷水:把栈设成不可执行,然后要求你继续拿shell。第一次看到这个任务我是有点懵的:shellcode没法直接跑了,还能怎么执行代码?
答案是把程序中已经存在的机器指令片段拼起来。这些片段叫gadget,通常以ret结尾,比如pop eax; ret、pop ebx; pop ecx; ret这样的短序列。攻击者把栈构造成一串"gadget地址 + 参数"的流水线,CPU执行完一个gadget后遇到ret,就从栈上弹出下一个gadget地址继续执行。整个过程没有往栈写入任何新指令,只是反复利用程序自身代码段里的既有指令,所以NX无法阻止。
这就像你被困在一个房间里,墙壁不可打穿,但房间里本来就有各种工具和机关。攻击者不自己造工具,而是把已有的工具按正确顺序触发一遍。
4.2 最简单的ret2libc:把返回地址指到system函数
实验最经典的入门方法是ret2libc。目标是从libc.so中拿到system函数地址,让它执行/bin/sh。攻击payload结构大致是:
- 填充垃圾数据到返回地址;
- 覆盖返回地址为
system函数地址; - 紧跟一个伪造的返回地址(用于
system返回后继续执行,通常填一个无害地址); - 再跟
/bin/sh字符串的地址。
为什么返回地址后面还要跟一个"空地址"?因为system被调用时会从栈上读取返回地址,然后把下一个栈槽当作参数。这是教科书里最容易忽略的细节。我在第一次构造时漏了它,结果程序没有弹出shell,反而在system正常返回后直接崩溃。
这个细节看起来很小,但它解释了一个重要原理:函数调用时的栈布局不只是"返回地址加参数",而是一个有严格顺序的协议。攻击payload本质上就是在伪造一个符合协议的栈布局,让被调用的函数以为自己是被正常调用的。
4.3 用工具查gadget与地址计算
实际操作中需要获得libc基址、system偏移、/bin/sh字符串偏移。本地实验里可以通过ldd找到libc加载地址,再用nm -D查看函数偏移,两者相加就是运行时地址。更进阶的做法是打开ASLR的情况下,先泄露某个libc地址,再计算基址,seedlab里通常要求你先在关闭ASLR的情况下完成基础版本,再打开ASLR完成进阶版本。
用工具检索gadget也很常规:
objdump -d ./target | grep -E "pop.*ret" ROPgadget --binary ./target --only "pop|ret"我个人更喜欢先用objdump看整体汇编,再配合ROPgadget补齐。工具查出来的gadget多了之后,真正难的不是"有没有",而是"怎么选"。一个可用的gadget要考虑寄存器约束、是否影响后续栈布局、在攻击payload中的位置是否和所需一致。这一步没有太多捷径,只能在GDB里单步走一遍看清楚寄存器变化。
4.4 三种攻击手段思维差异对比
| 攻击技术 | 核心思路 | 依赖条件 | 绕过目标 |
|---|---|---|---|
| 栈溢出+shellcode | 往栈上写代码并跳转 | 地址稳定、栈可执行 | NX关闭时直接执行 |
| 格式化字符串 | 利用格式串任意读写 | 用户输入进printf | 泄露地址或改写GOT/返回地址 |
| ROP/ret2libc | 复用已有机器指令 | gadget可寻址 | 绕过NX、部分绕过ASLR |
这个对比表我在写实验报告时反复用到。核心结论是:攻击者从来不会只用一种招数,而是根据防御配置灵活切换。作为防御方,做seedlab的意义恰恰在于知道"哪种组合能防住哪些招数"。
5. Set-UID与环境变量实验:软件安全不止内存布局这一亩三分地
5.1 Set-UID程序成为提权目标的逻辑
Linux中的Set-UID程序在运行时会临时获得文件属主权限,典型的是系统里的一些特权工具。seedlab要求你自己写一个root属主的Set-UID程序,然后尝试攻击它。这类程序的问题是:它以高权限运行,但处理的是低权限用户可以控制的数据。攻击者只要能让高权限程序执行一段自己的逻辑,就完成了提权。
这其实和前面实验的思维一脉相承:栈溢出是控制内存,setuid提权是控制程序的"行为"。只要程序把不可信的输入当作可信指令来处理,漏洞就存在。很多人在之前的实验里习惯了跟地址和字节打交道,突然切换到环境变量这个方向会不适应,但其实攻击模型完全一致。
5.2 PATH、LD_PRELOAD和system()的连环坑
实验里最经典的一类脆弱程序是:
#include <stdio.h> #include <stdlib.h> int main() { system("ls"); return 0; }system会调用shell来执行命令。shell解析ls时是按PATH环境变量查找可执行文件的。攻击者只要把当前目录放进PATH最前面,并且放一个名字为ls的恶意脚本,root权限程序一运行system("ls")就会执行攻击者的脚本。这就是PATH注入。
另一个经典路径是LD_PRELOAD。攻击者写一个动态库,里面定义同名函数,然后通过环境变量LD_PRELOAD让它优先加载,导致Set-UID程序里的库函数调用被劫持。例如攻击者可以劫持malloc、getuid这类函数,当程序在root权限下调用它们时,恶意代码就成功执行。
seedlab里还要求尝试多种传递环境变量的方式:在普通用户下设置的LD_PRELOAD,Set-UID程序是否会继承?结果通常是动态链接器出于安全考虑,不允许Set-UID程序继承LD_PRELOAD,但PATH注入这类攻击由于发生在shell内部,反而很难被环境变量处理机制拦截。理解这条边界是实验的核心收获之一。
5.3 防御实验:从编译参数到清理函数
防御侧seedlab会引导你:
- 编写Set-UID程序时避免调用
system,改用execve并显式指定可执行文件路径; - 在程序启动时清理环境变量,比如重置
PATH; - 使用
realpath解析路径,避免符号链接把程序引到攻击者控制的目录; - 尽量不把高风险操作放进Set-UID程序,换用特权和权限分离的设计。
我体验最深的是:很多所谓"系统漏洞"其实源于编程习惯,而非内核缺陷。system一行代码带来的风险远高于表面看起来的便利。现在我的代码审查清单里有一条硬性规则:凡是要提权或跨信任等级运行的进程,禁用system、禁用继承用户环境变量、禁用相对路径。
5.4 把四个实验串起来看攻击面的演化
做完setuid实验再回头看前三个lab,会发现seedlab的软件安全设计有一个清晰的递进:从"内存中代码能被控制"到"内存中代码不能被控制但程序自身的代码能被调遣",再到"程序运行依赖的外部命令和库能被用户伪造"。攻击者的关注点越来越靠上,从CPU指令一路攀到了进程环境。
防御者如果只修内存漏洞、不修信任边界,依然会被一层一层突破。这也是seedlab这类实验和单纯CTF题目最大的差别:CTF更多是在单一漏洞点上比技巧,而seedlab会用一系列实验逼你建立系统性的安全观。
6. 实验结束后的三件小事:我从中提炼出的防御习惯
6.1 把seedlab的编译参数当作"安全检查清单"
现在我在审查任何C/C++项目时,都会先看编译选项里有没有-z execstack、-no-pie、-fno-stack-protector。看到任意一条,就会标记为高风险。这不是矫情:seedlab告诉我,这些参数单独出现往往是业务需要,但同时出现基本等于把前门焊死、后门敞开。
我在一个模拟项目X里就见过整套flag同时出现,原因只是开发者照着网上旧教程抄了CFLAGS。这种问题用编译器自带的-fstack-protector-strong、-D_FORTIFY_SOURCE=2、-Wformat-security就能覆盖大部分场景。安全不一定要上多高级的产品,先把编译选项守好,就已经挡住了大量入门级攻击。
6.2 用GDB和ASan延续实验里的调试手感
做完seedlab之后,我调试C程序的习惯也变了。以前一遇到段错误就是加打印重编译,现在会先用GDB看崩溃点,再配合-fsanitize=address编译一个ASan版本复现。ASan能直接告诉你溢出发生在哪一行、是读还是写、越界了多少字节。这和seedlab里手动分析栈布局完全是两种体验,但前者让你会读底层,后者让你快速定位。
我建议把seedlab里的调试技巧延续到日常开发里,尤其是x/wx查看内存、info registers查看寄存器、bt查看调用栈这几条GDB命令,熟练之后遇到任何内存问题都不慌。
6.3 给后来者的路线建议
如果你刚开始刷seedlab软件安全系列,我的建议顺序是:
- 先完整跑一遍官方VM和文档,不要跳任务;
- 每个实验都先自己写攻击payload,失败后看GDB,再去看别人的思路;
- 把每一步截图和命令行记录保存好,最后写报告会省很多时间;
- 做完一个lab后,把对应防御机制单独打开,验证一次攻击失败;
- 如果卡壳超过半天,就休息一下再回来看,很多地址问题其实是状态乱了。
最后补充一句关于安全边界的话:seedlab环境是专门用来做漏洞教学的隔离虚拟机,我在整个过程中没有对任何真实系统发起过攻击。把它当作理解系统底层的练习,比当作"黑客教程"要有价值得多。当你能亲手复现一条攻击链,再去审视真实的软件设计时,你的视角会完全不一样。