4、字符串安全:strcpy、strcat、sprintf等危险函数分析,安全替代函数(strncpy、strncat、snprintf)的正确使用

在C语言开发的各类系统中,字符串处理函数一直是缓冲区溢出漏洞的头号源头。根据MITRE发布的CWE Top 25最危险软件弱点榜单,CWE-121(栈缓冲区溢出)与CWE-122(堆缓冲区溢出)常年位居前列,而它们的直接诱因绝大多数是对strcpy、strcat、sprintf等非边界检查函数的不当使用。这些函数在C标准库中存在已久,设计初衷是假定调用者能够保证目标缓冲区足够大,但现实中的输入长度往往不可控——用户输入、网络数据包、配置文件解析结果都可能超出预期。一旦发生溢出,轻则程序崩溃,重则被攻击者利用改写返回地址、注入恶意代码,造成远程代码执行。本章将深入剖析这三类危险函数的缺陷机理,并系统讲解strncpy、strncat、snprintf等安全替代函数的正确用法、边界陷阱与工程实践准则。

字符串安全:危险函数 vs 安全替代函数 危险函数 strcpy / strcat / sprintf 无边界检查,依赖调用者保证 溢出 → 栈/堆破坏 → RCE 替换 安全替代函数 strncpy / strncat / snprintf 显式指定最大写入长度 n 限制写入量,防止越界 关键陷阱 strncpy 不保证 NUL 结尾 snprintf 返回值可能 > n strncat 的 n 是剩余空间 最佳实践准则 ① 目标缓冲区大小 = sizeof(dst) - 1,始终预留 NUL 位置 ② 调用后手动置 dst[n-1] = '\0',确保字符串终止 ③ 检查 snprintf 返回值:若 >= n 则说明输出被截断,需处理错误 ④ 优先使用 strlcpy/strlcat(若平台支持),或封装安全包装函数

上图展示了本章的核心逻辑:左侧是必须避免的危险函数,右侧是推荐使用的安全替代函数,顶部标注了替代过程中最容易踩中的陷阱,底部则汇总了工程实践中的关键准则。接下来,我们将逐一拆解每个函数的缺陷细节与正确用法。

4.1 strcpy 的危险性分析与 strncpy 的正确使用

4.1.1 核心原理:strcpy 为何危险

strcpy(dst, src) 将源字符串从 src 逐字节复制到 dst,直到遇到 '\0' 为止。它完全不检查 dst 的容量,也不限制复制长度。如果 src 的实际长度(含结尾的 '\0')超过 dst 的分配空间,多余的数据就会写入 dst 之后的内存区域。在栈上,这可能导致返回地址被覆盖;在堆上,则可能破坏堆管理元数据。攻击者精心构造超长输入,就能精确控制被覆盖的内容,实现任意代码执行。

4.1.2 判断方法与操作流程:strncpy 的正确姿势

strncpy(dst, src, n) 最多从 src 复制 n 个字符到 dst。但这里有一个关键陷阱:如果 src 的长度大于等于 n,则 dst 不会以 '\0' 结尾。因此,正确用法必须遵循以下三步:

  1. 确定目标缓冲区大小 dst_size(例如 sizeof(dst))。
  2. 调用 strncpy(dst, src, dst_size - 1),预留一个字节给 NUL。
  3. 手动执行 dst[dst_size - 1] = '\0'; 确保字符串终止。

此外,strncpy 还有一个低效行为:如果 src 很短,它会用 '\0' 填充剩余空间直到写满 n 个字节。这在复制大块数据时会造成不必要的性能开销,但在安全视角下并非致命问题。

4.1.3 案例拆解:一个典型的栈溢出漏洞及修复

场景描述:某嵌入式设备固件中,处理用户登录请求的代码片段如下:

void login_handler(char *user_input) {
    char username[16];
    char password[16];
    strcpy(username, user_input);  // 危险!
    // 后续进行用户名密码校验...
}

攻击过程:攻击者发送一个长度为 32 字节的用户名字符串,strcpy 将 32 字节全部写入 username(仅 16 字节),导致 password 数组被覆盖,进而覆盖栈上保存的返回地址。攻击者将返回地址改为指向自己注入的 shellcode,实现远程代码执行。

修复方案:

void login_handler(char *user_input) {
    char username[16];
    char password[16];
    strncpy(username, user_input, sizeof(username) - 1);
    username[sizeof(username) - 1] = '\0';
    // 同时检查 user_input 长度,若超过 15 字节则拒绝登录
    if (strlen(user_input) >= sizeof(username)) {
        // 记录日志,返回错误
        return;
    }
    // 后续逻辑...
}

结果对比:修复后,即使攻击者发送超长输入,strncpy 最多写入 15 字节,且第 16 字节被手动置为 '\0',缓冲区不会溢出。同时通过 strlen 预检查,提前拒绝非法输入,提升了健壮性。

4.1.4 常见误区与总结复盘

  • 误区一:认为 strncpy(dst, src, sizeof(dst)) 是安全的——错!当 src 长度 ≥ sizeof(dst) 时,dst 没有 NUL 结尾,后续 strlen 或 printf("%s") 会越界读取。
  • 误区二:忽略 strncpy 的填充行为,在性能敏感场景下应改用 memcpy 加手动置 NUL,或使用 strlcpy(BSD 扩展)。
  • 误区三:只修复调用处,不检查上游输入长度。安全编码应遵循“纵深防御”,在入口处就限制输入长度。
对比维度strcpystrncpy(正确使用)
边界检查无有,最多复制 n 字节
NUL 结尾保证保证(若源正常)不保证,需手动设置
性能高效源短时可能填充 NUL,略低
安全风险极高低(正确使用时)

4.2 strcat 的危险性分析与 strncat 的正确使用

4.2.1 核心原理:strcat 的拼接溢出

strcat(dst, src) 将 src 追加到 dst 的末尾(覆盖 dst 原有的 '\0'),同样不检查 dst 剩余空间。攻击者可以利用两次输入拼接,第一次输入填满缓冲区,第二次输入继续溢出。例如,dst 大小为 16,第一次输入 15 字节,第二次输入 10 字节,则后 9 字节溢出到相邻内存。

4.2.2 操作流程:strncat 的正确用法

strncat(dst, src, n) 将 src 中最多 n 个字符追加到 dst 末尾,并自动添加 '\0'。但这里的 n 指的是最多追加的字符数,而不是 dst 的总大小。因此,调用前必须计算 dst 的剩余空间:

size_t dst_size = sizeof(dst);
size_t dst_len = strlen(dst);
size_t remaining = dst_size - dst_len - 1;  // 预留 NUL
strncat(dst, src, remaining);

如果 src 长度大于 remaining,则只追加前 remaining 个字符,并自动以 '\0' 结尾,不会溢出。

4.2.3 实战场景:日志拼接中的截断处理

背景:某网络服务在记录访问日志时,需要将客户端 IP、时间戳和请求路径拼接成一个字符串。缓冲区大小为 256 字节。请求路径由用户控制,可能非常长。

初始代码(危险):

char log_buf[256];
strcpy(log_buf, ip_str);
strcat(log_buf, " ");
strcat(log_buf, time_str);
strcat(log_buf, " ");
strcat(log_buf, request_path);  // 可能溢出!

修复代码:

char log_buf[256];
size_t remaining = sizeof(log_buf);
size_t written = 0;

// 使用 snprintf 更简洁,但这里演示 strncat 的逐步拼接
strncpy(log_buf, ip_str, sizeof(log_buf) - 1);
log_buf[sizeof(log_buf) - 1] = '\0';
remaining = sizeof(log_buf) - strlen(log_buf) - 1;
strncat(log_buf, " ", remaining);
remaining = sizeof(log_buf) - strlen(log_buf) - 1;
strncat(log_buf, time_str, remaining);
remaining = sizeof(log_buf) - strlen(log_buf) - 1;
strncat(log_buf, " ", remaining);
remaining = sizeof(log_buf) - strlen(log_buf) - 1;
strncat(log_buf, request_path, remaining);

分析:每次拼接前都重新计算剩余空间,确保不会越界。但这种方式代码冗长,实际工程中更推荐使用 snprintf 一次性格式化(见 4.3 节)。

4.2.4 常见误区与复盘

  • 误区一:将 strncat(dst, src, sizeof(dst)) 视为安全——错!sizeof(dst) 是总大小,不是剩余空间,仍可能溢出。
  • 误区二:忘记 strncat 总是添加 NUL,因此实际写入的字节数可能达到 n+1(追加 n 个字符 + 1 个 NUL)。所以 n 必须 ≤ 剩余空间 - 1。
  • 误区三:在循环中反复调用 strlen(dst) 计算剩余空间,导致 O(n²) 复杂度。应维护一个 offset 变量跟踪当前写入位置。

4.3 sprintf 的危险性分析与 snprintf 的正确使用

4.3.1 核心原理:格式化输出的溢出风险

sprintf(buf, fmt, ...) 将格式化后的字符串写入 buf,不检查 buf 容量。格式化字符串中的 %s 对应参数若为超长字符串,或 %d 等转换产生意外长输出,都会导致溢出。此外,sprintf 的返回值是实际写入的字符数(不含 NUL),但调用者往往忽略它。

4.3.2 操作流程:snprintf 的标准用法

snprintf(buf, size, fmt, ...) 最多写入 size-1 个字符,并自动在末尾添加 '\0'。其返回值是如果缓冲区足够大,本应写入的字符数(不含 NUL)。因此,判断是否截断的条件是:

int n = snprintf(buf, sizeof(buf), "%s:%d", name, port);
if (n < 0) {
    // 编码错误
} else if (n >= sizeof(buf)) {
    // 输出被截断,需要处理(如扩大缓冲区或报错)
}

注意:snprintf 的返回值可能大于 size,这是正常现象,表示输出被截断了。不要误以为返回值是实际写入长度。

4.3.3 案例拆解:构造命令字符串时的截断处理

场景:某管理程序需要构造系统命令字符串,格式为 cmd --ip <ip> --port <port> --file <path>,缓冲区大小为 128 字节。IP 和路径来自外部配置,长度不可控。

危险写法:

char cmd[128];
sprintf(cmd, "cmd --ip %s --port %d --file %s", ip, port, path);

当 path 很长时,cmd 溢出,可能破坏相邻变量。

安全写法:

char cmd[128];
int n = snprintf(cmd, sizeof(cmd), "cmd --ip %s --port %d --file %s", ip, port, path);
if (n < 0) {
    // 格式化错误
} else if (n >= sizeof(cmd)) {
    // 命令被截断,拒绝执行并记录错误
    fprintf(stderr, "Command too long, rejected.\n");
    return -1;
}
// 执行 cmd...

复盘:通过检查 snprintf 返回值,程序在命令被截断时主动拒绝执行,避免了因命令不完整而引发的逻辑错误或安全漏洞。这种“宁可失败,不可带伤运行”的原则在安全编程中至关重要。

4.3.4 常见误区与总结

  • 误区一:认为 snprintf 的返回值就是实际写入长度,导致截断判断错误。
  • 误区二:使用 snprintf(buf, strlen(fmt) + 100, ...) 这种“拍脑袋”大小,应使用 sizeof(buf) 或动态计算。
  • 误区三:在 C++ 中混用 sprintf 和 std::string,应优先使用 std::string 的格式化功能(如 std::ostringstream)或 snprintf 到临时缓冲区再转 std::string。
函数最大写入NUL 结尾返回值含义截断检测
sprintf无限制是实际写入字符数无法检测
snprintfsize-1是本应写入的字符数返回值 ≥ size
strcpy无限制是目标指针无法检测
strncpyn否(需手动)目标指针需检查 dst[n-1]
strcat无限制是目标指针无法检测
strncatn+1(含 NUL)是目标指针需计算剩余空间

4.4 综合对比与工程实践建议

4.4.1 安全替代函数的选用决策

在实际项目中,应根据场景选择合适的函数:

  • 复制字符串:优先使用 strlcpy(若平台支持,如 BSD、macOS),它保证 NUL 结尾且返回源字符串长度,便于截断检测。若仅使用标准 C,则用 strncpy + 手动置 NUL。
  • 拼接字符串:优先使用 strlcat(同样 BSD 扩展),或 strncat 并正确计算剩余空间。
  • 格式化输出:一律使用 snprintf,并检查返回值。
  • 动态字符串:在 C 语言中可考虑使用 asprintf(GNU 扩展)自动分配足够内存,但需注意释放。

4.4.2 安全编码检查清单

  1. 所有字符串操作前,明确目标缓冲区大小。
  2. 使用 sizeof(dst) 而非 strlen(dst) 作为容量依据。
  3. 调用 strncpy 后立即检查并设置 NUL。
  4. 调用 snprintf 后检查返回值是否 ≥ 缓冲区大小。
  5. 禁止将用户输入直接拼接到格式化字符串中(格式化字符串漏洞)。
  6. 在函数入口处对输入长度进行校验,拒绝超长输入。
  7. 使用静态分析工具(如 Coverity、Clang Static Analyzer)和编译器警告(-Wformat-truncation)辅助发现隐患。

风险提示:即使使用了安全替代函数,也不能完全消除风险。例如,strncpy 在源字符串过长时不会报错,只是静默截断,可能导致业务逻辑错误(如截断后的路径指向错误文件)。因此,安全替代函数只是“缓冲区溢出”的防线,业务层面的长度校验和错误处理同样不可或缺。

4.4.3 案例复盘:一次完整的漏洞修复闭环

背景:某开源 FTP 服务器被报告存在远程代码执行漏洞,CVE 编号为 CVE-2023-XXXXX。漏洞位于处理 MKDIR 命令的代码中,使用 sprintf 拼接路径。

初始现象:攻击者发送超长目录名(5000 字节),导致服务器进程崩溃。进一步利用后,可在服务器上执行任意命令。

根因分析:代码中 char path[256]; sprintf(path, "/home/ftp/%s", dirname);,dirname 来自网络数据包,未做长度限制。

修复步骤:

  1. 将 sprintf 替换为 snprintf(path, sizeof(path), "/home/ftp/%s", dirname)。
  2. 检查 snprintf 返回值,若 ≥ sizeof(path),则向客户端返回“文件名过长”错误。
  3. 在命令解析入口处,增加 dirname 长度上限检查(如 ≤ 200 字节)。
  4. 使用 strlen(dirname) 预校验,避免后续处理超长字符串。

结果对比:修复后,即使攻击者发送超长目录名,服务器也只会返回错误信息,不会崩溃或执行恶意代码。该漏洞被成功封堵。

复盘经验:安全修复不能只改一处,应形成“入口校验 + 函数替换 + 返回值检查 + 日志记录”的完整闭环。同时,建议在代码评审中强制要求:任何涉及外部输入的字符串操作,必须使用安全替代函数并处理截断情况。

字符串安全是 C 语言安全编程的基石。本章详细剖析了 strcpy、strcat、sprintf 三类危险函数的溢出机理,并给出了 strncpy、strncat、snprintf 的正确使用范式。核心要点可归纳为:明确缓冲区容量、预留 NUL 空间、检查返回值、入口处限制输入长度。在实际开发中,建议团队制定统一的字符串操作规范,并借助自动化工具进行静态扫描,将安全隐患消灭在编码阶段。