3. 缓冲区溢出漏洞:栈溢出原理、堆溢出原理、ARM架构下的利用差异

缓冲区溢出,这名字听起来有点学术。说白了,就是程序往一个固定大小的杯子里倒水,结果水满了还不停,洒得到处都是。洒出来的水,就可能把旁边重要的东西给淹了——比如函数的返回地址、关键数据指针。

我在做固件安全评估时,十次有八次的问题根源都是缓冲区溢出。它太常见了,尤其是在资源受限的MCU上。今天我们就把它彻底聊透。

3.1 栈溢出原理

栈溢出是最经典、也是最容易理解的溢出类型。先看一个典型的例子:

void vulnerable_function(char *user_input) {
    char buffer[64];          // 局部缓冲区,在栈上
    strcpy(buffer, user_input); // 没有长度检查!
    // ... 其他操作
}

这段代码有什么问题?strcpy 不会检查目标缓冲区的大小。如果 user_input 超过 64 字节,它就会一路写下去,覆盖栈上 buffer 之后的所有内容。

栈的布局是什么样的?我画了一张图,你一看就明白:

ARM Cortex-M 栈帧布局(函数调用时) 高地址 返回地址(Return Address) 保存的基址指针(FP / R11) 局部变量(buffer[64]) 溢出方向 低地址 关键点 • buffer[64] 在低地址,返回地址在高地址 • strcpy 从低地址向高地址写入 • 溢出时先覆盖 FP,再覆盖返回地址 • 攻击者控制返回地址 = 控制程序执行流

嗯,这里要注意:栈是向低地址生长的,但缓冲区内的数据是从低地址向高地址写的。所以 buffer[0] 在低地址,buffer[63] 在高地址。一旦越界,下一个被覆盖的就是保存的帧指针(FP),再下一个就是返回地址。

攻击者只要精心构造输入数据,让溢出数据恰好把返回地址改成自己想要的地址——比如一段恶意代码的入口——就能劫持程序执行流。这就是最经典的栈溢出利用。

我的经验: 在 Cortex-M 上做栈溢出测试时,我习惯先看函数有没有使用 -fno-stack-protector 编译。如果没开栈保护,那基本就是裸奔状态。我曾经在一个 IoT 网关的固件里,连续发现 7 个这样的漏洞,全是 strcpy 和 sprintf 惹的祸。

3.2 堆溢出原理

堆溢出比栈溢出稍微复杂一点。堆是动态分配的内存区域,用 malloc、free 这类函数管理。堆溢出发生在写入的数据超过了动态分配的内存块边界。

void heap_vulnerable(char *input) {
    char *buffer = (char *)malloc(64);
    char *important_flag = (char *)malloc(4);
    
    strcpy(important_flag, "OK");
    strcpy(buffer, input);  // 如果 input > 64 字节,就会溢出到 important_flag
    
    if (strcmp(important_flag, "OK") != 0) {
        // 权限绕过!
        grant_admin_access();
    }
    free(buffer);
    free(important_flag);
}

堆管理器的内部结构更复杂。每个堆块都有元数据(chunk header),包含大小、前一块/后一块的指针等信息。溢出时,攻击者可以:

  • 覆盖相邻堆块的数据:就像上面例子那样,改掉旁边的标志位
  • 篡改堆块元数据:伪造 size 字段,触发堆块合并时的任意地址写
  • 攻击堆管理器的空闲链表:在 free 时把恶意地址写入链表指针

堆溢出的利用,说白了就是利用堆管理器的「信任」。它相信元数据是合法的,但攻击者通过溢出把它改成了精心构造的假数据。

避坑指南: 我曾经在一个项目里调试堆溢出漏洞,花了整整两天。原因是堆块对齐问题——MCU 上的 malloc 实现(比如 newlib 的 nano malloc)对对齐要求很宽松,导致堆块之间的间隙和 PC 上不一样。你按 x86 的经验去算偏移,在 ARM 上可能完全对不上。所以,一定要先搞清楚目标平台用的哪个 malloc 实现。

3.3 ARM 架构下的利用差异

ARM 架构和 x86 在缓冲区溢出利用上有几个关键差异。我整理了一个对比表:

对比维度 x86 / x64 ARM (Cortex-M / Cortex-A)
返回地址存储 栈上保存返回地址 Cortex-M: 入栈时自动压入 PC 和 LR;Cortex-A: LR 寄存器保存返回地址,嵌套调用时 LR 入栈
指令集特性 x86 指令变长,Shellcode 构造相对灵活 ARM (Thumb-2) 指令 16/32 位混合,Shellcode 需要处理指令对齐
栈布局差异 函数调用时压入 EBP、返回地址 Cortex-M 硬件自动压栈:xPSR、PC、LR、R12、R3-R0;Cortex-A 软件压栈,通常压入 FP、LR
ROP 利用 ret 指令控制执行流 Cortex-M: 利用 POP {PC} 或 BX LR;Cortex-A: 利用 LDMFD SP!, {PC} 或 POP {PC}
对齐要求 无严格对齐要求 ARM 模式下指令必须 4 字节对齐;Thumb 模式下必须 2 字节对齐
NX/DEP 支持 通过页表 NX 位实现 Cortex-M3/M4 有 MPU 可配置内存属性;Cortex-A 有 MMU 的 XN 位

我重点说几个实际利用中容易踩的坑:

3.3.1 Cortex-M 的硬件自动压栈

Cortex-M 系列在进入异常或中断时,硬件会自动压栈 8 个寄存器(xPSR、PC、LR、R12、R3-R0)。这意味着栈帧布局和软件压栈完全不同。如果你在 Cortex-M 上做栈溢出利用,需要精确计算这 8 个寄存器的位置。

; Cortex-M 异常入口的硬件自动压栈布局(从高到低)
; 高地址
; [xPSR]    — 程序状态寄存器
; [PC]      — 返回地址(攻击目标!)
; [LR]      — 链接寄存器
; [R12]     — 通用寄存器
; [R3]
; [R2]
; [R1]
; [R0]      — 第一个参数
; 低地址
; 然后才进入软件压栈的局部变量

嗯,这里要注意:PC 在栈上的位置是固定的,偏移量取决于编译器是否压入了其他寄存器。我建议你反汇编看一下实际的栈帧布局,别光看理论。

3.3.2 Thumb 模式下的指令对齐

ARM 处理器可以在 ARM 模式和 Thumb 模式之间切换。Thumb-2 指令是 16 位和 32 位混合的。如果你要跳转到 Shellcode,必须确保目标地址的最低位是 1(表示 Thumb 模式),否则会触发异常。

关键技巧: 在构造 ROP 链时,gadget 的地址必须检查最低位。如果 gadget 在 Thumb 代码区,地址要 | 1。我见过有人因为这个细节调试了一整天,ROP 链死活跑不起来。

3.3.3 利用差异总结

我把 ARM 架构下利用缓冲区溢出时最需要注意的三点列出来:

  1. 栈帧布局不同:Cortex-M 有硬件自动压栈,Cortex-A 是软件压栈。偏移计算不能套用 x86 的经验。
  2. 指令对齐敏感:ARM 模式下 4 字节对齐,Thumb 模式下 2 字节对齐。跳转地址不对齐直接 HardFault。
  3. 返回地址控制方式不同:Cortex-M 通过覆盖栈上的 PC 值控制;Cortex-A 通过覆盖 LR 或栈上的 LR 值控制。
我的习惯: 每次拿到一个新的 MCU 固件,我会先做一个简单的栈溢出测试——输入一串 0xAA 填充,看 HardFault 时 PC 寄存器的值。如果 PC 变成了 0xAAAAAAAA,说明溢出点找对了。这个方法我用了很多年,简单粗暴但有效。

缓冲区溢出是 MCU 固件安全的老大难问题。栈溢出相对直接,堆溢出更考验对堆管理器的理解。ARM 架构下的利用,说白了就是搞清楚「返回地址在哪」和「怎么跳过去」。这两个问题搞明白了,剩下的就是细心和耐心。