2、C语言基础回顾:指针、数组、字符串、内存布局、函数调用栈、常见数据类型及其边界

C语言之所以在系统编程、嵌入式开发和底层安全领域占据不可替代的地位,核心在于它赋予了程序员对内存的近乎完全的控制权。然而,这种控制权是一把双刃剑:用好了,可以写出高效、精密的代码;用不好,就会引入缓冲区溢出、空指针解引用、内存泄漏等致命漏洞。本章作为安全编程的基石,将系统回顾C语言中与内存安全最相关的六个核心主题:指针、数组、字符串、内存布局、函数调用栈以及数据类型边界。理解这些概念的本质,是后续章节中分析漏洞成因、设计防护方案的前提。

C语言内存安全核心概念关联图 进程内存布局(低地址→高地址) .text(代码段) .data / .bss(已初始化/未初始化) 堆(Heap)→ 向高地址增长 ↓ 向低地址增长(栈) 栈(Stack) 内核空间(不可直接访问) 栈底在高地址,栈顶在低地址 指针与数组 指针:保存地址的变量(4/8字节) 数组:连续内存块,类型固定 数组名 = 首元素地址(常量) 指针运算:p+1 跳过 sizeof(类型) ⚠ 越界访问:无自动边界检查 指针与数组的混淆是漏洞主要来源 字符串与边界 C字符串 = char数组 + '\0' strcpy/strcat 不检查目标容量 strncpy 可能不补 '\0' 缓冲区溢出 → 覆盖相邻内存 推荐:snprintf / strlcpy 字符串操作是安全重灾区 函数调用栈 栈帧:参数、返回地址、保存寄存器 EBP/RBP:帧指针,ESP/RSP:栈指针 局部变量在栈帧内分配 溢出局部数组 → 覆盖返回地址 攻击链:控制EIP/RIP → RCE 栈溢出攻击的核心原理 数据类型边界 char: -128~127 / 0~255 int: 至少16位,通常32位 unsigned 溢出回绕(wrap around) 有符号溢出 = UB(未定义行为) 整数提升与截断陷阱 整数溢出可导致逻辑绕过 安全编程核心原则 1. 永远验证边界与长度 2. 使用安全函数替代危险函数 3. 警惕隐式类型转换 4. 理解UB并避免依赖 5. 编译期开启防护选项 (-fstack-protector / ASLR / NX)

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(数组名)返回整个数组的字节数,这是初学者最容易混淆的点。

关键认知:指针本身只是一个整数(地址值),但类型系统赋予它语义。如果将一个int*强制转换为char*并解引用,只会读取第一个字节。这种灵活性是C语言强大的根源,也是类型混淆漏洞(Type Confusion)的温床。

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); 之后再次使用 pfree后立即置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)。