三种安全

原文:A Tale of Three Safeties

Midori 是在三种安全性的基础上建立的:类型、内存以及并发安全性。这些安全性“从根子上(by-construction)”消除了一整类的 bug,而且在可靠性、安全性、开发人员生产力方面带来了巨大的提升。而且它们从基础上允许我们用一种新的更强有力的方式去依赖类型系统,去带来新的抽象,去完成创新的编译器优化,还有更多。再回过头看,我们项目最大的贡献是证明了,整个操作系统和它的系统服务、应用和类库生态系统能全都用安全代码写出来,而且没有性能损失,在若干重要的维度还有一些数量级的领先。

首先,让我们定义这三种安全性,根据基础性来排序:

  • 内存安全 禁止访问内存的非法区域。若违反内存安全,将出现多种缺陷,包括缓冲区溢出,释放(free)后使用内存,以及重复释放内存。总体来说,违反内存安全性是一种很严重的问题,会导致很多漏洞,譬如代码注入等。

  • 类型安全 禁止使用分配给别的类型的内存。若违反类型安全,将出现多种缺陷,包括类型混淆,转换错误,以及未初始化的变量。虽然通常没有违反内存安全那么严重,但违反类型安全性仍然会导致漏洞,特别是可能导致内存安全漏洞。

  • 并发安全 禁止共享内存不安全的并发使用。此类众所周知并发冒险(hazards)包括数据竞争,或读写、写读、写写冒险。通常,如果违反了并发安全,会频繁导致类型安全、进一步导致内存安全性被违反。这种问题比较微妙 —— 像内存撕裂一样 —— 我们经常说,并发漏洞是安全漏洞利用的“下一个前沿”。

目前已经存在很多方法来确保一项或多项以上安全,并/或保护目标免于危害。

软件故障隔离 建立内存安全防护罩避免最严重的漏洞。这会带来一些运行开销,虽然自带证明代码能够减轻这部分开销。这种技术没能给类型和并发安全提供什么好处。

另一方面,基于语言的安全性,是归纳地通过类型系统规则以及局部检测(相对于全局),保证某些操作不会发生,再加上可选的运行时检查(像在没有更强大的依赖类型系统 (dependent type system) 时的数组边界检查)。这种方法的好处在于排除安全漏洞通常更有效,因为开发者在写代码的时候就能找到问题,而不是等到程序运行的时候。但如果你能欺骗类型系统来允许执行非法的操作,你就完蛋了,因为就没有防备黑客突破内存安全性的防护罩了,他可以执行任何代码。

为了利用它们其中最好的部分,多种技术经常相互结合使用,称之为深度防护。

保证安全性的运行时方面的方法包括 Google 的 C++ sanitizers微软的 “/guard” 特性。语言方面的方法包括 C#、Java、绝大多数函数式语言、Go 等等。但我们已经发现一些攻击,因为 C# 有 unsafe 关键字,允许 unsafe 区域违反安全 。

那么,现在你要开发一个操作系统,主要任务是要控制硬件资源、缓冲区、服务和应用程序并行运行,诸如此类,所有的的这些都是该死的不安全(unsafe)的东西,你该怎样用安全的语言来搞定呢?好问题!

答案出乎意料的简单:分层。

系统里面当然有一些不安全代码。每一个不安全组件负责封装它们不安全的部分。说得容易做起来难,要搞对这些是系统里面最难的部分。这就是为什么我们要尽可能保持这个所谓的 可信计算基(TCB)越小越好。在操作系统核心和运行时里没有使用不安全的代码的东西,只有微内核里非常少的部分使用了它。是的,我们的操作系统调度器和内存管理器都是用安全代码编写的。所有的应用级程序和库都是 100% 的安全代码,譬如我们整个 Web 浏览器都是安全代码写的。

依赖类型安全的一个有趣的方面是你的编译器)变成了你可信计算基(TCB)的一部分。虽然我们的编译器使用安全语言写的,但它生成供处理器执行的指令。这风险可以通过类似自带证明代码和类型化汇编语言(TAL) 稍微补救一下。再加上运行时检查,如软件故障隔离等,也能减轻这种风险。

我们方法的一个成果就是系统是自举的。这是我们推到极限的一个关键原则。关于这个,我之前提到过一点儿。但当你的操作系统核心、文件系统、网络栈、设备驱动、UI 和图形栈,Web 浏览器、Web 服务器、多媒体栈、…、甚至编译器自己都是用你的安全编程模型写的,你当然会确信它能够搞定你扔给它的任何东西。

你可能想知道所有这些安全的开销。简而言之,没有了指针运算、数据争用这种操作,的确是有些事情你做不到的。我们做了很多工作来最小化额外的开销。现在我可以很高兴的说,最后我们的确完成了一个有竞争力的系统。在系统自己之上建立系统是让我们保持坦诚的关键。事实证明,像非阻塞式 IO、轻量级进程、细粒度并发、异步消息分发、还有更多类似的架构决定带来的好处,远远超过整个软件栈上下都实行安全带来的“无足轻重的”开销。

举例来说,我们真有些类型只是些数据位堆。但这些只是些被动数据结构(PODs)。这使我们能够在字节缓冲区中解析数据位 —— 而且能够在完全不同的“类型”中互相转换 —— 高效而且没有损失安全。我们有一个头等公民的切片类型,允许我们在缓冲区上构造安全的、检查(checked)的窗口,统一了我们所有访问系统中内存的方法。(我们加到 .NET 中的这个切片类型就是受它启发的。)

你可能也想知道支持类型安全的运行时类型信息 (RTTI) 的开销。感谢 PODs 和对可辨识联合(discriminated unions) 适当的支持,我们不需要经常转换类型。而且当我们需要做转换的时候,编译器能帮忙优化处理结构地狱(hell out of the structures)。结果就是不会比典型的只支持虚拟分发的 C++ 程序开销大多少(不用在意转换)。

通过这次旅程,一个普遍的观点是编译器技术在过去的 20 年里有了惊人的进步。大部分情况下,安全开销能得到非常有力的优化。不是说这些开销能降到零,但我们可以认为它们只是大部分有趣的程序的小噪音。而且令人惊奇的是,我们发现大量情况下安全性带来新的特别的优化技术!例如,通过在类型系统中使用不可变性,允许我们在多个堆和程序中更加激进地共享内存页;教编译器约定(contracts)知识让我们更积极地提升类型安全检查,等等。

另一个有争议的领域是并发安全。特别是这个项目开始的日子跟 2000 年代后期令人兴奋的多核时代有重叠。什么?没有并行性?你会这样问。

注意我可没说过我们禁止所有的并发,我们只是禁止不安全的并发。首先,大部分的系统内的并发表现为在轻量级的软件隔离进程间使用消息传递。其次,对于同一个进程的并发,我们形式化了安全的共享内存并行的规则,通过类型系统和编程模型规则来确保这点。结果就是你不会写出共享内存的数据竞争。

引致这种形式化的一个关键的洞悉是没有两个共享同一个地址空间的“线程”被允许同时看到可修改的同一个对象。多个线程可以同时读同一块内存,一个线程可以写,但不能多个线程同时写。一些细节在我们的 OOPSLA 论文(安全并行中的唯一性和引用不变性)中有讨论,还有 Rust 也完成了类似的成果有很棒的文档。对多数细粒度的并行来说,这种规则工作得足够好了,就像我们的多媒体栈一样。

Midori 之后,我一直致力于将怎样同时实现安全和高性能的经验带到 .NET 和 C++ 中。可能目前可见的成果只有我们近段时间作为 C++ 核心指引的一部分发布的安全 profiles。我希望在 C# 7 中和我们正在搞的 .NET 跨平台 C# AOT 中,有更多的成果出现。Midori 是一块处女地,而当前的这些环境需要微妙的折中和妥协,的确是充满乐趣,但减慢了将这些想法转化为产品的速度。我很高兴终于开始见到结出了一些果实。

内存、类型和并发安全的组合给了我们一个有力的基础。最重要的是,它提高了开发人员的生产力,让我们能更快地前进。极高成本的缓冲区溢出、数据竞争、死锁之类的玩意,就根本不会发生。总有一天,所有的操作系统都会用这种方式编写。

在这系列的下一篇文章中,我们将会看看这些安全性基础如何让我们带来作为编程模型和类型系统头等公民的基于权能的安全模型,并带来同样“从根子上(by-construction)”的解决方案,以消除 环境权限(ambient authority),默认情况下,在任何地方使用最小权限原则。下次见。