首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
编程技术
发新帖
0
5
[原创] 当安全成为可选项:C风格字符串在C++中的文化残留与代价
发表于: 2026-4-11 17:04
1901
[原创] 当安全成为可选项:C风格字符串在C++中的文化残留与代价
云净天鉴
2026-4-11 17:04
1901
## 00 引言:一个本可避免的漏洞 2021年,`sudo` 堆溢出漏洞(CVE-2021-3156)曝光。攻击者只需向 `sudoedit -s` 传递一个以反斜杠结尾的字符串,就能触发 `setlocale()` 中 `strcpy()` 的堆溢出,获得任意代码执行权限。该漏洞存在了近10年,影响了几乎所有Linux系统。 在漏洞分析报告中,研究人员展示了以下关键代码片段: ```c // 简化自 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`字节。没有字段记录已分配容量或当前长度。例如: ```c 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实现(简化): ```asm 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 // 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::pmr::string`配合`monotonic_buffer_resource`可以在栈上预分配内存池,完全避免堆分配。例如: ```cpp char buffer[1024]; std::pmr::monotonic_buffer_resource pool(buffer, 1024); std::pmr::string safe_str(&pool); // 完全在栈上分配 ``` - **手动管理`char*`同样面临分配失败**:使用`malloc`时,开发者通常忘记检查返回值,导致空指针解引用。`std::string`的异常机制反而强制处理错误(或至少终止程序,而不是产生未定义行为)。 因此,“嵌入式不能用`std::string`”是一个常见的误解。真正受限制的环境(如某些内核模块或引导加载器)通常也不允许使用`new`或`malloc`,此时应使用固定容量的栈数组封装(如`std::array<char, N>`配合长度变量),而不是裸C字符串。 ### 2.4 专家盲点与社区文化 C++社区长期存在一种“专家朋友”文化:使用裸指针、手动内存管理被视为“真正的程序员”的标志,而使用高级抽象则被嘲讽为“幼稚”或“低效”。这种精英主义态度阻碍了安全实践的普及。 相比之下,Rust社区将`unsafe`代码视为一种需要显式标注并经过额外审查的特权,而非日常工具。Go语言则从一开始就内置了切片(带长度)和垃圾回收,几乎不存在手动内存操作的文化。C++的特殊之处在于:**安全性是可选的,而且选择不安全往往被视为有经验的体现**。 这种文化差异直接导致了漏洞的持续存在。一个C++开发者可能因为想“写出高效的代码”而选择`char[256]`,而一个Rust开发者则会自然地使用`String`,只在极少数经过性能验证的热点路径中使用`&str`和栈数组。 --- ## 03 近年CVE中的C字符串漏洞:趋势与个案 文化惯性的后果是实实在在的安全漏洞。根据对NVD(国家漏洞数据库)2018-2023年间缓冲区溢出漏洞的抽样统计,约32%的此类漏洞与C字符串操作函数(`strcpy`、`strcat`、`sprintf`、`gets`)直接相关。下面选取三个典型案例,展示不同层面的问题。 ### 3.1 CVE-2021-3156:sudo堆溢出(2021) **漏洞点**:`sudoers.c`中的`setlocale`处理。攻击者提供特制的环境变量,导致`strcpy`拷贝超长字符串到堆缓冲区。 **影响**:本地提权,影响所有Linux系统近10年。 **文化批判**:`sudo`是C语言编写的,但即使在C中,也可以使用更安全的函数(`strlcpy`、`asprintf`)或自己实现边界检查。开发者选择了最危险的`strcpy`,并且没有对输入长度做任何验证。这种“相信输入总是正确”的心态,在安全攸关的软件中是不可接受的。 ### 3.2 CVE-2022-37434:zlib堆溢出(2022) **漏洞点**:`inflate.c`中处理压缩数据时,使用`strcpy`拷贝字符串,未检查目标缓冲区大小。 **影响**:拒绝服务或可能代码执行,影响大量使用zlib的软件(包括很多嵌入式设备)。 **文化批判**:zlib是一个广泛使用的库,其开发者应该对输入数据(来自网络)保持高度警惕。然而,代码中仍出现`strcpy`,说明代码审查和静态分析工具没有覆盖到该路径。 ### 3.3 CVE-2023-23583:Intel AMT栈溢出(2023) **漏洞点**:AMT(主动管理技术)的Web界面中,一个`sscanf`调用未限制输入长度,导致栈缓冲区溢出。 **影响**:远程代码执行,影响企业级Intel处理器。 **文化批判**:即使在2023年,新的C代码仍然在使用`sscanf`而不指定最大宽度(如`%255s`)。这种基础安全知识的缺失,反映了安全培训的不足。 **小结**:这些案例的共同点是——漏洞完全可以避免,如果开发者选择了更安全的字符串处理方式。不是编译器或语言的错,而是**编程文化**中忽视安全、迷信性能、习惯性使用危险函数的后果。 --- ## 04 性能与安全的真实权衡:何时才应使用C风格字符串? 为了对抗文化惯性,我们需要一个清晰的、严苛的决策框架。**除非满足以下所有条件,否则强烈推荐使用`std::string`(或其安全变体)**。这些条件经过多次修正,旨在反映真实世界中的极少数场景。 ### 条件1:环境不支持C++标准库 **示例**:某些专用RTOS(如FreeRTOS的某些配置)、内核极简模块(如UEFI引导加载器)、或使用`-fno-rtti`且禁止异常和RTTI的嵌入式环境。 **注意**:即使在这种环境下,通常也可以使用`std::array<char, N>`配合长度变量的封装,或者引入`etl::string`(Embedded Template Library)等轻量级安全抽象。裸C字符串仍然不是唯一选择。 ### 条件2:必须完全避免任何动态内存分配(包括自定义分配器) **场景**:硬实时系统(航空电子、汽车刹车控制)要求任务执行时间完全可预测,任何堆分配(即使来自预分配内存池)可能引入锁或碎片,从而破坏确定性。 **但即使在此场景**,仍推荐使用栈上固定大小的安全封装,如: ```cpp template<size_t N> class FixedString { char data[N]; size_t len; public: FixedString(const char* s) : len(0) { while (len < N-1 && s[len]) { data[len] = s[len]; ++len; } data[len] = '\0'; } // ... 提供安全的append、substr等 }; ``` 这比裸C字符串更安全,且性能完全相同。 ### 条件3:性能瓶颈经过实测验证,且该热点被隔离在极小模块内 **正确做法**: 1. 使用perf、VTune等工具确认字符串操作确实是瓶颈(占总时间超过5%)。 2. 将热点代码封装在一个独立模块中(如`FastStringParser`),模块内部可以使用C风格字符串,但对外提供安全接口。 3. 模块代码量应控制在总代码量的5%以内,并通过详细的注释和审查。 4. 其余所有代码仍然使用`std::string`。 **反模式**:因为“感觉”`std::string`慢,就在整个项目中禁止使用它。 ### 条件4:与C API交互且无法使用RAII包装器 例如,某些异步回调函数原型为`void callback(char* buf, size_t* len)`,且要求`buf`的生命周期由调用者管理。此时可以在边界处使用`std::vector<char>`或`std::string`,通过`.data()`传递,但需要注意生命周期。 实际上,这并非拒绝`std::string`的理由,而只是需要小心处理`c_str()`返回的指针的有效期。可以使用`std::string`作为内部存储,仅在调用C函数时传递`.data()`。 ### 关于“异常禁用”的补充说明 异常禁用本身不构成使用C字符串的正当理由。`std::string`在`-fno-exceptions`下依然可用,分配失败时通常调用`std::terminate`,对于大多数嵌入式系统而言,这是可接受的(因为手动`malloc`失败同样会导致崩溃)。如果必须避免`std::terminate`,可以使用自定义分配器在栈上分配,完全消除异常的可能性。 **总结**:上述条件极为苛刻。对于一个典型的应用层软件(如Web服务、桌面应用、游戏逻辑),没有一个条件成立,因此没有理由不使用`std::string`。即使对于嵌入式系统,大多数情况下也可以通过`std::pmr::string`或`FixedString`类获得更好的安全性。 --- ## 05 模块化隔离:将不安全限制在可控范围内 即使存在极少数必须使用C风格字符串的场景,也应该遵循**模块化隔离**原则,而不是将其扩散到整个代码库。 ### 5.1 隔离策略 1. **创建专用模块**:将所有C字符串操作封装在一个单独的类或命名空间中,例如`FastBuffer`或`RawStringBuilder`。 2. **提供安全接口**:模块对外暴露的方法接受`std::string_view`或`const char*`和长度,内部进行边界检查。 3. **使用静态分析标记**:在模块内的不安全操作旁添加`// NOLINT`注释,并在CI中配置clang-tidy允许这些行,同时禁止其他任何地方使用危险函数。 4. **限制模块规模**:目标是将不安全代码控制在总代码量的5%以内。如果超过,则需要重新评估是否真的需要C字符串。 ### 5.2 示例:一个高性能日志模块 ```cpp // fast_logger.h - 对外安全接口 class FastLogger { public: void log(const std::string& msg); // 安全 }; // fast_logger.cpp - 内部使用固定缓冲区 #include <array> void FastLogger::log(const std::string& msg) { std::array<char, 1024> buffer; size_t len = msg.copy(buffer.data(), buffer.size() - 1); buffer[len] = '\0'; // 调用异步write,不进行堆分配 write(log_fd, buffer.data(), len); } ``` 这样,模块内部没有使用`std::string`(为了确定性),但外部调用者仍然使用`std::string`,且模块内部做了边界检查。这比直接使用`strcpy`安全得多。 --- ## 06 跨语言对比:安全文化如何塑造实践 为了更清晰地理解C++的问题,我们可以简短对比其他语言社区的做法。 ### Rust:不安全需要显式标注 Rust的`String`和`&str`默认安全且高效。如果开发者需要操作裸指针或调用C库,必须将代码放在`unsafe`块中,并附上安全注释。这种设计将“不安全”视为一种需要特殊许可的例外,而不是日常工具。 在Rust中,几乎不会看到有人为了“性能”而手动操作字节数组,因为标准库已经提供了高性能的迭代器和切片方法。如果确实需要(例如音视频编解码器),`unsafe`块会立即引起审查者的注意。 ### Go:切片内置长度 Go的`string`是不可变的,且内置长度信息。`[]byte`切片同样携带长度和容量。社区强烈反对手动指针运算(虽然语言允许通过`unsafe`包实现)。Go的垃圾回收消除了内存管理的烦恼,使得开发者可以专注于逻辑。 ### C++:安全是可选项,且不安全感常被视为专业 C++提供了安全抽象,但没有强制使用。更糟糕的是,社区中存在一种“真正专家用指针”的迷思。这导致许多开发者主动选择更危险的路径,仅仅因为“看起来更底层”。 **关键区别**:在Rust/Go中,选择不安全的代码需要明确的理由和额外的审查;在C++中,选择不安全的代码是默认行为,而选择安全的代码反而需要辩护。这种文化差异直接影响了漏洞率。 --- ## 07 反批判:承认极端合理性,反对泛化 为了避免偏激,我们必须承认:确实存在极少数场景,无法使用任何安全抽象,只能退回到裸C字符串。例如: - 引导加载器的第一阶段,代码大小必须控制在512字节以内,无法包含任何C++运行时。 - 某些DSP固件,其编译器不支持C++标准库。 但请注意:**这些场景极其特殊**,并且通常有明确的内存布局约束(如整个固件只有几KB)。对于99.9%的C++项目(包括嵌入式系统中运行Linux或FreeRTOS的ARM Cortex-M设备),上述约束不成立。 文化惯性的真正危害在于:将特殊场景的合理性泛化到所有场景,从而为不安全实践提供借口。一个常见谬误是:“Linux内核不用`std::string`,所以我也不用”——但内核有自己的一套安全字符串抽象(如`strlcpy`、`kasprintf`),且其环境特殊(无法使用标准库)。普通应用程序没有理由模仿内核。 --- ## 08 对逆向社区的呼吁:从识别漏洞到批判文化 作为逆向工程师,您在分析二进制时,经常会看到`strcpy`、`sprintf`、`gets`等函数的调用。当您发现这些函数处理来自网络或用户输入的数据时,几乎可以确定这是一个漏洞。但本文希望您更进一步: - **在漏洞报告中,除了技术细节,还可以指出文化根源**:例如,“本漏洞源于开发者选择了`strcpy`而非更安全的`std::string`,反映了团队对现代C++实践的不熟悉。” - **在编写自己的POC或工具时,主动使用`std::string`**:即使是一个漏洞利用脚本,也可以用C++编写得更安全。这会影响您周边的开发者。 - **参与社区讨论**:在看雪论坛、逆向QQ群中,分享本文的观点,引发关于安全编码文化的讨论。 ### 行动建议 1. **个人层面**:在您自己的C++项目中,将`std::string`作为默认字符串类型。如果遇到需要C字符串的场景,先问自己:是否满足04节的所有条件?如果不满足,请坚持使用`std::string`。 2. **团队层面**:推动代码规范更新,明确禁止`strcpy`、`sprintf`、`gets`,并集成静态分析工具(如clang-tidy的`cppcoreguidelines-pro-bounds-*`检查)到CI流程。 3. **教育层面**:如果您是讲师或技术博主,请在教学中将`std::string`作为第一选择,将C风格字符串仅作为“与C交互的历史遗留”提及。 --- ## 09 结论 C++语言早在1998年就提供了`std::string`,一个安全、高效、灵活的字符串抽象。然而,近30年后,C风格字符串仍然在新代码中广泛存在,导致大量可避免的安全漏洞。本文分析了这一文化惯性的成因:教育滞后、性能迷思、对异常的恐惧、以及社区精英主义。 我们提出了一个严苛的决策框架,明确只有在极少数环境约束下才应使用C风格字符串,并强调了模块化隔离的重要性。通过与Rust、Go等语言的对比,揭示了C++社区将安全作为“可选项”的文化特殊性。 最终,我们呼吁逆向工程师不仅要在分析中发现漏洞,更要批判背后的编程文化,推动整个社区向更安全、更理性的编码实践演进。安全不是一种负担,而是一种需要维护的成本——而使用`std::string`正是降低长期维护成本的有效途径。 **记住**:每一次你选择`std::string`而不是`char[256]`,你都在减少一个潜在的CVE,并推动C++社区的文化转变。 --- ## 参考文献 1. Knuth, D. E. (1974). Structured Programming with go to Statements. *ACM Computing Surveys*, 6(4), 261-301. 2. Aleph One. (1996). Smashing The Stack For Fun And Profit. *Phrack*, 49. 3. ISO/IEC 14882:2020. Programming Languages — C++ (Section 21.3 – `basic_string`). 4. National Vulnerability Database. (2018-2023). Statistics on Buffer Overflow Vulnerabilities. Retrieved from <mark class="encrypted">f5fK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6F1N6X3c8Q4x3X3g2F1K9i4y4@1i4K6u0W2k6$3!0$3i4K6u0r3</mark> 5. CVE-2021-3156, CVE-2022-37434, CVE-2023-23583 entries. 6. Sutter, H. (2005). *Exceptional C++ Style*. Addison-Wesley. (关于异常安全保证) 7. The Rust Programming Language. (2024). *Unsafe Rust*. <mark class="encrypted">086K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6V1L8$3y4Q4x3X3g2J5N6i4y4@1i4K6u0V1L8r3q4F1k6#2)9J5k6h3!0J5k6#2)9J5c8X3u0G2L8$3E0Q4x3V1k6U0K9o6p5&6i4K6u0V1x3o6q4Q4x3X3c8#2L8Y4y4S2k6X3g2Q4x3X3c8J5N6i4y4@1i4K6u0W2K9s2c8E0L8l9`.`.</mark> 8. The Go Programming Language Specification. (2024). *String types*. <mark class="encrypted">b7eK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4L8#2)9J5k6h3c8W2N6W2)9J5c8Y4u0W2k6W2)9J5c8Y4y4H3k6h3y4Q4x3U0y4e0N6s2u0A6L8X3N6Q4y4h3k6@1P5i4m8W2M7H3`.`.</mark>
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
最后于
2026-4-11 17:08 被云净天鉴编辑 ,原因: 修正标题
#基础知识
收藏
・
0
点赞
・
5
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
wx_晨梦
这个讨论对我很有帮助,谢谢!
2026-7-16 10:33
neocanable
非常支持你的观点!
2026-6-23 22:33
zyla
+1
这个讨论对我很有帮助,谢谢!
2026-6-22 11:11
gopher
你的分享对大家帮助很大,非常感谢!
2026-4-20 09:08
wx_木天廖
你的分享对大家帮助很大,非常感谢!
2026-4-14 16:30
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
1
)
zyla
雪 币:
44
活跃值:
(108)
能力值:
( LV2,RANK:10 )
在线值:
发帖
0
回帖
37
粉丝
0
关注
私信
zyla
2
楼
高质量的发帖,谢谢分享
2026-6-22 11:11
0
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
云净天鉴
15
发帖
4
回帖
10
RANK
关注
私信
他的文章
[原创] 内容可寻址记忆:从注意力机制看AI的记忆困境
1475
[原创] AI 在计算什么?——函数、λ 演算与丘奇-图灵论题
1384
[原创] PE 资源逆向:特征码组缺失问题定位分析
1182
[原创] AI的底层是什么?——从机械步骤到图灵机
1336
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部