蓝色战队之锈:什么是“记忆安全”,真的吗?

2020-08-02 09:37:54

工具塑造了他们的用户和结果。C和C++的范例塑造了一代又一代的系统程序员,这两种语言的普及性和持久性证明了它们的实用性。但由此产生的软件已经遭受了数十年的内存崩溃CVE。

作为一种没有垃圾回收的编译语言,RUST支持传统意义上的C/C++域。这包括从高性能分布式系统到微控制器固件的所有领域。RUST提供了另一组范例,即所有权和生命周期。如果您从未尝试过RUST,那么想象一下与一位几乎无所不知但关注范围狭窄的完美主义者一起进行结对编程。这就是实现所有权概念的编译器组件--借用检查器有时会有的感觉。为了换取相关的学习,请想象一下如何与Rust一起进行结对编程。这就是实现所有权概念的编译器组件--借用检查器(Borry Checker)有时会给人的感觉。为了换取相关的学习,请想象一下与Rust一起进行结对编程的情景吧。这就是实现所有权概念的编译器组件。

和安全领域的许多人一样,我也被“铁锈”这个闪亮的承诺所吸引。但是,从技术层面来说,“安全”到底是什么意思呢?它的吸引力是“扑灭的飞蛾”,还是“铁锈”从根本上改变了游戏规则?

这篇文章是我试图回答这些问题的基础,基于我到目前为止所学到的知识。内存安全是一个深及膝盖的操作系统和计算机体系结构概念的主题,所以我必须假设具有令人敬畏的系统安全知识才能使这篇文章保持简短。无论您是已经在使用Rust,还是正在酝酿这个想法,希望它对您有所帮助!

让我们从好消息开始吧。Rust在很大程度上防止了信息泄露和恶意代码执行的主要载体:

堆栈保护:传统堆栈粉碎现在是一个异常,而不是内存损坏;尝试写入超过缓冲区末尾将触发死机,而不是导致缓冲区溢出。不管死机处理逻辑如何(例如,PARGIC_RESET),您的应用程序仍可能受到拒绝服务(DoS)攻击。这就是为什么“毛茸茸的铁锈”仍然值得一看的原因。但是,由于死机防止了攻击者控制的堆栈损坏,您不会成为任意或远程代码执行(分别为ACE和RCE)的受害者。尝试读取超过缓冲区末尾的操作同样会停止,因此不会出现心脏出血式的错误。强制是动态的:编译器在必要的地方插入运行时边界检查,导致很小的性能开销。边界检查比C编译器可能插入的堆栈cookie更有效,因为它们在索引线性数据结构时仍然适用,而使用Rust的迭代器API更容易正确执行这一操作。

堆保护:边界检查和死机行为仍然适用于堆分配的对象。此外,所有权范例消除了悬挂指针,防止了释放后使用(UAF)和双重释放(DF)漏洞:堆元数据永远不会损坏。如果程序员创建循环引用,内存泄漏(意味着永远不会释放分配,不会过度读取数据)仍然是可能的。编译时静态分析执行,对表示所有可能的动态执行的抽象状态进行合理的推理。没有运行时成本。有效性是最大的:程序根本不能进入坏状态。

引用总是有效的,变量在使用之前被初始化:安全锈不允许操作原始指针,从而确保指针解除引用是有效的。这意味着对于DoS没有空解引用,对于控制流劫持或任意读/写也没有指针操作。当NULL是程序员希望逻辑表达的概念时,选项类型有助于错误处理。这些是编译时保证,归功于所有权和生存期。类似的编译时保证确保变量在初始化之前不能被读取。在大多数现代C编译器中,使用未初始化的变量是一个警告;这方面并不新鲜。但确保有效的解除引用肯定是正确的。

完全消除了数据竞争:Rust的所有权系统确保任何给定的变量在任何给定的程序点只能有一个写入器(例如,可变引用),但读取器(例如,不可变引用)的数量是无限的。除了实现内存安全外,该方案还解决了经典的读写器并发问题。因此,Rust消除了数据竞争,有时不需要同步原语或引用计数-但通常不需要竞争条件。数据竞争预防可减少并发攻击的机会。

生活中所有美好的事情都有一个警告。让我们看看小字:

并不是所有的Rust代码都是内存安全的:满足编译器的分析是在Rust中实现某些数据结构(如双向链表)具有挑战性的部分原因。此外,某些低级操作,如内存映射I/O(MMIO),很难完全分析安全性。被标记为不安全的代码块被手动指定用于分析的盲点,绕过安全特定的检查,因为程序员保证它们的正确性。这包括Rust标准库的一部分,已经为其分配了CVE编号,通过扩展,还包括通过C外部函数接口(CFFI)调用的任何外部库。此外,研究人员发现,所有权的自动销毁可以在不安全的代码中创建新的(意味着铁锈独有的)UAF和DF模式。动态检查堆一致性不变量的强化分配器并没有完全过时。内存安全保证适用范围很广,但不是普遍适用。

“不安全”放弃了有限范围内的内存安全保证,并且不会消除所有检查:“不安全”并不是一场混战。类型、生存期和引用检查仍处于活动状态;高风险操作具有显式API(例如,GET_UNCHECKED)。虽然内存损坏在不安全的情况下是可能的,但这种可能性仅限于代码库的一小部分-根据一项估计,这还不到典型Rust库的1%。从安全审计的角度来看,这大大减少了一个主要漏洞类别的受攻击面。将不安全视为较大系统中的小型可信计算基础(TCB)。

内部可变性可以将借用检查推到运行时:内部可变性模式允许单个内存位置有多个可变别名,只要它们没有同时使用。它是借用检查器的一个回避步骤,当问题不能以一种惯用的方式重新构建以获得强大的编译时保证时,它是一个后备方案。不安全API的安全包装(例如RC<;RefCell<;T>;>;、Arc<;Mutex<;T>;>;)会在运行时验证独占性,从而导致性能下降并可能引发死机。我找不到衡量此模式使用范围有多广的指标,但我再次建议使用Fuzing进行概率恐慌检测。

老实说,几十年的硬件、操作系统和编译器级别的防御已经强化了C和C++的部署。内存损坏0天并不是很容易取得的成果。但锈蚀仍然感觉像是向前迈出了重要的一步,是对性能关键型软件的安全状态的显著改进。即使不安全的逃生口肯定存在,内存损坏--一种大而恶性的错误类别--基本上已经被消除了。

那么,Rust是不是新的救世主,被派来把我们从远程shell的地狱中拯救出来?绝对不是。Rust没有停止命令注入(例如,输入字符串的一部分作为参数结束于execve)。或者是配置错误(例如,后退到不安全的密码)。或者是逻辑错误(例如,忘记验证用户权限)。没有任何通用编程语言会使您的代码本质上是安全的,或者是形式上正确的。但至少您不必担心这些错误。

让我们假设嵌入式意味着没有操作系统抽象;软件堆栈是单一的单片二进制文件(例如AVR或Cortex-M固件)或操作系统本身的一部分(例如内核或引导加载程序)。Rust!s!#[NO_STD]属性简化了嵌入式平台的开发。!#[NO_STD]Rust库通常放弃动态集合(如VEC和HashMap),以便于移植到裸机环境(无内存分配器,无堆)。没有动态内存,借用检查器障碍最小,因此原型化简易性大致相当于嵌入式C语言-尽管支持的体系结构较少。

资源受限和/或实时的嵌入式系统!#[no_std]目标通常缺乏诸如内存保护单元(MPU)、禁止执行(NX)或地址空间布局随机化(ASLR)之类的现代缓解措施。我们正在谈论的是一个无法无天的地方,在那里内存是平坦的,没有人能听到你的分段错误。但是铁锈仍然给我们提供了在没有分配器的情况下运行裸机时的甜蜜的、甜蜜的绑定检查保险。这就是说,在没有分配器的情况下,Rust仍然给我们提供了一种甜蜜的、甜蜜的绑定检查保险。这就是说,在没有分配器的情况下,Rust仍然给我们提供了一种甜蜜的、甜蜜的绑定检查保险。值得注意的是,这可能是嵌入式场景中的第一道也是最后一道防线。只需记住,与硬件的低级交互可能需要一定数量的不安全代码,在这种情况下,不带边界检查的内存访问是选择加入的。

对于x86/x64,Rust编译器也会插入堆栈探测器来检测堆栈溢出。目前,此功能不适用于!#[NO_STD]或其他体系结构-尽管建议了创造性的链接解决方案。堆栈探测器(通常通过保护页实现)可防止由于未结束的递归而耗尽堆栈空间。另一方面,边界检查可防止堆栈或基于堆的缓冲区溢出错误。这是一个细微的区别,但也是一个重要的区别:

请记住,从Rust的角度来看,内存是一种软件抽象。当抽象结束时,保证也会结束。如果物理攻击(侧通道攻击、故障注入、芯片解封等)。是威胁模型的一部分,所以没有理由相信语言选择会提供任何保护。如果您忘记烧入适当的锁位,并且附带了暴露的调试端口,并且用于固件解密/身份验证的对称密钥位于EEPROM中:现场攻击者将不需要内存损坏错误。

依赖项管理并不像利用朗朗上口的营销名称或新时代编译器分析来防止它们那样迷人。但是,如果您曾经负责过生产基础设施,您就会知道补丁延迟通常是一个重要的指标。有时是您的代码被破坏,但更多的时候是您依赖的库将您的系统置于危险之中。这是Rust的包管理器Cargo非常宝贵的一个领域。

Cargo支持可组合性:您的项目可以将第三方库集成为静态链接的依赖项,在第一次构建时从集中存储库中下载它们的源代码。它使依赖项维护变得更容易-包括将最新的补丁程序、安全性或其他方面放入您的构建中。C或C++生态系统中的任何类似工具都不提供Cargo的语义版本控制,但是管理一组Git子模块可能会产生类似的效果。

与C/C++子模块鸭带不同,前面提到的可组合性在Rust中是内存安全的。C/C++库在传递struct指针时没有强制约定谁来执行清理:您的代码可能会释放库已经释放的对象(这是一个合理的错误),从而创建一个新的DF错误。Rust的所有权模型提供了一种约定,简化了API之间的互操作性。

最后,Cargo提供了一流的测试支持,这是现代C和C++经常被批评的一个疏漏。Rust';的工具链使软件工程的工程部分变得更容易:测试和维护很简单。在现实世界中,这对整体安全状态和内存安全一样重要。

不完全是。整数溢出绝对不是一个内存安全问题,它几乎肯定是一个更大的内存损坏错误链的一部分,以促进ACE。假设有问题的整数在写入攻击者控制的数据之前被用来索引到一个数组中-安全锈会仍然阻止该写入。

无论如何,整数溢出都可能导致严重的错误。Cargo使用可配置的构建配置文件来控制编译设置以及它们之间的整数溢出处理。默认的调试(低优化)配置文件包括overflow-check=true,因此二进制输出将在开发人员未显式显示的整数溢出时死机(例如u32::WRAPING_ADD)。除非被覆盖,否则发布(高优化)模式会起到相反的作用:允许静默绕回,就像C/C++一样,因为删除检查更有利于性能。与C/C++不同,整数溢出在R中不是未定义的行为。

如果性能是第一优先级,您的测试用例应该争取足够的调试版本覆盖率来捕获大多数整数溢出;如果安全性是第一优先级,请考虑在发布时启用溢出检查,并承受潜在死机的可用性打击。

内存安全并不是一个新概念,垃圾收集和智能指针已经存在一段时间了。但有时它是对现有好想法的正确实现,从而形成了一个新的好想法。Rust的所有权范例-它实现了仿射类型的系统-就是这个好想法,在不牺牲可预测的性能的情况下实现了安全。

现在我(不情愿地)的目标是实用主义,而不是教条主义。对于生产嵌入式项目,有完全正当的理由坚持使用成熟的提供的HAL和C工具链。许多现有的C/C++代码库应该模糊化、硬化和维护-而不是用Rust语言重写。一些库绑定,例如Z3求解器的库绑定,极大地受益于解释语言的动态类型化。在某些领域,具有Hoare逻辑前置条件和后置条件的语言可能会证明生产率是合理的。Z3求解器的一些库绑定就是一个例子。在某些领域,具有Hoare逻辑前置条件和后置条件的语言可能会证明生产率是合理的。

撇开免责声明不谈,我不记得上一次有一项新技术让我停下来关注是什么时候了,就像铁锈一样。该语言在编译器中明确了系统编程的最佳实践,以开发时认知负荷为代价换取运行时正确性。将内存置于危险境地的模式需要明确选择加入(例如,Unsafe、RefCell&T>;)。减轻重大错误类感觉就像是合法的左移:一个值得注意的可利用漏洞子集变成了编译时(Unsafe,RefCell&t>;T>;),这是将内存置于危险境地的模式所必需的。减轻重大错误类感觉就像是合法的左移:一个值得注意的可利用漏洞子集变成了编译时。费里斯有一个坚硬的外壳。