2、C语言基础回顾:指针、数组、字符串、内存布局、函数调用栈、常见数据类型及其边界
C语言之所以在系统编程、嵌入式开发和底层安全领域占据不可替代的地位,核心在于它赋予了程序员对内存的近乎完全的控制权。然而,这种控制权是一把双刃剑:用好了,可以写出高效、精密的代码;用不好,就会引入缓冲区溢出、空指针解引用、内存泄漏等致命漏洞。本章作为安全编程的基石,将系统回顾C语言中与内存安全最相关的六个核心主题:指针、数组、字符串、内存布局、函数调用栈以及数据类型边界。理解这些概念的本质,是后续章节中分析漏洞成因、设计防护方案的前提。
2.1 核心原理:内存布局与指针的本质
在深入代码之前,必须先建立进程内存的全局视图。一个C程序编译链接后,其虚拟地址空间从低地址到高地址依次为:代码段(.text)、已初始化数据段(.data)、未初始化数据段(.bss)、堆(heap)、内存映射区(mmap,用于共享库和动态分配)、栈(stack)以及内核空间。其中,堆向高地址增长(通过malloc/realloc),栈向低地址增长(通过函数调用和局部变量分配)。这两个区域相向生长,中间是未映射的空白区,一旦堆或栈增长过猛跨越该区域,就会发生段错误(Segmentation Fault)。
指针的本质是“保存内存地址的变量”。在64位系统上,指针占用8字节;在32位系统上占用4字节。指针的类型决定了两个关键属性:解引用时读取的字节数(如char*读1字节,int*读4字节)和指针运算时的步长(p+1实际地址增加sizeof(类型))。数组名在表达式中通常退化为指向首元素的指针,但sizeof(数组名)返回整个数组的字节数,这是初学者最容易混淆的点。
2.2 判断方法与操作流程:识别危险代码模式
在代码审计中,我们需要快速识别可能导致内存破坏的模式。以下是一个实用的检查清单,适用于人工审查或静态分析工具规则配置:
| 检查项 | 危险模式 | 安全替代方案 |
|---|---|---|
| 数组访问 | arr[i] 中 i 未验证范围 | 访问前检查 0 ≤ i < 数组长度 |
| 字符串拷贝 | strcpy(dst, src) | strncpy(dst, src, dst_size-1) 并手动补'\0' |
| 字符串拼接 | strcat(dst, src) | snprintf(dst+offset, remaining, "%s", src) |
| 格式化输出 | printf(user_input) | printf("%s", user_input) |
| 整数运算 | unsigned int a, b; a - b 可能回绕 | 先比较大小,或使用更大类型 |
| 指针释放 | free(p); 之后再次使用 p | free后立即置NULL |
| 内存分配 | malloc(size) 未检查返回值 | if (ptr == NULL) 处理错误 |
操作流程上,建议遵循“先验证,后使用”的原则。对于外部输入(网络数据、文件内容、用户参数),必须假设其不可信。具体步骤为:①确定缓冲区容量;②计算所需长度(注意整数溢出);③比较并截断或拒绝;④执行操作;⑤验证结果(如检查字符串是否以'\0'结尾)。
2.3 案例拆解:一个典型的栈缓冲区溢出
我们通过一个经典案例来串联上述概念。假设有一个服务器程序,接收用户输入的用户名并存储到固定大小的缓冲区中:
void process_login(char *user_input) {
char username[64];
int is_admin = 0;
strcpy(username, user_input); // 危险!未检查长度
if (strcmp(username, "admin") == 0) {
is_admin = 1;
}
// ... 后续逻辑
}
背景与约束:该程序运行在x86-32位Linux系统上,栈向低地址增长。局部变量在栈帧中的布局(由编译器决定,但常见顺序)为:username(64字节,低地址)和is_admin(4字节,高地址)。strcpy从user_input逐字节拷贝到username,直到遇到'\0'。
攻击过程:当用户输入超过63字节且不包含'\0'时,strcpy会继续向高地址写入,覆盖is_admin变量。如果精心构造输入,使第65-68字节为0x00000001,则is_admin被改为1,攻击者无需密码即可获得管理员权限。更严重的攻击是继续覆盖保存的EBP和返回地址,将控制流劫持到shellcode。
分析判断:此漏洞的根本原因有三:①strcpy不检查目标缓冲区大小;②局部变量在栈上相邻布局;③未对输入长度做任何验证。
修复方案:
void process_login(char *user_input) {
char username[64];
int is_admin = 0;
// 使用strncpy并确保终止
strncpy(username, user_input, sizeof(username) - 1);
username[sizeof(username) - 1] = '\0';
// 或者更安全:snprintf
// snprintf(username, sizeof(username), "%s", user_input);
if (strcmp(username, "admin") == 0) {
is_admin = 1;
}
}
结果与复盘:修复后,即使输入超长,也只会被截断,不会覆盖相邻变量。但要注意,strncpy在源字符串长度大于目标时不会自动补'\0',因此必须手动设置最后一个字节为'\0'。这个案例揭示了安全编码的第一要务:永远假设输入是不可信的,并且永远验证边界。
strncpy,如果目标缓冲区大小为N,而源字符串恰好为N个字符且无'\0',则strncpy不会写入终止符。因此,dst[N-1] = '\0'这行代码是必须的,不是可选项。
2.4 常见误区与总结复盘
在C语言安全编程中,存在几个高频误区,需要特别警惕:
- 误区一:sizeof(指针) 等于 sizeof(数组)。当数组作为函数参数传递时,退化为指针,sizeof得到的是指针大小(8字节),而非数组大小。因此,函数内无法通过sizeof得知数组长度,必须显式传递长度参数。
- 误区二:有符号整数溢出只是“小问题”。实际上,有符号整数溢出是未定义行为(UB),编译器可能进行任何优化。例如,
if (x + 1 > x)在x为INT_MAX时,编译器可能假设不会溢出而优化掉检查,导致逻辑错误。 - 误区三:char类型默认是有符号的。在ARM等嵌入式平台上,char默认是无符号的。当char用于存储字节数据(如0xFF)并与EOF(-1)比较时,会导致死循环。应使用
unsigned char或uint8_t处理原始字节。 - 误区四:忽略整数提升。两个char类型相加,会先提升为int再运算。如果结果赋回char,可能发生截断。例如
char a=200, b=100; char c = a+b;,a+b=300(int),赋给char时截断为44(300-256)。