4. 堆溢出基础

任务目标

编写一个C程序,在堆上分配两个相邻的缓冲区,通过向第一个缓冲区写入超出其大小的数据,覆盖第二个缓冲区的堆元数据(chunk header),最终触发崩溃或异常,直观展示堆溢出对堆管理结构的破坏。

核心原理

glibc的ptmalloc2分配器在堆块头部存储prev_size和size字段(各8字节)。当程序向堆缓冲区写入超出边界的数据时,会覆盖相邻堆块的头部元数据。在释放(free)或合并(consolidate)这些被破坏的堆块时,分配器会依据被篡改的元数据进行操作,导致段错误或双重释放等异常。

完整代码

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main() {
    // 分配两个相邻的堆块
    char *buf1 = (char *)malloc(8);   // 实际可用16字节(含8字节头部)
    char *buf2 = (char *)malloc(8);
    
    if (!buf1 || !buf2) {
        printf("内存分配失败\n");
        return 1;
    }
    
    printf("buf1 地址: %p\n", (void *)buf1);
    printf("buf2 地址: %p\n", (void *)buf2);
    printf("buf2 的堆块头部(前16字节)位于 buf1+16 处\n\n");
    
    // 正常写入buf1(8字节内)
    strcpy(buf1, "1234567");
    printf("正常写入后 buf1: %s\n", buf1);
    
    // 查看buf2的原始堆块头部(前16字节)
    printf("\n溢出前 buf2 头部(16字节):\n");
    for (int i = 0; i < 16; i++) {
        printf("%02x ", (unsigned char)buf2[-16 + i]);
    }
    printf("\n\n");
    
    // ===== 堆溢出触发点 =====
    // 向buf1写入超出8字节的数据,覆盖buf2的堆块头部
    // buf1实际可用16字节(含8字节头部),从buf1[0]到buf1[15]不越界
    // 但buf1[16]开始就是buf2的prev_size字段
    memset(buf1, 'A', 24);  // 写入24字节:8字节数据 + 8字节padding + 8字节覆盖buf2的prev_size
    
    // 此时buf2的prev_size被覆盖为0x4141414141414141
    // 但buf2的size字段尚未被破坏(位于buf2[-8]处)
    
    printf("溢出后 buf2 头部(16字节):\n");
    for (int i = 0; i < 16; i++) {
        printf("%02x ", (unsigned char)buf2[-16 + i]);
    }
    printf("\n\n");
    
    // 进一步覆盖buf2的size字段(位于buf2[-8])
    // 需要再写入8字节,使总覆盖达到32字节
    memset(buf1, 'B', 32);  // 覆盖到buf2的size字段
    
    printf("深度溢出后 buf2 头部(16字节):\n");
    for (int i = 0; i < 16; i++) {
        printf("%02x ", (unsigned char)buf2[-16 + i]);
    }
    printf("\n\n");
    
    // ===== 触发崩溃 =====
    // 释放被破坏的堆块,glibc在合并/释放时会读取被篡改的元数据
    printf("尝试释放被破坏的堆块...\n");
    free(buf1);  // 释放buf1时,glibc会检查相邻的buf2块并尝试合并
    // 由于buf2的size字段被篡改为0x4242424242424242,合并操作会访问非法地址
    
    // 如果程序没有崩溃,继续释放buf2
    printf("释放 buf2...\n");
    free(buf2);
    
    printf("程序未崩溃(某些系统/分配器配置下可能不崩溃)\n");
    return 0;
}

编译与运行

# 编译(关闭栈保护以稳定复现,但堆保护仍生效)
gcc -o heap_overflow heap_overflow.c -fno-stack-protector

# 运行
./heap_overflow

验证方法与判定标准

运行程序后,观察以下现象:

  • 溢出前:buf2头部显示正常的堆块元数据(size字段低3位为标志位,值为0x21或0x11等)
  • 第一次溢出(24字节):buf2的prev_size被覆盖为0x4141414141414141(即'AAAA'的ASCII码)
  • 深度溢出(32字节):buf2的size字段被覆盖为0x4242424242424242(即'BBBB')
  • free(buf1)时:glibc尝试向后合并buf2,读取被篡改的size字段,访问非法内存地址,触发段错误(Segmentation Fault)

通过条件:程序在free(buf1)处崩溃,输出类似Segmentation fault (core dumped),证明堆元数据被破坏后导致分配器异常。若程序未崩溃(某些系统或分配器配置下),则观察buf2头部被篡改的十六进制输出,同样证明堆溢出已破坏相邻堆块元数据。

关键说明

  • 堆块的实际可用空间可能大于请求大小(glibc最小分配16字节),因此malloc(8)实际可用16字节,写入16字节以内不会越界
  • buf2的堆块头部位于buf2 - 16处(prev_size占8字节,size占8字节)
  • glibc 2.27+引入了tcache机制,小堆块释放时先进入tcache,可能不会立即触发合并;若需稳定复现崩溃,可分配较大堆块(如malloc(200))绕过tcache,或使用mallopt(M_TCACHE, 0)禁用tcache
  • 本案例仅演示堆溢出对元数据的破坏机制,实际漏洞利用还需考虑ASLR、堆布局等因素
堆块内存布局与溢出覆盖示意 prev_size size=0x21 buf1[0..7] buf1[8..15] prev_size size=0x21 buf2[0..7] buf2[8..15] 偏移0 偏移8 偏移16 偏移24 偏移32 偏移40 偏移48 偏移56 堆块1 (malloc(8)实际16字节) 堆块2 (malloc(8)实际16字节) memset(buf1,'A',24) 覆盖prev_size memset(buf1,'B',32) 覆盖size字段 free(buf1) 时读取被篡改的size → 访问非法地址 → 段错误崩溃