论坛首页 安全编程开发区 阅读主题

[原创] 当安全成为可选项:C风格字符串在C++中的文化残留与代价

237 浏览 0 回复
#1 楼主 2026-06-01 21:08:58
00 引言:一个本可避免的漏洞
2021年,sudo 堆溢出漏洞(CVE-2021-3156)曝光。攻击者只需向 sudoedit -s 传递一个以反斜杠结尾的字符串,就能触发 setlocale() 中 strcpy() 的堆溢出,获得任意代码执行权限。该漏洞存在了近10年,影响了几乎所有Linux系统。
在漏洞分析报告中,研究人员展示了以下关键代码片段:
// 简化自 sudo 1.8.31 的 setlocale 处理
char *new_locale = malloc(strlen(user_locale) + 1);
strcpy(new_locale, user_locale); // 未检查长度,且 user_locale 可能缺少 '\0'

问题是:为什么在2011年(漏洞引入年份),std::string 早已成熟的情况下,开发者依然选择了 malloc + strcpy?
本文不试图给出简单的答案。我们将从编程文化、性能迷思、教育惯性等角度,分析C风格字符串在C++社区中持续存在的原因,并提出一种更理性的、结合维护成本的权衡框架。对于逆向工程师而言,理解这些文化背景,有助于在分析二进制时更快识别出危险模式,并推动社区向更安全的编码实践演进。

01 C风格字符串的技术本质:缺失边界的表征
在深入文化讨论之前,有必要回顾C风格字符串的底层表征,因为许多开发者对这些细节缺乏清晰认识,从而低估了风险。
1.1 内存布局:没有长度字段的序列
一个C字符串在内存中就是一个字符数组,末尾跟着一个\0字节。没有字段记录已分配容量或当前长度。例如:
char buf[6] = "Hello";

在内存中(假设小端架构):
地址: 0x1000 0x1001 0x1002 0x1003 0x1004 0x1005
值: 'H' 'e' 'l' 'l' 'o' '\0'

没有任何地方存储数字6或5。函数strlen(buf)必须从0x1000开始逐字节扫描,直到遇到\0,复杂度O(n)。而std::string内部通常包含三个指针或一个指针加长度/容量字段,size()是O(1)操作。
1.2 汇编视角:strcpy如何信任程序员
以下是strcpy的典型x86-64实现(简化):
strcpy:
mov rcx, -1
xor eax, eax
repne scasb ; 扫描源字符串找到 '\0'
not rcx
sub rcx, 1 ; rcx = strlen(src) + 1
mov rsi, rdx ; rsi = src
mov rdi, rcx ; rdi = dst
rep movsb ; 逐字节拷贝,不检查边界
ret

注意:没有任何指令检查目标缓冲区是否足够大。汇编器完全信任程序员提供的目标指针有效且空间充足。这种信任在存在恶意输入的现代软件中是致命的。
1.3 与安全抽象的对比
Pascal风格字符串(长度前缀)和std::string都显式存储长度,因此处理字符串时无需扫描\0,且可以在每次修改时保持长度同步。C++标准库甚至提供了强异常安全保证:如果std::string的拼接操作失败,原字符串保持不变;而手动管理char*时,任何失败都需要复杂的手动回滚。
小结:C字符串的设计是在1960-70年代内存极度稀缺、安全攻击尚未成为主要威胁的背景下做出的合理工程决策。但在今天,继续将其作为默认选择,则是一种文化上的滞后。

02 文化惯性:为什么std::string被拒绝?
C++98在1998年就引入了std::string,到C++11已经成熟且高效。然而,直至2025年,我们仍能在新代码中看到strcpy和char[256]。这种文化惯性由多个因素共同维持。
2.1 历史遗留与教育滞后
许多C++课程仍然以C风格字符串作为入门内容,将std::string推迟到“高级话题”。一项对Top 10 C++教程网站(2024年)的快速调查发现,其中6个在介绍字符串时首先演示char[]和strcpy,仅在后续章节提到std::string。这种教学顺序无形中将C风格字符串塑造为“默认选项”,而将安全抽象视为“额外的开销”。
在老项目(如Linux内核、早期网络服务)中,代码库充满了char*,新加入的开发者往往选择“入乡随俗”,而不是引入std::string。这种技术债务的累积进一步巩固了文化惯性。
2.2 性能迷思:未经验证的优化
最常见的借口是:“std::string太慢,因为它会进行堆分配。” 这个观点需要拆解:

短字符串优化(SSO):大多数std::string实现(libstdc++、libc++、MSVC)对于16-22字节以内的字符串,直接在对象内部存储,不发生堆分配。常见的文件名、用户名、命令参数都在此范围内。
strlen的O(n)代价:C字符串每次调用strlen都需要扫描整个字符串。而std::string::size()是常数时间。在循环中反复使用strlen是常见性能杀手。
过早优化:Knuth早在1974年就指出,“过早优化是万恶之源”。大多数代码路径不是性能瓶颈,而字符串操作极少成为热点。即使成为热点,也应当通过性能分析工具(perf、VTune)确认,而不是凭感觉猜测。

我们来看看一个典型例子。以下代码在解析日志时非常常见:
// C风格,低效且不安全
void process(const char* line) {
char cmd[16];
int i = 0;
while (line[i] != ' ' && i < 15) {
cmd[i] = line[i];
i++;
cmd[i] = '\0';
// ... 使用cmd

而使用std::string不仅更安全,也往往更快(因为std::string::find和substr内部使用高效的算法)。实际上,许多宣称“C字符串更快”的案例,都是基于不正确的微基准测试(例如未开启编译器优化,或重复测量strlen)。
性能迷思的危害在于:它将一个需要实测验证的问题,变成了一个普遍的、无需证据的信仰,从而阻碍了安全实践。
2.3 对异常与内存分配的恐惧
一些开发者拒绝std::string,因为它可能抛出std::bad_alloc,或者在禁用异常的环境(-fno-exceptions)下行为未定义。这种恐惧需要澄清:

异常禁用不是拒绝std::string的理由:即使在-fno-exceptions下,std::string通常仍然可用——分配失败时会调用std::terminate,对于许多嵌入式系统来说,这是可接受的行为(因为手动malloc失败同样需要处理)。
避免堆分配可通过自定义分配器实现:C++11起,std::basic_string支持自定义分配器。C++17的std::pm

...(已截断)

---
来源: 看雪论坛
原文链接: https://bbs.kanxue.com/thread-290772.htm

暂无回复,快来抢沙发吧!

请登录后参与讨论

立即登录 注册账号