探索最小CHERI架构:嵌入式系统的安全革新

微软研究团队成功将CHERI能力安全架构压缩至32位RISC-V微控制器,实现细粒度内存安全与隔离,通过硬件能力机制彻底消除内存漏洞,为数十亿物联网设备提供企业级安全保护。

什么是CHERI的最小变种?

Portmeirion项目是微软剑桥研究院、微软安全响应中心与Azure硅工程解决方案部的合作成果。过去一年中,我们一直在探索如何将CHERI的核心思想缩放到最廉价微控制器级别的小型核心。这些核心与Morello项目关注的桌面和服务器级处理器截然不同。

微控制器通常仍是有序系统,具有短流水线和数十到数百KB的本地SRAM。相比之下,Morello等系统具有宽而深的流水线,执行乱序操作,并通过多层缓存和具有多级页表的内存管理单元隐藏GB到TB级的DRAM。全球有数十亿微控制器,且它们越来越可能连接到互联网。缺乏虚拟内存意味着它们通常没有任何类似进程的抽象,因此在单一特权域中运行不安全语言。

该项目现已达到阶段:我们拥有一个正常工作的RTOS,可在隔离舱中运行现有的C/C++组件。我们将在未来几个月开源软件栈,并致力于验证基于lowRISC项目Ibex核心的拟议ISA扩展的生产级实现,我们打算将其贡献回上游。

我们的CHERI微控制器项目旨在探索:如果我们愿意共同设计指令集架构(ISA)、应用二进制接口(ABI)、隔离模型和软件栈的核心部分,是否能获得非常强大的安全保证。我们在整个项目中应用了与更广泛的CHERI项目相同的两个基本安全原则:

  • 最小权限原则:任何组件都不应以超过完成任务所需的权限运行。
  • 意图性原则:任何组件都不应在未明确尝试的情况下行使权限。

现有的硬件安全功能并未完全尊重这两点。传统处理器以保护环描述特权,每个环都比下一个环具有更严格的特权。在一个特权模式下运行的任何东西实际上对较低特权状态下运行的事物具有完全控制权,这远超过典型内核或虚拟机监控程序中大多数代码所需的能力。类似地,在一个特权模式下执行时,任何指令都会自动以该特权操作,即使这不是本意。SMAP等技术旨在帮助解决此问题,防止内核充当混淆代理并意外访问用户空间内存。

微控制器上的内存保护通常通过内存保护单元(MPU)完成,RISC-V称之为物理内存保护(PMP)单元。这做了大型系统上内存管理单元(MMU)所能做的一个子集:它为不同的地址范围提供保护,但不执行地址转换。这再次违反了意图性原则,因为授予的权限(对物理地址空间范围的访问)与访问该范围的操作(具有任意整数地址的加载和存储指令)是分离的。例如,PMP可能为栈定义一个显式区域,但对堆或全局指针的指针算术如果越界,仍可能最终写入栈区域。

我们的架构是对RISC-V32E的扩展,这是最小的核心RISC-V规范。它只有15个寄存器(RISC-V通常有31个,零在两者中都保留为零值)、32位地址空间、单一特权级别且无PMP。我们将所有寄存器扩展为容纳64位能力。所有RISC-V加载和存储指令都被修改为需要一个能力作为操作数。与大型CHERI系统不同,我们期望在微控制器上运行的所有内容都被重新编译,因此我们不提供对传统整数寻址加载和存储的兼容性。

我们从CHERI中获得什么?

CHERI最初由剑桥大学和SRI国际在DARPA资助下开发,提供了一个用于访问内存的能力模型。每次内存访问(加载、存储或指令获取)必须由能力授权。CHERI能力是一种硬件通过受保护操作保护的数据类型,它既描述又授权对内存的访问。在具有32位地址空间的系统上,CHERI能力是由不可寻址的标签位保护的64位值(总共65位)。它们不能凭空创建。在系统启动时,寄存器文件包含授予对地址空间完全访问的能力。系统中的每个能力都是通过简单复制、移除权限或限制其覆盖的内存范围从这些能力中派生出来的。

能力是编译器用于表示指针的硬件类型,因此C/C++程序员可以将能力和指针视为等效。CHERI-C系统中的指针是不可伪造的,具有不可绕过的边界检查,并且可能具有减少的权限(例如,它可能是只读的)。每个函数指针、每个数据指针以及每个隐式指针(如栈指针或全局指针)都是一个能力,因此硬件对每次访问强制执行边界检查。这为我们提供了一个构建块,可用于对象粒度内存安全和细粒度隔离。

CHERI自然尊重我们的两个安全原则。每个加载或存储指令必须有一个能力作为基地址。如果你使用一个偏移量,该偏移量会将指针从一个你拥有的对象带到另一个对象,那么它将失败,因为你的意图由指令捕获(你打算访问由作为操作数给出的指针标识的对象)。这也适用于间接跳转:它们将一个可执行能力作为操作数,如果函数指针没有执行权限,则会失败。尝试使用数据指针作为函数指针将陷入陷阱。一段运行代码可以访问的内存集受限于它持有的能力集,这使得强制执行最小权限原则变得容易。

缩小CHERI规模

迄今为止,大多数CHERI工作都集中在具有128位能力的64位架构上。Morello能力格式具有64位地址、20位边界、16位对象类型和18位权限。32位处理器只有32位来存储所有元数据,直接音译将使用超过一半的位用于权限。关于32位CHERI系统已经有一些早期工作,但编码有很多限制,例如3位精度,这意味着对于大分配需要大量填充。它可以为最多约64字节的对象提供字节粒度能力,但随后需要在基部和顶部进行更强的对齐。这足以证明32位CHERI是可能的,但不足以用于实际部署。

我们的编码通过观察安全系统永远不会使用CHERI提供的许多权限组合来节省空间,因此我们不需要能够表示它们。这些分解之一是由我们在剑桥大学的朋友几年前提出的:传达密封或解封权限的能力在与所有其他能力不同的命名空间上操作,因此可以是一种单独的格式。他们还提议分离可写和可执行能力,但事实证明,如果没有对POSIX或Windows软件进行侵入性更改,这是不可能的。

我们基于这些思想构建,将13个架构权限压缩到7位编码空间。密封和解封权限不能与任何内存访问权限结合。执行和存储权限是不相交的,因此没有能力可以同时传达执行和写入内存的权利。微控制器软件通常假设至少可以选择在哈佛架构上运行,因此通常避免在桌面或服务器代码库中使这种更改有问题的假设。

这种压缩为我们提供了更多的边界编码空间,并允许我们对任何对象(或子对象)具有高达510字节的字节粒度边界:对于嵌入式系统来说足够了。

权限压缩还意味着系统中没有单一的全能能力。当64位CHERI系统启动时,它为初始加载程序提供授予对整个地址空间所有形式访问的能力。当我们的核心启动时,它提供三种不同的根能力:一种用于密封,一种用于执行,一种用于写入内存。运行系统中的所有能力都是从这些能力中派生出来的。通过构造,CHERI不提供任何添加权限的机制,因此在我们的系统中永远无法构建写和执行能力,因为这样做需要向我们的根能力之一添加写或执行权限。

这并不排除代码持有两种能力:一种授予对某些内存的写访问权限,另一种允许执行相同内存,但这样做尊重意图性原则:代码必须为每个操作显式使用正确的权限。从意图性的角度来看,这是一个理想的属性:即使你被允许写入和执行同一内存,你也必须明确选择你正在执行的操作并正确授权它。

添加时间内存安全

我们在大型CHERI系统上的工作提出了许多优化,我们希望这些优化将提高服务器级CHERI系统上时间安全的性能。在小型系统上,我们有一个稍微简单的问题。缺乏虚拟内存意味着我们不需要担心别名。物理内存量小意味着我们能够在非常短的时间内扫描所有内存。此外,嵌入式系统通常是单核的,这消除了处理内存访问和对象被释放之间竞争的需要。

我们提供了撤销位图的硬件实现,如Cornucopia中所用,每8字节SRAM有一个1位标签,用于指示内存是否已被释放。在释放时,内存分配器设置分配的位,并延迟重用,直到有机会进行撤销扫描。对于小型CHERI系统,一个有效的创新是让主CPU流水线在加载能力时检查此位,如果能力指向已被标记为撤销的内存,则清除标签位。这意味着指向已释放对象的任何能力都不能加载到寄存器文件中。只要我们在上下文切换(根据定义发生)和从释放返回(作为调用内存分配器隔离舱的副作用发生)时溢出和重新加载寄存器,寄存器就永远不会持有过时指针。这反过来意味着我们不需要对数据加载和存储进行检查(尽管我们必须这样做,或者在多核微控制器上在释放时显式序列化核心)。

这对于释放后使用保护来说已经足够了,但在具有多个隔离舱的世界中,临时委托通常很有用。如果我将指向对象的指针从一个隔离舱传递给另一个隔离舱,我不必释放对象以确保被调用方没有指向它的指针,我想知道我可以立即重用它。我们提供了一个词法作用域委托机制,允许在跨隔离舱调用期间委托对对象图的访问。

我们在自CHERI开始以来一直是其一部分的2位信息流控制机制之上实现词法委托。这涉及两个权限:全局和存储本地。没有全局权限的能力称为本地能力,只能通过具有存储本地权限的能力存储。

在我们的系统中,只有两种能力具有存储本地权限:栈和用于上下文切换的寄存器保存区域。这意味着你可以将本地能力从一个隔离舱传递给另一个隔离舱,唯一可以存储它的地方是栈上。然后,在软件中,我们只需要确保在返回时清除栈。

对于我们的CHERI变体,我们用一个额外的权限扩展了本地/全局机制:允许间接加载全局。如果你有此权限,你可以加载设置了全局权限的能力。没有此权限,你加载的任何能力都将清除全局和允许间接加载全局权限。这是一种类似于我们与Arm合作添加到Morello的深度不可变性支持的机制。如果你从能力中清除允许间接加载全局和全局权限,那么你可以将其传递给另一个隔离舱,并保证在调用返回时,被调用方没有捕获从它可访问的任何内容。你可以将此与深度不可变性模型结合使用,以在调用期间临时授予对复杂数据结构的只读访问权限。

隔离舱和线程

我们的软件模型有两个关键隔离概念:隔离舱和线程。隔离舱定义空间所有权,线程定义时间所有权。隔离舱是代码和数据(全局变量)的组合,将函数公开为入口点。线程是可调度实体,拥有栈并调用隔离舱。在任何给定时间,系统在一个隔离舱中运行一个线程。

隔离舱被定义为CPU的两个能力寄存器。程序计数器能力(PCC)定义代码(和只读数据),能力全局指针(CGP)定义该隔离舱的(可变)全局变量范围。隔离舱内的函数调用是不改变PCC值的直接跳转。对全局变量的访问都通过CGP寄存器(如果你获取全局变量的地址,编译器会插入边界限制)。

在软件中,隔离舱还定义了一组用作域转换有效目标的入口点。隔离舱之间的调用在源代码中看起来像普通的C函数调用,但编译器插入一个通过隔离舱切换器跳转的调用序列。切换器负责确保跨隔离舱隔离。它保存被调用方保存的寄存器,清除临时和未使用的参数寄存器,截断栈,并将栈的委托部分清零,最后跳转到由被调用方导出表标识的入口点。切换器管理一个受信任的栈,包含保存的栈指针和跨隔离舱返回地址,主隔离舱代码无法访问该栈。受信任的栈还允许在隔离舱崩溃时从跨隔离舱调用返回。

栈截断确保被调用的隔离舱无法访问调用方栈的任何未显式作为参数传递的部分。栈清零(也在返回时发生)确保没有秘密或能力在隔离舱之间泄漏。

在隔离舱之间调用比函数调用慢得多,但仍然相当快(大约几百个周期)。清零栈听起来很慢,但请记住,这是一个嵌入式系统,典型的栈大小为1-2 KiB,有时更小。即使在相当慢的50 MHz嵌入式系统上,清零1 KiB的本地SRAM也相当快。不幸的是,我们无法在Morello上的CheriBSD等系统上轻松使用此技术,其中栈通常是8 MiB的DRAM。

栈和寄存器保存区域是系统中唯一存储本地能力的目标这一事实意味着无法将栈指针从一个线程传递给另一个线程,即使两者在同一隔离舱中执行。尝试将指向栈对象的指针存储到堆对象或全局变量中将陷入陷阱。这提供了线程之间的强非干扰保证,符合意图性原则:只有通过 intentionally shared 对象的存储在另一个线程中可见。

共享库

必须在隔离舱之间复制所有代码将显著增加某些嵌入式软件的内存需求。为避免这种情况,我们还提供了共享库的概念。这可以看作是一个不可变的隔离舱:它包含代码,但没有可变全局变量,因此通过跳转到哨兵能力调用是安全的。

哨兵(密封入口)能力是现有的CHERI功能,允许可执行能力用魔术对象类型密封,使其可以通过跳转指令自动解封。与任何其他密封能力一样,这不能被修改,因此密封能力提供了一种调用函数而不允许调用函数访问该函数的代码或只读全局变量的方法。

不允许共享库拥有全局变量对嵌入式软件的限制远小于对大型系统的限制。我们甚至能够将JavaScript解释器适配到此模型中,允许多个相互不信任的隔离舱都运行JavaScript代码。

一些库例程需要在中断禁用的情况下运行。在RISC-V上,通过写入控制和状态寄存器(CSR)中的标志位来禁用中断。我们可以通过移除访问系统寄存器CHERI权限来防止不受信任的代码访问此寄存器,但这是非常粗粒度的控制,并授予比库例程通常应该拥有的更多权限。相反,我们扩展了哨兵机制以编码中断状态。我们有三种哨兵类型:一种在跳转时禁用中断,一种启用它们,一种不改变中断状态。跳转链接指令将始终创建一个在跳转点时具有显式中断状态的返回能力。这些作为函数属性暴露给C,这意味着中断状态通过结构化编程控制,因此在源代码级别非常容易推理。

特权分离内核

系统中有几个部分需要以比普通隔离舱更大的权限运行,但只有一个组件以完全权限运行:加载程序。加载程序负责设置隔离舱,并在系统启动后立即运行。这意味着它开始执行时具有允许所有访问的能力集。然后它从它们派生出更受限制的能力。加载程序在执行期间保留三个根能力,但它所做的除从这些根派生新能力之外的所有事情都是使用更受限制的能力完成的。一旦加载程序完成,系统中的任何东西都不会以完全权限运行,直到下一次系统重置。

下一个特权最高的组件是切换器。它负责所有域转换。它构成了可信计算基(TCB)的一部分,因为它负责强制执行普通隔离舱依赖的一些关键保证。它具有特权,因为其程序计数器能力授予它对受信任栈寄存器的显式访问权限,这是一个特殊的能力寄存器(SCR),存储用于指向每个线程上用于跟踪跨隔离舱调用的小栈的能力。受信任的栈还包含指向线程寄存器保存区域的指针。在上下文切换时(通过中断或显式让出),切换器负责保存寄存器状态,然后将指向线程状态的密封能力传递给调度程序。

切换器除了通过受信任的栈从运行线程借用的状态外没有其他状态,并且足够小,可以轻松审计。它总是在中断禁用的情况下运行,因此易于进行安全审计。这至关重要,因为它可能通过不在隔离舱转换时正确清除状态来违反隔离舱隔离,并通过在将指向线程状态的指针传递给调度程序之前不密封它来违反线程隔离。

切换器是唯一处理不受信任数据并以访问系统寄存器权限运行的组件,并且少于200条RISC-V指令。

请注意,密封操作意味着调度程序无法访问线程的寄存器状态。调度程序可以告诉切换器接下来运行哪个线程,但它不能违反隔离舱或线程隔离。它在TCB中用于可用性(它可以拒绝运行任何线程),但不用于机密性或完整性。调度程序负责配置中断控制器,因此可以访问授予此访问权限的内存映射I/O(MMIO)空间,但这只是给它控制可用性:它可以控制是否传递中断,并在中断发生时选择调度哪个线程。

最终的TCB组件是内存分配器。对于任何CHERI系统,这始终是TCB的关键部分,因为它负责设置对象的边界。如果它不正确执行此操作,那么你甚至没有空间内存安全。在我们的系统上,它还负责管理撤销,因此错误可能引入可利用的释放后使用漏洞。

内存分配器负责管理在隔离舱之间共享的堆,因此其中的错误可能导致跨隔离舱内存泄露。请注意,这主要限于使用堆的隔离舱(在嵌入式系统上并非所有都使用)。分配器的大部分具有对堆内存的能力,但没有其他能力。唯一的例外是一个提供撤销服务的小型(隔离)组件。它必须能够扫描所有可变内存并使悬空指针无效。撤销服务可以在硬件中或在大约十条RISC-V指令的循环中实现。

保存密封

CHERI有一个密封机制,允许具有允许密封权限的能力将另一个能力转换为不能修改的不透明令牌。此密封能力在其“对象类型”字段中嵌入密封能力的地址字段,并且只能通过解封操作转换回可用能力,该操作具有其边界包括该值且具有允许解封权限的能力。在Morello上,对象类型是18位,因此可能有许多不同的不透明类型在隔离舱之间传递。

在我们的32位能力编码中,我们只有3位空闲用于对象类型。我们保留所有这些供特权组件使用。切换器有一个用于解封正在调用的隔离舱指针和密封线程状态。调度器有一个用于保护消息队列。分配器也有一个,我们用它来提供软件定义的能力机制。

软件定义的能力机制使用分配器的一个特殊入口点,该入口点分配一个具有标头的对象,该标头包含通常进入能力otype字段的值。因为这是在堆分配中,所以它可以是完整的32位值(减去硬件使用的少数值)。分配器返回指向此对象的密封能力,如果呈现具有匹配地址的允许解封能力,将返回指向不包括标头字的对象部分的未密封能力。这确保标头是防篡改的(仅在分配器内可访问),并允许大量密封类型,限制是只有指向整个对象的指针可以被密封。

例如,我们可以使用此机制让网络栈密封连接状态并将其返回给调用方,然后在调用时解封它,保护不同连接彼此不受影响。这让我们将意图性原则构建到更高级的抽象中:除非你呈现授权它的软件定义能力,否则你不能通过网络栈发送数据。

安全语言呢?

任何操作系统的核心部分,包括RTOS,都涉及做不安全的事情。例如,内存

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计