堆的申请与释放
在C语言中,堆内存的申请与释放通常通过标准库函数 malloc
、calloc
、realloc
和 free
来进行。下面是一个基本的示例,通过代码演示如何申请和释放堆内存,并简要说明每个步骤背后的过程。
示例代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main() {
// 1. 申请内存
// 使用 malloc 申请 100 字节的内存
char *buffer = (char *)malloc(100 * sizeof(char));
if (buffer == NULL) {
printf("内存申请失败!\n");
return 1;
}
// 2. 使用申请的内存
// 向申请的内存写入数据
strcpy(buffer, "Hello, Heap!");
printf("Buffer内容: %s\n", buffer);
// 3. 重新分配内存
// 使用 realloc 将内存大小扩展到 200 字节
buffer = (char *)realloc(buffer, 200 * sizeof(char));
if (buffer == NULL) {
printf("内存重新分配失败!\n");
return 1;
}
// 4. 继续使用扩展后的内存
strcat(buffer, " Welcome to dynamic memory allocation.");
printf("Buffer内容: %s\n", buffer);
// 5. 释放内存
free(buffer);
buffer = NULL; // 安全起见,将指针置为 NULL,以防止悬空指针
return 0;
}
解析
-
malloc 申请内存
char *buffer = (char *)malloc(100 * sizeof(char));
malloc
函数用于申请指定大小的内存块,这里申请了100
字节。- 如果申请成功,
malloc
返回指向这块内存的指针;如果失败,返回NULL
。 - 申请的内存块位于堆区,可以在程序中动态使用。
-
使用申请的内存
strcpy(buffer, "Hello, Heap!"); printf("Buffer内容: %s\n", buffer);
- 申请到内存后,可以向其中写入数据并进行操作。
- 这里使用
strcpy
函数将字符串拷贝到申请的内存空间,并输出结果。
-
realloc 重新分配内存
buffer = (char *)realloc(buffer, 200 * sizeof(char));
realloc
函数用于调整已经分配的内存块的大小。- 如果分配成功,返回指向新内存块的指针;如果失败,返回
NULL
。 - 需要注意的是,重新分配可能会移动内存块,如果移动了,旧指针将不再有效。
-
继续使用扩展后的内存
strcat(buffer, " Welcome to dynamic memory allocation."); printf("Buffer内容: %s\n", buffer);
- 使用
realloc
扩展内存块后,可以继续使用这块内存。 - 这里使用
strcat
函数将额外的字符串连接到原有字符串后面,并输出结果。
- 使用
-
free 释放内存
free(buffer); buffer = NULL; // 安全起见,将指针置为 NULL,以防止悬空指针
free
函数用于释放之前通过malloc
、calloc
或realloc
申请的内存。- 释放内存后,最好将指针置为
NULL
,以防止悬空指针(dangling pointer)。
堆结构
堆头结构是堆内存管理中非常关键的一部分,它用来存储关于每个堆块的信息。不同的堆实现可能会有不同的堆头结构,但许多现代堆实现(例如glibc中的ptmalloc2)都有相似的基本结构。
在glibc的ptmalloc2中,每个堆块(chunk)都有一个头部(header),这个头部包含了管理该块所需的关键信息。下面是一个基本的堆块头部结构:
堆头结构
struct malloc_chunk {
size_t prev_size; // 前一个块的大小,仅在前一个块是空闲块时有效
size_t size; // 当前块的大小和一些标志位
};
详细解释
-
prev_size:前一个块的大小
- 这个字段仅在前一个块是空闲块时有效。如果前一个块是已分配块,这个字段没有意义。
- 这个字段的存在是为了在合并空闲块时能够快速找到前一个空闲块的起始地址。
-
size:当前块的大小和一些标志位
- 这个字段存储当前块的大小,包括头部和尾部。
- 最低几位(通常是最低三位)存储了一些标志位,用于指示当前块的状态。
- PREV_INUSE(最低位,LSB):指示前一个块是否在使用中。如果前一个块在使用中,这个位为1;否则为0。
- IS_MMAPPED:指示该块是否是通过
mmap
函数分配的。 - NON_MAIN_ARENA:指示该块是否位于主堆区域之外的其他堆区域。
标志位的具体使用
标志位通常嵌入在 size
字段的最低几位中。例如,在glibc的实现中,最低三位可能被这样使用:
- 最低位(LSB):PREV_INUSE
- 第二位:IS_MMAPPED
- 第三位:NON_MAIN_ARENA
假设我们有一个大小为 0x90 字节的块,并且前一个块正在使用,那么它的 size
字段可能是 0x91
(即,0x90 字节的大小 + 1 表示前一个块在使用中)。
实例
假设有一个堆块,其 size
字段是 0x91
,这表示块的大小为 0x90
字节,并且前一个块正在使用。其内存布局可能如下:
0x00000000: 0x00000000 0x00000091 <-- prev_size = 0x00, size = 0x91 (PREV_INUSE)
0x00000008: 0x00000000 0x00000000 <-- 数据区
0x00000010: 0x00000000 0x00000000
...
0x00000088: 0x00000000 0x00000000
+----------------------+--------+
| prev_size (4 bytes) | 0x0000 |
+----------------------+--------+
| size (4 bytes) | 0x0091 |
+----------------------+--------+
| fd (8 bytes) | | fd 指针指向链表中的下一个空闲块
+----------------------+--------+
| bk (8 bytes) | | bk 指针指向链表中的上一个空闲块
+----------------------+--------+
| data (n-16 bytes) | |
+----------------------+--------+
什么是bin?
在内存管理器中,“bin” 是一种数据结构,用于组织和管理不同大小的内存块。堆内存通常被分成不同大小的块,这些块被存储在不同的 bin 中,以便快速分配和释放。常见的 bin 类型有:
- Fast Bins:用于非常小的内存块(通常小于 64 字节)。
- Small Bins:用于中等大小的内存块(例如 64 字节到 512 字节)。
- Large Bins:用于大块内存(通常大于 512 字节)。
- Unsorted Bins:用于释放后尚未重新分配的内存块。
堆 Bin 的工作原理
1. Fast Bins
Fast bins 用于快速分配和释放小块内存。它不进行合并操作,主要是为了性能考虑。每个 fast bin 都是一个单链表,存储小块内存的头指针。
// 伪代码表示 fast bin 的结构
struct fastbin {
struct fastbin *next; // 指向下一个空闲块
};
2. Small Bins
Small bins 用于中等大小的内存块。与 fast bins 不同,small bins 进行块的合并操作。每个 small bin 是一个双向链表,存储中等大小的空闲内存块。
// 伪代码表示 small bin 的结构
struct smallbin {
struct smallbin *prev; // 指向前一个空闲块
struct smallbin *next; // 指向下一个空闲块
};
3. Large Bins
Large bins 用于大块内存,结构与 small bins 类似,也是双向链表。
// 伪代码表示 large bin 的结构
struct largebin {
struct largebin *prev; // 指向前一个空闲块
struct largebin *next; // 指向下一个空闲块
};
4. Unsorted Bins
Unsorted bins 存储刚刚释放的内存块,这些块尚未被分类到合适的 bin 中。在下一次分配内存时,内存管理器会尝试从 unsorted bin 中找到合适的块然后分配出去。
// 伪代码表示 unsorted bin 的结构
struct unsortedbin {
struct unsortedbin *prev; // 指向前一个空闲块
struct unsortedbin *next; // 指向下一个空闲块
};
基本堆漏洞
堆溢出(Heap Overflow)、Use-After-Free(UAF)和Double Free(双重释放)都是常见的堆内存管理漏洞,容易被攻击者利用来进行恶意操作。下面通过具体的示例代码展示这些漏洞,并简要说明其工作原理和潜在的风险。
1. 堆溢出 (Heap Overflow)
堆溢出发生在程序试图写入超出分配给堆内存块的边界的数据时。下面是一个简单的堆溢出示例:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main() {
char *buffer = (char *)malloc(10 * sizeof(char));
if (buffer == NULL) {
printf("内存申请失败!\n");
return 1;
}
// 漏洞:将过长的数据拷贝到缓冲区,导致堆溢出
strcpy(buffer, "This is a very long string that will overflow the buffer.");
printf("Buffer内容: %s\n", buffer);
free(buffer);
return 0;
}
在这个示例中,strcpy
将超过 10
字节的数据拷贝到 buffer
中,导致堆溢出,可能覆盖相邻的内存区域,造成未定义行为或安全漏洞。
2. Use-After-Free (UAF)
Use-After-Free 漏洞发生在已经释放的内存块仍然被使用。这种行为可能导致程序崩溃或被攻击者利用进行任意代码执行。下面是一个 UAF 的示例:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main() {
char *buffer = (char *)malloc(100 * sizeof(char));
if (buffer == NULL) {
printf("内存申请失败!\n");
return 1;
}
strcpy(buffer, "Hello, World!");
printf("Buffer内容: %s\n", buffer);
// 释放内存
free(buffer);
// 漏洞:释放后的内存仍然被使用
printf("Buffer内容(释放后): %s\n", buffer);
return 0;
}
在这个示例中,buffer
被 free
释放后,仍然被访问。这种行为会导致未定义的结果,可能被攻击者利用进行恶意操作。
3. Double Free(双重释放)
Double Free 漏洞发生在同一块内存被释放了两次。这种行为会破坏堆管理数据结构,可能被攻击者利用进行堆喷射等攻击。下面是一个 Double Free 的示例:
#include <stdio.h>
#include <stdlib.h>
int main() {
char *buffer = (char *)malloc(100 * sizeof(char));
if (buffer == NULL) {
printf("内存申请失败!\n");
return 1;
}
strcpy(buffer, "Hello, World!");
printf("Buffer内容: %s\n", buffer);
// 释放内存
free(buffer);
// 漏洞:再次释放已经释放的内存
free(buffer);
return 0;
}
在这个示例中,buffer
被 free
释放了两次。双重释放会导致堆管理数据结构的不一致,可能被攻击者利用进行任意代码执行。