“这是每个开发者都应该阅读的一篇文章。 未来十年,软件行业将加速向内存安全转变。
2024 年 3 月 4 号,Google 官方博客[1]发文,宣布《安全设计 - Google 对内存安全的洞察》白皮书[2]。
这份白皮书中深入探讨了数据、解决内存不安全性的挑战,并讨论了实现内存安全的可能方法及其权衡。并且还将强调谷歌致力于实施白皮书中概述的几种解决方案,最近通过向 Rust 基金会提供 100 万美元的资助,推动了健壮的内存安全生态系统的发展。谷歌希望通过分享他们的见解和经验,能够激励更广泛的社区和行业采用内存安全的实践和技术,最终使技术更加安全。
2022 年标志着内存安全漏洞的 50 周年。自那时以来,内存安全风险变得更加明显。与其他公司一样,谷歌的内部漏洞数据和研究显示,内存安全漏洞广泛存在,并且是内存不安全代码库中漏洞的主要原因之一。这些漏洞危及最终用户、我们的行业和更广泛的社会。我们很高兴看到政府也对这个问题予以重视,比如美国国家网络主任办公室上周[3]发表了一篇关于这个主题的论文。
谷歌拥有数十年的经验,以大规模解决曾经与内存安全问题同样普遍的各类漏洞。此类的方法被称为“**安全编码**[4]”,将易受漏洞影响的编码结构本身视为危险因素(即独立于可能引发的漏洞,并且额外考虑),并且致力于确保开发人员在常规编码实践中不会遇到这些危险因素。
即便拥有如此丰富的安全经验,谷歌也是预计只有通过以全面采用具有严格内存安全保证的语言为核心的安全设计方法,才能实现高可靠性的内存安全。
在过去的几十年里,除了大规模的 Java 和 Go 内存安全代码库外,谷歌还开发和积累了数亿行正在使用和持续开发中的 C++ 代码。这个非常庞大的现有代码库对于实现内存安全的过渡带来了重大挑战。
谷歌认为对于新代码和特别是风险组件,补充过渡到内存安全语言是重要的,尽可能地改进现有的 C++ 代码的安全性。谷歌相信通过逐步过渡到部分内存安全的C++ 语言子集,并在可用时增加硬件安全功能,可以实现实质性的改进。例如,可以参考谷歌在 GCP 的网络堆栈中改进空间安全性的工作[5]。
谷歌正在积极投资于他们白皮书中概述的许多解决方案,并对美国联邦政府关于开源软件安全的请求[6]进行回应[7]。
大家都知道,内存安全的编程语言并不能解决所有的安全漏洞,但就像通过工具来消除跨站脚本攻击一样,消除大类漏洞不仅直接有益于软件的使用者,还能让我们开发者将注意力转向解决其他类别的安全漏洞。
内存安全漏洞是指当程序在内存访问构成未定义行为的状态下允许执行读取或写入内存的语句时产生的。当这样的语句在程序状态下可被对手控制(例如处理不可信输入)时,该漏洞通常代表着可被利用的漏洞(在最坏的情况下,允许执行任意代码)。
在这个背景下,如果一个语言满足以下条件,我们认为它是严格的内存安全的:
这可以通过编译时限制和运行时保护的任何组合来实现,只要运行时机制能够保证不会发生安全违规,就可以提供这种保证。除了极少数明确定义的例外情况外,所有代码都应该在明确定义的安全子集中可写。
在新的开发中,潜在不安全的代码应该只出现在明确选择使用安全语言子集之外的不安全结构的组件/模块中,并且应该暴露一个经过专家审查的安全抽象。只有在必要时才应使用不安全结构,例如出于关键性能原因或与低级组件交互的代码。
在使用非内存安全语言的现有代码时,不安全的代码应限制在以下用途中:
出于以下原因,目前在严格的内存安全考虑中不考虑数据竞争安全:
内存安全漏洞占大型 C/C++ 代码库中严重漏洞的大部分(约70%)。以下是由于内存不安全导致的漏洞百分比:
内存安全错误继续出现在“最危险的错误”列表的首位,例如 CWE Top 25 和 CWE Top 10。谷歌内部的漏洞研究反复证明,缺乏内存安全会削弱重要的安全边界。
这里使用的分类大致与苹果的内存安全分类相符。
在不安全的语言(如C/C++)中,程序员有责任确保满足安全前提条件,以避免访问无效的内存。例如,对于空间安全,当通过索引访问数组元素(例如,a[i] = x)时,程序员有责任确保索引在有效分配的内存范围内的安全前提条件
大型 C++ 代码库中经常出现内存安全漏洞。内存安全漏洞普遍存在的原因是:
最后,即使有工具的帮助,对安全前提条件进行推理并确定程序在每个可能的程序状态下是否确保这些条件也是困难的。例如:
许多潜在的错误,结合难以推理的安全前提和人类会犯错,导致了相对较多的实际错误。通过开发者教育和反应性方法(包括静态/动态分析以查找和修复错误,以及各种利用缓解措施),试图减轻内存安全漏洞的风险,但未能将这些漏洞的发生率降低到可接受的水平。
因此,如上所述,严重的漏洞仍然由这类漏洞引起。
解决内存安全问题需要采取多管齐下的方法,包括:
在谷歌这样的规模下,这三者都是解决内存安全问题所必需的。根据谷歌的经验,通过安全编码来预防问题是可持续实现高度保证的必要条件。
谷歌的经验表明,通过消除容易出现漏洞的编码结构,可以在规模上解决一类问题。
在这个背景下,谷歌认为一个结构是不安全的,如果它在使用时没有满足安全前提条件,就有可能出现错误(例如内存损坏)。不安全的结构要求开发人员确保前提条件。此类的方法称为“安全编码”,它将不安全的编码结构本身视为危险因素(即与可能引起的漏洞独立且额外的因素),并且致力于确保开发人员在日常编码实践中不会遇到这些危险因素。
本质上,安全编码要求默认禁止使用不安全的结构,并在大多数代码中用安全的抽象替代它们,但需经过仔细审查的例外情况除外。在内存安全领域,可以使用安全的抽象来提供。
在内存安全领域,安全编码方法体现在安全语言中,这些语言用安全的抽象替代了不安全的结构,例如运行时边界检查、垃圾回收引用或带有静态检查生命周期注解的引用。
经验表明,在像 Go 和 Java 这样的安全、垃圾回收语言中,内存安全问题确实很少见。然而,垃圾回收通常会带来显著的运行时开销。最近,Rust 作为一种语言出现,它以编译时检查的类型纪律为基础,体现了安全编码的方法,从而实现了最小的运行时开销。
数据显示,安全编码对内存安全非常有效,即使在性能敏感的环境中也是如此。例如,Android 13 引入了 150 万行 Rust 代码,没有出现任何内存安全漏洞。这预防了数百个内存安全漏洞的发生:“随着进入 Android 的新的不安全内存代码的减少,内存安全漏洞的数量也相应减少。2022 年是 Android 的内存安全漏洞不再占大多数的第一年。虽然相关性不一定意味着因果关系,但这一变化与上述行业趋势形成了明显的分离,这些趋势已经持续了十多年。”
作为另一个例子,Cloudflare 报告称他们的 Rust HTTP 代理性能优于 NGINX,并且“已经处理了数万亿个请求,但由于我们的服务代码而从未崩溃。” 通过将一部分预防性内存安全机制应用于不安全的语言,如 C++,我们可以部分地防止内存安全问题的发生。例如:
利用缓解措施使内存安全漏洞的利用变得复杂,而不是修复这些漏洞的根本原因。例如,缓解措施包括对不安全库的沙箱化、控制流完整性和数据执行预防。
利用缓解措施旨在使攻击者难以从某些攻击手段升级到无限制的代码执行。
攻击者经常绕过这些缓解措施,这引发了对其安全价值的质疑。为了有用,缓解措施应该要求攻击者链接额外的漏洞,或者发明一种新的绕过技术。随着时间的推移,绕过技术对攻击者来说比任何单个漏洞更有价值。一个设计良好的缓解措施的安全益处在于绕过技术应该比漏洞要稀少得多。
利用缓解措施很少是免费的;它们往往会产生运行时开销,通常为低个位数的百分比。它们在安全性和性能之间提供了一种权衡,我们可以根据每个工作负载的需求进行调整。通过直接在芯片中构建缓解措施,可以减少运行时开销,就像对指针身份验证、影子调用栈、着陆点和保护密钥所做的那样。由于它们的开销和硬件功能的机会成本,对于采用和投资这些技术的考虑是微妙的。
根据我们的经验,沙箱技术是一种有效的缓解内存安全漏洞的方法,在谷歌常用于隔离容易出现漏洞的不稳定库。然而,采用沙箱技术存在几个挑战:
总的来说,漏洞利用缓解措施是改善大型现有 C++ 代码库安全性的重要工具,也将有益于内存安全语言中对不安全结构的残留使用。
静态分析和模糊测试是检测内存安全漏洞的有效工具。它们减少了我们代码库中的内存安全漏洞数量,因为开发人员会修复检测到的问题。
然而,根据我们的经验,仅仅通过找到漏洞并不能达到对于内存不安全语言的可接受的保证水平。例如,最近的 webp 高危 0-day 漏洞(CVE-2023-4863)影响了大量经过模糊测试的代码。尽管在相关文件中的模糊测试覆盖率很高(97.55%),但仍然错过了这个漏洞。实际上,我们错过了许多内存安全漏洞,这可以从经过充分测试的内存不安全代码中不断出现的内存安全漏洞中得到证明。
此外,仅仅发现漏洞并不能提高安全性。这些漏洞必须被修复并部署补丁。有证据表明,发现漏洞的能力正在超过修复漏洞的能力。例如,我们的内核模糊测试工具syzkaller 在上游 Linux 内核中发现了 5k+ 个漏洞,因此在任何给定的时间都会有数百个未解决的漏洞(其中很大一部分可能与安全有关),这个数字自 2017 年以来一直在稳步增长。
我们仍然认为,找出错误是解决内存不安全问题的重要组成部分。对于减少错误修复压力的错误查找技术尤为宝贵。
此外,Bug 查找工具可以发现超出内存安全范畴的 Bug 类别,这扩大了对这些工具的投资影响。它们可以发现可靠性、正确性和其他安全问题,例如:
谷歌开发了安全编码[15],这是一种可扩展的方法,可以大幅减少常见类型的漏洞发生,并确保漏洞不存在的高度保证。
在过去的十年中,我们在谷歌的规模上非常成功地应用了这种方法,主要是针对所谓的注入漏洞,包括SQL注入和XSS。虽然在技术层面上与内存安全漏洞非常不同,但存在相关的相似之处。
与内存安全漏洞一样,“成千上万的潜在漏洞”导致了数百个实际漏洞。反应式方法(代码审查、渗透测试、模糊测试)在很大程度上没有取得成功。
为了在大规模和高保证性的情况下解决这个问题,谷歌将安全编码应用于注入漏洞领域。这是毫无疑问的成功,并导致了 XSS 漏洞的显著减少,有些情况下甚至完全消除。例如,在2012年之前,像 GMail 这样的 Web 前端每年经常出现几十个 XSS 漏洞;在重构代码以符合安全编码要求之后,缺陷率已经降低到接近零。谷歌照片的 Web 前端(从一开始就采用了全面应用安全编码的 Web 应用程序框架进行开发)在其整个历史中没有报告过任何 XSS 漏洞。
接下来,我们将更详细地讨论安全编码方法如何应用于内存安全,并将其成功应用于消除网络安全领域中的漏洞类别进行类比。
根据我们的经验,消除错误类别的关键是识别导致这些错误的编程结构(API 或语言本地结构),然后在常见的编程实践中消除对这些结构的使用。这需要引入具有等效功能的安全结构,通常采用对底层不安全结构的安全抽象形式。
例如,XSS 是由于使用不安全的 Web 平台 API 与部分受攻击者控制的字符串进行调用而引起的。为了消除在我们的代码中使用这些易受 XSS 攻击的 API,我们引入了一些等效的安全抽象,旨在共同确保在调用底层不安全构造(API)时满足安全前提条件。这包括类型安全的 API 包装器、带有安全约定的词汇类型和安全的 HTML 模板系统。
确保内存安全前提条件的安全抽象可以采用现有语言中的包装器 API(例如,使用智能指针代替原始指针)。
包括 MiraclePtr(在Chrome浏览器进程中保护50%的使用后释放问题免受利用),或与语言语义密切相关的构造(例如,Go/Java 中的垃圾回收;Rust 中的静态检查的生命周期)。
安全构造的设计需要在运行时成本(CPU、内存、二进制大小等)、开发时间成本(开发者摩擦、认知负荷、构建时间)和表达能力之间进行三方权衡。例如,垃圾回收提供了一种通用的解决方案来确保时间安全,但可能导致性能的问题变化。Rust 生命周期与借用检查器结合,可以为大量代码在编译时完全保证安全(无运行时成本),但需要程序员在前期付出更多的努力来证明代码确实是安全的。这类似于静态类型相比动态类型需要更多的前期努力,但可以在编译时防止大量类型错误。
有时,开发人员需要选择替代的习语来避免运行时开销。例如,通过使用范围 for 循环可以避免对向量进行索引遍历时的运行时边界检查开销。
为了成功减少错误的发生率,一组安全的抽象需要足够表达力,以允许大部分代码在不使用不安全的结构(也不使用复杂、非惯用代码,虽然在技术上是安全的,但难以理解和维护)的情况下编写。
根据我们的经验,仅仅将安全的抽象方法提供给开发者并非足够(例如,通过样式指南建议),因为太多的不安全结构和因此产生的错误风险往往会保留下来。相反,为了确保代码库没有漏洞,我们发现有必要采用一种模式,只在特殊情况下使用不安全结构,并由编译器强制执行。
该模型包括以下关键要素:
在我们对注入漏洞的工作中,通过语言级别和构建时的可见性限制对不安全 API 的访问,以及在某些情况下通过自定义静态检查,我们实现了规模上的安全性。
在内存安全的背景下,实现规模上的安全性要求语言默认禁止使用不安全的构造(例如,对数组/缓冲区进行未经检查的索引)。除非代码的一部分明确选择进入不安全的子集,否则不安全的构造应该在编译时引发错误,如下一节所讨论的。例如,Rust 只允许在明确定界的不安全块内使用不安全的构造。
如上所述,我们假设可用的安全抽象足够表达,以便大多数代码只使用安全构造编写。然而,在实践中,我们预计大多数较大的程序在某些情况下需要使用不安全的构造。此外,安全抽象本身通常是对底层不安全构造的包装 API。例如,围绕堆内存分配/释放的安全抽象的实现最终需要处理原始内存,例如mmap(2)
。
当开发人员引入(即使是少量的)不安全代码时,重要的是在不抵消使用大部分安全代码编写程序的好处的情况下这样做。
为此,开发人员应遵循以下原则:不安全构造的使用应封装在可证明安全的 API 中。
这意味着,不安全的代码应该被封装在一个对任何调用该API的任意(但是类型正确的)代码都是安全的API后面。应该可以证明、审查/验证该模块暴露的 API 界面是安全的,而不需要对调用代码做任何假设(除了它的类型正确)。
例如,假设一个类型的实现使用了一个潜在不安全的结构。那么,当调用该不安全结构时,类型的实现有责任独立地确保不安全结构的前提条件成立。实现不能对其调用者的行为做任何假设(除了类型正确性),例如其方法按特定顺序调用。
在我们对注入漏洞的工作中,这个原则体现在对所谓的未检查转换的使用的指南中(在我们的词汇类型学中代表不安全的代码)。在 Rust 社区中,这个属性被称为Soundness:如果一个包含不安全块的模块与任意良好类型的安全 Rust 组合在一起,不会出现未定义行为,那么这个模块是安全的。
在某些情况下,遵循这个原则可能会很困难或不可能,比如当一个使用安全语言(如 Rust 或 Go)编写的程序调用不安全的 C++ 代码时。这个不安全的库可能被包装在一个“相对安全”的抽象层中,但实际上无法证明该实现是真正安全的,且没有内存安全漏洞。
推理不安全代码是困难的,容易出错,尤其对于非专家来说:
最终,我们的目标是确保整个二进制系统的充分安全姿态。
二进制文件通常包含大量的直接和传递的库依赖。这些通常由 Google 内的许多不同团队维护,甚至在第三方代码的情况下也可能由外部维护。然而,任何依赖项中的内存安全漏洞都有可能导致依赖二进制文件的安全漏洞。
一个安全的语言,结合开发规范,确保不安全的代码被封装在健全、安全的抽象中,可以使我们能够可扩展地推理大型程序的安全性。
当所有的传递依赖都属于这两个类别之一时,我们可以确信整个程序没有安全违规。重要的是,我们不需要推理每个组件与程序中的其他组件如何交互;相反,我们可以仅仅通过推理每个组件的独立性来得出这个结论。
为了在整个程序的生命周期中保持和确保对安全关键二进制文件的断言,我们需要机制来确保对二进制文件的所有传递依赖的“健全性级别”施加约束(即它们是否仅由安全代码组成或已经经过专家审查以确保健全性)。
在实践中,一些传递性依赖关系的可靠性水平会较低。例如,第三方的开源软件依赖可能使用不安全的结构,但并未设计成能够清晰划分安全抽象并能够有效审查其可靠性。或者,一个依赖关系可能由一个 FFI 包装器组成,将完全由不安全语言编写的遗留代码包装起来,使其几乎不可能以高度可靠的方式进行审查。
安全关键的二进制文件可能希望表达诸如“所有传递依赖项要么不包含不安全的结构,要么经过专家审查以确保安全性,以下是具体的例外情况”的约束条件,其中例外情况可能会受到额外的审查(例如广泛的模糊覆盖)。这使得关键二进制文件的所有者能够维持一个被充分理解和可接受的不安全风险水平。
将安全编码原则应用于编程语言及其周边生态系统(库、程序分析工具)的内存安全性涉及权衡,主要是在开发时间(例如,对开发人员施加的认知负担)和部署和运行时间之间产生的成本。
空间安全相对来说很容易融入到语言和库生态系统中。编译器和容器类型(如字符串和向量)需要确保所有访问都在边界内进行检查。如果基于静态分析或类型不变式证明检查是不必要的,那么可以省略检查。通常,这意味着类型实现需要元数据(大小/长度)进行检查。
std::vector::operator
带有安全断言)安全语言如 Rust、Go、Java 等及其标准库,对所有索引访问都进行边界检查。只有在可以证明冗余时才会省略这些检查。
尽管尚未在像 Google 的单一代码库或 Linux 内核这样的大规模代码库中进行证明,但似乎有可能将 C 或 C++ 这样的不安全语言进行子集化以实现空间安全。
边界检查会产生一些小的、但不可避免的运行时开销。开发者需要设计代码结构,以便在边界检查会导致显著开销的情况下,可以省略这些检查。
使语言类型和初始化安全可能包括:
reinterpret_cast
。在静态类型语言中,类型安全主要可以在编译时进行保证,而不会有运行时开销。然而,在某些情况下可能会存在一些运行时开销,例如:
Option<NonZeroUsize>
。reserve
和 push
或 Option
类型。时间安全性基本上比空间安全性更难解决的问题:对于空间安全性,可以相对廉价地对程序进行插桩( instrument),以便通过廉价的运行时检查(边界检查)来检查安全前提。在常见情况下,可以简单地构造代码,使得边界检查可以被省略(例如使用迭代器)。
相比之下,对于堆分配对象的时间安全性,没有直接的方法来建立安全前提。
指针和它们所指向的分配,可能包含指针本身,会导致一个有向(可能是循环的)图。由任意程序的分配和释放序列引发的图可以变得任意复杂。基于程序代码的静态分析无法推断出这个图的属性。
当一个分配被释放时,手头只有与该分配对应的图节点。没有一种先验的高效(常数时间)的方法来确定是否仍然存在另一个入边(即指向该分配的另一个可达指针)。释放一个仍然存在入边指向的分配会隐式地使这些指针无效(将它们变成“悬空”指针)。对这样一个无效指针的未来解引用将导致未定义的行为和“使用后释放”错误。
由于图是有向的,因此没有有效的(常数时间,甚至是与入度指针数量成线性关系的时间)方法来找到所有仍然可达的指向即将被删除的分配的指针。如果可用,这可以用于显式地使这些指针无效/为空,或者推迟分配直到图中的所有入度指针都被删除。
因此,每当解引用指针时,没有有效的方法来确定这个操作是否构成未定义行为,因为指针的目标已经被释放。
实现严格的时间安全保证通常有三种方法:
引用计数和垃圾回收都提供了所需的安全性,但代价较高。隔离释放(Quarantining of deallocations)是一种强有力的缓解措施,但并不能完全保证安全性,而且仍然带有开销。内存标记依赖于专用硬件,并且只提供概率性的缓解。
“
注:"Quarantining of deallocations" 是一种内存安全技术,用于缓解和防范软件中的使用后释放(Use-After-Free, UAF)漏洞。当一个对象被释放(deallocated)时,该对象的内存不会立即返回给操作系统或内存池,而是被放入一个“隔离区”(quarantine)。这样,即使程序错误地尝试再次使用这块已释放的内存,它也不能访问到实际的资源,因为该资源已经不在可用的内存池中。在隔离期间,释放的内存区域通常会被监视或者特别标记。如果程序在隔离期尝试访问这些内存,就会被检测到,从而触发错误报告或异常处理。这个过程帮助开发者发现和修复UAF漏洞,并增加了攻击者利用这类漏洞的难度。这个机制通常是由运行时环境、安全敏感的编译器插桩,或者专用的内存管理工具提供。它是现代编程语言和工具在内存安全方面的一个重要功能。
在所有情况下,为了时间安全,没有廉价(更不用说免费)的午餐。要么开发人员结构化和注释代码,使得编译时检查器(例如Rust借用检查器)能够静态地证明时间安全,要么我们付出运行时开销来实现安全性,甚至部分减轻这些错误的影响。
不幸的是,根据各种报告显示,时间安全问题仍然占据了内存安全问题的很大比例:
已经探索了多种运行时仪器技术来解决时间安全问题,但它们都存在挑战性的权衡。在多线程程序中使用时,它们必须考虑并发,并且在许多情况下只能减轻这些错误而无法提供保证的安全性。
std:shared_ptr
,Rust 的 Rc/Arc
,Swift 或 Objective-C 中的自动引用计数,以及 Chrome 对 DanglingPointerDetector 的实验。强制排他性可以与引用计数结合使用,以减少其开销,但不能完全消除。在 Java 和 Kotlin 中,不安全的内存代码明确地划定并限制在使用 Java 本地接口(JNI)的范围内。JDK 标准库依赖于大量的本地方法来调用低级系统原语并使用本地库,例如图像解析。后者受到内存安全漏洞的影响(例如 CESA-2006-004,Sun Alert)。
Java 是一种类型安全的语言。JVM 通过运行时边界检查和基于垃圾回收堆的时间安全性来确保空间安全。
Java 不将安全编码原则扩展到并发性:一个类型良好的程序可能存在数据竞争。然而,JVM 确保数据竞争不会违反内存安全性。例如,数据竞争可能导致高级不变量的违反和异常的抛出,但不会导致内存损坏。
在 Go 语言中,不安全的内存代码明确地被划定并限制在使用 unsafe 包的代码中(除了由数据竞争引起的内存不安全情况)。
Go 是一种类型安全的语言。Go 编译器确保所有值默认使用它们类型的零值进行初始化,通过运行时边界检查确保空间安全,并通过垃圾回收堆实现时间安全。除了使用 unsafe 包之外,没有其他方式可以不安全地创建指针。
Go 不将安全编码原则扩展到并发:一个类型良好的 Go 程序可能存在数据竞争。此外,数据竞争可能导致违反内存安全不变式。
在 Rust 中,不安全的内存代码明确地划定并限制在 unsafe 块中。Rust 是一种类型安全的语言。安全的 Rust 强制要求所有值都被初始化,并在必要时添加边界检查以确保空间安全。在安全的 Rust 中,不允许解引用原始指针。
Rust 是唯一一种成熟、可用于生产的语言,可以在没有运行时机制的情况下提供时间安全性。
垃圾回收或普遍应用的引用计数,适用于大部分代码。Rust 通过对变量和引用的生命周期进行编译时检查,提供了临时安全性。
借用检查器所施加的限制阻止了某些结构的实现,特别是涉及循环引用图的结构。Rust 标准库包含了允许安全实现这些结构的 API,但会带来运行时开销(基于引用计数)。
除了内存安全之外,Rust 的安全子集还保证了数据竞争安全("无畏并发")。顺便提一下,数据竞争安全使得 Rust 在使用运行时临时安全机制时能够安全地避免不必要的开销:**Rc
和 Arc
都实现了引用计数指针**。然而,由于 Rc
的类型不允许在线程之间共享,所以Rc
可以安全地使用更便宜的非原子计数器。
Carbon 语言是 C++ 的实验性继任语言,其明确的设计目标是促进从现有 C++ 代码库的大规模迁移。截至 2023 年,Carbon 的安全策略细节仍在变动中。Carbon 0.2计划引入一个安全子集,提供严格的内存安全保证。然而,它仍需要保留对现有不安全 C++ 代码的有效迁移策略。处理不安全和安全碳代码的混合将需要类似于处理C++ 和像 Rust 这样的安全语言的混合的防护措施。
虽然我们期望新编写的 Carbon 是在其内存安全子集中,但从现有 C++ 迁移而来的Carbon 很可能依赖于不安全的 Carbon 结构。我们预计从不安全的 Carbon 自动进行大规模后续迁移将会很困难,而且通常是不切实际的。在剩余的不安全代码中减轻内存安全风险将基于通过构建模式加固(类似于我们处理传统 C++ 代码的方式)。加固的构建模式将启用运行时机制,试图防止利用内存安全漏洞。
鉴于现有的大量 C++ 代码,我们认识到转向内存安全语言可能需要几十年的时间,在此期间,我们将开发和部署由安全和不安全语言混合组成的代码。因此,我们认为有必要提高 C++(或其后继语言,如果适用的话)的安全性。
在定义一个严格的内存安全的 C++ 子集,既足够人性化又易于维护的问题上仍然存在着一个开放的研究问题,但原则上可能是有可能定义一个的 C++ 的子集,提供相对较强的内存安全保证。C++ 的安全工作应采用迭代和数据导向的方法来定义更安全的 C++ 子集:识别出最高的安全和可靠性风险,并部署具有最高影响和投资回报率的保证和缓解措施。
更安全的 C++ 子集将为过渡到内存安全语言提供一个过渡阶段。例如,在 C++ 代码库中强制进行明确初始化或禁止指针算术运算将简化最终迁移到 Rust 或安全 Carbon的过程。同样地,为 C++ 添加生命周期将改善与Rust的互操作性。因此,除了针对顶级风险进行目标设定外,C++ 安全投资还应优先考虑那些能够加速和简化逐步采用内存安全语言的改进。
特别是,安全、高性能和人体工效学的互操作性是逐步过渡到内存安全的关键要素。安卓和苹果都在围绕互操作性制定过渡策略,分别使用 Rust 和Swift 。
为此,我们需要改进的互操作性工具和对现有构建工具中混合语言代码库的改进支持。特别是,现有的用于 C++/Rust 的生产级互操作性工具假设了一个狭窄的 API 表面。这对于一些生态系统(如 Android )已经足够,但其他生态系统有额外的要求。更高保真度的互操作性可以在其他生态系统中逐步采用,就像 Swift 已经做到的那样,并且在 Crubit 中探索了 Rust 的可能性。对于 Rust 来说,仍然存在一些未解决的问题,比如如何保证 C++ 代码不违反 Rust 代码的独占性规则,这将产生新的未定义行为形式。
通过逐个替换组件,安全改进可以持续交付,而不是在长时间重写的最后一刻一次性完成。请注意,使用这种增量策略最终可能会实现完全重写,但不会带来通常与大型系统完全重写相关的风险。事实上,在此期间,系统仍然是一个单一的代码库,持续进行测试和可交付。
内存标记是一种 CPU 功能,适用于ARM v8.5a,它允许将内存区域和指针标记为16个标签之一。启用后,解引用具有不匹配标签的指针会引发错误。
可以在 MTE 上构建多种安全功能,例如:
使用这两种技术,MTE 可以产生:
改进的可靠性和安全性,因为这些错误得到修复。
这突显了及时修复 MTE 违规行为以实现MTE安全潜力的重要性。为了不给开发人员带来过多压力,MTE 应与积极的工作相结合,以减少错误的数量。
未采样的 MTE 也可以作为一种漏洞利用缓解措施部署,以确定性地保护 10%-15% 的内存安全漏洞(假设没有类似垃圾回收的扫描)。然而,由于非平凡的内存和运行时开销,我们预计生产部署主要将在占用空间较小但安全关键的工作负载中进行。
尽管存在一些限制,但我们认为 MTE 是减少大型现有 C++ 代码库中时间安全性错误数量的一条有希望的途径。目前还没有其他能够实际规模部署的 C++ 时间安全性替代方案。
CHERI 是一个引人注目的研究项目,有潜力为传统的 C++ 代码(也许包括强化模式下的 Carbon)提供严格的内存安全保证,而且只需进行最少的移植工作。CHERI 的时间安全保证依赖于对已释放内存的隔离和全面撤销,目前尚不清楚运行时开销是否能够满足生产工作负载的要求。
除了内存安全之外,CHERI 能力还可以实现更多有趣的安全缓解措施,例如细粒度沙盒化。
经过 50 年,内存安全漏洞仍然是最顽固和最危险的软件弱点之一。作为漏洞的主要原因之一,它们继续导致重大的安全风险。越来越明显的是,内存安全是安全软件的必要属性。因此,我们预计在未来十年内,该行业将加速向内存安全的转变。我们对谷歌和其他大型软件制造商已经取得的进展感到鼓舞。
我们认为,高可靠性内存安全需要采用安全设计的方法,这需要采用具有严格内存安全保证的语言。考虑到长期以来的在过渡到内存安全语言的时间线中,还有必要尽可能通过消除漏洞类别来提高现有的 C 和 C++ 代码库的安全性。