Post

一个四十年还没解决的问题:C++ 内存安全技术发展史

2022 年,美国国家安全局(NSA)发了一份文件,点名建议「别再用 C/C++ 写新代码」。理由是:微软和谷歌各自统计发现,自家产品里约 70% 的安全漏洞是内存安全问题。可 C++ 明明为内存安全折腾了四十年——RAII、智能指针、Valgrind、Sanitizer、FORTIFY、各种注解……为什么还是被点名?这篇文章把这四十年的技术一件件摆出来,你会看到一条清晰的主线:C++ 的内存安全,是一部「打补丁」史——每一块补丁都堵住了一个洞,但它们有一个共同的命门。

众所周知广泛应用于需要极致 C++ 性能的场景,但是它的安全性问题也一直被人诟病。这个问题并非没人想解决。这些年来我用过 Valgrind、gperftools、Sanitizers、_GLIBCXX_DEBUGstrcpy_s、FORTIFY、各种代码检查工具,也确实被它们救过。但是 C++ 的安全问题依然没有彻底解决,并且还在发展之中,本文就从头盘点一下 C++ 内存安全技术的发展历史。

引子:当 NSA 开始劝你别用 C++

先把那份「判决书」说清楚。

2022 年 11 月,NSA 发布了一份《Software Memory Safety》信息简报,白纸黑字建议组织从 C/C++ 这类「内存不安全」语言迁移到内存安全语言(点名了 Rust、Go、C#、Java、Swift 等)。紧接着 2023 年 CISA(美国网络安全和基础设施安全局)推出《内存安全路线图》,2024 年 2 月白宫国家网络总监办公室(ONCD)更是发了篇《回到基石》报告,几乎是在国家层面把「内存安全」定成了软件安全的头等议题。

它们引用的核心数据来自微软和谷歌各自的独立统计:产品线里大约 70% 的高危漏洞,根子都是内存安全问题——缓冲区溢出、释放后使用(use-after-free)、越界读写这些。不是逻辑 bug,就是这几类「C/C++ 允许你干、但你不该干」的底层内存错误。

这份判决刺痛了整个 C++ 社区,但也逼出一个诚实的问题:C++ 为内存安全做的努力,一点都不少,怎么就不够? 要回答它,得先弄清「内存安全」到底包含哪几件事。

内存安全的全景:不是一个问题,是一类问题

「内存安全」是个筐,里面装着好几种性质不同的错误。把它们分清楚,后面每个技术在解决什么才看得明白:

类别典型错误一句话
空间安全(spatial)缓冲区溢出、数组越界、野指针解引用访问了「不该访问的地址」
时间安全(temporal)释放后使用(UAF)、重复释放、悬垂指针访问了「已经不该存在的对象」
初始化安全读未初始化内存读到了「还没写过的值」
资源泄漏内存泄漏、句柄泄漏该还的没还(严格说不算「不安全」,但同源)
线程安全数据竞争(data race)、撕裂读写多个线程「同时踩同一块内存」

这五类里,空间安全和时间安全是漏洞的重灾区(NSA 那 70% 主要就是它们);泄漏更多是稳定性问题;线程安全在多核时代单独成了一座大山。后面你会看到,C++ 的技术演进基本是沿着这几条线分头推进的——有的语言层面根治,有的运行时兜底,有的编译期拦截。

下面按「四条路」来讲:语言抽象、运行时检测、编译器/库加固、以及最新的「默认安全」之争。它们大致也是按时间顺序叠上来的。

第一条路:用抽象,让你根本没机会犯错

最治本的思路是:把容易出错的手动操作,藏进一个不会出错的抽象后面。 你不写 malloc/free、不碰裸指针、不手算数组长度,自然就不会在这些地方出错。这是 C++ 相对于 C 最大的底气,也是 Bjarne Stroustrup 一以贯之的主张。

RAII:C++ 内存安全的地基

RAII(Resource Acquisition Is Initialization,资源获取即初始化) 是 Stroustrup 在 C++ 早期就确立的核心惯用法,也是后面几乎一切的地基。思想极简:把资源的生命周期绑定到一个栈对象的生命周期上——构造时获取,析构时释放。对象离开作用域,编译器保证调用析构,资源自动归还,哪怕中途抛异常也不漏。

1
2
3
4
{
    std::lock_guard<std::mutex> lk(mtx);  // 构造即加锁
    // ... 中间随便 return / throw
}                                          // 析构即解锁,绝不漏

RAII 把「记得释放」这件靠人自觉的事,变成了编译器强制的机械保证。C++ 里一切「自动管理」的东西——容器、字符串、智能指针、锁守卫——本质都是 RAII。

容器与 string:干掉裸数组和 char*

内存安全最古老的伤口,是 C 风格的裸数组和 char* 字符串:长度要自己记、边界要自己查、strcpy 一不小心就溢出。C++ 的答案是用 STL 容器(std::vector 等)和 std::string 取而代之——它们自己管理内存、自己记长度、自动增长,strcpy 溢出这类经典漏洞在 std::string 的世界里根本不存在。

「能用 vector/string 就别用裸数组」是 C++ 内存安全最朴素也最有效的一条准则——可惜遗留代码里裸数组遍地都是,这也正是问题至今没根除的原因之一。

智能指针:把「谁来 delete」写进类型

裸指针的两大原罪——忘了 delete(泄漏)delete 之后还用(UAF)——催生了智能指针,用 RAII 管理堆对象的所有权:

  • auto_ptr(C++98):第一次尝试,但它的「拷贝即转移所有权」语义反直觉又危险,是公认的反面教材,C++11 弃用、C++17 移除。
  • unique_ptr(C++11):独占所有权,move-only,零开销,是今天的默认选择。
  • shared_ptr(C++11,源自 Boost/TR1):引用计数的共享所有权,计数归零自动释放。
  • weak_ptr:配合 shared_ptr 打破循环引用——引用计数天生治不了环,weak_ptr 就是那把剪刀。
1
2
auto p = std::make_unique<Widget>();   // 独占,出作用域自动 delete
auto s = std::make_shared<Config>();   // 共享,计数归零才 delete

「用智能指针表达所有权」——现代 C++ 里几乎不该再出现裸 new/delete。但要注意:智能指针治的是所有权/生命周期,治不了悬垂引用(你完全可以拿一个指向已析构对象的裸引用或 .get() 指针)——这个洞留到后面「默认安全」那节才有人认真去堵。

optional 与 variant:把「空」和「多态」也变类型安全

两个更年轻的抽象,堵的是另一类经典坑:

  • std::optional(C++17):取代「用空指针 / -1 / 魔法值表示『没有』」的老套路。「可能没有值」被编码进类型,逼你在用之前先判断,从源头上减少了空指针解引用。
  • std::variant(C++17):类型安全的联合体,取代裸 union。裸 union 是内存安全的老雷区——你可以按一个类型写、按另一个类型读,variant 记住「当前是哪个类型」,用 std::get 读错类型会抛 std::bad_variant_access 异常,而不是给你一段乱码内存。

它们的共同点,和前面一脉相承:把「运行时才会爆的错误」搬到「类型系统里」去表达,让编译器帮你盯着。

领域自建 GC:当所有权关系太复杂

有些场景对象关系是一张复杂的图(游戏世界里成千上万互相引用的实体),手工或引用计数都力不从心,于是有人在 C++ 里自建垃圾回收。最有名的是 Unreal Engine 的 UObject 系统:所有 UObject 派生类通过 UPROPERTY 反射登记引用关系,引擎跑一个标记-清除(mark-and-sweep)的 GC 定期回收不可达对象。COM / ATL 的 CComPtr、很多脚本引擎的绑定层,也都是各自领域的「自建生命周期管理」。

值得一提的是,C++ 标准本身也认真尝试过拥抱 GC:C++11 加入了一组「最小垃圾回收支持」API(std::declare_reachablestd::pointer_safetystd::get_pointer_safety 等,源自 Hans Boehm 等人的提案 N2670),本意是给可选的垃圾回收器和「基于可达性的泄漏检测」留出标准钩子。但几乎没有任何实现真正用它,最终在 C++23 被整个移除(提案 P2186)。这段「引入又删除」的插曲本身就说明问题:GC 从没能成为 C++ 的主流方向——社区用脚投票,选了 RAII + 智能指针这条「确定性析构」的路。

这说明一件事:C++ 没有内置 GC,但它给了你造 GC 的自由。 代价是每个大项目都在重新发明一套生命周期管理,彼此不通用——这种「灵活但各自为政」的气质,贯穿 C++ 的方方面面。

线程安全:把锁也 RAII 化

多核时代,数据竞争成了内存安全的新前线。C++ 的应对同样是 RAII + 类型:

  • std::lock_guard / std::scoped_lock(C++17)/ unique_lock:把加锁解锁绑进作用域,绝不漏解锁、异常安全。scoped_lock 还能一次锁多个互斥量并避免死锁
  • std::atomic(C++11):无锁原子操作,从语言层面提供跨线程可见性保证。
  • C++11 内存模型:这是里程碑——C++ 第一次在标准里定义了多线程语义,明确「数据竞争是未定义行为」,也给了 atomic 的 memory order 一套严谨语义。在此之前,多线程全靠平台约定和运气。
  • std::jthread(C++20):自动 join 的线程,又是一次 RAII 化。

但线程安全的难处在于:RAII 能保证「锁被释放」,却保证不了「你在该加锁的地方加了锁」。 漏加锁、加错锁导致的数据竞争,纯语言层面拦不住。这个洞有两层补法:

  • 编译期:Clang Thread Safety Analysis。 Clang 提供一套线程安全注解(源自 Google,Chromium / abseil 大量在用):用 GUARDED_BY(mu) 标明「这个字段必须持有 mu 才能访问」,用 REQUIRES(mu) / EXCLUDES(mu) 标明函数对锁的要求,把互斥量建模成一种「能力(capability)」。开 -Wthread-safety 后,编译器就能在编译期静态查出「访问了 GUARDED_BY 的字段却没持有锁」这类错误——零运行时开销,还能拦住测试根本没跑到的路径。

    1
    2
    3
    4
    5
    6
    7
    
    class BankAccount {
      std::mutex mu_;
      int balance_ GUARDED_BY(mu_);        // 声明:碰 balance_ 必须持 mu_
      void deposit(int n) {
        balance_ += n;                     // 编译告警:没加锁就动 balance_
      }
    };
    
  • 运行时:ThreadSanitizer。 注解要人去标,也覆盖不了所有模式;标不到、查不出的漏网数据竞争,再靠 TSan 在运行时抓现行(见下文)。

编译期静态查 + 运行时动态抓,两层配合,才把「漏加锁」这个洞尽量堵严。

一个必须澄清的误区:回到 C 不是退路

每逢内存安全被拿出来说事,总有一种声音:「C++ 太复杂、坑太多,不如退回 C,简单就是安全。」这是把「特性少」错当成「更安全」了——恰恰相反,绝大多数情况下 C 比 C++ 更不安全。

道理就在这一整节里:C++ 用来提升安全的那些抽象,C 一样都没有——

  • 没有 std::string / std::vector,只能裸 char* + 裸数组,长度自己记、边界自己查,strcpy 溢出是家常便饭;
  • 没有 RAII 和智能指针,资源全靠手动 malloc/free 配对,漏一个就泄漏、错一个就 UAF;
  • 没有 optional/variant、没有模板带来的类型安全,void* 和裸 union 满天飞;
  • 没有构造函数保证初始化、没有析构函数保证清理。

也就是说,C 把 C++ 好不容易建起来的每一道防线都拆光了,只剩最原始的裸内存操作。 「简单」是真的,「安全」恰恰相反。

唯一值得一提的例外,是 C++ 的 lambda 按引用捕获([&]:闭包捕获了一个局部变量的引用,一旦闭包的生命周期超过了那个变量,就成了悬垂引用——这类坑 C 反而没有(因为 C 压根没有闭包)。但这是极少数 C++ 比 C 多出来的陷阱,拿它来论证「C 更安全」是以偏概全。

顺带澄清一个常被问到的问题:那已发现的漏洞里,C 和 C++ 各占多少? 老实说,很难干净地分开,而且不同口径结论还会打架。 微软的「70% CVE 是内存安全问题」、Chromium 的 70%、Android 曾高达约 76%,统计时都是把 C 和 C++ 合在一起算的;CVE/NVD 数据库里根本没有「实现语言」字段。少数把两者分开统计的(如 Mend 的开源漏洞报告)显示:按总量 C 遥遥领先(约占一半),但按高危漏洞占比 C++ 反而略高于 C——而这些数字都被「C 存量代码最老最多」严重干扰,也没按代码量归一化,说明不了「谁天生更危险」。所以任何「C 占 X%、C++ 占 Y%」的精确拆分,都别太当真

还得泼一盆冷水在一个流行说法上——「C++ 的内存漏洞主要出在它继承自 C 的那部分(裸指针、strcpy)」:这话只对了一半。 C 子集确实是重灾区,但现代 C++ 特性会制造自己的内存 bugstring_view / span 悬垂、迭代器失效、[&] 捕获悬垂,以及占比最高的 use-after-free——很多都发生在智能指针和引用之间,而非裸指针。连 Herb Sutter 都坦承,没有数据能证明「光靠 RAII + 智能指针」就足够安全。这恰恰把界线指向了本文的终点:真正划分安不安全的,不是「C 还是 C++」,甚至不全是「老写法还是新写法」,而是「安全有没有被默认强制」——也就是下一节 Profiles / Safe C++ 要啃的硬骨头。

结论呼应本文的大主线:解决内存安全靠的是「往前走」——加更强的抽象、更严的检查、更安全的默认值;而不是「往回退」,把已有的防线一并拆掉。 后面要讲的 Rust、Safe C++,全都是「向前」的方向。

抽象能防住「新写的规范代码」,但拦不住已经写下的 bug遗留代码。于是另一条路是:让程序带着「监工」跑起来,一旦碰内存就当场抓现行。

Purify:鼻祖

这条路的祖师爷是 Rational Purify(1991 年,Pure Software,作者之一 Reed Hastings——对,后来创立 Netflix 那位)。它用目标码插桩,在每次内存访问前后插入检查,抓越界、UAF、未初始化读、泄漏。它几乎是凭一己之力开创了「内存排错工具」这个品类,在 1990 年代是 C/C++ 开发者的标配神器。

它更深远的贡献是那套核心思想:给每一块内存维护一份「状态」(已分配 / 已释放 / 已初始化 / 不可访问),每次访问都对照状态检查。 这个「影子状态」的思路,正是后来 Valgrind 的 V-bits、以及 AddressSanitizer 的影子内存(shadow memory)的直接祖先——三十年后的现代工具,骨子里还在用 Purify 立下的范式。

Electric Fence 一族:用虚拟内存当陷阱

一个更轻巧的思路是利用 MMU 硬件Electric Fence(efence,Bruce Perens) 把每次分配单独放一页,并在紧邻处放一个不可访问的保护页——一旦越界,直接触发段错误,调试器停在犯罪现场那一行,而不是等到很久以后堆损坏才崩。同族的还有 DUMA、macOS 的 Guard Malloc(libgmalloc、Windows 的 PageHeap / Application Verifier。特点是精准定位越界的那一刻,代价是内存开销巨大(每次分配至少占一页)。

Valgrind:不用重编译的重武器

Valgrind(2002,Julian Seward) 是划时代的:它基于动态二进制翻译,把程序跑在一个合成 CPU 上,不需要重新编译就能上,核心工具 Memcheck 能抓几乎所有内存错误——越界、UAF、未初始化读、泄漏,一网打尽。代价是慢,约 20~30 倍。但「拿来就能用、什么都能抓」让它成了二十多年来的事实标准,直到 Sanitizer 出现才被部分取代。

Debug 迭代器:STL 自带的安检

STL 用错了同样会内存越界(迭代器失效、越界下标)。于是标准库实现自带了「调试模式」,给容器和迭代器加运行时检查:

  • libstdc++-D_GLIBCXX_DEBUG
  • MSVC STL_ITERATOR_DEBUG_LEVEL(取代了老的 _SECURE_SCL / _HAS_ITERATOR_DEBUGGING
  • libc++:并入了后面要讲的 hardening 模式

打开后,vector::operator[] 越界、用失效的迭代器、给算法传错区间,都会当场报错而不是给你一段未定义行为。调试构建默认开、发布构建默认关——又是一次「安全 vs 性能」的取舍。

gperftools:顺带管泄漏

Google 的 gperftoolstcmalloc(高性能内存分配器)为核心,捆了一套 heap profiler / heap checker——能画出「内存都被谁吃了」,也能在程序退出时报告泄漏。它和 Valgrind 的泄漏检测互补:开销小得多,适合挂在长跑的服务上。

Sanitizer:编译器插桩,现代主力

2011 年前后,Google 的一批工程师(Konstantin Serebryany 等)在 LLVM 里搞出了 AddressSanitizer(ASan),彻底改变了格局。它不再靠二进制翻译,而是在编译期插桩——编译器在每次内存访问前插入检查,配合「影子内存(shadow memory)」和「红区(redzone)」记录每块内存的可访问状态。只慢约 2 倍(Valgrind 是几十倍),精度还更高。一家人越来越壮:

Sanitizer抓什么基本原理大致开销
ASan越界、UAF、重复释放(空间+时间)影子内存(每 8 字节配 1 字节影子记状态)+ 分配两侧插「红区」+ 释放后进「隔离区」延迟复用CPU ~2×,内存 ~2–3×
LSan内存泄漏程序退出时做一次可达性扫描(类似保守式 GC),扫不到的已分配块即泄漏近乎零(只在退出时跑)
TSan数据竞争影子内存记录每次访问的历史 + 向量时钟推导 happens-before 关系CPU ~5–15×,内存 ~5–10×
MSan读未初始化内存逐位影子记录「初始化没」,并随计算传播这个「脏」状态CPU ~3×;要求所有代码(含依赖库)都插桩
UBSan各类未定义行为(整数溢出、错位、空指针解引用……)在特定运算处内联插入轻量检查,就地判断低(视开启的检查项而定)
HWASan同 ASan借 ARM「地址高位忽略(TBI)」把 tag 塞进指针高位,与内存 tag 比对内存远低于 ASan,CPU 适中;主要限 ARM64

一句话概括它们的共性与差异:ASan/TSan/MSan/HWASan 都靠「影子内存」记录每块内存的额外状态,区别只在记的是什么(可访问性 / 访问历史 / 初始化状态);UBSan 不用影子,而是就地插检查LSan 最轻,只在退出时扫一遍。开销上 TSan 最重、UBSan/LSan 最轻,也决定了用法:ASan/UBSan 适合天天挂 CI,TSan 一般单独针对并发代码专门跑。

Sanitizer 今天是 C++ 内存排错的绝对主力,-fsanitize=address,undefined 几乎是 CI 标配。但注意它的定位:它是「测试期动态检测」,不是「生产期防护」——你得让测试真的覆盖到出错的路径,它才抓得到。没跑到的代码,它一无所知。这是所有动态检测的共同天花板。

模糊测试(Fuzzing):自动喂出「会让它崩」的输入

上面那个天花板——「得先跑到」——正是 Fuzzing(模糊测试) 要解决的。思路是自动生成海量畸形 / 随机输入去猛灌程序,撞出崩溃和异常。这个词是 Barton Miller 1988 年在威斯康星大学提出的,早期就是「盲目乱灌」(dumb fuzzing),效率有限。

真正的飞跃是覆盖率引导(coverage-guided)AFL(American Fuzzy Lop,Michał Zalewski,2013) 用编译期插桩记录每个输入走过的代码路径,再用类似遗传算法的思路留下能探到新路径的输入、继续变异,像进化一样一步步钻进程序深处。libFuzzer(LLVM,进程内)是同思路的库化版本。

Fuzzing 和 Sanitizer 是天作之合:Fuzzer 负责「找到会触发 bug 的输入」(广度),Sanitizer(ASan/UBSan/MSan)负责「把那一刻的内存错误精确抓出来」(精度)——一个探路、一个定罪。这套组合是今天挖内存漏洞的主力打法。Google 更把它工业化成了 OSS-Fuzz(2016):给几百个重要开源项目挂上 7×24 的持续 fuzzing,累计找出了数以万计的漏洞(其中大量是内存安全问题)。同类还有社区维护的 AFL++、Google 的内核 fuzzer syzkallerhonggfuzz 等。

Fuzzing 补的正是 Sanitizer「得跑到」的洞,但它也不是万能:它擅长「有明确输入入口」的解析器、编解码器、协议、文件格式,对那些需要复杂前置状态或特定业务逻辑才能触发的深层路径,依旧力不从心。

第三条路:编译器与库加固——静态拦截 + 运行时兜底

第三条路不改你的代码结构,而是让编译器和标准库更聪明地帮你挡:有些在编译期就报警,有些悄悄插入运行时检查。

注解:把「契约」告诉编译器

编译器默认不知道你的函数「不接受空指针」「返回值必须用」。注解就是把这些契约标出来,让编译器帮你静态检查:

  • GCC/Clang 属性__attribute__((nonnull))(参数非空)、__attribute__((warn_unused_result))(返回值必须用,比如别忽略错误码)、__attribute__((cleanup))(给 C 也加个 RAII)、__attribute__((malloc))
  • 标准 attribute[[nodiscard]](C++17,warn_unused_result 的标准版)。
  • 微软 SAL(Source-code Annotation Language):一套丰富的注解(_In__Out__When_、缓冲区大小标注 _In_reads_ 等),描述参数的方向、可空性、缓冲区大小,喂给 MSVC 的静态分析器(/analyze)去查越界和空指针。SAL 是注解方向做得最系统的一个,Windows 头文件里铺得到处都是。

注解的价值是把人脑里的隐含约定变成机器可检查的约束,缺点是要人去标——遗留代码不会自己长出注解。

安全函数:给危险 API 加个「带长度」的版本

针对 strcpy/sprintf/gets 这些「不带边界」的危险函数,微软搞了一套安全 CRT——strcpy_ssprintf_s 等,强制传入目标缓冲区大小,越界就触发处理程序。它后来被提进 C11 标准的 Annex K(Bounds-checking interfaces)

但这里有段失败史值得记:Annex K 基本被非微软阵营抵制了。glibc 从没实现它;WG14 的报告 N1967《Field Experience With Annex K》直言其 API 设计有缺陷、几乎没有可移植的实现、真实世界采用寥寥,建议从标准里删掉。所以今天你在 Windows 上看到满屏 _s 函数,换到 Linux 上却基本用不了——这是一次「好意但设计欠佳、又没跨平台共识」的标准化尝试的教训。 微软自己还提供了 banned.h(把危险函数标成编译错误)和 strsafe.h 作为配套。

FORTIFY_SOURCE:编译期算得出大小,就替你插检查

一个很巧的思路:很多缓冲区的大小,编译器其实在编译期就能算出来。glibc 的 _FORTIFY_SOURCE 就利用这点——用 -D_FORTIFY_SOURCE=2 -O2 开启后(它必须配合优化,因为要靠优化器算缓冲区大小),编译器把 memcpystrcpysprintf改写成带检查的 __memcpy_chk__strcpy_chk 变体,用 __builtin_object_size 算出目标缓冲区的大小,运行时一旦发现要写超了就立即中止,而不是让它默默溢出。

1
2
3
char buf[10];
strcpy(buf, src);   // 开 _FORTIFY_SOURCE 后 → __strcpy_chk(buf, src, 10)
                    // src 超过 10 字节?直接 abort,而非溢出

它的妙处是几乎零成本、对源码零侵入——重编一下就有,能算出大小的地方自动加保护,算不出的地方原样放行。_FORTIFY_SOURCE1/2/3 三档,级别越高检查越严。这是发行版默认给系统软件加的一道「免费护栏」。

系统级加固:CPU + OS + 链接器联手,让漏洞「打不穿」

还有一大类技术不去「消除」漏洞,而是让漏洞即使存在也难以被利用成功——它们是一整套「利用缓解(exploit mitigation)」:CPU 提供硬件机制,操作系统 / 加载器 / 编译器协同启用。这是内存安全里最依赖硬件的一层,值得专门看看「谁提供、谁管理、谁受益」的配合关系:

  • 不可执行 + 地址随机化(地基两件套):CPU 提供 NX / XD / XN 位(页面「不可执行」),OS 据此把数据页标成不可执行,就是 DEP / W^X——注入的 shellcode 跑不起来。同时 OS 做 ASLR(配合 PIE,随机化栈、堆、库和可执行文件基址)让攻击者猜不到地址,内核那份叫 KASLR。这两样是 1990 年代末以来现代系统的默认底座。(ASLR 依赖 PIE,PIE 又牵涉重定位——见《链接器发展简史》 里的 RELR 一节。)

  • 守住控制流——前向边与后向边:溢出最经典的目的是改返回地址或劫持函数指针,于是有一整套护栏:
    • 后向边(返回地址):编译器插栈金丝雀(canary,StackGuard,Crispin Cowan 1998 / ProPolice 的思路,随机值由 OS 经 AT_RANDOM 提供,-fstack-protector-strong 是今天常规);更硬的是 Intel CET 影子栈(CPU 另存一份返回地址副本、返回时比对,Windows 叫「硬件强制堆栈保护」)和 ARM PAC(指针认证)(用密钥给指针签名,改一下就失配)。
    • 前向边(间接调用 / 跳转):软件方案是 Clang 的 -fsanitize=cfi;硬件方案是 Intel CET 的 IBT(ENDBRANCH 落地点)ARM BTI 和 Windows 的 CFG / XFG(控制流保护)——限制间接跳转只能落到合法目标,堵死「跳进 gadget 中段」。这些都要 CPU 出指令、编译器插标记、OS/加载器标页三方配合。
  • 内核 / 用户隔离Intel SMEP/SMAPARM PXN/PAN——CPU 禁止内核态直接执行或读写用户态内存,OS 打开开关。这堵死了「内核漏洞跳去执行用户态预布好的代码/数据」这一大类提权套路。

  • 加载期锁死入口:链接器 + 加载器合作的 RELRO(Relocation Read-Only)+ BIND_NOW,在启动时把 GOT 等元数据解析完就设为只读,堵掉「改 GOT 劫持函数调用」的经典手法。还有内存保护键(Intel MPK/PKU、ARM POE),让同一进程内划出多个保护域,做轻量的进程内隔离

  • 内存标签与能力(最接近治本的硬件方向)ARM MTE(内存标签扩展) 给每 16 字节内存和每个指针打 4 位 tag,访问时硬件比对、不匹配就异常——相当于把 ASan 做进硬件、开销低到能上生产;但它要 OS 内核处理 tag 异常 + 分配器给内存打 tag 才跑得起来。Pixel 8(2023) 是首款把 MTE 开放给用户的量产机型,不过目前默认关闭、需在开发者选项里手动打开(约 5% 开销)。更彻底的 CHERI(剑桥大学与 SRI International 提出)用「能力指针」把边界和权限焊进指针本身,从硬件强制空间与时间安全,ARM 据此做出了 Morello 原型板(配 CheriBSD)。

要分清两件事:Sanitizer / Fuzzing 是「找出 bug」(开发期),这一层是「就算有 bug 也别被打穿」(生产期)。 它们不修复漏洞,只层层抬高利用门槛——和真正消除漏洞的抽象、检测互补,共同兜底那些漏网之鱼。

静态分析:不运行也能查

还有一整路是静态分析——不跑程序,直接分析代码找可疑模式。它和 Sanitizer 正好互补:Sanitizer 要「跑到」才抓得到,静态分析不依赖执行、能覆盖测试跑不到的路径(当然代价是误报)。这条路上工具很多,从轻到重、从免费到商业:

  • 编译器自带的告警-Wall -Wextra(乃至 -Werror)是最该先开、最便宜的一道静态防线,能拦住一大批未初始化、比较符号错误、可疑类型转换等问题。GCC 从 10 起还内置了专门的 -fanalyzer(做符号执行式的深度分析,查 UAF、双重释放、泄漏),MSVC 则有 /analyze(配合前面说的 SAL 注解)。
  • Clang Static Analyzer:Clang 的路径敏感分析器,用符号执行沿各条路径推演,能查空指针解引用、UAF、内存泄漏、未初始化读等。它看的是「跨语句的执行路径」,比单条规则强,但也更慢。
  • clang-tidy:基于 Clang AST 的可扩展 linter,拥有成百上千条可开关的检查,按类分组特别适合内存安全:bugprone-*(易错模式)、cppcoreguidelines-*(直接对应 Core Guidelines,如「别用裸 new」「用 gsl::span」)、clang-analyzer-*(复用上面的静态分析器)、concurrency-*(并发问题)等。它能融进 CI 和编辑器,是今天团队里落地最广的一个,还能自动修复(--fix部分问题。
  • cppcheck:老牌开源静态分析器,独立于编译器、上手快,越界/泄漏/未初始化都查,常作为 clang-tidy 的补充。
  • Facebook Infer:跨过程(interprocedural)分析,擅长查跨函数的空指针和资源泄漏,规模化能力强。
  • 商业重器CoverityPVS-StudioKlocwork 等,分析更深、跨过程跨模块能力更强、误报控制更好,是大型/关键项目常用的选择。

静态分析的共同软肋是误报(假阳性)——规则越激进,噪音越大;以及对复杂跨函数、跨模块的场景力不从心(指针到底指向谁,静态推断天生有极限)。所以现实里的最佳实践是分层叠加:编译器告警打底 → clang-tidy 进 CI → 关键项目再上商业工具,和动态的 Sanitizer 一起,形成「静态 + 动态」的双保险。

AI 加持:让机器像审查者一样读代码

近两年最新的变量,是 AI(大语言模型) 进入了这条链路。它不像传统静态分析那样死磕规则,而是能像人类审查者一样「读懂」代码的意图,因此在几个方向上补强了前面的手段:

  • AI 代码审查 / 分析:GitHub 的 Copilot 代码审查 / Copilot Autofix,以及各类接入大模型的安全审查工具,能在 PR 阶段指出可疑的空指针、越界、生命周期问题,甚至直接给出修复补丁。它对「这段代码到底想干嘛」「跨函数的意图」的理解,往往比纯规则的静态分析更接近人的判断,给出的解释也更好懂。
  • AI 驱动的 Fuzzing:写 fuzz 目标(harness)一直是苦力活,Google 的 OSS-Fuzz-Gen 用大模型自动生成 fuzz 目标,大幅降低了给一个新项目接上 fuzzing 的门槛——相当于把上一节的 Fuzzing 也自动化了。
  • AI 漏洞挖掘 agent:更前沿的是让大模型像安全研究员一样自主分析代码找漏洞。Google 的 Big Sleep(前身 Project Naptime)在 2024 年底用这种方式,在 SQLite 里挖出了一个真实、可利用的 0-day,被视为「AI agent 找到真实漏洞」的标志性案例。

但要清醒:AI 现在是「加速器」,不是「替代品」。 它会「幻觉」出并不存在的 bug、也会漏掉真的 bug,产出必须经人或其他工具复核。把它当成「读得快、覆盖广、会解释的初审」,再压上 Sanitizer、Fuzzing、静态分析和人工把关,才是当下靠谱的用法。

硬化标准库:给容器加「生产级」检查

前面的 debug 迭代器太重、只适合调试。近年 libc++ 引入了分级的 hardening 模式_LIBCPP_HARDENING_MODE,取代旧的 _LIBCPP_ENABLE_ASSERTIONS):从 fast(低开销、只查最关键的越界)到 debug(全量检查),让你能在发布构建里也开一档轻量边界检查。配合 C++20 的 std::span(带长度的数组视图)、C++17 的 std::string_view、以及一直都在的 at()(带边界检查的下标),标准库这些年在「默认更安全」上明显在使劲。

第四条路(进行时):把「默认安全」写进语言

走到这里,你应该已经看出那个共同的命门了:前面所有技术,几乎都是 opt-in(可选加入)的。

RAII 你得主动用、智能指针你得主动选、Sanitizer 你得主动开、注解你得主动标、FORTIFY 你得主动编译。只要有人写一行裸指针、裸数组、漏一个锁——洞就在那儿。 而 Rust 的杀手锏恰恰相反:默认安全,不安全要显式写 unsafe NSA 点名 C++ 的,根本不是「C++ 没有安全工具」,而是「C++ 默认不安全」。

于是最新的战场,是把安全变成默认。而把这个议题从「C++ 圈内的自我改进」推成「关乎存亡的紧迫大事」的,是一个外部变量——Rust

Rust 的冲击:安全不再需要牺牲性能

C++ 社区几十年里有一个心照不宣的信念:「内存安全是有代价的——要么上 GC(像 Java),要么忍受运行时检查开销。C++ 选择性能,所以只能默认不安全。」 这个信念是 C++「默认不安全」最大的挡箭牌。

Rust(2015 年 1.0)把这块挡箭牌打碎了。 它用所有权 + 借用检查器(borrow checker),在编译期就根治了时间安全和数据竞争(几乎零开销);空间安全(数组越界)则靠编译期分析 + 少量运行时边界检查兜底(实测通常是个位数百分比的开销)。整个过程既没有 GC,性能又和 C++ 同档。它的模型和 C++ 正好反过来:默认安全,你想干危险的事必须显式写 unsafe 把它圈起来。 这一下就证明了:「安全」和「高性能」不是二选一——是 C++ 过去的设计没做到,而不是做不到。

比「证明可行」更有杀伤力的是真实战果

  • Google Android:随着新代码转向 Rust 等内存安全语言,内存安全漏洞占比从 2019 年的约 76% 一路降到 2024 年的约 24%;而且报告称 Rust 写的新代码里内存安全漏洞几乎为零——用数据说明了「换语言」比「打补丁」见效快得多。
  • 落地版图Linux 内核自 2022 年起接纳 Rust;微软在用 Rust 重写 Windows 部分组件;Android、Chromium、AWS、Cloudflare 纷纷把关键路径迁往 Rust。
  • 政府背书:前面 NSA、CISA、白宫的文件里,Rust 几乎是被点名推荐的头号「内存安全语言」

这才是真正的压力来源。在 Rust 之前,「不安全但快」的 C++ 没有对手,那句挡箭牌无人能反驳;Rust 之后,存在一个可信的、安全的、无 GC 的、同样高性能的替代品,还带着国家背书——「Rewrite it in Rust」从一句玩笑变成了很多团队的真实决策。

正是这股冲击,逼出了 C++ 下面这两条自救路线——它们本质上都是在回答同一个问题:C++ 能不能在不背叛自己(性能、向后兼容)的前提下,也做到「默认安全」?

路线一:Core Guidelines + Profiles(渐进加固)

Stroustrup 和 Herb Sutter 2015 年推出 C++ Core Guidelines,配套 GSL(Guidelines Support Library) 提供 gsl::span(→ 已进标准成 std::span)、gsl::not_null 等类型,还有一个雄心勃勃的 Lifetime profile——想靠局部静态分析在编译期抓悬垂指针。

近年 Stroustrup/Sutter 力推 Profiles(一组可开关的安全子集,如类型安全、边界安全、生命周期安全 profile):思路是不改语言、不搞大重写,通过分级开启的规则和检查,让现有代码渐进地变安全。卖点是兼容存量代码

路线二:Safe C++(引入借用检查器)

另一派认为渐进加固不够彻底。Sean Baxter(Circle 编译器作者)2024 年提出 Safe C++(提案 P3390):直接给 C++ 移植一套 Rust 式的借用检查器(borrow checker),引入 safe/unsafe 上下文和一套安全标准库 std2,目标是可证明的内存安全,而非「尽量少出错」。它更彻底,但代价是要引入一套新方言和新标准库,和存量代码的融合是巨大挑战。

两条路线之争,本质是 C++ 的灵魂之问:是守住向后兼容、渐进打补丁(Profiles),还是壮士断腕、引入借用检查换取真正的安全保证(Safe C++)?截至目前委员会更倾向前者,但争论远未结束。而背景音里,是 Rust 的持续施压和各国政府的 memory-safety mandate——这一次,外部压力是真的大。

最新前沿:这两三年正在发生的

前面四条路是主干,最后补一组这两三年才真正落地或成型的新东西——它们几乎都在做同一件事:把过去「可选」的安全,往「默认打开」再推一步。

  • 默认初始化,消灭「未初始化读」:编译器新增 -ftrivial-auto-var-init=zero/pattern,自动把栈变量清零或填入毒值,Linux 内核、Android、Chrome 都已默认开启。C++26 更进一步引入 「erroneous behavior(错误行为)」(P2795):读未初始化变量不再是纯粹的未定义行为,而是「有明确定义的错误行为」,可被工具稳定诊断——从语言层面给这一类 bug 判了刑。

  • 生产级、低开销的检测与缓解:Sanitizer 太重、上不了线上,于是有了 GWP-ASan——采样式的堆错误检测器,开销低到能挂在真实用户流量上,靠概率长期捞 bug。配套还有强化型分配器 Scudo(LLVM,Android 默认)、hardened_malloc(GrapheneOS)。Chrome 则用 MiraclePtr(raw_ptr<T> / BackupRefPtr) 把 use-after-free 变成不可利用的安全崩溃——不追求消灭 UAF,而是让它「即使发生也打不穿」。

  • 给 C/C++ 直接补上边界安全:Apple 在 Clang 里推 -fbounds-safety__counted_by 注解,让 C 的裸指针也带上长度、做边界检查,已用在 XNU 内核等代码上——相当于把「胖指针」的思路低成本塞进现有 C。

  • 语言标准自己发力(C++26):除了上面的 erroneous behavior,C++26 还带来 Contracts(契约,P2900)——把前置/后置条件写进语言,可在运行时检查空指针、越界等;以及标准库强制硬化的方向,把 libc++ 那种「发布版也开边界检查」的模式往标准里推。

  • 硬件安全真正量产:ARM MTE 不再只是纸面特性——Google Pixel 8(2023) 起在消费级设备上提供了内存标签(目前是可选开关、非默认开启);学术界的 CHERI(剑桥大学 + SRI International)也有了 ARM 造的 Morello 原型板,把「胖指针即能力」的硬件方案推到了可实测阶段。

  • 务实的中间路线:C++/Rust 互操作:既然全量重写不现实,就让两种语言共存——Google 的 Crubit、社区的 cxxautocxx 做双向绑定,让新模块用 Rust 写、旧 C++ 逐步替换。这也是当前各大厂最现实的迁移姿势。

  • 更激进的实验:也有人干脆造「安全的 C」——比如 Fil-C,用垃圾回收 + 能力指针把 C/C++ 变得内存安全的编译器(代价是性能),2024 年起颇受关注。这类项目未必成主流,但代表了「不换语言、直接让 C 安全」的另一种想象。

一句话收口:这些新潮流方向惊人地一致——要么把安全的「默认值」往上拧,要么在不重写的前提下给存量 C/C++ 加装护栏。 这恰好是下一节这条主线的注脚。

一条主线:四十年,一直在补,但总差一层

把这四十年摊平了看,主线极其清晰:

C++ 的内存安全技术,是一层一层叠上来的补丁,每一层都精准地堵一类洞:

  • 抽象(RAII / 容器 / 智能指针 / optional / variant)——从源头上让你少犯错;
  • 动态检测(Purify / efence / Valgrind / debug 迭代器 / Sanitizer / Fuzzing)——把已经犯的错在运行时抓现行;
  • 编译器/库加固(注解 / 安全函数 / FORTIFY / canary / ASLR / MTE / 静态分析 / AI 审查 / 硬化 STL)——静态拦一批,运行时兜一批,实在漏了也让它难以被利用;
  • 默认安全(Core Guidelines / Profiles / Safe C++)——正在试图补上那最关键、也最难的一层。

每一层都真实有效,叠起来威力也确实可观——认真用满这套组合拳的 C++ 项目,内存安全水平其实相当高。 但它们有一个共同的命门,也正是 NSA 判决书的核心:这一切都是 opt-in 的,而安全一旦可选,就总有人不选、总有一行代码漏掉。 四十年的补丁再多,也没能把 C++ 的默认状态从「不安全」翻成「安全」。

这就是今天这场「Profiles vs Safe C++」之争的全部分量——它不是又一块补丁,而是在试图改写 C++ 的默认值。C++ 能不能在守住兼容性的同时,把最后这一层补上,大概是它未来十年最重要的一仗。

理解了「一层层打补丁、但始终差『默认安全』这一层」这条主线,再回头看 RAII、Sanitizer、FORTIFY、Safe C++ 这一堆眼花缭乱的名词,就都对上号了——它们不是各自为战,而是同一场四十年战争里的一波波攻势。

推荐阅读

This post is licensed under CC BY 4.0 by the author.

© chen3feng. Some rights reserved.

Using the Chirpy theme for Jekyll.