“
这是每个开发者都应该阅读的一篇文章。 未来十年,软件行业将加速向内存安全转变。
2024 年 3 月 4 号,Google 官方博客[1]发文,宣布《安全设计 - Google 对内存安全的洞察》白皮书[2]。
这份白皮书中深入探讨了数据、解决内存不安全性的挑战,并讨论了实现内存安全的可能方法及其权衡。并且还将强调谷歌致力于实施白皮书中概述的几种解决方案,最近通过向 Rust 基金会提供 100 万美元的资助,推动了健壮的内存安全生态系统的发展。谷歌希望通过分享他们的见解和经验,能够激励更广泛的社区和行业采用内存安全的实践和技术,最终使技术更加安全。
2022 年标志着内存安全漏洞的 50 周年。自那时以来,内存安全风险变得更加明显。与其他公司一样,谷歌的内部漏洞数据和研究显示,内存安全漏洞广泛存在,并且是内存不安全代码库中漏洞的主要原因之一。这些漏洞危及最终用户、我们的行业和更广泛的社会。我们很高兴看到政府也对这个问题予以重视,比如美国国家网络主任办公室上周[3]发表了一篇关于这个主题的论文。
谷歌拥有数十年的经验,以大规模解决曾经与内存安全问题同样普遍的各类漏洞。此类的方法被称为“**安全编码**[4]”,将易受漏洞影响的编码结构本身视为危险因素(即独立于可能引发的漏洞,并且额外考虑),并且致力于确保开发人员在常规编码实践中不会遇到这些危险因素。
即便拥有如此丰富的安全经验,谷歌也是预计只有通过以全面采用具有严格内存安全保证的语言为核心的安全设计方法,才能实现高可靠性的内存安全。
在过去的几十年里,除了大规模的 Java 和 Go 内存安全代码库外,谷歌还开发和积累了数亿行正在使用和持续开发中的 C++ 代码。这个非常庞大的现有代码库对于实现内存安全的过渡带来了重大挑战。
谷歌看不到 C++ 进化为一种具有严格内存安全保证(包括时间安全)的语言的现实路径。
将所有现有的 C++ 代码大规模重写为一种不同的、内存安全的语言似乎非常困难,而且很可能仍然不切实际。
谷歌认为对于新代码和特别是风险组件,补充过渡到内存安全语言是重要的,尽可能地改进现有的 C++ 代码的安全性。谷歌相信通过逐步过渡到部分内存安全的C++ 语言子集,并在可用时增加硬件安全功能,可以实现实质性的改进。例如,可以参考谷歌在 GCP 的网络堆栈中改进空间安全性的工作[5]。
谷歌正在积极投资于他们白皮书中概述的许多解决方案,并对美国联邦政府关于开源软件安全的请求[6]进行回应[7]。
在过去几年中,Android 已经使用 Rust 编写了几个组件,从而实现了引人注目的安全改进[8]。在 Android 的超宽带(UWB)模块中,这提高了模块的安全性,同时减少了内存使用和过程间调用。
Chrome 已经开始用 Rust 实现一些功能[9];在某些情况下,Chrome 通过采用用Rust 编写的新的内存安全库,将其 QR 码生成器移出沙箱,从而实现了更好的安全性和性能。
谷歌最近宣布向 Rust 基金会提供 100 万美元的资助[10],以增强与 C++ 代码的互操作性。这将有助于在现有的不安全内存代码库中逐步采用 Rust,这对于在内存安全语言中实现更多新的开发至关重要。与此相关的是,谷歌还在努力解决在同一个二进制文件中混合使用 Rust 和 C++ 时可能发生的跨语言攻击[11]问题。
谷歌正在通过 ISRG Prossimo[12] 和 OpenSSF 的 Alpha-Omega[13] 项目投资于构建内存安全的开源生态系统。早在 2021 年,谷歌就资助了将 Rust 引入 Linux 内核的工作,现在谷歌可以编写内存安全的驱动程序[14]。这些资金还将用于提供内存安全语言的关键开源库的替代品或升级,例如提供内存安全的TLS实现。
大家都知道,内存安全的编程语言并不能解决所有的安全漏洞,但就像通过工具来消除跨站脚本攻击一样,消除大类漏洞不仅直接有益于软件的使用者,还能让我们开发者将注意力转向解决其他类别的安全漏洞。
内存安全漏洞是指当程序在内存访问构成未定义行为的状态下允许执行读取或写入内存的语句时产生的。当这样的语句在程序状态下可被对手控制(例如处理不可信输入)时,该漏洞通常代表着可被利用的漏洞(在最坏的情况下,允许执行任意代码)。
在这个背景下,如果一个语言满足以下条件,我们认为它是严格的内存安全的:
默认为一个明确定义的安全子集,并
确保防止在安全子集中编写的任意代码引发空间、时间、类型或初始化安全违规
这可以通过编译时限制和运行时保护的任何组合来实现,只要运行时机制能够保证不会发生安全违规,就可以提供这种保证。除了极少数明确定义的例外情况外,所有代码都应该在明确定义的安全子集中可写。
在新的开发中,潜在不安全的代码应该只出现在明确选择使用安全语言子集之外的不安全结构的组件/模块中,并且应该暴露一个经过专家审查的安全抽象。只有在必要时才应使用不安全结构,例如出于关键性能原因或与低级组件交互的代码。
在使用非内存安全语言的现有代码时,不安全的代码应限制在以下用途中:
使用安全语言编写的代码,调用了由使用不安全语言编写的遗留代码库实现的库。
对现有不安全的遗留代码库进行代码添加/修改,其中代码混杂得太深,无法使用安全语言进行开发。
出于以下原因,目前在严格的内存安全考虑中不考虑数据竞争安全:
数据竞争安全是一种独立的错误类型,只部分重叠于内存安全。例如,Java 不提供数据竞争安全的保证,但在 Java 中的数据竞争不会导致低级堆完整性不变式的违反(内存损坏)。
目前没有同样级别的证据表明,在其他严格内存安全语言(例如 Go)编写的软件中,数据竞争不安全会导致系统安全和可靠性问题。
内存安全漏洞占大型 C/C++ 代码库中严重漏洞的大部分(约70%)。以下是由于内存不安全导致的漏洞百分比:
Chrome:高/严重漏洞的占比为 70%
Android:70% 的高/严重漏洞
谷歌服务器:16-29% 的漏洞
0Day 漏洞中的 68% 在外部发现
微软:有 CVE 编号的漏洞占 70%
内存安全错误继续出现在“最危险的错误”列表的首位,例如 CWE Top 25 和 CWE Top 10。谷歌内部的漏洞研究反复证明,缺乏内存安全会削弱重要的安全边界。
空间安全漏洞(例如“缓冲区溢出”,“越界访问”)发生在内存访问引用超出被访问对象分配区域之外的内存时。
临时安全漏洞是指在对象的生命周期之外发生对对象的内存访问的情况。一个例子是当一个函数返回指向其堆栈帧中的值的指针("返回后使用"),或者由于指向已被释放的堆分配内存的指针,并且可能已经重新分配给另一个对象("释放后使用")。
在并发程序中,由于线程同步不当,这些错误很常见,但当初始的安全违规发生在对象的生命周期之外时,我们将其归类为时间上的安全违规。
类型安全错误是指从内存中读取一个给定类型的值,而该内存不包含该类型的成员。一个例子是在无效的指针转换后读取内存。
初始化安全性错误是指在内存被初始化之前读取内存的情况。这可能导致信息泄露和类型/时间安全性错误。
数据竞争安全漏洞是由不同线程的未同步读写引起的,这可能导致对象处于不一致的状态。其他形式的安全漏洞也可能由于不正确或缺失的同步而产生,但我们不将其归类为数据竞争安全漏洞,并且在上面进行处理。只有当读写操作在其他方面是正确的,除了未同步之外,它们才被视为数据竞争安全漏洞。一旦发生数据竞争安全违规,后续的执行可能导致进一步的安全漏洞。我们将这些归类为数据竞争安全漏洞,因为初始违规严格来说只是一个数据竞争问题,没有其他明显的漏洞。
这里使用的分类大致与苹果的内存安全分类相符。
在不安全的语言(如C/C++)中,程序员有责任确保满足安全前提条件,以避免访问无效的内存。例如,对于空间安全,当通过索引访问数组元素(例如,a[i] = x)时,程序员有责任确保索引在有效分配的内存范围内的安全前提条件
大型 C++ 代码库中经常出现内存安全漏洞。内存安全漏洞普遍存在的原因是:
首先,在不安全的语言中,程序员需要确保在每个语句执行之前,其内存安全的前提条件在可能到达的任何程序状态下都成立,可能受到程序的对手输入的影响。
其次,在 C/C++ 程序中,存在许多可能导致内存安全错误的不安全语句,如数组访问、指针解引用和堆分配。
最后,即使有工具的帮助,对安全前提条件进行推理并确定程序在每个可能的程序状态下是否确保这些条件也是困难的。例如:
关于指针/索引的有效性的推理涉及到整数算术的包装,这对人类来说相当不直观。
关于堆对象的生命周期的推理通常涉及复杂而微妙的整个程序不变量。即使是局部作用域和生命周期也可能是微妙而令人惊讶的。
许多潜在的错误,结合难以推理的安全前提和人类会犯错,导致了相对较多的实际错误。通过开发者教育和反应性方法(包括静态/动态分析以查找和修复错误,以及各种利用缓解措施),试图减轻内存安全漏洞的风险,但未能将这些漏洞的发生率降低到可接受的水平。
因此,如上所述,严重的漏洞仍然由这类漏洞引起。
解决内存安全问题需要采取多管齐下的方法,包括:
通过安全编码来防止内存安全漏洞。
通过增加攻击的成本来减轻内存安全漏洞。
尽早在开发生命周期中检测内存安全漏洞。
在谷歌这样的规模下,这三者都是解决内存安全问题所必需的。根据谷歌的经验,通过安全编码来预防问题是可持续实现高度保证的必要条件。
谷歌的经验表明,通过消除容易出现漏洞的编码结构,可以在规模上解决一类问题。
在这个背景下,谷歌认为一个结构是不安全的,如果它在使用时没有满足安全前提条件,就有可能出现错误(例如内存损坏)。不安全的结构要求开发人员确保前提条件。此类的方法称为“安全编码”,它将不安全的编码结构本身视为危险因素(即与可能引起的漏洞独立且额外的因素),并且致力于确保开发人员在日常编码实践中不会遇到这些危险因素。
本质上,安全编码要求默认禁止使用不安全的结构,并在大多数代码中用安全的抽象替代它们,但需经过仔细审查的例外情况除外。在内存安全领域,可以使用安全的抽象来提供。
静态或动态确保的安全不变量
防止错误的引入。编译时检查和编译器发出或运行时提供的机制可以保证特定类别的错误不会发生。例如:
在编译时,生命周期分析可以防止一部分时间安全性错误。
在运行时,自动对象初始化保证了不存在未初始化的读取。
运行时错误检测,在检测到内存安全违规时引发错误,而不是继续使用已损坏的内存执行。潜在的错误仍然存在,需要修复,但漏洞被消除(除了拒绝服务攻击)。例如:
通过验证给定的索引是否在范围内,数组查找可以提供空间安全错误检测。
在安全性已经静态证明的情况下,可以省略检查。
类型转换可以通过检查转换后的对象是否是结果类型的实例(例如,在 Java 中的 ClassCastException 或在 C++ 中的 CastGuard )来提供类型安全错误检测。
在内存安全领域,安全编码方法体现在安全语言中,这些语言用安全的抽象替代了不安全的结构,例如运行时边界检查、垃圾回收引用或带有静态检查生命周期注解的引用。
经验表明,在像 Go 和 Java 这样的安全、垃圾回收语言中,内存安全问题确实很少见。然而,垃圾回收通常会带来显著的运行时开销。最近,Rust 作为一种语言出现,它以编译时检查的类型纪律为基础,体现了安全编码的方法,从而实现了最小的运行时开销。
数据显示,安全编码对内存安全非常有效,即使在性能敏感的环境中也是如此。例如,Android 13 引入了 150 万行 Rust 代码,没有出现任何内存安全漏洞。这预防了数百个内存安全漏洞的发生:“随着进入 Android 的新的不安全内存代码的减少,内存安全漏洞的数量也相应减少。2022 年是 Android 的内存安全漏洞不再占大多数的第一年。虽然相关性不一定意味着因果关系,但这一变化与上述行业趋势形成了明显的分离,这些趋势已经持续了十多年。”
作为另一个例子,Cloudflare 报告称他们的 Rust HTTP 代理性能优于 NGINX,并且“已经处理了数万亿个请求,但由于我们的服务代码而从未崩溃。” 通过将一部分预防性内存安全机制应用于不安全的语言,如 C++,我们可以部分地防止内存安全问题的发生。例如:
缓冲区强化 RFC 可能消除 C++ 中的一部分空间安全问题。
同样,一个边界安全的 RFC 可能会消除 C 语言中的一部分空间安全问题。
C++ 中的生命周期注解可能消除一部分时间安全问题。
利用缓解措施使内存安全漏洞的利用变得复杂,而不是修复这些漏洞的根本原因。例如,缓解措施包括对不安全库的沙箱化、控制流完整性和数据执行预防。
利用缓解措施旨在使攻击者难以从某些攻击手段升级到无限制的代码执行。
攻击者经常绕过这些缓解措施,这引发了对其安全价值的质疑。为了有用,缓解措施应该要求攻击者链接额外的漏洞,或者发明一种新的绕过技术。随着时间的推移,绕过技术对攻击者来说比任何单个漏洞更有价值。一个设计良好的缓解措施的安全益处在于绕过技术应该比漏洞要稀少得多。
利用缓解措施很少是免费的;它们往往会产生运行时开销,通常为低个位数的百分比。它们在安全性和性能之间提供了一种权衡,我们可以根据每个工作负载的需求进行调整。通过直接在芯片中构建缓解措施,可以减少运行时开销,就像对指针身份验证、影子调用栈、着陆点和保护密钥所做的那样。由于它们的开销和硬件功能的机会成本,对于采用和投资这些技术的考虑是微妙的。
根据我们的经验,沙箱技术是一种有效的缓解内存安全漏洞的方法,在谷歌常用于隔离容易出现漏洞的不稳定库。然而,采用沙箱技术存在几个挑战:
沙盒化可能会导致显著的延迟和带宽开销,以及所需的代码重构成本。这有时需要在请求之间重复使用沙盒实例,这会削弱防护效果。
创建一个足够严格的沙盒策略以实现有效的缓解对开发人员来说可能是具有挑战性的,特别是当沙盒策略以低抽象级别表达时,比如系统调用过滤器。
沙箱化可能会导致可靠性风险,当在生产环境中执行不寻常(但无害)的代码路径并触发沙箱策略违规时。
总的来说,漏洞利用缓解措施是改善大型现有 C++ 代码库安全性的重要工具,也将有益于内存安全语言中对不安全结构的残留使用。
静态分析和模糊测试是检测内存安全漏洞的有效工具。它们减少了我们代码库中的内存安全漏洞数量,因为开发人员会修复检测到的问题。
然而,根据我们的经验,仅仅通过找到漏洞并不能达到对于内存不安全语言的可接受的保证水平。例如,最近的 webp 高危 0-day 漏洞(CVE-2023-4863)影响了大量经过模糊测试的代码。尽管在相关文件中的模糊测试覆盖率很高(97.55%),但仍然错过了这个漏洞。实际上,我们错过了许多内存安全漏洞,这可以从经过充分测试的内存不安全代码中不断出现的内存安全漏洞中得到证明。
此外,仅仅发现漏洞并不能提高安全性。这些漏洞必须被修复并部署补丁。有证据表明,发现漏洞的能力正在超过修复漏洞的能力。例如,我们的内核模糊测试工具syzkaller 在上游 Linux 内核中发现了 5k+ 个漏洞,因此在任何给定的时间都会有数百个未解决的漏洞(其中很大一部分可能与安全有关),这个数字自 2017 年以来一直在稳步增长。
我们仍然认为,找出错误是解决内存不安全问题的重要组成部分。对于减少错误修复压力的错误查找技术尤为宝贵。
“向左移动”,例如在提交前进行模糊测试,可以减少发送到生产环境的新错误的数量。在软件开发生命周期的早期发现错误更容易修复,从而增加了我们的错误修复能力。
Bug 查找技术,如静态分析,也可以提供修复建议,可以通过 IDE 或拉取请求提供,或自动应用于主动更改现有代码。
像 sanitizers 这样的错误查找工具,可以识别根本原因并生成可操作的错误报告,帮助开发人员更快地修复问题,同时也增加了我们的错误修复能力。
此外,Bug 查找工具可以发现超出内存安全范畴的 Bug 类别,这扩大了对这些工具的投资影响。它们可以发现可靠性、正确性和其他安全问题,例如:
基于属性的模糊测试可以找到违反应用程序级不变性的输入,例如开发人员编码的正确性属性。例如,cryptofuzz 在加密库中发现了 150 多个错误。
模糊测试发现资源使用错误(例如无限递归)和影响可用性的崩溃。特别是运行时错误检测(例如边界检查)转换将内存安全漏洞转化为运行时错误,这仍然是可靠性和拒绝服务的问题。
谷歌开发了安全编码[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++ 代码时。这个不安全的库可能被包装在一个“相对安全”的抽象层中,但实际上无法证明该实现是真正安全的,且没有内存安全漏洞。
推理不安全代码是困难的,容易出错,尤其对于非专家来说:
判断一个包含不安全结构的模块是否实际上暴露了一个安全的抽象需要领域专业知识。例如,在网络安全领域,决定是否将未经检查的转换转为 SafeHtml 词汇类型是安全的,需要对 HTML 规范、适用的数据转义和净化规则有详细的了解。决定带有不安全标记的 Rust 代码是否安全需要对不安全 Rust 语义和未定义行为的边界有深入的了解(这是一个正在积极研究的领域)。
根据我们的经验,专注于解决手头问题的开发人员似乎并不重视安全封装不安全代码的重要性,也不尝试设计安全的抽象。需要专家审查来引导这些开发人员进行安全封装,并帮助设计适当的安全抽象。在网络安全领域,我们发现有必要在许多情况下强制要求对不安全结构进行专家审查,比如对未经检查的转换的新用途。如果没有强制审查,我们观察到大量不必要/不可靠的不安全结构的使用,这削弱了我们在规模上对安全性的推理能力。强制审查要求需要仔细考虑对开发人员和审查团队带宽的影响,并且只有在足够罕见的情况下才是合适的。
最终,我们的目标是确保整个二进制系统的充分安全姿态。
二进制文件通常包含大量的直接和传递的库依赖。这些通常由 Google 内的许多不同团队维护,甚至在第三方代码的情况下也可能由外部维护。然而,任何依赖项中的内存安全漏洞都有可能导致依赖二进制文件的安全漏洞。
一个安全的语言,结合开发规范,确保不安全的代码被封装在健全、安全的抽象中,可以使我们能够可扩展地推理大型程序的安全性。
仅使用该语言的安全子集编写的组件在构建时是安全的,没有安全违规。
包含不安全结构的组件向程序的其余部分提供安全的抽象。对于这些组件,专家审查可以确保它们的可靠性,并且在与任意其他组件组合时不会导致安全违规。
当所有的传递依赖都属于这两个类别之一时,我们可以确信整个程序没有安全违规。重要的是,我们不需要推理每个组件与程序中的其他组件如何交互;相反,我们可以仅仅通过推理每个组件的独立性来得出这个结论。
为了在整个程序的生命周期中保持和确保对安全关键二进制文件的断言,我们需要机制来确保对二进制文件的所有传递依赖的“健全性级别”施加约束(即它们是否仅由安全代码组成或已经经过专家审查以确保健全性)。
在实践中,一些传递性依赖关系的可靠性水平会较低。例如,第三方的开源软件依赖可能使用不安全的结构,但并未设计成能够清晰划分安全抽象并能够有效审查其可靠性。或者,一个依赖关系可能由一个 FFI 包装器组成,将完全由不安全语言编写的遗留代码包装起来,使其几乎不可能以高度可靠的方式进行审查。
安全关键的二进制文件可能希望表达诸如“所有传递依赖项要么不包含不安全的结构,要么经过专家审查以确保安全性,以下是具体的例外情况”的约束条件,其中例外情况可能会受到额外的审查(例如广泛的模糊覆盖)。这使得关键二进制文件的所有者能够维持一个被充分理解和可接受的不安全风险水平。
将安全编码原则应用于编程语言及其周边生态系统(库、程序分析工具)的内存安全性涉及权衡,主要是在开发时间(例如,对开发人员施加的认知负担)和部署和运行时间之间产生的成本。
空间安全相对来说很容易融入到语言和库生态系统中。编译器和容器类型(如字符串和向量)需要确保所有访问都在边界内进行检查。如果基于静态分析或类型不变式证明检查是不必要的,那么可以省略检查。通常,这意味着类型实现需要元数据(大小/长度)进行检查。
边界检查已纳入 API 中(比如 std::vector::operator
带有安全断言)
编译器插入的边界检查,可能辅以注释
硬件支持,如边界检查的 CHERI 能力
安全语言如 Rust、Go、Java 等及其标准库,对所有索引访问都进行边界检查。只有在可以证明冗余时才会省略这些检查。
尽管尚未在像 Google 的单一代码库或 Linux 内核这样的大规模代码库中进行证明,但似乎有可能将 C 或 C++ 这样的不安全语言进行子集化以实现空间安全。
边界检查会产生一些小的、但不可避免的运行时开销。开发者需要设计代码结构,以便在边界检查会导致显著开销的情况下,可以省略这些检查。
使语言类型和初始化安全可能包括:
禁止不安全类型的代码结构,如(未标记的)联合体和 reinterpret_cast
。
通过编译器插桩(instrumentation )在堆栈上初始化值(除非编译器能够证明该值在后续的显式写入之前不会被读取)。
确保(可访问的)元素被初始化的容器类型实现。
在静态类型语言中,类型安全主要可以在编译时进行保证,而不会有运行时开销。然而,在某些情况下可能会存在一些运行时开销,例如:
Unions 必须在运行时包含一个 判别值,并且以类型安全的高级结构(例如,和类型)表示。在某些情况下,可以优化掉由此产生的内存开销,例如 Rust 中的 Option<NonZeroUsize>
。
可能存在一些从未被读取的多余值的初始化,但编译器无法证明。在开销较大的情况下(例如大型向量的默认初始化),程序员有责任通过结构化代码来避免多余的初始化,例如使用 reserve
和 push
或 Option
类型。
时间安全性基本上比空间安全性更难解决的问题:对于空间安全性,可以相对廉价地对程序进行插桩( instrument),以便通过廉价的运行时检查(边界检查)来检查安全前提。在常见情况下,可以简单地构造代码,使得边界检查可以被省略(例如使用迭代器)。
相比之下,对于堆分配对象的时间安全性,没有直接的方法来建立安全前提。
指针和它们所指向的分配,可能包含指针本身,会导致一个有向(可能是循环的)图。由任意程序的分配和释放序列引发的图可以变得任意复杂。基于程序代码的静态分析无法推断出这个图的属性。
当一个分配被释放时,手头只有与该分配对应的图节点。没有一种先验的高效(常数时间)的方法来确定是否仍然存在另一个入边(即指向该分配的另一个可达指针)。释放一个仍然存在入边指向的分配会隐式地使这些指针无效(将它们变成“悬空”指针)。对这样一个无效指针的未来解引用将导致未定义的行为和“使用后释放”错误。
由于图是有向的,因此没有有效的(常数时间,甚至是与入度指针数量成线性关系的时间)方法来找到所有仍然可达的指向即将被删除的分配的指针。如果可用,这可以用于显式地使这些指针无效/为空,或者推迟分配直到图中的所有入度指针都被删除。
因此,每当解引用指针时,没有有效的方法来确定这个操作是否构成未定义行为,因为指针的目标已经被释放。
实现严格的时间安全保证通常有三种方法:
通过编译时检查确保指针/引用不能超出其所指向的分配范围。例如,Rust 通过借用检查器和排他规则实现了这种方法。这种模式支持堆和栈对象的时间安全性。
确保在没有有效指针指向时才释放分配的内存。
在运行时支持下,确保指针在其所指向的分配被释放时变为无效,并在稍后对此无效指针进行解引用时引发错误。
引用计数和垃圾回收都提供了所需的安全性,但代价较高。隔离释放(Quarantining of deallocations)是一种强有力的缓解措施,但并不能完全保证安全性,而且仍然带有开销。内存标记依赖于专用硬件,并且只提供概率性的缓解。
“
注:"Quarantining of deallocations" 是一种内存安全技术,用于缓解和防范软件中的使用后释放(Use-After-Free, UAF)漏洞。当一个对象被释放(deallocated)时,该对象的内存不会立即返回给操作系统或内存池,而是被放入一个“隔离区”(quarantine)。这样,即使程序错误地尝试再次使用这块已释放的内存,它也不能访问到实际的资源,因为该资源已经不在可用的内存池中。在隔离期间,释放的内存区域通常会被监视或者特别标记。如果程序在隔离期尝试访问这些内存,就会被检测到,从而触发错误报告或异常处理。这个过程帮助开发者发现和修复UAF漏洞,并增加了攻击者利用这类漏洞的难度。这个机制通常是由运行时环境、安全敏感的编译器插桩,或者专用的内存管理工具提供。它是现代编程语言和工具在内存安全方面的一个重要功能。
在所有情况下,为了时间安全,没有廉价(更不用说免费)的午餐。要么开发人员结构化和注释代码,使得编译时检查器(例如Rust借用检查器)能够静态地证明时间安全,要么我们付出运行时开销来实现安全性,甚至部分减轻这些错误的影响。
不幸的是,根据各种报告显示,时间安全问题仍然占据了内存安全问题的很大比例:
Chrome:高/严重内存安全漏洞的占比为 51%
Android: 高/严重内存安全CVE中的 20%
Project Zero: 33% 在真实环境中被利用
微软:32% 的内存安全CVEs
GWP-ASan:在多个生态系统中发现的 UAFs 比 OOBs 多 4 倍
已经探索了多种运行时仪器技术来解决时间安全问题,但它们都存在挑战性的权衡。在多线程程序中使用时,它们必须考虑并发,并且在许多情况下只能减轻这些错误而无法提供保证的安全性。
引用计数,用于提供正确的生命周期或检测和防止错误的生命周期。这种技术的变体包括 std:shared_ptr
,Rust 的 Rc/Arc
,Swift 或 Objective-C 中的自动引用计数,以及 Chrome 对 DanglingPointerDetector 的实验。强制排他性可以与引用计数结合使用,以减少其开销,但不能完全消除。
垃圾收集堆。强制排他性也可以与 GC 结合使用以减少开销。
基于引用计数和分配污染的 Chrome BackupRefPtr 提出的释放隔离,或者结合MarkUs 提出的指向隔离释放的指针的遍历和失效,这些方法避免了干扰析构函数的时序,但在某些情况下可能只提供部分缓解而不是真正的时间安全性。它们可以被视为不干扰析构函数时序的引用计数和垃圾回收的变体,同时防止在悬空指针后面重新分配,但通过引入 poison 值(和导致未定义行为)来进行权衡,如果在释放后访问则会在运行时产生未定义行为。
内存标记使用一组小的标签(颜色)来标记指针和已分配的内存区域。当内存被释放和重新分配时,根据定义的策略重新着色。这隐式地使仍然具有“旧”颜色的剩余指针无效。实际上,标签/颜色的集合很小(例如,ARM MTE的情况下为16)。因此,在大多数情况下,它提供的是概率性的缓解而不是真正的安全性,因为存在非常小的机会(例如,6.25%),即悬空指针未被标记为无效,因为它们被随机重新着色为相同的颜色。MTE还带来了显著的运行时开销。内存标记还加速了 MarkUs 和 Scan 方法,提供了强大的时间安全性。
在 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 上构建多种安全功能,例如:
使用后释放和越界检测。当内存被释放(或重新分配)时,它会被随机重新标记。这会隐式地使剩余的指针无效,这些指针仍然具有“旧”的标记。实际上,标记的集合很小(16个)。因此,它提供的是概率性的缓解而不是真正的安全性,因为有一定的机会(6.25%)悬空指针不会被标记为无效(因为它们被随机重新标记为相同的标记)。
同样,这也可以概率性地检测出界错误。
这可以确定地检测到跨分配的线性溢出,假设分配器确保连续的分配永远不会共享相同的标签。
可能可以在 MTE 的基础上构建一个类似于 MarkUs 的附加 GC 扫描的确定性堆使用后释放预防机制。
采样使用后释放和越界检测。与上述相同,但仅在部分分配上进行,以便在广泛部署时减少运行时开销。使用采样的 MTE,预计攻击在几次尝试后会成功:攻击不会被停止。然而,失败的尝试会产生噪音(即MTE崩溃),我们可以进行检查。
使用这两种技术,MTE 可以产生:
在软件开发生命周期中更早发现错误。未采样的MTE应该足够便宜,可以在预提交和金丝雀部署。
生产中检测到更多的错误。与相同成本的 GWP-ASan 相比,采样 MTE 允许3个数量级更高的采样率。
可操作的崩溃报告。同步的 MTE 报告,显示了错误发生的位置,而不是由于错误的次要影响而导致崩溃。此外,采样的 MTE 可以与堆仪器结合使用,提供与 GWP-ASan 相似准确度的错误报告。
改进的可靠性和安全性,因为这些错误得到修复。
攻击者的利润率下降。攻击者要么需要找到额外的漏洞来确定性地绕过 MTE,要么冒着被检测的风险。
防御者的反应速度将取决于他们区分开利用企图和其他 MTE 违规行为的能力。利用企图可能能够隐藏在自然发生的 MTE 违规行为的噪音中。
即使无法区分利用尝试和有机的 MTE 违规行为,MTE 应该减少利用窗口,即攻击者可以重复使用给定的漏洞的频率和时间。修复 MTE 违规行为越快,利用窗口就越短,这降低了利用漏洞的投资回报率。
这突显了及时修复 MTE 违规行为以实现MTE安全潜力的重要性。为了不给开发人员带来过多压力,MTE 应与积极的工作相结合,以减少错误的数量。
未采样的 MTE 也可以作为一种漏洞利用缓解措施部署,以确定性地保护 10%-15% 的内存安全漏洞(假设没有类似垃圾回收的扫描)。然而,由于非平凡的内存和运行时开销,我们预计生产部署主要将在占用空间较小但安全关键的工作负载中进行。
尽管存在一些限制,但我们认为 MTE 是减少大型现有 C++ 代码库中时间安全性错误数量的一条有希望的途径。目前还没有其他能够实际规模部署的 C++ 时间安全性替代方案。
CHERI 是一个引人注目的研究项目,有潜力为传统的 C++ 代码(也许包括强化模式下的 Carbon)提供严格的内存安全保证,而且只需进行最少的移植工作。CHERI 的时间安全保证依赖于对已释放内存的隔离和全面撤销,目前尚不清楚运行时开销是否能够满足生产工作负载的要求。
除了内存安全之外,CHERI 能力还可以实现更多有趣的安全缓解措施,例如细粒度沙盒化。
经过 50 年,内存安全漏洞仍然是最顽固和最危险的软件弱点之一。作为漏洞的主要原因之一,它们继续导致重大的安全风险。越来越明显的是,内存安全是安全软件的必要属性。因此,我们预计在未来十年内,该行业将加速向内存安全的转变。我们对谷歌和其他大型软件制造商已经取得的进展感到鼓舞。
我们认为,高可靠性内存安全需要采用安全设计的方法,这需要采用具有严格内存安全保证的语言。考虑到长期以来的在过渡到内存安全语言的时间线中,还有必要尽可能通过消除漏洞类别来提高现有的 C 和 C++ 代码库的安全性。
参考资料
[1]
Google 官方博客: https://security.googleblog.com/2024/03/secure-by-design-googles-perspective-on.html
[2]
《安全设计 - Google 对内存安全的洞察》白皮书: https://storage.googleapis.com/gweb-research2023-media/pubtools/pdf/0d8ad2cd7c5c02835c024af736844c722e7fa0f9.pdf
[3]
美国国家网络主任办公室上周: https://www.whitehouse.gov/wp-content/uploads/2024/02/Final-ONCD-Technical-Report.pdf
[4]
安全编码: https://research.google/pubs/secure-by-design-at-google/
[5]
谷歌在 GCP 的网络堆栈中改进空间安全性的工作: https://bughunters.google.com/blog/6368559657254912/llvm-s-rfc-c-buffer-hardening-at-google
[6]
美国联邦政府关于开源软件安全的请求: https://www.regulations.gov/document/ONCD-2023-0002-0001
[7]
回应: https://www.regulations.gov/comment/ONCD-2023-0002-0074
[8]
引人注目的安全改进: https://security.googleblog.com/2022/12/memory-safe-languages-in-android-13.html
[9]
开始用 Rust 实现一些功能: https://groups.google.com/a/chromium.org/g/chromium-dev/c/UhwVDk4HZFA/m/UAA2D96QBAAJ
[10]
Rust 基金会提供 100 万美元的资助: https://security.googleblog.com/2024/02/improving-interoperability-between-rust-and-c.html
[11]
使用 Rust 和 C++ 时可能发生的跨语言攻击: https://bughunters.google.com/blog/4805571163848704/llvm-cfi-and-cross-language-llvm-cfi-support-for-rust
[12]
ISRG Prossimo: https://www.memorysafety.org/
[13]
OpenSSF 的 Alpha-Omega: https://alpha-omega.dev/
[14]
内存安全的驱动程序: https://lore.kernel.org/lkml/20231101-rust-binder-v1-0-08ba9197f637@google.com/
[15]
安全编码: https://github.com/google/safe-html-types/blob/main/doc/index.md#introduction-to-safe-coding