4、字符串安全:strcpy、strcat、sprintf等危险函数分析,安全替代函数(strncpy、strncat、snprintf)的正确使用
在C语言开发的各类系统中,字符串处理函数一直是缓冲区溢出漏洞的头号源头。根据MITRE发布的CWE Top 25最危险软件弱点榜单,CWE-121(栈缓冲区溢出)与CWE-122(堆缓冲区溢出)常年位居前列,而它们的直接诱因绝大多数是对strcpy、strcat、sprintf等非边界检查函数的不当使用。这些函数在C标准库中存在已久,设计初衷是假定调用者能够保证目标缓冲区足够大,但现实中的输入长度往往不可控——用户输入、网络数据包、配置文件解析结果都可能超出预期。一旦发生溢出,轻则程序崩溃,重则被攻击者利用改写返回地址、注入恶意代码,造成远程代码执行。本章将深入剖析这三类危险函数的缺陷机理,并系统讲解strncpy、strncat、snprintf等安全替代函数的正确用法、边界陷阱与工程实践准则。
上图展示了本章的核心逻辑:左侧是必须避免的危险函数,右侧是推荐使用的安全替代函数,顶部标注了替代过程中最容易踩中的陷阱,底部则汇总了工程实践中的关键准则。接下来,我们将逐一拆解每个函数的缺陷细节与正确用法。
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' 结尾。因此,正确用法必须遵循以下三步:
- 确定目标缓冲区大小
dst_size(例如sizeof(dst))。 - 调用
strncpy(dst, src, dst_size - 1),预留一个字节给 NUL。 - 手动执行
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 扩展)。 - 误区三:只修复调用处,不检查上游输入长度。安全编码应遵循“纵深防御”,在入口处就限制输入长度。
| 对比维度 | strcpy | strncpy(正确使用) |
|---|---|---|
| 边界检查 | 无 | 有,最多复制 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 | 无限制 | 是 | 实际写入字符数 | 无法检测 |
| snprintf | size-1 | 是 | 本应写入的字符数 | 返回值 ≥ size |
| strcpy | 无限制 | 是 | 目标指针 | 无法检测 |
| strncpy | n | 否(需手动) | 目标指针 | 需检查 dst[n-1] |
| strcat | 无限制 | 是 | 目标指针 | 无法检测 |
| strncat | n+1(含 NUL) | 是 | 目标指针 | 需计算剩余空间 |
4.4 综合对比与工程实践建议
4.4.1 安全替代函数的选用决策
在实际项目中,应根据场景选择合适的函数:
- 复制字符串:优先使用
strlcpy(若平台支持,如 BSD、macOS),它保证 NUL 结尾且返回源字符串长度,便于截断检测。若仅使用标准 C,则用strncpy+ 手动置 NUL。 - 拼接字符串:优先使用
strlcat(同样 BSD 扩展),或strncat并正确计算剩余空间。 - 格式化输出:一律使用
snprintf,并检查返回值。 - 动态字符串:在 C 语言中可考虑使用
asprintf(GNU 扩展)自动分配足够内存,但需注意释放。
4.4.2 安全编码检查清单
- 所有字符串操作前,明确目标缓冲区大小。
- 使用
sizeof(dst)而非strlen(dst)作为容量依据。 - 调用
strncpy后立即检查并设置 NUL。 - 调用
snprintf后检查返回值是否 ≥ 缓冲区大小。 - 禁止将用户输入直接拼接到格式化字符串中(格式化字符串漏洞)。
- 在函数入口处对输入长度进行校验,拒绝超长输入。
- 使用静态分析工具(如 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 来自网络数据包,未做长度限制。
修复步骤:
- 将
sprintf替换为snprintf(path, sizeof(path), "/home/ftp/%s", dirname)。 - 检查
snprintf返回值,若 ≥sizeof(path),则向客户端返回“文件名过长”错误。 - 在命令解析入口处,增加
dirname长度上限检查(如 ≤ 200 字节)。 - 使用
strlen(dirname)预校验,避免后续处理超长字符串。
结果对比:修复后,即使攻击者发送超长目录名,服务器也只会返回错误信息,不会崩溃或执行恶意代码。该漏洞被成功封堵。
复盘经验:安全修复不能只改一处,应形成“入口校验 + 函数替换 + 返回值检查 + 日志记录”的完整闭环。同时,建议在代码评审中强制要求:任何涉及外部输入的字符串操作,必须使用安全替代函数并处理截断情况。
字符串安全是 C 语言安全编程的基石。本章详细剖析了 strcpy、strcat、sprintf 三类危险函数的溢出机理,并给出了 strncpy、strncat、snprintf 的正确使用范式。核心要点可归纳为:明确缓冲区容量、预留 NUL 空间、检查返回值、入口处限制输入长度。在实际开发中,建议团队制定统一的字符串操作规范,并借助自动化工具进行静态扫描,将安全隐患消灭在编码阶段。