3. 缓冲区溢出漏洞:栈溢出原理、堆溢出原理、ARM架构下的利用差异
缓冲区溢出,这名字听起来有点学术。说白了,就是程序往一个固定大小的杯子里倒水,结果水满了还不停,洒得到处都是。洒出来的水,就可能把旁边重要的东西给淹了——比如函数的返回地址、关键数据指针。
我在做固件安全评估时,十次有八次的问题根源都是缓冲区溢出。它太常见了,尤其是在资源受限的MCU上。今天我们就把它彻底聊透。
3.1 栈溢出原理
栈溢出是最经典、也是最容易理解的溢出类型。先看一个典型的例子:
void vulnerable_function(char *user_input) {
char buffer[64]; // 局部缓冲区,在栈上
strcpy(buffer, user_input); // 没有长度检查!
// ... 其他操作
}
这段代码有什么问题?strcpy 不会检查目标缓冲区的大小。如果 user_input 超过 64 字节,它就会一路写下去,覆盖栈上 buffer 之后的所有内容。
栈的布局是什么样的?我画了一张图,你一看就明白:
嗯,这里要注意:栈是向低地址生长的,但缓冲区内的数据是从低地址向高地址写的。所以 buffer[0] 在低地址,buffer[63] 在高地址。一旦越界,下一个被覆盖的就是保存的帧指针(FP),再下一个就是返回地址。
攻击者只要精心构造输入数据,让溢出数据恰好把返回地址改成自己想要的地址——比如一段恶意代码的入口——就能劫持程序执行流。这就是最经典的栈溢出利用。
-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 时把恶意地址写入链表指针
堆溢出的利用,说白了就是利用堆管理器的「信任」。它相信元数据是合法的,但攻击者通过溢出把它改成了精心构造的假数据。
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 架构下利用缓冲区溢出时最需要注意的三点列出来:
- 栈帧布局不同:Cortex-M 有硬件自动压栈,Cortex-A 是软件压栈。偏移计算不能套用 x86 的经验。
- 指令对齐敏感:ARM 模式下 4 字节对齐,Thumb 模式下 2 字节对齐。跳转地址不对齐直接 HardFault。
- 返回地址控制方式不同:Cortex-M 通过覆盖栈上的 PC 值控制;Cortex-A 通过覆盖 LR 或栈上的 LR 值控制。
缓冲区溢出是 MCU 固件安全的老大难问题。栈溢出相对直接,堆溢出更考验对堆管理器的理解。ARM 架构下的利用,说白了就是搞清楚「返回地址在哪」和「怎么跳过去」。这两个问题搞明白了,剩下的就是细心和耐心。