前置知识: C

安全函数与边界检查

38 minIntermediate2026/7/21

C11 Annex K安全函数

概述

C 语言自 1972 年诞生以来,始终将性能与简洁置于安全之上。strcpysprintfgets 等不安全函数因缺少边界检查,成为缓冲区溢出(buffer overflow)漏洞的温床。1988 年 Morris 蠕虫利用 fingerd 缓冲区溢出感染数千台主机,首次让世界认识到 C 语言安全问题的严重性。此后 Code Red、Slammer、Blaster 等大规模蠕虫均利用缓冲区溢出攻击 Windows 服务器。

为缓解此类漏洞,C89/C99 标准引入 strncpysnprintf 等带边界版本;微软在 2004 年 MS04-025 后推动 C11 Annex K “Bounds-checking interfaces”,定义 strcpy_ssprintf_smemcpy_s 等带运行时约束检查的安全函数。2007 年 OpenBSD 提出 strlcpy/strlcat,在 BSD 与 macOS 中流行。本文系统化阐述 C 安全函数族、边界检查机制、编译期与运行期加固技术及生产实践。

学习目标

识记层(Remember)

  • 列举 C 标准中带边界检查的字符串函数(strncpystrncatsnprintfstrlcpystrlcat)及其差异。
  • 复述 C11 Annex K 安全函数的命名约定(_s 后缀)与运行时约束处理机制(constraint_handler_t)。
  • 说明 errno_trsize_tRSIZE_MAX 的定义与作用。

理解层(Understand)

  • 解释缓冲区溢出(stack/heap overflow)的内存布局与攻击原理。
  • 阐述 ASLR、DEP/NX、Canary、PIE、RELRO 等编译器与操作系统级加固机制的工作原理。
  • 推导 strncpy 在源串长于 n 时不补 ‘\0’ 的设计动机与陷阱。

应用层(Apply)

  • 使用 snprintfstrlcpy 替换不安全的 sprintfstrcpy 调用。
  • 启用 GCC -D_FORTIFY_SOURCE=2-fstack-protector-strong-fPIE -pie 等编译选项。
  • 通过 AddressSanitizer (ASan)、UndefinedBehaviorSanitizer (UBSan)、Valgrind 检测内存越界。

分析层(Analyze)

  • 对比 C11 Annex K、OpenBSD strlcpy、POSIX.1-2008 snprintf 三套方案在可移植性、性能、安全性上的差异。
  • 分析 -D_FORTIFY_SOURCE 在不同优化级别(-O0/-O2)下的行为差异。
  • 推导 strlen(s) + 1 模式在多线程与信号处理函数中可能引发的整数溢出。

评价层(Evaluate)

  • 评估 CERT C、MISRA C:2012、ISO/IEC TS 17961 三套 C 安全编码标准在企业项目中的适用性。
  • 论证”全部使用 _s 安全函数”策略在跨平台项目中的可行性。
  • 评判 Stack Buffer Overflow 与 Heap Buffer Overflow 在现代利用链中的相对严重性。

创造层(Create)

  • 设计一套面向大型 C 项目的字符串与缓冲区管理封装库,统一安全接口与错误处理。
  • 构建基于静态分析与 fuzzing 的 C 代码安全测试流水线,集成 OSS-Fuzz、libFuzzer、AFL++。
  • 实现一个支持运行时缓冲区完整性检查的调试 allocator(类似 Electric Fence、DUMA)。

历史动机与背景

1. Morris 蠕虫与缓冲区溢出元年

1988 年 11 月 2 日,Cornell 研究生 Robert Tappan Morris 释放 Morris 蠕虫,感染约 6000 台 Unix 主机(占当时 ARPANET 10%)。其利用的漏洞之一就是 fingerd 服务中 gets 调用导致的栈缓冲区溢出。这被认为是 Internet 上首次大规模安全事件,促使 DARPA 成立 CERT/CC(Computer Emergency Response Team)。Morris 蠕虫后,C 语言的安全问题正式进入学术界与工业界视野。

2. Aleph One 与 Smashing the Stack

1996 年 Phrack 杂志第 49 期发表 Aleph One(化名 Elias Levy)的文章《Smashing The Stack For Fun And Profit》,系统化讲解了栈缓冲区溢出利用技术,包括 shellcode 注入、返回地址覆盖、NOP sled 等技术。此文使缓冲区溢出利用从黑盒技术变成大众知识,直接催生了此后十年的安全攻防研究。

3. 微软 SDLC 与安全函数推动

2002 年比尔·盖茨发布”Trustworthy Computing”备忘录,微软全面推行安全开发生命周期(SDLC)。2004 年发布 MS04-025 补丁后,微软推动 C 标准化组织采纳”安全函数库”提案,最终在 C11 标准中以 Annex K 形式纳入。同时微软在 Visual Studio 中通过 #define _CRT_SECURE_NO_WARNINGS_s 函数替代物逐步淘汰不安全 API。

4. 现代缓冲区溢出防御

操作系统与编译器层面引入多重防御:

  • DEP/NX(2003 Windows XP SP2、Linux PaX):数据段不可执行,阻止 shellcode 注入。
  • ASLR(2005 Linux、2007 macOS、2007 Windows Vista):地址空间随机化,提高返回地址预测难度。
  • Stack Canary(1998 Crispin Cowan StackGuard、2003 GCC -fstack-protector):在栈帧插入随机值,溢出时被破坏触发 abort。
  • PIE(2006 Fedora 推广):可执行文件加载基址随机化,与 ASLR 配合。
  • RELRO(2006):GOT 表只读,防止 GOT 覆盖攻击。
  • CFI(2015 LLVM 控制流完整性):限制间接调用目标,防御 ROP/JOP。

这些防御使传统栈溢出利用难度大幅提升,但缓冲区漏洞本身仍是软件缺陷,需从代码层面修复。

形式化定义

1. 缓冲区与边界的形式化

设缓冲区 B=a,nB = \langle a, n \rangle,其中 aa 是起始地址,nn 是字节容量。合法访问操作:

access(B,i,sz) is safe    0ii+szn\text{access}(B, i, sz) \text{ is safe} \iff 0 \le i \land i + sz \le n

不安全访问 access(B,i,sz) where i+sz>n\text{access}(B, i, sz) \text{ where } i + sz > n 即为缓冲区溢出。

2. 字符串长度与缓冲区大小

C 字符串 SS 是以 '\0' 结尾的字节序列,其长度:

strlen(S)=min{i0:S[i]=0}\text{strlen}(S) = \min\{i \ge 0 : S[i] = 0\}

存储 SS 所需最小缓冲区大小为 strlen(S)+1\text{strlen}(S) + 1。安全函数要求显式传递缓冲区大小 nn,并在 n<strlen(S)+1n < \text{strlen}(S) + 1 时截断或报错。

3. 安全函数返回值语义

C11 Annex K 安全函数返回 errno_t,定义为 int:

ret={0成功EINVAL参数无效(如 NULL 指针、大小为 0)ERANGE缓冲区过小\text{ret} = \begin{cases} 0 & \text{成功} \\ \text{EINVAL} & \text{参数无效(如 NULL 指针、大小为 0)} \\ \text{ERANGE} & \text{缓冲区过小} \end{cases}

失败时调用 constraint_handler_t 处理函数,默认调用 abort(),可由 set_constraint_handler_s 自定义。

4. rsize_t 与 RSIZE_MAX

C11 Annex K 引入 rsize_t(通常为 size_t 别名),RSIZE_MAX 为最大合法大小(通常 SIZE_MAX >> 1)。当函数参数声明为 rsize_t 时,传入超过 RSIZE_MAX 的值被视为运行时约束违反,触发约束处理。这防止了”整数溢出导致巨大 size_t”类漏洞。

5. 边界检查的代数模型

带边界检查的 strncpy_s(dst, dstsz, src, count) 满足:

copy_len=min(strlen(src),count,dstsz1)\text{copy\_len} = \min(\text{strlen}(src), \text{count}, \text{dstsz} - 1)

复制完成后强制 dst[copy_len] = '\0',保证结果始终为合法 C 字符串。当 dstsz <= 0dstsz > RSIZE_MAX 时触发约束违反。

理论推导

1. 整数溢出导致 size 为负

C 标准库函数原型多为 void f(void *dst, size_t n)。若 n 来自外部输入且经过运算:

size_t n = a + b + 1;  /* 可能溢出 */
malloc(n);
memcpy(dst, src, n);

a + b + 1 > SIZE_MAX 时,n 回绕到一个小值,malloc 分配小缓冲,memcpy 拷贝大量数据,触发堆溢出。

形式化地,设 a,bNa, b \in \mathbb{N},实际大小 N=a+b+1N = a + b + 1,但计算值 N~=(a+b+1)mod232\tilde{N} = (a + b + 1) \bmod 2^{32}。当 N>232N > 2^{32}N~N\tilde{N} \ll N,导致分配不足。防御:

if (a > SIZE_MAX - b - 1) return ERROR;
size_t n = a + b + 1;

2. strncpy 不补 ‘\0’ 的陷阱

strncpy(dst, src, n) 行为:

  • strlen(src) < n:复制全部 src 并补 ‘\0’ 直到 n。
  • strlen(src) >= n:复制前 n 字节,不补 ‘\0’。

后者导致 dst 非合法 C 字符串,后续 strlenprintf("%s") 可能越界读取。形式化:

dst after strncpy={src00nsrcsrc<nsrc[0:n]srcn(无终止符)\text{dst after strncpy} = \begin{cases} src \cup \underbrace{\text{0}\cdots\text{0}}_{n - |src|} & |src| < n \\ src[0:n] & |src| \ge n \quad (\text{无终止符}) \end{cases}

应使用 snprintf(dst, n, "%s", src)strlcpy(dst, src, n) 替代。

3. 整数转换的符号扩展

int len = get_len();           /* 可能为负 */
size_t n = len;                 /* 负数转为巨大 size_t */
memcpy(dst, src, n);            /* 越界 */

形式化:设 int 范围 [231,2311][-2^{31}, 2^{31}-1],size_t 为无符号 32/64 位。当 len < 0 时,转换后 n = len + 2^{32}len + 2^{64},变成巨大正数。

防御:在转换前显式检查非负。

4. 栈缓冲区溢出的返回地址覆盖

栈帧布局(从高地址到低地址):

[函数参数]
[返回地址]
[保存的 RBP]
[局部变量 buf[N]]   <- 攻击者输入

gets(buf) 读入超过 N 字节时,数据依次覆盖:buf → RBP → 返回地址。攻击者将返回地址覆盖为 shellcode 地址,函数返回时跳转到 shellcode。Stack Canary 在 RBP 与 buf 之间插入随机值,溢出时先破坏 canary,函数返回前检查 canary 不一致即 abort。

5. Return-Oriented Programming (ROP)

DEP/NX 使数据段不可执行,直接注入 shellcode 失效。ROP 攻击将返回地址覆盖为现有可执行代码中的”gadget”序列(以 ret 结尾的几条指令),通过串联 gadget 完成任意操作。设攻击者可控返回地址序列 {r1,r2,,rk}\{r_1, r_2, \dots, r_k\},每个 rir_i 指向一个 gadget,执行流依次跳转:

ret1gadget1ret2gadget2retk\text{ret}_1 \to \text{gadget}_1 \to \text{ret}_2 \to \text{gadget}_2 \to \dots \to \text{ret}_k

ASLR、CFI 等机制通过随机化与控制流验证降低 ROP 可行性。

代码示例

示例 1:不安全函数及其修复

/* 文件: unsafe_vs_safe.c
 * 演示不安全函数与安全函数的差异
 */
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

#define BUF_SIZE 16

/* 反模式:gets 已在 C11 移除,仍存在于旧代码
 * 危险:无边界检查,任意长度输入导致栈溢出
 */
void unsafe_gets(void) {
    char buf[BUF_SIZE];
    if (gets(buf) == NULL) {  /* 编译告警,链接可能失败 */
        return;
    }
    printf("read: %s\n", buf);
}

/* 正确做法:使用 fgets,指定最大读取长度
 * 注意:fgets 会保留换行符,需手动处理
 */
void safe_fgets(void) {
    char buf[BUF_SIZE];
    if (fgets(buf, sizeof(buf), stdin) == NULL) {
        return;
    }
    /* 移除可能的换行符 */
    size_t len = strlen(buf);
    if (len > 0 && buf[len - 1] == '\n') {
        buf[len - 1] = '\0';
    }
    printf("read: %s\n", buf);
}

/* 反模式:strcpy 不检查目标缓冲区大小 */
void unsafe_strcpy(const char *src) {
    char buf[BUF_SIZE];
    strcpy(buf, src);  /* src 长于 BUF_SIZE 时溢出 */
    printf("copied: %s\n", buf);
}

/* 正确做法 1:使用 snprintf(可移植) */
void safe_snprintf(const char *src) {
    char buf[BUF_SIZE];
    snprintf(buf, sizeof(buf), "%s", src);  /* 自动截断并补 '\0' */
    printf("copied: %s\n", buf);
}

/* 正确做法 2:使用 strlcpy(BSD/macOS) */
void safe_strlcpy(const char *src) {
    char buf[BUF_SIZE];
    strlcpy(buf, src, sizeof(buf));  /* 截断并补 '\0' */
    printf("copied: %s\n", buf);
}

/* 正确做法 3:使用 C11 Annex K strcpy_s(MSVC/glibc 可选) */
void safe_strcpy_s(const char *src) {
    char buf[BUF_SIZE];
    errno_t rc = strcpy_s(buf, sizeof(buf), src);
    if (rc != 0) {
        fprintf(stderr, "strcpy_s failed: %d\n", rc);
        return;
    }
    printf("copied: %s\n", buf);
}

示例 2:动态缓冲区分配

/* 文件: dyn_buf.c
 * 安全的动态缓冲区字符串拼接
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdarg.h>

/* 安全的 sprintf 替代:动态分配缓冲区
 * 返回值:成功 0,失败 -1
 * 输出参数:out_str 指向新分配的字符串,调用者需 free
 */
int safe_asprintf(char **out_str, const char *fmt, ...) {
    va_list ap;
    va_start(ap, fmt);

    /* 第一遍:计算所需长度 */
    va_list ap_copy;
    va_copy(ap_copy, ap);
    int len = vsnprintf(NULL, 0, fmt, ap_copy);
    va_end(ap_copy);

    if (len < 0) {
        va_end(ap);
        return -1;
    }

    /* 检查整数溢出:len + 1 可能溢出 */
    if ((size_t)len == SIZE_MAX) {
        va_end(ap);
        return -1;
    }

    char *buf = malloc((size_t)len + 1);
    if (!buf) {
        va_end(ap);
        return -1;
    }

    /* 第二遍:实际写入 */
    vsnprintf(buf, (size_t)len + 1, fmt, ap);
    va_end(ap);

    *out_str = buf;
    return 0;
}

int main(void) {
    char *result = NULL;
    if (safe_asprintf(&result, "name=%s age=%d", "Alice", 30) == 0) {
        printf("%s\n", result);
        free(result);
    }
    return 0;
}

示例 3:边界检查读取

/* 文件: safe_read.c
 * 安全的网络数据读取
 * 确保不超过缓冲区容量
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>

/* 安全读取:确保不超过 buf_size 字节
 * 返回值:>0 已读字节数,0 EOF,-1 出错
 */
ssize_t safe_read(int fd, void *buf, size_t buf_size) {
    if (buf_size == 0) {
        errno = EINVAL;
        return -1;
    }
    ssize_t n = read(fd, buf, buf_size);
    return n;
}

/* 安全读取定长数据:循环读取直到 size 字节或 EOF */
ssize_t read_full(int fd, void *buf, size_t size) {
    size_t total = 0;
    char *p = (char *)buf;
    while (total < size) {
        ssize_t n = read(fd, p + total, size - total);
        if (n < 0) {
            if (errno == EINTR) continue;
            return -1;
        }
        if (n == 0) break;  /* EOF */
        total += (size_t)n;
    }
    return (ssize_t)total;
}

/* 安全写入定长数据 */
ssize_t write_full(int fd, const void *buf, size_t size) {
    size_t total = 0;
    const char *p = (const char *)buf;
    while (total < size) {
        ssize_t n = write(fd, p + total, size - total);
        if (n < 0) {
            if (errno == EINTR) continue;
            return -1;
        }
        if (n == 0) break;
        total += (size_t)n;
    }
    return (ssize_t)total;
}

/* 带长度前缀的消息读取(常见网络协议) */
int read_message(int fd, char **out_buf, size_t *out_len) {
    uint32_t net_len;
    if (read_full(fd, &net_len, sizeof(net_len)) != (ssize_t)sizeof(net_len)) {
        return -1;
    }

    /* 主机字节序转换 */
    uint32_t msg_len = ntohl(net_len);

    /* 防御性检查:限制最大消息长度,防止 DoS */
    if (msg_len > 16U * 1024U * 1024U) {
        errno = EMSGSIZE;
        return -1;
    }

    char *buf = malloc(msg_len + 1);
    if (!buf) return -1;

    if (read_full(fd, buf, msg_len) != (ssize_t)msg_len) {
        free(buf);
        return -1;
    }
    buf[msg_len] = '\0';

    *out_buf = buf;
    *out_len = msg_len;
    return 0;
}

示例 4:启用 AddressSanitizer

/* 文件: asan_demo.c
 * 演示 AddressSanitizer 检测越界
 * 编译: gcc -fsanitize=address -g -O0 asan_demo.c -o asan_demo
 * 运行: ./asan_demo
 */
#include <stdio.h>
#include <string.h>

int main(void) {
    char buf[10];
    /* 故意越界 1 字节 */
    memset(buf, 'A', 11);
    printf("buf = %.*s\n", 10, buf);
    return 0;
}

/* 运行时 ASan 报告示例:
 * ==12345==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x...
 * WRITE of size 11 at 0x... thread T0
 *     #0 0x... in main asan_demo.c:9
 *     ...
 * Address 0x... is located in stack of thread T0 at offset 0x... in frame
 *     #0 0x... in main asan_demo.c:6
 *   This frame has 1 object(s):
 *     [0x..., 0x...) 'buf' (line 7) <== Memory access at offset 0x... overflows this variable
 */

示例 5:启用 FORTIFY_SOURCE

/* 文件: fortify_demo.c
 * 演示 _FORTIFY_SOURCE 在编译期与运行期检查
 * 编译: gcc -D_FORTIFY_SOURCE=2 -O2 fortify_demo.c -o fortify_demo
 */
#include <stdio.h>
#include <string.h>

void f(const char *s) {
    char buf[8];
    /* FORTIFY_SOURCE=2 下编译期检查:
     * 若 s 来源已知且长于 8,编译告警;
     * 运行期:__strcpy_chk 在运行时检测溢出,调用 abort
     */
    strcpy(buf, s);
    printf("%s\n", buf);
}

int main(void) {
    /* 编译期告警:__builtin___strcpy_chk 警告 will always overflow */
    f("hello world this is too long");
    return 0;
}

示例 6:自定义 constraint handler

/* 文件: constraint_handler.c
 * 演示 C11 Annex K 自定义约束处理
 */
#define __STDC_WANT_LIB_EXT1__ 1
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

/* 自定义约束处理函数 */
static void my_handler(const char *restrict msg,
                       void *restrict ptr,
                       errno_t error) {
    fprintf(stderr, "[constraint violation] %s (errno=%d, ptr=%p)\n",
            msg ? msg : "(null)", error, ptr);
    /* 实际产品可记录日志后 abort 或 longjmp */
    abort();
}

int main(void) {
    /* 设置约束处理函数 */
    set_constraint_handler_s(my_handler);

    char buf[8];
    /* 源串过长,触发约束处理 */
    errno_t rc = strcpy_s(buf, sizeof(buf), "this is too long");
    if (rc != 0) {
        printf("strcpy_s returned %d\n", rc);
    }
    return 0;
}

示例 7:自定义安全字符串库

/* 文件: safe_str.h
 * 跨平台安全字符串操作封装
 */
#ifndef SAFE_STR_H
#define SAFE_STR_H

#include <stddef.h>
#include <stdarg.h>

/* 安全字符串复制:类似 strlcpy 但可移植
 * 返回值:实际复制长度(不含终止符),若 dst_size == 0 返回 0
 */
static inline size_t safe_strlcpy(char *dst, const char *src, size_t dst_size) {
    if (dst_size == 0) return 0;
    size_t i = 0;
    for (; i < dst_size - 1 && src[i] != '\0'; i++) {
        dst[i] = src[i];
    }
    dst[i] = '\0';
    return i;
}

/* 安全字符串拼接:保证结果始终以 '\0' 结尾
 * 返回值:拼接后总长度(不含终止符),若超出返回 dst_size
 */
static inline size_t safe_strlcat(char *dst, const char *src, size_t dst_size) {
    if (dst_size == 0) return 0;
    size_t dst_len = 0;
    while (dst_len < dst_size && dst[dst_len] != '\0') dst_len++;
    if (dst_len == dst_size) return dst_size;

    size_t i = 0;
    for (; dst_len + i < dst_size - 1 && src[i] != '\0'; i++) {
        dst[dst_len + i] = src[i];
    }
    dst[dst_len + i] = '\0';
    return dst_len + i;
}

/* 安全格式化:返回实际写入长度(不含终止符),失败返回 -1 */
static inline int safe_snprintf(char *buf, size_t size, const char *fmt, ...) {
    if (size == 0) return 0;
    va_list ap;
    va_start(ap, fmt);
    int n = vsnprintf(buf, size, fmt, ap);
    va_end(ap);
    if (n < 0 || (size_t)n >= size) {
        /* 截断或出错 */
        buf[size - 1] = '\0';
        return -1;
    }
    return n;
}

/* 安全内存分配:检查乘法溢出
 * 返回值:成功返回分配的指针,失败返回 NULL
 */
static inline void *safe_calloc(size_t nmemb, size_t size) {
    /* 检查 nmemb * size 是否溢出 */
    if (nmemb != 0 && size > (size_t)-1 / nmemb) {
        return NULL;
    }
    return calloc(nmemb, size);
}

#endif /* SAFE_STR_H */

对比分析

1. 字符串函数族横向对比

函数标准是否补 ‘\0’是否触发约束可移植性典型用途
strcpyC89全平台已弃用
strncpyC89否(源长时)全平台历史代码
strlcpyBSDBSD/Linux/macOS跨平台推荐
strcpy_sC11 Annex KMSVC/C11 可选Windows 推荐
snprintfC99全平台通用
sprintfC89全平台已弃用
sprintf_sC11 Annex KMSVC/C11 可选Windows 推荐

2. 编译选项加固对比

选项防御目标性能开销兼容性
-D_FORTIFY_SOURCE=1/2/3标准库函数越界微小GCC/Clang
-fstack-protector栈溢出函数序言/尾声GCC/Clang
-fstack-protector-strong栈溢出(更广覆盖)中等GCC/Clang
-fstack-protector-all栈溢出(全部函数)较大GCC/Clang
-fPIE -pie地址随机化微小Linux
-Wl,-z,relro,-z,nowGOT 覆盖启动稍慢Linux
-fsanitize=address内存错误(测试用)2-5 倍GCC/Clang
-fsanitize=undefined未定义行为微小GCC/Clang
-fcf-protection=full控制流完整性1-3%GCC/Clang (x86)
-mbranch-protection=standardARM BTI/PAC微小ARM64

3. 安全编码标准对比

标准发布机构范围工具支持
CERT CCERT/SEI通用 C 安全Coverity, cppcheck
MISRA C:2012MISRA汽车/嵌入式PC-lint, Polyspace
ISO/IEC TS 17961:2013ISOC 代码安全多种商业工具
CWE Top 25MITRE通用漏洞分类NIST SAMATE
ISO 26262ISO汽车功能安全Polyspace, QA-C
IEC 62304IEC医疗软件静态分析工具

4. 运行时检查工具对比

工具检测目标性能开销平台
AddressSanitizer (ASan)内存越界、UAF、double-free2xGCC/Clang
MemorySanitizer (MSan)未初始化内存读取3xClang
UndefinedBehaviorSanitizer整数溢出、UB微小GCC/Clang
ThreadSanitizer (TSan)数据竞争5-15xGCC/Clang
Valgrind/Memcheck内存错误10-30xLinux/macOS
Electric Fence堆越界中等Linux
libefence/DUMA堆越界中等Linux

常见陷阱与反模式

1. off-by-one 错误

事故案例:某 HTTP 服务器在解析 Content-Length 时分配 len 字节缓冲,但 memcpy(buf, body, len + 1) 多拷贝一字节,长期运行导致堆破坏。

反模式:

char *buf = malloc(len);   /* 缓冲长度 len */
memcpy(buf, src, len + 1); /* 拷贝 len + 1 字节,越界 */

正确做法:统一”长度 vs 容量”语义,分配 len + 1(为 ‘\0’),拷贝 len

2. 混淆 size 与 length

事故案例:某日志库将 size_t length 误传给 snprintf(dst, length, ...),导致 dst 缺 ‘\0’ 终止符,后续 strcat 越界。

正确做法:文档与命名严格区分:长度不含终止符,容量含终止符。

3. sizeof(指针) 误用

反模式:

void f(char *buf) {
    snprintf(buf, sizeof(buf), "%s", src);  /* sizeof(char*) 而非缓冲区大小 */
}

sizeof(buf) 在指针退化为 4 或 8 字节,绝非缓冲区容量。函数无法从指针推导缓冲区大小,必须显式传递。

正确做法:

void f(char *buf, size_t buf_size) {
    snprintf(buf, buf_size, "%s", src);
}

4. 整数转换有符号错误

事故案例:某 PDF 解析器从文件读 int32 长度字段,直接转 size_t,malloc 分配巨大内存,触发 OOM。

正确做法:

int32_t raw_len = read_int32();
if (raw_len < 0) return ERROR;
if ((uint32_t)raw_len > MAX_LEN) return ERROR;
size_t len = (size_t)raw_len;

5. 多线程下 strlen 不安全

事故案例:线程 A 调用 strlen(s),线程 B 同时修改 s 字符串末尾字节,导致 strlen 返回错误长度。

正确做法:字符串在多线程下应只读;需修改时使用 mutex 保护或使用不可变字符串。

6. snprintf 返回值误用

反模式:

char buf[8];
int n = snprintf(buf, sizeof(buf), "%s", long_str);
/* n 是"想写入"的长度,可能 >= sizeof(buf)
 * 后续 snprintf(buf + n, sizeof(buf) - n, ...) 可能溢出
 */

正确做法:snprintf 返回值若 >= size 表示截断,后续操作应基于实际写入长度 min(n, size-1)

7. strncpy 后忘补 ‘\0’

反模式:

char buf[8];
strncpy(buf, src, sizeof(buf));  /* src 长于 8 时无终止符 */
printf("%s", buf);  /* 越界读 */

正确做法:strncpy 后显式 buf[sizeof(buf) - 1] = '\0'; 或改用 strlcpy/snprintf。

8. sscanf 无边界检查

反模式:

char name[16];
sscanf(input, "%s", name);  /* %s 不带宽度,任意长度越界 */

正确做法:

sscanf(input, "%15s", name);  /* 最多读 15 字符,保留 '\0' */

工程实践

1. 编译选项加固清单(Linux GCC/Clang)

# 生产环境推荐编译选项
CFLAGS="-O2 -g \
    -D_FORTIFY_SOURCE=2 \
    -fstack-protector-strong \
    -fstack-clash-protection \
    -fPIE \
    -Wl,-pie \
    -Wl,-z,relro \
    -Wl,-z,now \
    -Wl,-z,noexecstack \
    -fcf-protection=full \
    -fexceptions \
    -Wformat -Wformat-security \
    -Werror=format-security \
    -Werror=implicit-function-declaration \
    -Wall -Wextra -Wpedantic"

# 测试环境追加 sanitizer
CFLAGS_DEBUG="-O0 -g -fsanitize=address,undefined -fno-omit-frame-pointer"

2. CI 集成静态分析

# .github/workflows/security.yml 示例
name: Security
on: [push, pull_request]
jobs:
  analyze:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - run: sudo apt-get install -y cppcheck flawfinder clang-tidy
      - name: cppcheck
        run: cppcheck --enable=all --inconclusive --suppress=missingInclude
                  --error-exitcode=1 --inline-suppr src/
      - name: clang-tidy
        run: run-clang-tidy -checks='-*,bugprone-*,cert-*,cppcoreguidelines-*,security-*,clang-analyzer-*' src/
      - name: flawfinder
        run: flawfinder --error-level=3 src/
      - name: build with ASan
        run: make CC=gcc CFLAGS="-O0 -g -fsanitize=address -fno-omit-frame-pointer"
      - name: run tests
        run: ./build/tests
      - name: build with UBSan
        run: make clean && make CC=clang CFLAGS="-O0 -g -fsanitize=undefined -fno-sanitize-recover=all"
      - name: run tests
        run: ./build/tests

3. Fuzzing 集成

/* 文件: fuzz_parser.c
 * libFuzzer 入口,测试解析器安全性
 * 编译: clang -fsanitize=fuzzer,address -g -O1 fuzz_parser.c -o fuzz_parser
 */
#include <stdint.h>
#include <stddef.h>
#include <string.h>

extern int parse_input(const uint8_t *data, size_t size);

int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    /* 限制输入大小,避免 OOM */
    if (size > 65536) return 0;
    parse_input(data, size);
    return 0;
}

4. 安全 allocator 封装

/* 文件: safe_alloc.c
 * 带边界检查与 canary 的 allocator
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>

#define CANARY 0xDEADBEEFCAFEBABEULL
#define MAX_ALLOC (1ULL << 30)  /* 1GB 上限 */

typedef struct {
    uint64_t canary_pre;     /* 前导 canary */
    size_t user_size;        /* 用户请求大小 */
    size_t alignment;        /* 对齐填充 */
    /* 用户数据紧随其后 */
} alloc_header_t;

void *safe_malloc(size_t size) {
    if (size == 0 || size > MAX_ALLOC) return NULL;
    /* 分配 size + sizeof(header) + sizeof(canary) */
    size_t total = sizeof(alloc_header_t) + size + sizeof(uint64_t);
    if (total < size) return NULL;  /* 溢出检查 */

    char *raw = malloc(total);
    if (!raw) return NULL;

    alloc_header_t *hdr = (alloc_header_t *)raw;
    hdr->canary_pre = CANARY;
    hdr->user_size = size;

    /* 尾部 canary */
    uint64_t *post = (uint64_t *)(raw + sizeof(alloc_header_t) + size);
    *post = CANARY;

    return raw + sizeof(alloc_header_t);
}

void safe_free(void *ptr) {
    if (!ptr) return;
    char *user = (char *)ptr;
    alloc_header_t *hdr = (alloc_header_t *)(user - sizeof(alloc_header_t));

    /* 检查前导 canary */
    if (hdr->canary_pre != CANARY) {
        fprintf(stderr, "safe_free: heap corruption (pre-canary)\n");
        abort();
    }

    /* 检查尾部 canary */
    uint64_t *post = (uint64_t *)(user + hdr->user_size);
    if (*post != CANARY) {
        fprintf(stderr, "safe_free: buffer overflow detected\n");
        abort();
    }

    /* 清零内存,防止 use-after-free */
    memset(hdr, 0, sizeof(alloc_header_t) + hdr->user_size);
    free(hdr);
}

5. 输入验证清单

数据类型验证规则
字符串长度len <= MAX_LEN
整数范围INT_MIN <= x <= INT_MAX
数组索引0 <= i < array_size
文件路径拒绝 ..、绝对路径、特殊字符
用户输入长度、字符集、格式三重检查
网络消息长度前缀 + 上限 + 校验和
SQL/命令参数化查询,禁止字符串拼接
HTML/JS转义输出,使用白名单

案例研究

案例 1:OpenSSL Heartbleed(CVE-2014-0160)

2014 年 4 月披露的 OpenSSL Heartbleed 漏洞允许攻击者读取服务器进程内存,泄露私钥与用户会话。根因是 TLS heartbeat 扩展实现中,服务端直接信任客户端发送的 payload 长度字段,未与实际数据长度对比:

/* 漏洞代码(简化) */
memcpy(response, request + 1, request->length);  /* length 来自客户端 */

修复:严格校验 request->length <= 实际接收长度。该漏洞导致全球 17% HTTPS 网站受影响,直接经济损失估计数亿美元,是 C 语言边界检查缺失的标志性案例。

案例 2:Stagefright(CVE-2015-1538)

2015 年 Android 多媒体库 libstagefright 在解析 MP4 视频时,整数溢出导致堆缓冲区溢出,攻击者通过 MMS 发送恶意视频即可远程执行代码。根因:

size_t size = width * height * 3 / 2;  /* 32 位乘法溢出 */
uint8_t *buf = malloc(size);            /* 分配过小 */
memcpy(buf, data, real_size);           /* 越界写入 */

修复:所有尺寸计算使用 __int128 或显式溢出检查。该漏洞促使 Google 推出 Android 月度安全更新机制。

案例 3:Linux Kernel get_user(CVE-2016-0728)

Linux 内核 keyctl 系统调用中,引用计数使用 int 类型,长时间反复调用导致整数溢出,使计数变为 0 后再次释放,触发 use-after-free,可本地提权。修复:将引用计数改为 atomic_t 并使用 refcount_t 检测溢出。

案例 4:SQLite FTS3 越界(CVE-2017-15369)

SQLite FTS3 全文搜索模块在处理特殊查询时,内部偏移计算错误,导致堆越界读取。根因是 intsize_t 混用,符号扩展导致巨大偏移。修复:统一使用 size_t 并增加边界检查。该漏洞影响所有使用 SQLite 的应用(包括 iOS、Android 系统组件)。

习题

基础题

题 1:strcpystrncpystrlcpystrcpy_s 四者的核心区别是什么?

参考答案:

  • strcpy:不检查边界,源串过长时溢出。
  • strncpy:有边界,但源串长于 n 时不补 ‘\0’。
  • strlcpy:有边界,源串长时截断并补 ‘\0’,返回源串长度。
  • strcpy_s:有边界与约束处理,失败时调用 constraint_handler 并设置 errno

题 2:为什么 gets 在 C11 标准中被移除?

参考答案:gets 无法限制读取长度,任意输入都可能导致栈溢出,无法安全使用。C11 标准正式移除,改用 fgets 替代。

题 3:-D_FORTIFY_SOURCE=2-D_FORTIFY_SOURCE=1 的区别?

参考答案:=1 仅在编译期可确定缓冲区大小时检查;=2 还包括运行期检查(如缓冲区大小来自变量),覆盖更广。

进阶题

题 4:分析以下代码的安全问题并给出修复:

void log_msg(const char *user, const char *msg) {
    char buf[256];
    strcpy(buf, user);
    strcat(buf, ": ");
    strcat(buf, msg);
    printf("%s\n", buf);
}

参考答案要点:

  • 问题 1:strcpy/strcat 无边界检查,user 或 msg 过长导致溢出。
  • 问题 2:多次 strcat 性能差,每次重新计算长度。
  • 修复:使用 snprintf(buf, sizeof(buf), "%s: %s", user, msg)

题 5:设计一个安全的字符串分割函数,要求:

  • 输入:源字符串、分隔符、输出数组与容量。
  • 输出:实际分割数,每段长度不超过输出缓冲区。

参考答案要点:

  • 每段使用 strlcpy/snprintf 复制,确保终止符。
  • 检查分割数不超过数组容量,超出则截断并报告。
  • 处理连续分隔符(根据需求视为空段或跳过)。
  • 处理源串为空、分隔符为空等边界情况。

挑战题

题 6:某网络协议消息格式为 |4 字节长度|N 字节 payload|,长度为 payload 字节数。设计一个安全的接收函数,要求:

  • 长度字段使用大端字节序。
  • 最大消息长度 1MB,超过则报错。
  • payload 可能包含任意字节,包括 '\0'

参考答案要点:

int recv_message(int fd, uint8_t **out_payload, uint32_t *out_len) {
    uint8_t len_buf[4];
    if (read_full(fd, len_buf, 4) != 4) return -1;
    uint32_t len = ((uint32_t)len_buf[0] << 24) |
                   ((uint32_t)len_buf[1] << 16) |
                   ((uint32_t)len_buf[2] << 8) |
                   ((uint32_t)len_buf[3]);
    if (len > 1024U * 1024U) return -1;
    uint8_t *buf = malloc(len);
    if (!buf) return -1;
    if (read_full(fd, buf, len) != (ssize_t)len) {
        free(buf);
        return -1;
    }
    *out_payload = buf;
    *out_len = len;
    return 0;
}

题 7:分析 ASan 与 Valgrind 的检测能力差异,说明在 CI 中如何选择。

参考答案要点:

  • ASan:编译期插入,运行期开销 2x,可检测栈/堆/全局越界、UAF、double-free,但要求重新编译。
  • Valgrind:二进制插桩,运行期开销 10-30x,可检测内存泄漏、未初始化使用,但不重新编译,且不检测栈越界。
  • CI 选择:开发与测试期用 ASan(快),发布前用 Valgrind 检测泄漏。

参考文献

[1] Seacord, R. C. 2013. Secure Coding in C and C++, 2nd edition. Addison-Wesley. ISBN 978-0-321-82213-0.

[2] ISO/IEC. 2011. ISO/IEC 9899:2011 - Programming languages - C. ISO. https://www.iso.org/standard/57853.html

[3] ISO/IEC. 2013. ISO/IEC TS 17961:2013 - Information technology - Programming languages, their environments and system software interfaces - C secure coding rules. ISO. https://www.iso.org/standard/61133.html

[4] CERT. 2014. CERT C Secure Coding Standard, 2nd edition. Addison-Wesley. ISBN 978-0-321-82377-9.

[5] Aleph One. 1996. Smashing the stack for fun and profit. Phrack Magazine 7, 49 (November 1996). http://phrack.org/issues/49/14.html

[6] Cowan, C. et al. 1998. StackGuard: automatic adaptive detection and prevention of buffer-overflow attacks. In Proceedings of the 7th USENIX Security Symposium (SECURITY ‘98). USENIX Association. https://www.usenix.org/legacy/events/sec98/cowan.html

[7] Shacham, V. et al. 2004. On the effectiveness of address-space randomization. In Proceedings of the 11th ACM Conference on Computer and Communications Security (CCS ‘04), 62-74. DOI: https://doi.org/10.1145/1030083.1030094

[8] Roemer, R. et al. 2012. Return-oriented programming: systems, languages, and applications. ACM Transactions on Information and System Security 15, 1 (March 2012), 1-34. DOI: https://doi.org/10.1145/2133375.2133377

[9] Serebryany, K. et al. 2012. AddressSanitizer: a fast address sanity checker. In Proceedings of the 2012 USENIX Annual Technical Conference (USENIX ATC ‘12). USENIX Association. https://www.usenix.org/conference/atc12/technical-sessions/presentation/serebryany

[10] Miller, T. et al. 2014. The Heartbleed bug. CVE-2014-0160. https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2014-0160

延伸阅读

官方文档

经典教材

  • Robert C. Seacord. Secure Coding in C and C++, 2nd ed., Addison-Wesley, 2013.
  • Michael Howard, David LeBlanc. Writing Secure Code, 2nd ed., Microsoft Press, 2003.
  • Jon Erickson. Hacking: The Art of Exploitation, 2nd ed., No Starch Press, 2008.
  • Aleph One. Smashing the Stack for Fun and Profit, Phrack 49, 1996.

前沿论文与资料

开源项目与工具

总结

安全函数与边界检查是 C 代码防御内存安全漏洞的核心手段。本文从 Morris 蠕虫到 Heartbleed 的历史脉络出发,推导了缓冲区溢出、整数溢出、整数转换等核心漏洞的数学模型,提供了从安全函数替换、编译选项加固、ASan/UBSan 检测到自定义 allocator 的多个生产级代码示例,分析了 8 类常见陷阱与生产事故案例,并通过 OpenSSL、Stagefright、Linux Kernel、SQLite 四个真实案例展示边界检查缺失的严重后果。

掌握本文内容后,读者应能:

  1. 识别并替换代码中的不安全函数(strcpysprintfgets 等)。
  2. 启用 GCC/Clang 的 -D_FORTIFY_SOURCE-fstack-protector-strong、ASan 等加固选项。
  3. 编写带边界检查与整数溢出检测的安全代码。
  4. 设计 CI 流水线,集成静态分析、fuzzing、sanitizer 测试。
  5. 排查生产环境中的内存越界、UAF、double-free 等安全漏洞。

C 语言因历史包袱难以从根本上消除内存安全问题,但通过严格的编码规范、编译器加固、运行时检测三重防御,可使生产代码达到接近内存安全语言的可靠性水平。Rust、Go 等内存安全语言的兴起对 C 提出了挑战,但 C 在系统编程、嵌入式、性能敏感领域的地位短期难以撼动,掌握 C 安全编程仍是软件工程师的核心竞争力。

返回入门指南