Rust是一种新兴的编程语言,旨在在不牺牲太多效率的情况下防止内存安全bug。声明的属性对开发人员非常有吸引力,许多项目开始使用该语言。然而,Rust能实现内存安全的承诺吗?本文通过调查186个真实的bug报告来研究这个问题,这些bug报告收集自多个来源,其中包含了所有现存的关于内存安全问题的Rust cve(常见漏洞和暴露),截止到2020年12月31日。我们手动分析每个bug并提取它们的罪魁祸首模式。我们的分析结果表明,Rust可以遵守它的承诺,即所有内存安全bug都需要不安全的代码,而且我们数据集中的许多内存安全bug都是轻微的可靠性问题,只有在没有不安全代码的情况下才有可能编写内存安全bug。此外,我们总结了三种典型的内存安全漏洞,包括自动内存回收、不健全的功能和不健全的通用或特征。虽然自动内存请求bug与Rust新采用的基于所有权的资源管理方案的副作用有关,但不健全的功能揭示了Rust开发在避免不健全的代码方面的基本挑战,而不健全的通用或特征则加剧了引入不健全的风险。基于这些发现,我们提出了提高Rust开发安全性的两个有希望的方向,包括使用特定api和方法来检测涉及不安全代码的特定bug的几个最佳实践。我们的工作旨在引发更多关于Rust的内存安全问题的讨论,并促进该语言的成熟。
内存安全错误(例如,缓冲区溢出)是关键的软件可靠性问题[32]。这样的错误通常存在于允许任意内存访问的系统编程语言(如C/ c++)编写的软件中。这个问题很有挑战性,因为在不牺牲语言的可用性和效率的情况下管理内存访问是很困难的。Rust就是这样一种旨在解决这个问题的系统编程语言。自2015年稳定版发布以来,Rust的社区发展很快,很多热门项目都是用Rust开发的,比如web浏览器Servo[3],操作系统TockOS[21]。
为了实现内存安全编程,Rust引入了一套严格的语义规则,用于编写编译代码,从而防止出现错误。这些规则的核心是基于所有权的资源管理(OBRM)模型,它为每个值引入一个所有者。所有权可以在两种模式下作为引用或别名在变量之间借用:可变或不可变。主要原则是一个值在一个程序点上不能有多个可变别名。一旦某个值不再属于任何变量,就会立即自动删除该值。为了更灵活和可扩展,Rust还支持可能打破原则的不安全代码,但应该用不安全标记表示,这意味着开发人员应该对使用这种代码的风险负责。现有的由Rust编译器强制的内存安全检查机制对于不安全的代码是不可靠的。
随着Rust迅速流行起来,软件社区的一个关键问题是Rust如何与内存安全bug作斗争。在我们的工作之前,Evans等人[14]已经进行了一项大规模的研究,表明不安全的代码在Rust板条箱(项目)中被广泛使用。然而,目前还不清楚不安全的代码是否以及如何会破坏现实世界中Rust程序的内存安全。由于现有的材料缺乏深入的理解,我们试图通过实证调查现实世界Rust项目中报告的一组关键bug来解决这个问题。当我们准备这项工作时,我们注意到另一个独立的工作[27]也研究同样的问题。虽然他们的工作已经发展出了一些类似于我们的论文的理解,但我们的分析涉及到更多的记忆安全漏洞,并提供了一些不同于它们的新发现,例如使用通用或特征的罪魁祸首模式。我们将在第8.1节中澄清这些差异。
为了更详细地说明,我们的工作收集了来自不同来源的内存安全漏洞数据集,包括advisor - db、Trophy Case和GitHub上的几个开源项目。该数据集总共包含186个内存安全问题,是2020-12-31之前所有存在内存安全问题的Rust cve的超集。对于每个bug,我们手动分析其后果和罪魁祸首,然后将其划分到不同的组中。由于其结果与传统的C/ c++[32]相比并没有什么特别之处,所以我们采用自顶向下的方法,并直接使用诸如缓冲区溢出/ overread、use-after-free和double -free等标签。然而,我们没有关于Rust中罪犯的模式的先验知识,所以我们采用一种自底向上的方法,如果两个罪犯表现出相似的模式,我们就对他们进行分组。
基于这些bug,本文主要研究了三个问题。第一个问题是Rust在防止内存安全bug方面有多有效。我们的检查结果显示,我们数据集中的所有bug都需要不安全的代码,除了一个编译器bug。主要的魔力在于Rust不会容忍它的标准库(std-lib)或第三方库提供的不健全的api。因此,许多内存安全缺陷只是引入了不可靠的api的轻微问题,它们本身不能完成内存安全犯罪。因此,我们可以推测Rust遵守了它的承诺,即开发人员不使用不安全的代码就无法编写内存安全的bug。
我们的第二个问题是关于Rust中内存安全漏洞的典型特征。为此,我们提取了三种典型的内存安全bug,分别是自动内存回收、不健全的功能和不健全的通用或特征。自动内存回收bug与Rust OBRM的副作用有关。具体来说,Rust编译器强制自动销毁未使用的值,例如,通过调用drop(v),当v耗尽它的效用时。该设计旨在缓解内存泄漏问题,因为Rust没有运行时垃圾收集,但它会导致数据集中许多释放后使用、双释放和释放无效指针(由于未初始化的内存访问)的问题。Rust的默认堆栈解除策略进一步加剧了这种副作用。不健全的功能的类别显示了Rust开发人员在代码中避免不健全问题的基本挑战。当使用复杂的语言特性(如多态性和继承)时,不健全的泛型或界限的类别揭示了Rust开发人员在交付健全的api时面临的另一个基本挑战。此外,还有一些bug不属于上述类别,它们主要是由常见的错误造成的,如算法溢出或边界检查问题,我们对此不感兴趣。
我们的第三个问题研究如何使Rust程序更安全。一方面,我们发现了一些常见的错误和补丁模式,可以作为代码建议的最佳实践,例如在实现发送或同步特征时,将泛型参数与发送或同步特征绑定,或尽可能早地禁用自动删除(与ManuallyDrop)。另一方面,我们可以扩展当前Rust编译器的静态分析方案,以支持不安全的代码,从而检测一些特定类型的bug,特别是与自动内存回收相关的bug。
我们强调我们的论文的贡献如下。
本文是Rust在防止内存安全漏洞方面的初步实证研究。它对真实内存安全错误的罪魁祸首进行了深入分析,并生成了几个主要类别,包括自动内存回收、不健全的功能、不健全的通用或特征,以及其他错误。
我们强调Rust开发的一个独特挑战是交付可靠的api,并且使用Rust的高级特性(例如,通用的或特性的)加剧了引入不可靠问题的风险。此外,我们强调,自动内存回收bug与Rust新采用OBRM的副作用有关,而Rust的默认堆栈展开策略进一步加剧了这类问题。
•我们的工作为提高Rust发展的安全性提出了两个有前景的方向。对于开发人员,我们提供了一些帮助他们编写声音代码的最佳实践。对于编译器/工具开发人员,我们的建议是考虑将当前的安全检查扩展到不安全代码领域,特别是在检测与自动内存回收相关的bug方面。
本文的其余部分组织如下。第二节回顾了Rust的记忆安全之战;第三部分介绍了我们的研究方法;第4节概述我们收集的bug的特征;第5节演示了这些bug的详细类别和模式。第6节讨论了经验教训;第7条明确了有效性的威胁;第8节介绍相关工作;最后,第9部分对本文进行总结。
本节回顾了内存安全错误的初步情况,并讨论了Rust防止这些错误的机制。
内存安全缺陷是软件系统的严重问题。根据MITRE1的统计报告,内存安全漏洞被列为最危险的软件漏洞之一。接下来,我们回顾内存安全bug的概念,并讨论为什么很难防止它们。
只要开发人员使用指针,根除内存安全bug即使不是不可能,也是非常困难的。对抗这些漏洞的经典策略主要有两方面。一种是设计内存安全保护机制,如栈金丝雀和阴影栈[11]来保护栈的完整性,ASLR[15]来防止代码重用,glibc fasttop来检测双自由,等等。虽然这种机制是有效的,但它们只是提高了攻击的门槛,而不能阻止攻击。另一种策略是从一开始就防止引入内存安全bug,例如使用类型安全语言[30]和禁用原始指针(如Java)。但是,内存安全特性的实施也可能对语言造成限制,使其在具有严格性能要求的系统级软件开发中效率低下。
Rust是一种系统编程语言,旨在防止内存安全漏洞,同时又不牺牲性能。它通过在编译器级别引入一组严格的语义规则来实现这一目标。通过这种方式,Rust可以比其他依赖运行时内存检查和垃圾收集[9]的编程语言(例如Go)更高效。
图1概述了Rust的思想。Rust本质上是一种混合编程语言,包括一个安全部分和一个不安全部分。安全部分保证了所有代码和api的行为都是良好定义的,并且只使用安全api的程序应该不会有内存安全问题的风险。不安全的部分没有这样的保证,可能会导致未定义的行为,但它需要满足一些特定的需求,例如对性能要求严格的软件开发需要高效的内存访问。任何可能导致未定义行为的代码都应该声明为不安全的,例如解除对原始指针的引用、调用FFIs(外部函数接口)和不安全的api。实际上,许多安全的api也在内部使用不安全的api,而这些api之所以安全,是因为它们消除了所有内存安全风险,例如通过条件代码。调用不安全api的函数可以声明为安全或不安全,这主要取决于开发人员的决定。Rust无法检查申报是否正确因此,错误地将API声明为安全的是危险的,可能会破坏安全Rust的可靠性。此外,Rust对内存泄漏问题不感兴趣。任何可能导致内存泄漏的代码在Rust中都是安全的。因此,我们接下来的讨论将不包括内存泄漏问题。
safe Rust的核心是一种新的基于所有权的资源管理模型[18]。所有权意味着一个值应该有一个变量或标识符作为它的所有者,并且所有权是独占的,一个值只能有一个所有者。但是,所有权可以在两种模式下作为引用或别名在变量之间借用:不可变(默认)或可变(带有额外的互斥标记)。该模型假设,在任何程序点上,只有一个变量可以对某个值进行可变访问,而其他别名在那个点上既没有可变访问,也没有不可变访问,或者只有不可变访问可以在多个别名之间共享。Rust在它的编译器中实现了一个借用检查器来实现这个目标。当程序执行退出作用域时,借用的所有权将自动过期。如果一个值不再由变量拥有,它将立即被删除,以回收不再使用的缓冲区。与模型相关联的是,Rust引入了一种生命周期推断机制(类似于类型推断[26]),它确保借用的所有权的生命周期能够持续足够长的时间以供使用。它们共同构成了Rust防止内存安全bug的基础。基于所有权的资源管理的实现基于RAII(资源获取即初始化)习语。RAII强调使用构造函数创建对象时的资源分配,以及使用析构函数销毁对象时的资源回收[28,31]。因此,借用检查和生命期推断算法只需要考虑初始化良好的对象及其引用。换句话说,OBRM使Rust编译器不必解决复杂的指针分析问题,从而极大地简化了编译器的工作。
OBRM模型应用于Rust支持的所有类型,包括自定义的结构、泛型和特征。泛型与Rust的多态性特性有关,这意味着在编译过程中确定的未指定类型(通常用T表示)(类似于c++模板)。Trait与Rust的继承特征有关,这意味着一个Trait类型可以被继承,它的成员函数可以被派生类型重新实现,如克隆Trait和Drop Trait。当实现Rust代码时,一个泛型可以与一些特性绑定。
OBRM在防止内存安全bug方面奠定了Rust的基础。通过OBRM和其他相关设计,Rust编译器向Rust开发人员保证,如果不使用不安全的代码,他们不会遇到内存安全问题。下面,我们将证明OBRM在防止不同的内存安全问题方面的有效性。
防止悬浮指针:根据OBRM,变量或指针在定义时应该用值初始化。这使编译器能够跟踪值的抽象状态(所有权和生存期),并执行合理的程序分析。这样的分析保证了安全的Rust可以禁止共享可变别名,因此消除了解引用或释放指针的风险,这些指针的值已经用另一个别名释放了。请注意,虽然在安全的Rust中定义原始指针是有效的,但解除对它们的引用只允许作为不安全的。然而,当使用不安全的代码时,Rust编译器的可靠性将会失效。例如,不安全的代码可能会引入共享的可变别名,从而使程序容易受到自动内存回收方案的双重释放。我们将在第5.1节详细说明这个问题。
防止缓冲区溢出/过读:OBRM还有利于范围内的缓冲区访问,因为每个变量或指针都指向特定类型的值,比如i32。它保证内存数据正确对齐。对于Rust std-lib的高级数据容器,如Vec, Rust通常会为对象维护一个长度字段,并在运行时自动执行边界检查。同样,这些机制只适用于安全的Rust,不安全的代码(例如不安全的类型转换或解除对原始指针的引用)可能会导致超出范围的访问。
防止未初始化的内存访问:默认情况下,Safe Rust不允许未初始化的内存。例如,使用uninitialized()或alloc()创建缓冲区是不安全的;为vector保留容量是安全的,但直接使用set_len()增加其长度是不安全的。
OBRM也适用于并发编程,因此可以减少由于数据竞争引起的内存安全风险。例如,当从子线程访问在主线程中定义的变量时,需要使用标记移动来转移变量的所有权。为了在多个进程中并发访问变量,Rust提供了互斥锁和Arc,分别实现互斥锁和原子访问,并且Rust的类型系统确保除非实现了发送或同步特性,否则线程之间不能共享所有权,例如:基于Mutex或Arc。这些特性可以帮助开发人员避免在代码中引入数据竞争或竞争条件。
Rust的另一个基本技巧是,安全的Rust不容忍不可靠,也就是说,如果一个代码片段提供了一些安全的api,但是开发人员可以使用它来完成未定义的行为而不使用不安全的代码,那么这个代码片段就包含了不可靠。Rust要求所有安全的api都不能引发未定义的行为,否则它就是不安全的。这个需求适用于所有的Rust项目和开发人员,并且它在Rust社区逐渐发展和许多第三方库可用时扮演着重要的角色。只有当所有这些库都是健全的,Rust编译器的健全才能得到保证。