背景
研究 redis zmalloc 变更历史时中的一段
基于 11965 里面的 示例代码
这段代码展示了一个包含柔性数组的结构体,以及内存分配和缓冲区溢出的相关问题
#include <stdio.h>
#include <malloc.h>
#include <string.h>
struct a {
char c;
char buf[];
};
int main() {
const int BUFSIZE = 5;
struct a *a = malloc(sizeof(*a) + BUFSIZE);
printf("sizeof(struct a)=%zu \n", sizeof(struct a));
printf("sizeof(*a)=%zu \n", sizeof(*a));
size_t n = malloc_usable_size(a);
printf("a usable size=%zu \n", n);
// Dummy condition to prevent compiler throw warning on compile time
// n will always be n >= BUFSIZE
size_t copy = n >= BUFSIZE ? BUFSIZE + 3 : BUFSIZE;
memcpy(a->buf, "12345678", copy);
printf("%.*s \n", (int) copy, (char*) a->buf);
return 0;
}
gcc main.c
./a.out
能 “正常” 运行
sizeof(struct a)=1
sizeof(*a)=1
a usable size=24
12345678
gcc -O2及以上
gcc -O2 main.c
sizeof(struct a)=1
sizeof(*a)=1
a usable size=24
*** buffer overflow detected ***: terminated
Aborted (core dumped)
ida反编译
gcc -O0
gcc -O2
变成 __memcpy_chk
代码解析
结构体定义
/*
* 柔性数组(Flexible Array Member)示例
* 柔性数组是C99标准引入的特性,允许结构体的最后一个成员是未知大小的数组
*/
struct a {
char c; // 固定大小的成员
char buf[]; // 柔性数组成员
};
buf
是一个柔性数组成员,它不占用结构体的静态大小,而是在运行时通过动态内存分配确定大小
内存分配
malloc
struct a *a = malloc(sizeof(*a) + BUFSIZE);
- 分配的内存大小为
sizeof(struct a) + BUFSIZE
(即1 + 5 = 6
字节) sizeof(struct a)
是 1(因为只有char c
占用 1 字节,柔性数组buf
不占用静态大小
malloc_usable_size
- 这个函数返回实际可用的内存大小(可能比请求的大小更大,因为内存对齐和分配策略)
- 这里返回 24,说明实际分配的内存是 24 字节(这是 glibc 的内存分配策略,通常会有最小分配大小和对齐)
memcpy 和缓冲区溢出
size_t copy = n >= BUFSIZE ? BUFSIZE + 3 : BUFSIZE;
memcpy(a->buf, "12345678", copy);
copy
的值是BUFSIZE + 3 = 8
(因为n = 24 >= 5
)memcpy
尝试拷贝 8 字节(“12345678”)到a->buf
,但a->buf
的实际可用大小是BUFSIZE = 5
,因此会发生缓冲区溢出
glibc 内存分配策略
glibc 的内存分配策略中,最小分配大小(minimum allocation size) 和 内存对齐(alignment) 是两个相关但不同的概念,它们共同影响 malloc
返回的内存块的实际大小
1. 最小分配大小(Minimum Allocation Size)
定义
glibc 的 malloc
不会精确分配请求的字节数,而是会分配一个至少满足请求大小的内存块,并且通常会分配更多(由于内存池管理和效率优化)
原因
- 减少内存碎片(避免频繁分配和释放小内存块)
- 提高内存管理效率(glibc 使用
ptmalloc
,基于chunk
的内存管理机制)
典型值
- 在 64 位系统上,glibc 的
malloc
最小分配通常是 16 或 24 字节(即使你只请求 1 字节)
2. 内存对齐(Alignment)
定义
malloc
返回的内存地址必须满足特定的对齐要求(通常是 2 * sizeof(void*)
或 16
字节对齐,取决于平台)
原因
- 某些 CPU 架构要求特定类型的数据(如
double
、long long
、SSE
指令等)必须对齐访问,否则会引发性能下降或崩溃 - 例如,在 x86-64 上,
malloc
通常返回 16 字节对齐 的地址
对齐规则
- 32 位系统:通常 8 字节对齐
- 64 位系统:通常 16 字节对齐(但可能更高,如 32 字节对齐用于 AVX 指令)
两者的关系
特性 | 最小分配大小 | 内存对齐 |
---|---|---|
作用 | 确保分配的内存块足够大,避免频繁分配小内存 | 确保返回的地址满足 CPU/指令集的对齐要求 |
影响 | 决定 malloc_usable_size 返回的实际可用大小 |
决定返回地址的低位是否为零(如 address % 16 == 0 ) |
典型值 | 16/24 字节(64 位) | 16 字节(64 位) |
是否可调整 | 由 glibc 内部策略决定,不可直接调整 | 可通过 aligned_alloc 或 posix_memalign 自定义对齐 |
相同点
- 两者都会导致
malloc
分配的内存比请求的更大 - 都是出于性能和正确性的考虑
不同点
- 最小分配大小 关注的是内存块的最小大小(防止碎片化)
- 内存对齐 关注的是返回地址的数值是否满足 CPU 要求
malloc 分配的内存比请求的大,超出部分能用吗?
malloc
分配的内存通常会比请求的更大(由于 最小分配大小 和 内存对齐),但 超出请求大小的部分是否能用?
这涉及 C 语言标准、未定义行为(UB)和 实际实现 的问题
1. 标准规定:超出请求部分不可用
C 语言标准(C11/C17)明确规定:
malloc(size)
返回的内存块只能安全访问[0, size)
字节,超出部分属于 未定义行为(Undefined Behavior, UB)
- 即使
malloc_usable_size()
返回更大的值,也不能保证超出部分可用 - 使用超出部分可能导致:
- 缓冲区溢出(Buffer Overflow)
- 内存损坏(Memory Corruption)
- 程序崩溃(Crash)
- 安全漏洞(如被攻击者利用)
2. malloc_usable_size
的作用
malloc_usable_size
(glibc 扩展)返回 实际可用的内存大小,但:
- 它仅用于调试和优化,不能依赖它扩展内存访问范围
- 它的返回值可能比
malloc
请求的更大(由于内存池管理、对齐等) - 但标准并未保证超出部分可安全使用
示例
char *p = malloc(10); // 请求 10 字节
size_t usable = malloc_usable_size(p); // 可能返回 24
- 你可以安全使用
p[0..9]
(请求的 10 字节) p[10..23]
虽然可能“能用”,但属于未定义行为(UB)
3. 为什么“能用”但“不应用”?
不同环境行为不同
- Linux glibc:可能允许访问超出部分(但未来可能改变)
- Windows CRT:可能立即崩溃(如启用
_FORTIFY_SOURCE
) - ASAN(AddressSanitizer):会检测并报错
内存池管理可能破坏数据
malloc
返回的内存块可能被 glibc 用于内部管理(如chunk
结构)- 写入超出部分可能破坏堆元数据,导致
free()
崩溃或安全漏洞
未来可能失效
- 新版本 glibc 可能优化内存分配策略,导致超出部分不再可用
- 编译器优化可能假设你遵守标准,并优化掉“越界”访问
最佳实践
-
永远不要依赖
malloc_usable_size
扩展访问范围 -
如果需要更大内存,应直接
malloc
更大的空间 -
使用
realloc
调整大小(如需动态扩展) -
启用ASAN安全选项检测越界访问
gcc知识
Function Attributes alloc_size
#include <stdio.h>
#include <malloc.h>
int main() {
char *p = malloc(5);
memcpy(p, "1234567890", 10); // 写入 10 字节(超出请求 5)
printf("%s\n", p); // 可能正常打印,但属于 UB!
return 0;
}
仿 redis 之 attribute alloc_size
#include <stdio.h>
#include <malloc.h>
#include <string.h>
struct a {
char c;
char buf[];
};
#define UNUSED(x) ((void)(x))
__attribute__((alloc_size(2), noinline)) void *extend_to_usable(void *ptr, size_t size) {
UNUSED(size);
return ptr;
}
int main() {
const int BUFSIZE = 5;
struct a *a = malloc(sizeof(*a) + BUFSIZE);
printf("sizeof(struct a):%zu \n", sizeof(struct a));
printf("sizeof(*a):%zu \n", sizeof(*a));
size_t n = malloc_usable_size(a);
printf("a usable size: %zu \n", n);
// Dummy condition to prevent compiler throw warning on compile time
// n will always be n >= BUFSIZE
size_t copy = n >= BUFSIZE ? BUFSIZE + 3 : BUFSIZE;
a=extend_to_usable(a, n);
memcpy(a->buf, "12345678", copy);
printf("%.*s \n", (int) copy, (char*) a->buf);
return 0;
}
UNUSED 宏
#define UNUSED(x) ((void)(x))
此宏可以强制的产生一个对该变量的使用操作, 从而消除编译器产生的”unused variable”警告
extend_to_usable
extend_to_usable
是一个纯声明性质的函数, 通过alloc_size(2)
属性标记, 向编译器表明这个函数的第二个参数是 ptr 执行的内存空间的实际大小
此函数通常与redis里的zmalloc_size
函数配合使用, 通过zmalloc_size
函数获取实际分配的内存空间大小后, 使用extend_to_usable
函数向编译器声明后续代码会按照实际分配的空间大小使用
ASan
gcc -fsanitize=address
-fsanitize=address
: 这个选项启用的是 AddressSanitizer (ASan),它是一个动态内存错误检测工具,能够检测到内存越界、使用后释放、堆栈溢出等错误。ASan 通过拦截程序的内存分配和释放操作,插入一些检查代码来实现这些功能
-fsanitize=address
依赖于拦截 malloc
和 free
等函数
_FORTIFY_SOURCE
_FORTIFY_SOURCE
是 GCC 和 glibc 提供的一个 编译时+运行时的安全增强机制,主要用于 检测常见的缓冲区溢出和其他内存相关的安全漏洞
基本概念
_FORTIFY_SOURCE
是一个宏定义,当被启用时,它会:
- 替换某些容易出错的函数调用(如
strcpy
,memcpy
等)为更安全的版本 - 在编译时检查一些明显的缓冲区溢出
- 在运行时检查缓冲区边界
gcc -O2 -Wall -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=3 main.c
FORTIFY_SOURCE
-
3 在 2 的基础上,可以对 malloc 出来的内存进行检测 (gcc> 12)
-
2 在 1 的基础上,可以对栈变量进行检测
-
1 在编译时进行检测
-
优化等级必须要大于 O2 这个选项才会生效