Sanitizer、安全编码与 CMake CI:把 C/C++ 风险关进工程流水线
C/C++ 的工程质量不能靠代码评审单点支撑。 语言边界太低,错误类型太多,平台差异太真实。 可靠做法是把 sanitizer、静态分析、安全编码规则、CMake target、编译矩阵、产物审计和回滚策略串成流水线。 这篇文章讨论如何让风险在合并前被看见,而不是在线上崩溃后才被猜测。
工程化的目标是证据链
一个 C/C++ 变更至少应该留下这些证据:
- 用哪个编译器编译。
- 使用哪个语言标准。
- 开启哪些 warning。
- 链接了哪些库。
- sanitizer 是否运行。
- 静态分析是否通过。
- 单元测试覆盖哪些平台。
- 导出符号是否变化。
- 二进制体积是否异常。
- 出问题时如何降级和回滚。
没有证据链,生产故障只能靠经验猜。
warning 策略要分层
-Wall 不等于所有 warning。
不同编译器 warning 集合也不同。
项目应该建立基础 warning 集,并把关键 warning 升级为 error。
target_compile_options(core PRIVATE
$<$<CXX_COMPILER_ID:Clang,GNU>:
-Wall -Wextra -Wconversion -Wshadow -Werror=return-type>
)
第三方依赖不要直接套同样规则。 否则外部 warning 会淹没业务信号。 内部代码和外部代码要有隔离。
ASan 观察内存访问
AddressSanitizer 通过编译器插桩和运行时 shadow memory 发现内存错误。 它常见发现:
- heap buffer overflow。
- stack buffer overflow。
- global buffer overflow。
- use-after-free。
- use-after-return。
- double-free。
- invalid free。
c++ -std=c++23 -g -O1 \
-fsanitize=address \
-fno-omit-frame-pointer \
test.cpp
ASan 会改变内存布局和性能。 它适合测试、CI、灰度诊断。 它不能替代权限隔离和输入校验。
UBSan 观察语言边界
UndefinedBehaviorSanitizer 发现部分未定义行为。
c++ -std=c++23 -g -O1 \
-fsanitize=undefined \
ub_test.cpp
典型发现包括:
- 有符号整数溢出。
- 错位访问。
- 非法枚举值。
- 除零。
- 空指针相关错误。
- 非法 downcast。
UBSan 不是完整证明。 它只能报告执行到的路径。 但它能把很多“Release 才出错”的问题前移到测试阶段。
TSan 观察数据竞争
ThreadSanitizer 观察线程访问。 它适合并发模块、缓存、任务队列和共享状态。
c++ -std=c++23 -g -O1 \
-fsanitize=thread \
queue_test.cpp
TSan 和 ASan 通常不能同时启用。 CI 需要拆分不同 job。 并发测试要有重复运行和超时控制,避免偶发路径漏掉。
静态分析补足未执行路径
sanitizer 依赖运行。 静态分析可以检查未执行路径。 常见工具包括 clang-tidy、编译器 analyzer、商业 SAST 和自定义规则。
clang-tidy src/*.cpp -- -std=c++23 -Iinclude
静态分析适合发现:
- 返回局部地址。
- 未检查返回值。
- 可疑拷贝。
- 锁使用错误。
- 所有权转移不清。
- 不安全 C API。
误报要分类处理。 不要因为有误报就关闭整套工具。
CERT 规则是安全审计语言
SEI CERT C/C++ 编码标准不是风格指南。 它把常见漏洞根因整理成规则。 项目可以从高风险规则开始落地。
| 风险域 | 规则方向 | 工程动作 |
|---|---|---|
| 输入 | 边界检查、整数转换 | fuzz、UBSan |
| 内存 | 生命周期、释放、越界 | ASan、所有权封装 |
| 并发 | 数据竞争、死锁 | TSan、锁层级审计 |
| API | 不安全函数替代 | lint、封装 |
| 错误处理 | 返回值和异常路径 | 故障注入 |
规则的价值在于形成共同语言。 评审时不要只说“这里危险”,要说明违反了哪类边界。
CMake target 是工程边界
现代 CMake 应围绕 target 组织。 target 承载源文件、include 路径、编译选项、链接库和传播规则。
add_library(core STATIC
src/buffer.cpp
src/parser.cpp
)
target_compile_features(core PUBLIC cxx_std_23)
target_include_directories(core
PUBLIC include
PRIVATE src
)
target_link_libraries(app PRIVATE core)
PUBLIC、PRIVATE、INTERFACE 决定依赖是否传播。
这和 C/C++ 头文件契约直接相关。
错误传播会让编译选项和 include 路径污染全局。
sanitizer 应该是 target 配置
不要把 sanitizer 散落在手写命令里。 用 CMake option 控制,保证 CI 和本地一致。
option(ENABLE_ASAN "Enable AddressSanitizer" OFF)
if (ENABLE_ASAN)
target_compile_options(core PRIVATE -fsanitize=address -fno-omit-frame-pointer)
target_link_options(core PRIVATE -fsanitize=address)
endif()
大型项目可以抽成函数:
function(enable_asan target_name)
target_compile_options(${target_name} PRIVATE -fsanitize=address -fno-omit-frame-pointer)
target_link_options(${target_name} PRIVATE -fsanitize=address)
endfunction()
这样 sanitizer 配置可审计、可复用、可回滚。
CI 矩阵要覆盖标准、编译器和配置
只在一个平台构建,无法证明可移植性。 至少要覆盖:
- GCC release。
- Clang release。
- Debug + ASan/UBSan。
- TSan job。
- Release + warnings。
- 最低支持标准库版本。
- 目标平台交叉编译。
linux-gcc-release
linux-clang-asan-ubsan
linux-clang-tsan
macos-clang-release
windows-msvc-release
矩阵不是越多越好。 要围绕项目真实部署面和风险面设计。
fuzz 能打穿解析器假设
处理输入的 C/C++ 模块适合 fuzz。 协议解析、文件格式、压缩、编解码、SQL 扩展都属于高价值目标。
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
parse_packet(data, size);
return 0;
}
fuzz 要配合 sanitizer。 这样随机输入触发的越界和 UB 能被立即报告。 语料库和 crash case 要归档,避免回归。
产物审计覆盖链接和 ABI
构建通过还不够。 发布产物要检查:
- 导出符号。
- 动态库依赖。
- RPATH。
- 二进制大小。
- 调试符号。
- 许可证和第三方库。
- ABI diff。
nm -D --defined-only libcore.so | sort > symbols.txt
readelf -d libcore.so > dynamic.txt
这些结果应和基线比较。 不明符号新增可能意味着 ABI 暴露面扩大。
安全降级要提前设计
C/C++ 模块经常位于高性能或底层链路。 一旦出现错误,系统需要有降级路径。
例子:
- 原生解析失败,切换安全慢路径。
- SIMD 路径崩溃,回退标量实现。
- 插件加载失败,禁用插件而非终止主进程。
- 内存池异常,回退系统分配器。
- 超时任务取消并释放资源。
降级不是掩盖 bug。 降级是保护系统边界,同时保留审计证据。
日志要服务二进制排查
C/C++ 崩溃排查需要构建信息。 日志应至少包含:
- 版本号和 git commit。
- 编译器和标准库。
- 架构和 ABI。
- sanitizer 配置。
- 动态库版本。
- 请求或任务 ID。
- 资源句柄状态。
没有这些信息,崩溃栈很难关联到具体产物。
工程清单
- 内部代码启用 warning 基线。
- ASan/UBSan/TSan 拆分 CI job。
- clang-tidy 或静态分析进入合并前检查。
- 高风险输入模块接入 fuzz。
- CMake 以 target 为边界组织依赖。
- sanitizer 配置通过 CMake option 管理。
- 发布产物保存符号和动态依赖。
- ABI 变更需要审计说明。
- 关键原生路径有降级策略。
- 崩溃日志包含构建和 ABI 信息。
小结
C/C++ 的安全不是写完代码后加一个检查命令。 它是一条从源码、编译选项、target 依赖、运行时观测、静态分析、fuzz、符号审计到发布回滚的证据链。 把这条链路建立起来,语言的低层能力才不会变成低层风险。