CHERIoT安全研究入门指南:探索嵌入式系统的内存安全革命

本文详细介绍了微软CHERIoT项目的安全研究入门方法,包括环境搭建、威胁模型分析及实际漏洞利用案例。通过移除关键安全检查演示如何破坏系统完整性、机密性和可用性,展现CHERI指令集在嵌入式系统中的强大防护能力。

CHERIoT安全研究入门指南

在微软,我们投入大量时间研究和探索内存安全的可能性。由于现有代码库绝大多数是用不安全编程语言编写的,保护遗留代码的任务非常重要。硬件解决方案是一个有吸引力的方法,因为它们引入了非常强大的安全属性,与纯软件解决方案相比开销较低。关于MTE和CHERI已经发布了很多研究,我们鼓励您查看。在博客文章“2022年12月安全缓解和架构调查”中有很好的概述和大量参考文献。

物联网和嵌入式生态系统带来了一些独特的挑战。不幸的是,这些生态系统建立在用C/C++(主要是C)编写的多样化代码库之上,通常由于实际或感知的开销而没有任何缓解措施。这意味着存在许多容易利用的内存安全漏洞。此外,现有硬件通常根本不提供隔离。当提供时,通常仅限于2-4个特权级别和少量隔离内存区域,无法扩展到对象粒度的内存安全。

2022年11月,我们(MSR、MSRC和Azure Silicon)发布了一篇关于我们为这些空间开发的新项目的博客文章——“什么是最小的CHERI变体?”。这个想法是构建一个基于CHERI的微控制器,旨在探索如果我们愿意共同设计指令集架构(ISA)、应用程序二进制接口(ABI)、隔离模型和软件栈的核心部分,是否/如何获得非常强大的安全保证。结果非常出色,在我们的博客文章中,我们详细介绍了我们的微控制器如何实现以下安全属性:

  • 空间安全性的确定性缓解(使用CHERI-ISA能力)
  • 堆和跨隔间栈时间安全性的确定性缓解(使用加载屏障、归零、撤销和一位信息流控制方案)
  • 细粒度隔离(使用额外的CHERI-ISA功能和一个微型监控器)

2023年2月初,我们在GitHub上开源了硬件和软件栈,以及我们的技术报告 - CHERIoT:重新思考低成本嵌入式系统的安全性。此版本包括ISA的可执行形式规范、基于lowRISC的Ibex核心的ISA参考实现、LLVM工具链的端口以及一个全新的特权分离嵌入式操作系统。

这篇博客文章旨在让安全研究人员能够接触我们的项目,拥有一个可工作的设置并直接进行实验。我们鼓励每个人发现错误、贡献并成为这项努力的一部分。

开始使用CHERIoT

入门指南提供了如何构建和安装所有依赖项的概述。如果您使用我们提供的开发容器,无论是使用本地Visual Studio Code还是GitHub Codespaces,都可以跳过大多数步骤。

使用GitHub Codespaces非常简单、直接且快速。只需创建一个新的Codespace,选择microsoft/cheriot-rtos存储库,就完成了。您将在浏览器中获得一个IDE,所有依赖项都已预安装。从现在开始,每当您想为项目使用Codespaces时,只需打开您的Codespaces,就可以从那里启动它们。

这将让您浏览代码,语法高亮和自动完成配置为与CHERIoT语言扩展一起工作,并提供一个终端用于构建组件并在从ISA的形式模型生成的模拟器中运行。

要运行示例,只需进入每个示例的目录,运行xmake构建它,然后将生成的固件映像传递给/cheriot-tools/bin/cheriot_sim:

cheriot_sim工具支持指令级跟踪。如果您传递-v,它将打印执行的每条指令,让您准确看到代码中发生的情况。

当然,您也可以在您喜欢的环境中本地工作。我们提供了一个开发容器映像,其中预安装了依赖项。支持开发容器规范的VS Code和其他编辑器应自动使用它。您也可以自己构建依赖项。如果我们的说明对您喜欢的环境不起作用,请提交问题或提出PR。

威胁模型

我们在隔间之间提供强大的保证,并提供一些额外的深度防御,使得破坏单个隔间变得困难。我们将分别讨论这些。

隔间内

在我们的博客“什么是最小的CHERI变体?”中,我们介绍了编译器和运行时环境可以使用CHERI强制执行的所有内存安全属性的低级细节:

  • 栈空间安全
  • 具有静态存储持续时间的对象的空间安全
  • 传递到隔间的指针的空间安全
  • 传递到隔间的指针的时间安全
  • 堆空间安全
  • 堆时间安全
  • 指针完整性(不可能伪造或损坏指针)

编译器负责强制执行前两个属性。这意味着汇编代码可以违反这两个属性,但不能违反任何其他属性。

这使我们能够在隔间内对遗留C/C++(和有错误但非主动恶意的汇编)代码强制执行非常强大的内存安全。例如:网络栈、媒体解析器等处理不受信任输入和对象管理的所有内容都会自动进行边界检查和使用期限强制执行,无需修改源代码。

任何会破坏我们安全规则的指令都保证会陷入陷阱,因此不会造成任何损害。这就是为什么我们获得的缓解措施是确定性的。

隔间间

每个隔间都是一个隔离的、独立的安全上下文。任何隔间都不应能够破坏另一个隔间构建的对象模型。我们假设隔间内存在任意代码执行——即攻击者可以拥有自己的隔间和自己的代码,我们希望强制执行以下系统安全属性:

  • 完整性:攻击者不能修改另一个隔间的状态,除非明确允许这样做。
  • 机密性:攻击者不能看到另一个隔间的数据,除非明确共享。
  • 可用性:攻击者不能阻止另一个同等或更高优先级的线程取得进展。

通常,完整性是这些中最重要的,因为能够违反完整性的攻击者可以修改代码或数据以泄露数据或阻止向前进展。机密性是其次最重要的。即使是简单的物联网设备,如联网灯泡,通过泄露其内部状态的一位,也可能让攻击者了解房屋是否有人居住,因此机密性是其次最重要的。

可用性的重要性取决于情况。对于安全关键设备,它可能比另外两个属性更重要(例如,大多数安装起搏器的人宁愿攻击者破坏他们的医疗记录,也不愿远程触发心脏病发作)。对于其他设备,如果设备(或设备上的服务)崩溃并需要重置,那只是小不便,尤其是当启动时间只有几分之一秒时。

在当前版本中,我们的威胁模型包括机密性和完整性,但可用性方面的工作仍在进行中。例如,我们有一种机制允许隔间检测故障并从中恢复,但尚未在所有地方使用。因此,任何违反机密性或完整性的错误都被视为严重,任何违反可用性的错误都需要在任何人考虑将平台用于安全关键任务之前修复。

相互不信任

我们的系统建立在相互不信任的原则上。对于某些核心组件,这有些引人注目。例如,内存分配器的用户信任它不会将相同的内存分配给不同的调用者,但分配器保留对所有堆内存的访问权限;然而,调用内存分配器并不会授予它访问您隔间的任何非堆内存的权限。类似地,调度程序在核心上时间切片一组线程,但任何这些线程都不信任其机密性或完整性,因为它为每个线程只持有一个不透明的句柄,无法解引用,允许它选择下一个运行的线程但不访问相关状态。相比之下,大多数操作系统建立在分层不信任的环模型上,其中虚拟机监控程序可以看到客户VM拥有的所有状态,客户内核可以看到它们运行的进程拥有的所有状态。

在我们的相互不信任模型中,获得完全控制需要破坏多个隔间。例如,破坏调度程序并不会让您访问所有线程,但它确实让您能够尝试攻击依赖调度程序服务(如锁定)的线程。破坏内存分配器让您违反堆内存安全并攻击依赖堆的隔间。

示例

为了了解一些保证是如何实现的,让我们尝试移除一些检查,看看我们获得了哪些利用原语。

示例#1 –(尝试)攻击调度程序

例如,让我们移除调度程序调用的代码路径中的一个检查,并探索系统可用性不受影响的有趣原因。正如我们在博客文章中写的:

“请注意,密封操作意味着调度程序无法访问线程的寄存器状态。调度程序可以告诉切换器下一个运行哪个线程,但不能违反隔间或线程隔离。它在可用性方面是TCB的一部分(它可以拒绝运行任何线程),但在机密性或完整性方面不是。”

这种设计意味着调度程序不在完整性和机密性的TCB中,这意味着任何潜在的错误只影响可用性,并且只影响可用性。

此外,请注意调度程序没有安装错误处理程序。这是因为,如果没有错误处理程序,切换器将强制展开到调用隔间。这消除了大多数对调度程序的潜在DoS攻击,因为它总是强制展开到调用者,而不影响系统的可用性、完整性或机密性。调度程序的编写确保在解引用可能引发陷阱的用户提供指针之前,其状态始终一致。

我们要移除的检查是一个重要的检查。看看heap_allocate_array sdk/core/allocator/main.cc中的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
[[cheri::interrupt_state(disabled)]] void *
heap_allocate_array(size_t nElements, size_t elemSize, Timeout *timeout)
{
	...
	if (__builtin_mul_overflow(nElements, elemSize, &req))
	{
		return nullptr;
	}
	...
}

如您所见,我们有一个对分配大小整数溢出的检查。因为我们使用纯能力CHERI,即使没有这个检查,也不可能访问超出其边界的分配。这是因为无论堆分配什么大小,确切的边界仍然在能力中设置。我们将在第一次加载/存储超出边界时触发CHERI异常(是的,CHERI很有趣!)。

让我们看看实际发生的情况。我们将移除整数溢出检查,并触发对heap_allocate_array的调用,参数溢出并触发小分配(当调用者意图大得多时)。通常,这种情况会导致野拷贝。但同样,使用CHERI-ISA,处理器不会让这种情况发生。

调度程序(sdk/core/scheduler/main.cc)公开队列API。其中之一是queue_create,它使用我们控制的参数调用calloc。而calloc又调用heap_allocate_array。您可以查看项目中的生产者-消费者示例,了解如何使用此API。这非常简单。

对于我们的示例,我们进行了以下更改以从tests下的queue-test触发错误:

在tests/queue-test.cc中更改传递为itemSize的值:

1
2
-       int rv = queue_create(&queue, ItemSize, MaxItems);
+       int rv = queue_create(&queue, 0x80000001, MaxItems);

在sdk/core/allocator/main.cc中移除整数溢出检查:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
-       size_t req;
-       if (__builtin_mul_overflow(nElements, elemSize, &req))
-       {
-               return nullptr;
-       }
+       size_t req = nElements * elemSize;
+
+       //if (__builtin_mul_overflow(nElements, elemSize, &req))
+       //{
+       //      return nullptr;
+       //}

在sdk/core/scheduler/main.cc中(在queue_recv和queue_send中):

1
2
-                       return std::pair{-EINVAL, false};
+                       //return std::pair{-EINVAL, false};

并且,仅用于调试目的,在sdk/core/scheduler/queue.h的send函数中,在memcpy之前添加以下调试跟踪:

1
2
+                               Debug::log("send(): calling memcpy with ItemSize == {}", ItemSize);
                                memcpy(storage.get(), src, ItemSize); // NOLINT

这将分配大小为2的存储,并在memcpy中故障。然后,我们将强制展开到调用隔间并失败,如您在此处所见:

我们强制从调度程序展开到queue_test隔间,并在queue_send的返回值中看到失败。调度程序继续运行,系统的可用性不受影响。

示例#2 - 破坏完整性和机密性

正如我们在博客文章中写的,最有特权的组件是切换器:

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

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

切换器是唯一处理不受信任数据并以访问系统寄存器权限运行的组件。它略多于~300条RISC-V指令,这意味着我们的TCB非常小。

因此,例如,让我们移除切换器中的一个检查,看看它如何引入影响一切——可用性、完整性和机密性的漏洞。

在切换器中,在compartment_switcher_entry的开头,我们有以下检查:

1
2
3
4
	// make sure the trusted stack is still in bounds
	clhu               tp, TrustedStack_offset_frameoffset(ct2)
	cgetlen            t2, ct2
	bgeu               tp, t2, .Lout_of_trusted_stack

这段代码确保可信栈没有耗尽,我们有足够的空间操作。如果没有,我们跳转到一个名为out_of_trusted_stack的标签,触发强制展开,这是这种情况下的正确行为。让我们看看如果我们移除这个检查并尝试耗尽可信栈会发生什么。

第一个要问的问题是“如何耗尽可信栈?”。这恰好非常简单——如果我们想使用越来越多的可信栈内存,我们需要做的就是继续跨隔间调用函数,而不返回。我们在tests/stack_exhaustion_trusted.cc中添加的测试正是这样做的:

我们在stack-test隔间中定义一个__cheri_callback(一个安全的跨隔间函数指针),它调用另一个隔间的入口点。让我们称那个隔间为stack_exhaustion_trusted。

stack_exhaustion_trusted的入口点获取一个函数指针并调用它。

由于函数指针指向stack-test中的代码,调用触发跨隔间调用到stack-test隔间,它再次调用stack_exhaustion_trusted隔间的入口点,这无限递归运行直到可信栈耗尽。

通过这样做,切换器故障,我们进入故障处理路径,要么展开,要么调用故障隔间中的错误处理程序。这里的问题是故障发生在切换器中,但被视为发生在调用隔间中。当切换器调用隔间的错误处理程序时,它传递故障点时寄存器状态的副本。此寄存器转储包括一些永远不应泄漏到切换器之外的能力。

我们的恶意隔间可以安装错误处理程序,捕获异常,并获得具有有效切换器能力的寄存器!特别是,ct2寄存器持有当前线程的可信栈的能力,其中包括未密封的能力到任何调用者的代码和全局变量,以及由其他能力给出的栈范围的栈能力。更糟的是,此能力是全局的,因此故障隔间可能将其存储在全局变量或堆上,然后通过从另一个线程访问它来破坏线程隔离。

以下函数获取ErrorState *frame(错误处理程序获得的寄存器上下文)并检测切换器能力:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
using namespace CHERI;

bool is_switcher_capability(void *reg)
{
	static constexpr PermissionSet InvalidPermissions{Permission::StoreLocal,
	                                                  Permission::Global};

	if (InvalidPermissions.can_derive_from(Capability{reg}.permissions()))
	{
		return true;
	}

	return false;
}

bool holds_switcher_capability(ErrorState *frame)
{
	for (auto reg : frame->registers)
	{
		if (is_switcher_capability(reg))
		{
			return true;
		}
	}

	return false;
}

很好,我们有一种简单的方法来检测切换器能力。如果我们有一个有效的切换到切换器隔间能力,我们可以拥有系统并破坏完整性和机密性(和可用性,但这是给定的)。

现在我们需要实现耗尽切换器中可信栈的逻辑,并确保我们有一个异常处理程序检查寄存器上下文中的切换器能力。

这正是我们在stack_exhaustion_trusted.cc中所做的。代码再简单不过了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
bool *leakedSwitcherCapability;

extern "C" ErrorRecoveryBehaviour
compartment_error_handler(ErrorState *frame, size_t mcause, size_t mtval)
{
	debug_log("Detected error in instruction {}", frame->pcc);
	debug_log("Error cause: {}", mcause);

	if (holds_switcher_capability(frame))
	{
		*leakedSwitcherCapability = true;
		TEST(false, "Leaked switcher capability");
	}

	return ErrorRecoveryBehaviour::ForceUnwind;
}

void self_recursion(__cheri_callback void (*fn)())
{
	(*fn)();
}

void exhaust_trusted_stack(__cheri_callback void (*fn)(),
                           bool *outLeakedSwitcherCapability)
{
	debug_log("exhaust trusted stack, do self recursion with a cheri_callback");
	leakedSwitcherCapability = outLeakedSwitcherCapability;
	self_recursion(fn);
}

并且,在stack-test.cc中,我们将__cheri_callback定义为:

1
2
3
4
5
__cheri_callback void test_trusted_stack_exhaustion()
{
	exhaust_trusted_stack(&test_trusted_stack_exhaustion,
	                      &leakedSwitcherCapability);
}

让我们执行它!我们需要做的就是构建测试套件,通过在tests目录中运行xmake。然后: /cheriot-tools/bin/cheriot_sim ./build/cheriot/cheriot/release/test-suite

总结

如您所见,将CHERI扩展到小核心是物联网和嵌入式生态系统改变生活的一步。我们鼓励您为GitHub上的项目做出贡献,并成为这些努力的一部分!

参考文献:

  • 什么是最小的CHERI变体?
  • CHERIoT:重新思考低成本嵌入式系统的安全性

感谢, Saar Amar – Microsoft Security Response Center David Chisnall, Hongyan Xia, Wes Filardo, Robert Norton – Azure Research Yucong Tao, Kunyan Liu – Azure Silicon Engineering & Solutions Tony Chen – Azure Edge & Platform

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