C++中驯服 UB(Undefined Behavior)怪物
驯服 Tiamat,解除 Cthulhu 的召唤:C++ 中驯服 UB 怪物
为了更多关于 C++ 安全和安全问题的背景知识,包括“语言安全”和“软件安全”的定义以及类似术语,请参阅我 2024 年 3 月的文章“语境中的 C++ 安全性”。 本文将从上一篇文章结束的地方继续讲述,重点关注 undefined behavior(又称 UB)。
本文是关于当前正在进行的 C++ 软件加固和安全改进的状态更新。
C++ 社区正在广泛地进行大量加固工作。 在整个行业中,这包括各个供应商正在进行的工作,他们随后将这些工作贡献给标准化流程,以便 C++ 程序员可以移植地使用它。 在标准中,它包括我们已经使用了一段时间的东西(无 UB 的 constexpr 编译时代码),到我们最近所做的事情(在 C++26 草案中:erroneous behavior,边界加固的标准库,以及用于功能安全的 contracts),以及我们接下来积极追求的提案(进行中:Bjarne Stroustrup 的 profiles,Úlfar Erlingsson 的远程代码执行加固)。
所有这些工作的一个共同潜在主题是,每一部分都在解决越来越多的 C++ undefined behavior(又称 UB),尤其是攻击者利用最多的 UB。 我们有条不紊地解决 UB,首先解决常见的高价值案例,这些案例将最大限度地加强我们的代码:未初始化的变量、越界访问、指针误用,以及对手需要实施远程代码执行的关键 UB 案例。 这些是攻击者利用的弱点,我们正在锁定这些弱点以将他们拒之门外。
常见的(误)解:“UB 对于 C++ 来说太重要了,试图充分改进它毫无希望”
为了便于讨论,假设笼子不受龙息和灵能的影响。 这只是一个比喻。
技术权威人士似乎仍然普遍认为,UB 与 C++ 的规范和程序有着根本的纠缠,以至于 C++ 永远无法解决足够的 UB 来真正发挥作用。 确实,目前很容易一不小心就让无声 UB 的触角普遍地潜伏在我们的 C++ 代码中。
简而言之:在 C++ 中,(通常是意外地)执行 UB 的代码是我们内存安全和安全漏洞问题的根本原因。 当一个程序包含 UB 时,任何事情都可能发生; 通常将整个事情称为“UB 龙”,并说“UB 可以重新格式化您的硬盘驱动器或让恶魔从您的鼻子里飞出来”——因此有了 Tiamat 和 Cthulhu 的隐喻。 然而,比这些事情更糟糕的是,UB 经常导致可利用的安全漏洞和其他难以修复的错误。 (有关 UB 的更多详细信息,请参阅附录。)
因此,问一个问题是有效的:C++ 能否并且将会采取足够的措施来处理 UB,从而产生重大影响?
总结和剧透
在这篇文章中,我很高兴地报告说,对 C++ UB 的认真驯服正在进行中……
(1)自 2011 年的 C++11 以来,越来越多的 C++ 代码已经变得无 UB。 大多数人只是没有注意到。
- 剧透: 所有
constexpr/consteval编译时代码都是无 UB 的。 截至 C++26,几乎整个语言和大部分标准库都可以在编译时使用,并且在编译时执行时是无 UB 的(但代码在运行时执行时不是,因此以下所有额外工作都是关于运行时执行的)。
(2)自 2024 年 3 月以来,C++26 标准草案已经删除了关键的“唾手可得”的运行时 UB 案例,这些案例是导致重大安全漏洞的根本原因。
- 剧透: 在 C++26 草案中,未初始化的局部变量不再是 UB,并且加固的标准库中大多数常见的非迭代器边界错误,例如
string和vector以及string_view和span的错误,在“加固”实现中将不再是 UB。 (C++26 还具有语言 contracts,用于安全性的另一个方面,即功能安全,用于防御性编程以减少一般错误。)
(3)现在,我们正在着手添加更多工具,并系统地编目和解决 C++ 语言中的运行时 UB。
- 剧透: 尽可能静态地(在编译时)解决每个 UB 案例,或者在必要时使用运行时检查。 主要工具:(a) C++26 erroneous behavior (EB);(b) Bjarne Stroustrup 的 profiles 和 Gabriel Dos Reis 的 profiles 框架,默认选择完全安全,并在需要时再次有策略地退出(有时您确实想在特定循环中喷火);和/或 (c) 应用 C++26 contract assertions 来检查语言功能。 EB 和基本 contract assertions 已经是 C++26 的一部分; profiles 目前正在进行工作,重点关注 profiles 框架的实现和部署,以及一些关键 profiles,以便在整个 C++ 生态系统中进行实验。 此外,Úlfar Erlingsson 正在提出一个 profile,以外科手术般地消除攻击者用于进行远程代码执行 (RCE) 的 UB,这有望消除许多恶意软件漏洞(并让开发人员选择消除几乎所有恶意软件漏洞)在重新编译的 C++ 代码中。
如果成功,这些步骤将实现与其他现代内存安全语言(以安全漏洞的数量衡量)的对等性,这将消除不使用 C++ 的任何与安全性相关的原因。 请注意,与其他语言保持一致仍然意味着所有语言中都需要解决其他安全问题,例如功能安全的逻辑错误(C++26 contracts 将在此处有所帮助); 我们首先解决最有价值的目标,以与其他现代语言保持一致,然后将继续做更多的事情。
重要的是,这种加强 C++ 的方法不会改变 C++ 的价值主张——它使 C++ 仍然是 C++,它不会试图将 C++ 变成“其他东西”,例如通过要求强制性的性能开销。 以上所有内容都接受 C++ 的现有源代码及其“零开销,不用不付费”的核心价值观,并且只是使内存安全成为默认设置变得方便——始终可以选择退出,以便在您想要让 Tiamat 和 Cthulhu 在您的控制下并为您的利益使用他们的力量时,始终可以获得全部性能和控制。
它旨在具有超级可采用性,以推进现有代码:
- 许多改进无需任何代码更改即可采用(真的!)——只需使用 C++26 编译器重新编译您的现有项目,您的代码就会更安全。 这很重要,因为当您编写代码时,您会编写错误,即使在您编写代码来修复错误时,您也会编写新的错误; 这是要求代码更改的成本的一部分,我们希望将其降至最低。
- 即使您选择默认拒绝不安全代码的 profile 语言子集,您仍然可以选择退出以使用显式、可搜索和可审核的“此处禁止安全规则”注释来编写不安全的内容(类似于其他语言中的“unsafe”)。
就这样——如果您在这里停止阅读,您就掌握了完整的故事。
但我认为这些细节非常有趣,所以如果您愿意,请加入我们,我们将进一步深入研究上述 (1)、(2) 和 (3) 点……
(1) 自 2011 年以来:constexpr 代码
从 C++11 开始,C++ 的编译时 constexpr 世界已经成为一个免受 undefined behavior 的沙箱,通过启用强大的编译时计算并确保安全,悄然彻底改变了 C++。 在 constexpr 评估期间,该语言强制执行明确定义的行为——没有野指针、没有未初始化的读取、没有意外。 如果一个操作可能触发 undefined behavior,编译器只会_在编译时_拒绝 constexpr 评估。 这保证了_在_执行时间之前的正确性,使开发人员能够编写更快、更安全和更具表现力的代码。
C++ 的每个版本都在不断地使更多的语言和标准库在编译时 constexpr 代码中可用,因此截至 C++26,几乎整个语言和大部分标准库都可以在 constexpr 代码中使用。
这是现代 C++ 的最佳体现:释放编译时能力,同时强制执行其正确性。
这是在生产中使用,而不是空中楼阁: 所有主要的编译器都支持无 UB 的 constexpr 编译时代码已经超过十年,并且它已在广泛的生产中使用。 除非是非常旧的代码且使用非常旧的编译器编译,否则可能今天几乎每个重要的 C++ 项目都已经在使用至少一些无 UB 的 constexpr 代码。
(2) 自 2024 年以来:为 C++26 采用的语言安全和软件安全改进
在过去的一年中,C++26 在语言安全和软件安全方面取得了进一步的扎实进展。 简而言之,以下是 C++26 已经采用的内容(其中一些材料是从我之前的旅行报告中重复的; 有关更多详细信息和讨论,请参见链接):
- 在 2024 年 3 月(请参阅我的 2024 年 3 月旅行报告),C++26 草案通过将其转变为一种新的行为:erroneous behavior(又称 EB)消除了未初始化变量的 UB,该行为仍然被认为是“错误的代码”(因此编译器仍然应该对此发出警告),但现在已明确定义,因此即使您的代码确实违反了,它也不再是 UB 龙的诱饵。 这消除了严重的安全漏洞的一类根本原因。
- 上个月(请参阅我的 2025 年 2 月旅行报告),C++26 草案还添加了加固的标准库的规范。 仅使用加固的库重新编译即可为我们的程序提供许多常见的非迭代器 C++26 标准库操作的边界安全保证,包括对非常流行的标准类型的常见操作:
string、string_view、span、mdspan、vector、array、optional、expected、bitset和vararray。 (在同一次会议上,我们还采用了 language contracts 来帮助改进功能安全,以进行防御性编程,以减少一般错误。)
重要的是,这两者都实现了可采用性的圣杯:“只需使用 C++26 编译器/加固的库重新编译您所有的现有代码,它就会更安全。” 这是一个很棒的采用故事。 如果您看过我的任何最近的演讲,您就会知道这与我的内心息息相关…… 尤其请参阅我在波兰的 11 月演讲中的这个短片,以及关于改进 C++ 的社会价值的问答环节中的这个短片。 当然,要获得完全的安全改进有时需要更改代码,没有人会说否则——例如,如果您因为您的代码对所有权感到困惑而编写了一个悬空指针,那么您真的需要去修复并可能重组您的代码。 但很高兴我们甚至可以通过重新编译我们现有的代码来获得一部分安全改进!
同样,这是在生产中使用,而不是空中楼阁: 对未初始化变量和加固的标准库的支持对于 C++26 标准草案来说可能是新的,但它们已经在现有的编译器上得到了很好的支持。 对于未初始化变量,您已经可以使用预标准编译器开关 -ftrivial-auto-var-init=pattern (GCC, Clang) 和 /RTC1 (MSVC)。 对于加固的标准库,正如 P3471 作者所指出的,它已经在主要的商业环境中部署(您今天可以在 libc++ 中使用它,请在此处参阅文档; MS-STL 和 libstdc++ 具有一些类似的选择):
“我们有在 Apple 平台上在几个现有代码库中部署加固的经验。 Google 最近发表了一篇文章,其中他们描述了他们将这项技术部署到数亿行代码的经验。 他们报告了低至 0.3% 的性能影响,并发现了 1000 多个错误,包括安全关键的错误。 Google Andromeda 发表了一篇文章 大约 1 年前,介绍了他们成功启用加固的经验。 libc++ 维护人员收到了大量关于启用加固并帮助查找代码库中错误Informal 的报告。 总的来说,标准库加固取得了巨大的成功,事实上我们从未预料到会取得如此大的成功。 反响非常积极……”
这真正证明了解决唾手可得的问题的价值,以及 Pareto principle(又称 80/20 规则):通常 80% 的好处来自最初 20% 的投资。
(3) 自上个月以来:在 C++26 时间范围内正在进行的更多工作
大约一年来,多位 C++ 委员会专家独立提出了系统地编目和/或解决 C++ 中的 UB:
- 2023 年 12 月:Shafik Yaghmour 的提案 P3075R0,用于编目 C++ 的语言 UB,并将其作为标准的附录进行记录。 (基于他早期的疫情前论文 P1705R1。) 这受到了核心语言规范子组(又称 CWG)在 2024 年 3 月会议上的鼓励。
- 2024 年 10 月:我的提案 P3436R0,用于编目 UB 并使用 Bjarne Stroustrup 和 Gabriel Dos Reis 的语言 profiles 提案的选择加入机制系统地解决它,该提案能够将 profiles 指定为相关的编译时限制和运行时检查的“命名组”,这些组很容易选择加入以使安全成为默认设置。 有关更多详细信息,请参阅我的 2024 年 11 月旅行报告。 这在 2024 年 11 月的会议上获得了安全和保障子组(又称 SG23)的一致鼓励。
- 2024 年 10 月:Timur Doumler、Gašper Ažman 和 Joshua Berne 的提案 P3100R1,用于编目 UB 并使用新的 C++26
contract_assert功能系统地将其作为 contract violations 来解决,以便也对有问题的语言功能执行运行时检查。 有一个相关的提案 P3400,用于将 contract 标签指定为相关的运行时检查的“命名组”,这些组很容易选择加入以使安全成为默认设置。 P3100 在 2024 年 11 月的会议上获得了 Contracts 子组(又称 SG21)的一致鼓励。
您可以看到这种模式:有提案人且志愿者可以
- 系统地编目语言 UB,
- 指定一种消除 UB 的方法(使其非法,或者明确定义,包括在必要时进行运行时检查,例如边界检查),
- 尽可能地一直进行消除,只要它足够高效(正如 C++26 对未初始化的局部变量所做的那样),或者在可以轻松选择加入的命名组下(profile 名称或 contract 标签名称),以及
- 意识到不同的 UB 案例需要以不同的方式解决,并且我们愿意投入精力……没有魔法棒,只有工程。
在我们 2025 年 2 月的会议上,负责所有语言演进的主要子组(又称 EWG)采纳了这些建议并将其收集在一起,该小组批准了一项授权,以追求 “…一个语言安全白皮书,在 C++26 时间范围内包含对 C++ 中 核心语言 Undefined Behavior 的系统处理,涵盖 Erroneous Behavior,Profiles 和 Contracts。”
请注意,这与 C++26 是分开的,因为 C++26 现在正在进行功能冻结,并且将在明年进行评论审查和润色,因此我们现在无法向 C++26 本身添加新材料(例如 UB 缓解措施)。 但是我们想保持我们的势头,并且不希望这项重要工作等待 C++29,因此与 C++26“在 C++26 时间范围内”同时,我们打算编写一份白皮书来编目和解决 C++ 语言 UB,我们希望在大约与 C++26 发布的同时发布。
注意:白皮书是 ISO 出版物,它是技术规范 (TS) 的一种形式; 将白皮书或 TS 视为“功能分支”。 自 2012 年以来,C++ 委员会已经发布了十几个 TSes,例如 concepts 和 modules TSes,其中大部分已经合并到“主干”国际标准(又称 IS)中。 白皮书和 TS 在 C++ 委员会中使用相同的流程,但是与 TS 相比,白皮书在最后只有更少的 ISO 繁文缛节,因此可以更快地发布。
因此,现在以及在接下来的一两年内,我们正在着手系统地编目 C++ 语言中的 UB 案例,以便在每个尖牙和触手上贴上可见的标签。 然后,从最重要的高价值目标开始,开始决定是否以及如何以最合适的方式解决每个目标,但可能会使用授权中提到的三种工具:
- C++26 erroneous behavior,您会记得 C++26 标准草案已经在使用它来处理未初始化的局部变量。
- Bjarne Stroustrup 的 profiles 和 Gabriel Dos Reis 的 P3589 profiles 框架,它们允许我们创建规则和检查的命名组,以便程序代码可以轻松地默认选择完全安全,并在需要时有策略地退出。 目前正在进行的努力重点关注 profiles 框架的实现和部署,以及一些关键 profiles,以便在整个 C++ 生态系统中进行实验。
- C++26 contract assertions 检查语言功能,并扩展了 P3400 标签,该标签允许我们创建检查的命名组。
我不会说谎:这将是一项非常艰巨的工作。 而且我认为有些人不希望 C++ 能够做到这一点。 但我认为这是可以实现的,而且值得,我们感谢并感谢所有已经表示有兴趣志愿提供帮助的委员会成员——谢谢!
一周前的新闻:P3656 强烈鼓励
Gašper Ažman 和我被任命尝试组织这项工作。 因此,为了开始这项工作,Gašper 和我编写了论文 P3656,详细说明了提议的程序和计划。 3 月 19 日,EWG 在电话会议中审查了此内容,并强烈鼓励 “P3656 在为制作**“核心语言 UB** (and IF-NDR)”**白皮书提出的策略上是“正确的方向”。”
因此,以下是我们计划在未来一两年内,在与 C++26 相同的时间框架内完成的工作的快速概述……
首先,列出案例:枚举语言 UB
这部分的目标是在标准的 LaTeX 源代码中直接标记语言 UB 的每个案例,至少带有简短的描述和代码示例。 在标准的源代码中直接使用 LaTeX 标签将使我们能够自动构建另一个附录,在一个地方列出 UB,正如标准已经对语法所做的那样。 其他详细的讨论和选定的缓解措施将进入白皮书。
我们还可能会标记每个 UB 的一些基本属性,例如:
- 让安全专家标记它是否可以直接利用,以便我们可以优先考虑安全关键的唾手可得的成果; 和
- 标记它是否可以以已经可用的信息(例如使用
ptr != nullptr易于在本地检查的空指针解引用)在本地进行廉价检查,或者是否需要更多信息(例如其他非空悬空指针解引用更具挑战性,并且某些 UB 可能太昂贵而无法完全消除)。
这也产生了减少添加未来 UB 的反作用力,要求在此列表中讨论和记录任何新的 UB 提案。
其次,列出工具:创建一个“非详尽的工具入门菜单”
这个想法是创建一个我们可以应用于每个 UB 案例的工具的初始列表。
EWG 授权已经包括 erroneous behavior (EB)、profiles 和 contracts 作为主要预期工具,因此稍微更详细的候选列表可能是:
- 使 UB 明确定义(始终修复它,无需选择加入; 这可能是运行时检查);
- 使 UB 无法编译(例如,使格式不正确,这可能会更改 SFINAE 代码的含义,该代码可以使用不同的回退路径来避免 UB 路径,或者使直接拒绝,而不会更改任何含义),无论始终还是在强制执行 profile/标签时;
- 使 UB 被弃用,无论是始终还是在强制执行 profile/标签时; 和/或
- 使 UB 成为 EB,无论是始终(正如我们对未初始化的局部变量所做的那样)还是在强制执行 profile/标签时。
此列表并不详尽; 我们可能会发现想要使用另一种技术处理的 UB,但我希望可以使用这些工具很好地处理大多数 UB 案例。
我们还打算编写一些初始指南,供 EWG 审查和批准,关于何时使用每个工具,包括性能注意事项、采用障碍(如该 UB 的频率或崩溃的后果)和其他常见注意事项。
第三,应用:对于每个 UB 案例,说明我们计划如何解决它
在许多情况下,这将需要周密的论文,包括在性能或可部署性可能存在困难的风险时,需要强大的实现经验。 我希望我们会发现可以 在一篇论文中处理的所有相似 UB 组,但关键是我们希望对此进行有条不紊…… 我们的目标是 快速行动,但这里的主要目标是确保我们实际 un 打破东西。
第四,分组:将 UB 案例分组为有凝聚力的组(profile 名称/contract 标签)
最后,我们可以识别程序想要一起解决的有凝聚力的 UB 组,这使得它们易于作为一个单元选择加入; 例如,“bounds_safety”组可以包括所有与边界安全相关的 UB。 这些组可以重叠; 例如,相同的 UB 修复可以作为“bounds_safety”组的一部分进行选择,也可以作为一般较大的“strict_cplusplus”组的一部分进行选择。
几天前的新闻:正在努力锁定恶意代码所依赖的特定 UB
与此相关的是,Google 云安全 DE Úlfar Erlingsson 在 2 月份的 ISO C++ 会议上提出了一个非常有趣的提案,P3627R0 (slides):“易于采用的安全 profiles,用于防止现有 C++ 代码中的 RCE(远程代码执行)。”
总结 Úlfar 的前提:
- 我们已经在现代编译器中开发了足够多的加固实现技术,可以有效地加固现有的 C++ 代码,而无需更改代码——不是通过广泛地针对语言内存安全保证,而是通过外科手术般地针对使远程代码执行 (RCE) 成为可能的关键 UB。 具体来说:Stack integrity、control-flow integrity (CFI)、heap data integrity 以及 pointer integrity and unforgeability。(注意:Úlfar 是第一个通过强大的保证有效地实现堆栈完整性的人,他与最初在 CCured 中设计它的 George Necula 合作; 他和合作者是第一个提出和实现 CFI 的人。)
- 如果我们只是消除可以用作 RCE 构建块的 UB(即使我们仍然允许其他损坏),那么不良行为者将失去他们用来控制执行并运行其恶意软件的大部分工具,并且我们将极大地加强世界的代码。
- 一个关键问题是,现在这些技术作为单独的功能存在,而真正的优势来自于一起启用它们,因此我们应该标准化一个 profile,让程序员告诉他们的编译器一起激活它们。
上周四,Úlfar 发表了一篇新论文,详细阐述了这些想法:“如何在没有内存安全的情况下保护现有的 C 和 C++ 软件” 描述了这些技术如何不仅可以防止大多数 RCE,而且通常可以从攻击者手中夺回执行控制权。
非常值得一读。 一份更新的论文,提议将此材料用于 C++ 标准化,预计很快将在 C++ 委员会中发布。 正如 Úlfar 所指出的(强调): “这是一个很大的变化,需要团队合作: 研究人员和标准机构需要共同努力,定义一组保护 profiles,可以轻松地应用于保护现有软件——没有新的风险或困难,只需翻转一个标志……”
注意:一周前更新的相关新出版物是 OpenSSF “C 和 C++ 的编译器选项加固指南”。_这是一个有用的指南,介绍现有安全选项,这些选项值得了解,并且可以在当今的编译器中使用。 这些选项添加了各种警告和机制,可以帮助提高安全性,包括 Úlfar 的提案中使用的一些机制(CFI 和地址空间布局随机化,又称 ASLR)。 但是,这些选项都是