教学 > 二进制安全学习路径 > 堆利用技术
课程进度:75%

堆利用技术

二进制安全学习路径 | 模块2 | 课程4

1. 引言

堆内存是现代程序中动态分配内存的主要方式,与栈内存不同,堆内存的分配和释放由程序员显式控制。这种灵活性虽然强大,但也带来了复杂的安全挑战。堆利用是二进制安全中一个重要且复杂的领域,掌握堆利用技术对于理解和发现现代软件中的高级漏洞至关重要。

学习目标: 理解堆内存管理机制,识别常见堆漏洞类型,掌握基本堆利用技术,了解堆内存保护机制及绕过方法,学习如何编写安全的堆操作代码。

2. 堆内存基础

2.1 什么是堆内存

堆是程序运行时可动态分配的一块内存区域,不同于栈的自动分配和回收机制,堆内存需要程序员手动申请和释放。

2.2 堆与栈的区别

  • 分配方式:栈是自动分配和释放,堆需要手动管理
  • 内存布局:栈是连续的线性结构,堆是非连续的树状或图状结构
  • 分配效率:栈分配通常比堆快,因为栈只需要移动栈指针
  • 内存大小:堆通常可以申请更大的内存空间
  • 生存周期:栈中变量生命周期与函数调用关联,堆中对象生命周期由程序员控制

2.3 堆内存管理函数

在C/C++中,常用的堆内存管理函数包括:

// C语言堆操作函数
void *malloc(size_t size);            // 分配指定大小的内存
void *calloc(size_t nmemb, size_t size); // 分配并清零
void *realloc(void *ptr, size_t size);   // 重新调整内存大小
void free(void *ptr);                 // 释放内存

// C++中的相关操作
int *p = new int;       // 分配单个对象
int *arr = new int[10]; // 分配数组
delete p;               // 释放单个对象
delete[] arr;           // 释放数组

2.4 内存分配过程

当程序调用malloc等函数分配内存时,通常会经历以下步骤:

  1. 检查之前释放的内存块中是否有合适大小的(快速分配)
  2. 如果找到合适的空闲块,进行分割或直接使用
  3. 如果没有合适的块,向操作系统请求更多内存
  4. 更新内部管理数据结构
  5. 返回可用内存指针给应用程序

3. 堆分配器实现

3.1 常见堆分配器

不同系统使用不同的堆内存分配器:

  • ptmalloc:Linux系统中默认的GNU C库(glibc)堆分配器
  • jemalloc:FreeBSD和Firefox使用的分配器,注重多线程扩展性
  • tcmalloc:Google开发的线程缓存分配器,用于Chrome等项目
  • Windows Heap:Windows操作系统的堆实现

3.2 glibc堆实现细节

作为最常见的堆实现之一,glibc的ptmalloc2分配器有以下特性:

  • 使用多个内存池(arena)支持多线程
  • 基于边界标记(boundary tag)算法管理空闲块
  • 使用bins和chunks组织内存
  • 采用合并(coalescing)技术减少碎片

3.3 Chunk结构

在glibc堆实现中,内存以"chunk"为单位进行管理:

struct malloc_chunk {
    size_t prev_size;   // 前一个chunk的大小(如果前一个chunk空闲)
    size_t size;        // 当前chunk的大小和标志位
    
    // 使用中的chunk
    union {
        struct {
            struct malloc_chunk* fd;     // 指向下一个空闲chunk
            struct malloc_chunk* bk;     // 指向上一个空闲chunk
        };
        char user_data[1];  // 实际给用户的数据区域
    };
};
堆块结构图

典型的malloc chunk结构示意图

3.4 Bins分类

空闲的chunks被组织在不同的链表中,这些链表称为"bins":

  • Fast bins:小型快速重用的chunk链表
  • Small bins:按精确大小排序的小型chunk链表
  • Large bins:按范围排序的大型chunk链表
  • Unsorted bin:最近释放的chunk暂存处
  • Top chunk:当前arena的最大连续空闲内存

4. 常见堆漏洞

4.1 堆溢出(Heap Overflow)

堆溢出发生在程序向堆区域写入超出分配空间的数据时,导致相邻堆块的元数据被覆盖:

char *buffer = (char *)malloc(10);
strcpy(buffer, "This string is too long for the allocated buffer");
// 溢出:写入的数据超过了分配的10字节

安全提示: 堆溢出通常比栈溢出更难以利用,但由于堆上通常存储着更多敏感数据和函数指针,成功利用往往能产生更严重的后果。

4.2 Use-After-Free (UAF)

当程序释放内存后继续使用该内存指针时,就会发生UAF漏洞。如果在free和后续使用之间有新的分配,可能导致对象类型混淆。

char *ptr = (char *)malloc(10);
free(ptr);
// 其它操作...可能包括新的分配
strcpy(ptr, "Hello");  // 使用已释放的内存 - UAF漏洞

4.3 Double Free

Double Free漏洞发生在同一块内存被释放两次时:

char *ptr = (char *)malloc(10);
free(ptr);
// ...
free(ptr);  // 二次释放同一个指针 - Double Free漏洞

4.4 Null Byte Overflow

一种特殊的堆溢出,只覆盖了chunk头部的一个空字节,通常利用字符串结束符('\0'):

char *buf1 = (char *)malloc(16);
char *buf2 = (char *)malloc(16);
// 写入恰好17个字节(包括结束符)
strcpy(buf1, "AAAAAAAAAAAAAAAA");
// 最后的'\0'会覆盖buf2的size字段低字节

4.5 Off-by-One

Off-by-One错误通常发生在循环边界条件出错时,导致多写入或少写入一个字节:

char *buf = (char *)malloc(8);
for(int i = 0; i <= 8; i++) {  // 注意是<= 而不是<
    buf[i] = 'A';  // 当i=8时,越界写入一个字节
}

5. 堆利用技术

5.1 Heap Spray

堆喷射(Heap Spray)是一种用大量相同的数据填充堆内存的技术,增加定位关键数据的成功率:

  • 分配大量内存,填充shellcode或特定模式
  • 提高攻击成功率,尤其是在存在内存地址随机化时
  • 通常与其他漏洞结合使用
// 简单的堆喷射示例
char *blocks[1000];
for(int i = 0; i < 1000; i++) {
    blocks[i] = (char *)malloc(1024);
    memset(blocks[i], 0x90, 1024);  // 填充NOP指令
    // 在每个块末尾放置shellcode
    memcpy(blocks[i] + 1000, shellcode, 24);
}

5.2 Chunk Overlapping

通过操作堆块元数据,可以创建重叠的内存块,这会导致同一块内存被不同对象引用:

  1. 创建相邻的堆块
  2. 利用溢出修改相邻块的大小信息
  3. 释放并重新分配内存创建重叠区域

5.3 The House of Force

这是一种利用修改Top Chunk的size来控制下一次内存分配位置的技术:

  1. 利用溢出漏洞修改Top Chunk的size为一个非常大的值(通常是0xFFFFFFFF)
  2. 请求特定大小的内存,使得下一次分配指向任意位置
  3. 分配内存到目标区域(如.got表)

6. 堆元数据操作

6.1 Unlink操作利用

unlink是堆管理器合并空闲块的一种操作,可以通过伪造chunk结构利用它进行写入任意地址:

// unlink操作伪代码
#define unlink(P, BK, FD) {            \
    FD = P->fd;                        \
    BK = P->bk;                        \
    FD->bk = BK;                       \
    BK->fd = FD;                       \
}

// 如果我们能够控制P->fd和P->bk,
// 就可以实现*(P->fd + offsetof(chunk, bk)) = P->bk
// 和*(P->bk + offsetof(chunk, fd)) = P->fd
// 即两次任意地址写入操作

典型的unlink利用步骤:

  1. 创建一个能够被控制的chunk
  2. 设置其fd和bk指向伪造的结构或目标地址
  3. 触发free操作导致unlink
  4. 完成任意内存写入

6.2 Fast Bin Attack

Fast Bin是一种专门用于小内存快速分配的单链表结构,Fast Bin Attack利用其单向链表特性:

  • Fast Bin仅检查chunk大小,不检查前后chunk
  • 通过Double Free创建循环链表
  • 覆盖fd指针指向任意位置
  • 导致下次分配返回被控制的地址
// Fast Bin Attack示例
void *chunk1 = malloc(24);  // 在Fast Bin范围内
void *chunk2 = malloc(24);
free(chunk1);
free(chunk2);
free(chunk1);  // Double Free - 创建循环链表

// 链表现在是: chunk1 -> chunk2 -> chunk1 -> ...

void *chunk3 = malloc(24);  // 返回chunk1
void *chunk4 = malloc(24);  // 返回chunk2
// 修改chunk2的fd指针指向目标地址
*(void **)(chunk4) = target_addr - offsetof(chunk, fd);
void *chunk5 = malloc(24);  // 返回chunk1
void *chunk6 = malloc(24);  // 返回目标地址

6.3 Unsafe Unlink

在早期的glibc实现中,unlink操作没有足够的安全检查,可以更容易地利用:

struct chunk {
    size_t prev_size;
    size_t size;
    struct chunk *fd;
    struct chunk *bk;
};

// 伪造一个chunk
fake_chunk.fd = &fake_chunk - 3*sizeof(size_t);
fake_chunk.bk = &target_addr - 2*sizeof(size_t);

// 触发unlink操作
free(controlled_chunk);  // 导致target_addr被写入任意值

7. 保护机制与绕过

7.1 现代堆保护机制

现代堆分配器实现了多种保护机制:

  • Safe Unlinking:检查fd->bk和bk->fd是否指向当前chunk
  • Chunk大小检查:确保chunk大小在合理范围内
  • Canary值:类似于栈保护的金丝雀值
  • Double Free检测:检测同一块内存被释放两次
  • 内存标记:标记已释放内存以检测UAF

7.2 Safe Unlinking

为防止unlink攻击,现代glibc增加了安全检查:

// 现代安全unlink操作
#define unlink(P, BK, FD) {                      \
    FD = P->fd;                                  \
    BK = P->bk;                                  \
    if (__builtin_expect(FD->bk != P || BK->fd != P, 0)) \
        malloc_printerr("corrupted double-linked list"); \
    else {                                       \
        FD->bk = BK;                             \
        BK->fd = FD;                             \
    }                                            \
}

7.3 ASLR与堆利用

地址空间布局随机化(ASLR)增加了堆利用的难度:

  • 堆基址每次运行时随机变化
  • 需要结合信息泄露漏洞获取实际内存地址
  • 使用相对偏移而非固定地址

7.4 保护绕过技术

尽管存在保护机制,仍有多种方法可能绕过:

  • 信息泄露:利用UAF或格式化字符串泄露堆地址
  • 部分覆盖:只修改地址的低字节,绕过ASLR
  • House of系列技术:一系列针对特定堆实现的利用方法
  • 类型混淆:利用对象重叠导致类型解释错误

安全注意: 保护机制的有效性取决于堆分配器的实现和版本。某些旧版本或特定系统上的保护可能被绕过。始终保持系统和库的更新是防御堆攻击的重要一环。

8. 堆分析工具

8.1 调试工具

分析和调试堆漏洞需要使用专门的工具:

  • GDB:基本的调试工具,配合堆专用插件使用
  • PEDA:Python Exploit Development Assistance,增强GDB功能
  • GEF:GDB Enhanced Features,提供更多堆分析命令
  • pwndbg:专为漏洞利用设计的GDB插件

8.2 libheap

libheap是一个专门用于分析堆结构的GDB Python扩展:

# GDB中使用libheap命令示例
(gdb) heap
===================================Heap Dump=====================================

Arena(s) found:
         arena @ 0x7f7383b22b20

Chunk(s) found:
         addr                size                 status
     0x55a08b08dcb0         0x250               ALLOCATED
     0x55a08b08df00         0x220               ALLOCATED
     0x55a08b08e120         0x90                ALLOCATED
     0x55a08b08e1b0         0x410               FREE
     ...

8.3 Valgrind

Valgrind是一个内存调试工具,可以检测多种堆相关问题:

  • 内存泄漏检测
  • UAF漏洞检测
  • 未初始化内存使用检测
  • Double Free检测
# 使用Valgrind检测UAF示例
$ valgrind --leak-check=full --track-origins=yes ./program
==12345== Invalid read of size 4
==12345==    at 0x4006A0: main (main.c:15)
==12345==  Address 0x5b00c80 is 0 bytes inside a block of size 16 free'd
==12345==    at 0x4C30D3B: free (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==12345==    by 0x400692: main (main.c:12)

8.4 AddressSanitizer (ASAN)

AddressSanitizer是一种编译器扩展,可以在运行时检测内存错误:

  • 堆溢出检测
  • UAF检测
  • Double Free检测
  • 内存泄漏检测
// 使用ASAN编译程序
$ gcc -fsanitize=address -g source.c -o program

// 运行检测UAF
$ ./program
=================================================================
==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x7f7b367eb050
READ of size 1 at 0x7f7b367eb050 thread T0
    #0 0x4929b4 in main example.c:8:10
    ...
0x7f7b367eb050 is located 0 bytes inside of 10-byte region [0x7f7b367eb050,0x7f7b367eb05a)
freed by thread T0 here:
    #0 0x4922a0 in free
    #1 0x4929a0 in main example.c:7:3
    ...

9. 实际漏洞案例

9.1 CTF挑战示例

在CTF竞赛中,堆利用是常见的挑战类型,通常需要多种技术组合使用。下面是一个典型的堆利用挑战示例代码,原本压缩在一行中难以阅读,我们提供了两种格式的查看方式:

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

struct note {
    char *content;
    int size;
};

struct note notes[10];
int note_count = 0;

void add_note() {
    if (note_count >= 10) {
        printf("笔记本已满!\n");
        return;
    }
    
    printf("输入笔记大小: ");
    int size;
    scanf("%d", &size);
    getchar(); // 消耗换行符
    
    notes[note_count].content = (char *)malloc(size);
    notes[note_count].size = size;
    
    printf("输入笔记内容: ");
    fgets(notes[note_count].content, size, stdin);
    
    printf("笔记 #%d 已添加!\n", note_count);
    note_count++;
}

void delete_note() {
    printf("输入要删除的笔记编号: ");
    int index;
    scanf("%d", &index);
    
    if (index < 0 || index >= note_count) {
        printf("无效的笔记编号!\n");
        return;
    }
    
    free(notes[index].content);
    // 漏洞:没有将指针设置为NULL
    printf("笔记 #%d 已删除!\n", index);
}

void edit_note() {
    printf("输入要编辑的笔记编号: ");
    int index;
    scanf("%d", &index);
    getchar(); // 消耗换行符
    
    if (index < 0 || index >= note_count) {
        printf("无效的笔记编号!\n");
        return;
    }
    
    printf("输入新内容: ");
    // 漏洞:可能导致堆溢出
    fgets(notes[index].content, notes[index].size + 10, stdin);
    
    printf("笔记 #%d 已更新!\n", index);
}

void print_note() {
    printf("输入要显示的笔记编号: ");
    int index;
    scanf("%d", &index);
    
    if (index < 0 || index >= note_count) {
        printf("无效的笔记编号!\n");
        return;
    }
    
    printf("笔记 #%d: %s\n", index, notes[index].content);
}

int main() {
    int choice;
    
    while (1) {
        printf("\n=== 笔记管理系统 ===\n");
        printf("1. 添加笔记\n");
        printf("2. 删除笔记\n");
        printf("3. 编辑笔记\n");
        printf("4. 显示笔记\n");
        printf("5. 退出\n");
        printf("选择: ");
        
        scanf("%d", &choice);
        
        switch (choice) {
            case 1: add_note(); break;
            case 2: delete_note(); break;
            case 3: edit_note(); break;
            case 4: print_note(); break;
            case 5: return 0;
            default: printf("无效选择!\n");
        }
    }
    
    return 0;
}
// 一个典型的堆利用CTF挑战程序
#include <stdio.h> #include <stdlib.h> #include <string.h> struct note { char *content; int size; }; struct note notes[10]; int note_count = 0; void add_note() { if (note_count >= 10) { printf("笔记本已满!\n"); return; } printf("输入笔记大小: "); int size; scanf("%d", &size); getchar(); notes[note_count].content = (char *)malloc(size); notes[note_count].size = size; printf("输入笔记内容: "); fgets(notes[note_count].content, size, stdin); printf("笔记 #%d 已添加!\n", note_count); note_count++; } void delete_note() { printf("输入要删除的笔记编号: "); int index; scanf("%d", &index); if (index < 0 || index >= note_count) { printf("无效的笔记编号!\n"); return; } free(notes[index].content); // 漏洞: 没有将指针设置为NULL printf("笔记 #%d 已删除!\n", index); } void edit_note() { printf("输入要编辑的笔记编号: "); int index; scanf("%d", &index); getchar(); if (index < 0 || index >= note_count) { printf("无效的笔记编号!\n"); return; } printf("输入新内容: "); // 漏洞: 可能导致堆溢出 fgets(notes[index].content, notes[index].size + 10, stdin); printf("笔记 #%d 已更新!\n", index); } void print_note() { printf("输入要显示的笔记编号: "); int index; scanf("%d", &index); if (index < 0 || index >= note_count) { printf("无效的笔记编号!\n"); return; } printf("笔记 #%d: %s\n", index, notes[index].content); } int main() { int choice; while (1) { printf("\n=== 笔记管理系统 ===\n"); printf("1. 添加笔记\n"); printf("2. 删除笔记\n"); printf("3. 编辑笔记\n"); printf("4. 显示笔记\n"); printf("5. 退出\n"); printf("选择: "); scanf("%d", &choice); switch (choice) { case 1: add_note(); break; case 2: delete_note(); break; case 3: edit_note(); break; case 4: print_note(); break; case 5: return 0; default: printf("无效选择!\n"); } } return 0; }

此程序存在多个堆漏洞:

  • UAF:删除笔记后未将指针设为NULL
  • 堆溢出:编辑笔记时可写入比分配空间更多的数据
  • Double Free:可以多次释放同一笔记

9.2 真实CVE案例

以下是一些著名的堆相关CVE漏洞:

  • CVE-2019-5736:Docker运行时中的容器逃逸漏洞,涉及堆内存操作
  • CVE-2019-0708:"BlueKeep"远程桌面协议堆漏洞
  • CVE-2020-0796:"SMBGhost"微软SMB协议堆缓冲区溢出
  • CVE-2018-1000001:glibc中的堆管理漏洞

9.3 exploitdb上的实例

Exploit Database上收集了大量堆利用漏洞示例,帮助安全研究者理解利用技术:

  • 浏览器中的堆喷射攻击
  • 服务器软件中的堆溢出
  • 数据库中的UAF漏洞
  • 各种开源项目中的堆管理问题

10. 安全编程实践

10.1 堆内存安全编码

预防堆相关漏洞的最佳编码实践:

  • 始终检查内存分配是否成功
  • 释放内存后立即将指针设为NULL
  • 使用边界检查确保不会溢出
  • 避免使用危险函数(如strcpy、gets)
  • 保持合理的代码结构,避免复杂的内存管理逻辑
// 安全的堆内存操作示例
char *buffer = (char *)malloc(size);
if (!buffer) {
    // 处理分配失败
    return ERROR;
}

// 使用安全的函数,避免溢出
strncpy(buffer, source, size - 1);
buffer[size - 1] = '\0';  // 确保以NULL结尾

// 使用完毕后
free(buffer);
buffer = NULL;  // 防止UAF

10.2 使用安全的库和工具

许多现代库和工具可以帮助提高堆安全性:

  • 智能指针:C++中的unique_ptr、shared_ptr自动管理内存
  • 安全扫描工具:使用静态和动态分析工具检测漏洞
  • 更安全的分配器:考虑使用更安全的堆分配器实现
  • 边界检查工具:如Bounds Checker等运行时检查工具

10.3 代码审计清单

审计代码中的堆安全问题时,应重点关注:

  • malloc/free配对是否正确
  • 是否有可能存在UAF(释放后使用)
  • 是否有可能发生堆溢出
  • 是否可能发生Double Free
  • 是否正确处理内存分配失败的情况
  • 是否存在整数溢出导致分配大小错误

最佳实践: 在开发过程中,结合使用静态分析工具、动态检测工具和手动代码审计,可以大幅降低堆相关漏洞的风险。定期的安全培训和代码审查也是减少堆安全问题的有效手段。

准备好测试您的知识了吗?

完成本课程后,尝试解决与堆利用相关的挑战,巩固您的知识并获得实践经验。

开始挑战