Holistic Control-Flow Protection on Real-Time Embedded Systems with Kage
usenix secrity 2022
abstract
This paper presents Kage: a system that protects the control data of both application and kernel code on microcontroller-based embedded systems.
Kage consists of a Kage-compliant embedded OS that stores all control data in seqarate memory regions from untrusted data, a compiler that transforms code to protect these memory regions efficiently and to add forwardedge control-flow integrity checks, and a secure API that allows safe updates to the protectd data.
We implemented Kage as an extension to FreeRTOS, an embedded real-time operating system. We evaluated Kage’s performance using the CoreMark benchmark.
Kage incurred a 5.2% average run-time overhead and 49.8% code size overhead.
Furthermore, the code size overhead was only 14.2% when compared to baseline FreeRTOS with the MPU enabled.
We also evaluated Kage’s security guarantees by measuring and analyzing reachable code-reuse gadgets.
Compared to FreeRTOS, kage reduces the number of reachable gadgets from 2276 to 27, and the remaining 27 gadgets cannot be stitched together to launch a practical attack.
introduction
现在许多嵌入式系统使用微控制器而不是通用处理器来降低成本并简化软件设计。
但简单是有代价的,由于C不是一种内存安全的编程语言,因此这些嵌入式系统可能会遭受可利用的内存安全错误。
保护控制流的尝试受到微控制器严重资源限制的阻碍,例如内存通常为千字节数量级,没有虚拟内存原语等。这些约束很难有效的将安全关键数据结构与不可信代码隔离开。
实时操作系统的整体控制流还必须解决几个额外的挑战,首先由于微控制器不包含内存管理单元,所有应用程序任务和操作系统内核共享相同的物理地址空间;任务之间或任务与操作系统内核之间没有内存隔离。其次除了返回地址和函数指针之外,还有一些额外的数据结构需要保护,包括保存在上下文切换种的处理器状态和包含控制数据的内核数据结构。第三上下文切换、中断和异常使控制流复杂化,需要小心处理。
作者提出kage,一种保护RTOS内核的控制数据和应用程序任务免受控制流劫持攻击的软件下系统,Kage为每个应用程序任务和内核提供受保护的影子堆栈,保护保存的处理器上下文切换和异常处理的状态,并将内核数据结构与不受信任的代码隔离开来。并且作者在FreeRTOS上评估了性能和安全改进。
background
应用市场主导的ARMv7-M架构,该架构包括ARM Cortex-M产品,专为资源受限、节能和低成本的微控制器设计,因此ARMv7-M的设计不同于通用处理器,例如x86和arm cortex-a。
两种硬件特权级别:特权模式和非特权模式。ARMv7-M支持一组特殊的非特权存储指令,无论处理器当前的执行模式如何,它们始终检查非特权访问权限。例如,即使即使处理器当前正在特权模式下执行,非特权存储指令也只能写入可在非特权模式下写入的内存位置。尝试写入仅限特权的内存位置将会触发内存管理错误。
ARMv7-M步提供内存管理单元MMU,也不支持虚拟内存。所有内存区域、外设和处理器的控制寄存器都在同一个地址空间中,为了实施访问控制策略,提供了一个内存保护单元MPU作为可选功能,MPU允许开发人员定义内存保护区域的起始地址和长度以及每个区域的访问权限。保护区域的数量因不同的硬件实现而异。例如作者使用的开发板支持8个区域。
ARMv7-M在执行异常处理程序时自动将当前处理器状态的子集存储在堆栈中,并在异常返回时自动恢复它,如果需要,异常处理程序负责保存其他的寄存器,当另一个异常处理程序发生异常时,ARMv7-M允许异常链接。如果新异常的优先级高于当前异常,则新异常会抢占当前异常,否则新异常将保持挂起状态,直到当前异常的处理程序返回。
当嵌入式系统需要实时性能时,开发人员通常会求助于实时操作系统RTOS,Amazon的FreeRTOS将流行的FreeRTOS内核与用于连接到Amazon的Web服务库相结合,FreeRTOS可以在只有千字节内存的系统上运行,同时提供强大的功能,如实时调度,软件计时器,共享队列。
FreeRTOS的应用程序代码被划分为一组任务,大致相当于桌面进程中的一个线程。对于每个任务,FreeRTOS维护一个任务控制块来存储任务的重要数据,例如堆栈指针和MPU配置,FreeRTOS的调度程序在任务之间切换以满足预定义的时序约束。具体说调度程序确保 处理器将始终执行 准备在系统中执行的最高优先级任务。
Design
Kage包含以下组件:
- 基于FreeRTOS的嵌入式操作系统
- 基于Sihouette的编译器
- 二进制代码扫描器
Threat Model and System Assumptions
为了提高性能和降低编程复杂性,任务和内核默认以相同的特权级别执行,这些设计决策的结果是任务中的内存错误可能会导致系统完全妥协。
作者假设存在强大的攻击者可以劫持系统的控制流,攻击者可以访问不受信任代码中的内存错误,可以使用该错误来操作存储在内存中的任何控制程序,包括任务和内核的返回地址、间接分支、函数指针以及保存在上下文切换和异常期间的处理器状态处理。
不受信任的代码包括所有应用程序任务、这些任务可访问的库以及内核的一部分。一个例外是libgcc和compiler-rt 标准c库和编译器运行时库,作者假设这些库中不存在内存安全错误,也假设攻击者可能利用不受信任代码中的内存安全错误来劫持这些库的控制流,从而使它们的常规存储指令来破坏特权内存。
本文侧重于代码注入和代码重用攻击,其他攻击例如非控制数据攻击,则不在范围之内。
Security Guarantees
为了减轻上述威胁,Kage提供以下保证:
- 保证1 Return Address Integrity(RAI) 返回地址完整性 返回指令将始终跳转到函数序言中保存的合法返回地址
- 保证2 Control-Flow Integrity(CFI) 控制流完整性 间接函数调用将始终分支到函数的开头。
函数序言:是在函数启动时候运行的一系列指令,这些指令的功能是 在栈里保存EBP寄存器的内容,将ESP的值复制到EBP寄存器。
push ebp
mov ebp,sep
sub esp,X
函数尾声:函数在退出时,要做启动过程中的反操作,释放栈中的申请内存,还原EBP寄存器的值,将代码控制权还原给调用者函数。
- 保证1来自kage使用每个任务的影子栈来存储返回地址,保证2由在编译时插入的CFI工具来提供。但是仅保证1和2不足以减轻控制流劫持攻击,由于应用程序代码在硬件的特权模式下运行,可能会破坏操作系统内核维护的控制数据,堆栈指针和其他安全关键数据。因此Kage提供以下附加保证
- 保证3 在上下文切换时,任务保存的处理器状态将始终与任务从CPU中取出的状态相同,当一个任务第一次开始执行时,它的初始处理器状态,包括任务的初始程序计数器、堆栈指针和控制寄存器,将始终是任务初始化中定义的初始值,这保证了每个任务从其主入口点开始执行。
- 保证4 保存在中断和异常上的处理器状态永远不会损坏。从中断或异常返回时,加载到处理器上的处理器状态与中断或异常之前的处理器状态相匹配。
- 保证5 处理器中断向量表的位置和内容不能被不受信任的代码修改,这些保证严重限制了攻击者可以操纵系统控制流的程度。
- 保证6 不可信代码可写的内存不能执行,反之亦然
这些保证的基础是Kage使用与不受信任的代码隔离的特权内存区域。例如Kage的上下文切换和异常处理机制使用这些特权区域来存储处理器状态,从而提供保证3和4.通过将中断向量表的位置定义为特权,Kage确保了保证5。最后作为不受信任的代码只能写入非特权区域,Kage将这些区域配置为不可执行,即保证6。
Kage Overview
包含3个组件:
- 用于microcontroller的RTOS,为内核和每个任务提供受保护的影子栈,并保护安全关键数据免受内存错误的破坏,包括保存在上下文切换和调度程序和任务管理数据上的处理器状态;
- 一种编译器系统,通过将不受信任代码中所有存储指令转化为ARMv7-M的非特权存储指令,将返回地址保存到受保护的影子栈,并添加前沿控制流完整性检查,从而提供有效的地址空间隔离;
- 二进制代码扫描器,用于检查编译器生成的二进制文件是否包含任何可能绕过Kage安全保证的代码序列;
Kage-Compliant Embedded OS
Kage的核心是RTOS,作者的设计假设与FreeRTOS有相同的任务模型,任务数据,包括任务堆栈指针,存在在任务控制块中。Kage将代码分为可信和不可信组件,将内存分为特权和非特权区域,除了不受信任的代码使用的函数指针之外,所有控制数据都存储在特权内存中,只有受信任的组件可以直接写入特权内存区域,但不受信任的组件的函数序言可以将返回地址存储到特权区域。
为了减少控制流从不受信任的代码被恶意重定向到标准C库或编译器运行时库的情况,Kage为每个库提供了两个单独编译的版本,Kage编译器转换一份副本以供不受信任的代码安全使用,另一份则保持不变以供受信任的内核使用,转换后的库函数无法写入特权内存区域,因此无法覆盖控制数据。
Privileged and Unprivileged Memory
上图说明了Kage的非特权和特权内存区域。不受信任的代码只能写入前者,受信任的代码可以写入这两种类型,这些访问限制部分是使用存储强化转化实现的,并由MPU在运行时强制执行。任何从不受信任的代码写入特权内存的尝试都会触发错误。
Kage 的非特权内存区域包括非特权初始化全局数据、非特权未初始化全局数据、非特权内核堆栈、任务堆栈和非特权堆。特权内存区域包括所有影子堆栈、所有控制数据(不受信任代码中的函数指针除外)和其他安全关键数据结构,例如任务控制块和调度程序数据结构。
Kage为受信任和不受信任的代码使用单独的堆,所有不受信任的代码都使用相同的堆来提高内存利用率。然而Kage可以适应使用多个非特权堆区域。Kage为受信任和不受信任的组件提供单独的动态内存分配和释放功能。可信分配函数从特权堆区域分配内存,而不可信代码使用不可信分配函数来管理非特权堆中的内存。
值得注意的是,虽然所有任务堆栈都驻留在非特权内存中,但Kage将不受信任的代码限制为 仅写入当前任务的堆栈。这个限制对于在上下文切换期间保护控制数据是必要的,并且为保证3提供了基础。为了检测堆栈溢出,特权区域围绕每个任务堆栈。
System区域也具有特权,因为它包含攻击者不能访问的内存映射系统寄存器。例如对该区域的写入可用于更改中断向量表的地址。通过将System区域设置为特权,Kage确保了保证5的一部分,Peripheral和Device也具有特权。
最后包含中断向量表的只读数据区域设置为只读,提供保证5的另一部分。为了执行保证6,可信和不可信代码区域也设置为只读并且是唯一的可执行区域。
Secure API
受信任的内核为不受信任的内核组件和任务提供了一个安全API,安全API允许不受信任的代码去执行任务管理,执行与调度程序相关的操作以及访问HAL。这些操作通常需要方位特权内存,安全API允许非特权代码执行这些操作,而不会违反Kage的安全保证。
安全API函数分为3类:
- 为所有不受信任的代码设计的函数 例如延迟任务 删除任务 恢复任务
- 不受信任的内核使用的函数 例如提高任务的执行优先级,延迟任务等待事件,并在事件后恢复任务
- 不受信任的异常处理程序使用的函数 例如在异常返回后,从延迟中恢复任务
安全API的设计遵循以下原则
- 不应覆盖控制数据,除非不再使用此类控制数据,例如删除任务,这有助于强制执行保证1,3,4
- 不应禁用或覆盖Kage用于保护硬件的配置,例如禁用MPU,更改Kage的内存访问权限,更改异常优先级或覆盖中断向量表。内存保护对执行kage的所有保证至关重要,控制异常优先级有助于执行保证4。保护中断向量表的完整性对于执行保证5至关重要。
- 必须写新的控制数据到特权内存区域,例如影子栈或用来保证1、3和4的任务控制块。
这些设计原则可以应用于任何实时操作系统内核。
Kage包括额外的运行时检查,以审查可能被攻击者控制的参数。
- 首先安全API检查所有指针参数,对于指向任务控制块的指针,API对照执行有效任务控制块的指针表检查指针,对于其他指针,API会验证它们是否指向非特权内存区域。 这些检查可以防止攻击者欺骗安全API代码覆盖特权区域中的控制数据,或覆盖内存映射系统寄存器。
- 其次API包含允许不受信任的代码提供新的MPU配置的功能,但它会检查新配置是否违反Kage的基本MPU策略。
- 第三,API确保只有在系统启动时运行的系统初始化序列才能调用任务创建API函数,此检查是必要的,以防止攻击者使用一个和另一个影子栈重叠的栈区创建新任务,这可能会违反保证1
- 第四,不受信任的异常处理程序的API函数要求异常处理程序在调用安全API函数之前暂时提高执行优先级,以使其他不受信任的异常处理程序无法抢占执行。这些API函数检查当前的优先级,如果检查失败,Kage将执行开发人员定义的代码序列。在Kage中这段代码执行了一个无限循环,通过确保保存在中断和异常中的中断程序状态的完整性来强制执行保证4.
不受信任的代码只允许使用直接函数调用来访问安全API,Kage不为安全API函数分配CFI标签,因此不受信任的代码不能使用间接分支来执行安全的API函数。
Context Switching
当任务之间的上下文切换或发生异常时,内核需要将处理器状态存储到内存中,由于此状态包含控制流数据,Kage必须保护已保存状态以确保保证3. 受保护状态还必须包括任务的堆栈指针,以防止不受信任的代码违反保证1.
此外,由于安全API将其Frame放在调用任务的堆栈中,Kage还必须防止操纵此帧数据。如下所述,Kage 通过特权内存区域、MPU配置和专门构建的PendSV处理程序的组合来提供这些保护。
Kage在上下文切换和异常处理期间将处理器状态存储在当前任务的影子堆栈中,影子栈是一个特权内存区域,因此他不能被不受信任的代码修改。处理器状态包括所有通用寄存器,LR链接寄存器,程序状态寄存器,控制寄存器,堆栈指针和所有浮点寄存器。此外对于异常,Kage还保护异常返回地址。
ARMv7-M提供PendSV中断有效切断上下文,并且Kage包含一个用于此中断的处理程序。由于性能原因,处理器会在Kage的处理程序执行之前自动将将处理器状态的子集保存到内存中。在某些情况下,这种行为会导致处理器状态被保存到非特权内存(例如任务的堆栈)。因此,Kage 的 PendSV 处理程序首先将处理器自动保存的寄存器从任务堆栈复制到任务的影子堆栈。然后,处理程序将处理器状态的其余部分存储到任务的影子堆栈中,并将控制权转移到内核的调度程序组件。
调度程序组件然后检查前一个任务是否在上下文切换之前溢出其堆栈。考虑到不受信任的任务代码尝试写入与其堆栈相邻的特权内存时,该检查似乎没有必要。然而,一个微妙的竞争条件可能仍然允许发生溢出。特别是,如果上下文切换发生在堆栈指针递减到任务堆栈末尾之后但在任何会触发故障的非特权存储指令之前,ARMv7-M 的自动寄存器溢出机制可以写入相邻的特权区域 - 即,放置任务的影子堆栈的位置——绕过保证 1。
在调度程序组件将控制权交还给处理程序后,Kage 从相应的影子堆栈中恢复为下一个任务保存的处理器状态。请注意,对于处理器自动存储的状态子集,Kage 将该状态传输回适当的任务堆栈以供处理器恢复。
Kage 的 PendSV 处理程序还重新配置 MPU 以允许对下一个任务的堆栈区域进行非特权写访问,并禁止访问前一个任务的堆栈区域。通过禁止对其他任务的堆栈区域进行非特权写入访问,Kage 确保一个任务不会干扰另一个任务的堆栈数据。由于任务堆栈可能包含来自 Secure API 的帧,因此此 MPU 配置可防止不受信任的代码操纵 Secure API 的运行时检查使用的局部变量。
Kage 防止不受信任的代码在上下文切换的中间执行,确保在处理程序返回之前恢复到任务堆栈的处理器状态不会被破坏。它这样做如下。首先,Kage 防止不受信任的异常处理程序抢占PendSV处理程序的执行。其次,虽然可信异常处理程序的子集可以抢占Kage的PendSV处理程序,但这些可信异常不会将控制权转移给不可信代码。
由于 Kage 在异常调度和上下文切换时复制、保存和恢复固定数量的寄存器,并且由于堆栈溢出检查是恒定时间的,因此 Kage 对保存的处理器状态的保护会产生恒定的开销,因此适用于实时系统设计。
Exception Handling
Kage将异常程序分为两种:
- 可信异常处理,可信内核的一部分,可以访问特权内存。包括系统计时器systick处理程序,上下文切换pendsv程序,系统调用svc程序,内存保护故障memmanage程序,内存总线故障busfault程序和不可恢复故障hardfault程序,hal库中的处理程序等。
- 所有其他处理程序都是不受信任的,只能写入非特权内存
不受信任的异常处理程序可能会中断受信任的代码。因此安全处理异常具有挑战性。例如,考虑一个任务何时调用安全 API 来执行可信内核中的函数。安全 API(以及它调用的任何受信任的内核函数)使用非特权任务堆栈来存储局部变量。在正常的控制流程下,这不是问题。但是,如果发生其处理程序不受信任的异常,则不受信任的处理程序可能会破坏任务堆栈上安全 API 的堆栈帧。
为了解决上述问题,Kage 为每个不受信任的异常处理程序添加了一个受信任的调度程序函数。当其处理程序不受信任的异常发生时,相应的调度程序首先执行。调度程序函数将所有处理器状态保存到影子堆栈并配置 MPU,以使整个任务堆栈和任务影子堆栈区域都是只读的(对于受信任和不受信任的代码)。只有这样,调度程序才会将控制权转移给不受信任的异常处理程序。不可信处理程序返回后,调度程序恢复处理器状态并恢复 MPU 配置。将处理器状态保存到受保护的影子堆栈并从受保护的影子堆栈中恢复它会强制执行保证 4。与上下文切换类似,异常调度程序可防止不受信任的异常处理程序使安全 API 的运行时检查无效。
异常嵌套进一步使调度程序的行为复杂化。首先,只有在处理完所有不受信任的异常后,才能恢复 MPU 配置。其次,在保存和恢复处理器状态时,调度程序临时将其优先级设置为最大可配置优先级,防止其他不受信任的异常处理程序抢占它。 ARMv7-M 需要三个指令来提高优先级。为了防止在三个指令的小窗口期间发生另一个不受信任的异常,调度程序首先使用单指令 (CPS) 来禁用所有异常,直到它完成提升其优先级。提高异常优先级可防止来自另一个不受信任的异常处理程序的不受信任代码在调度程序保存它们之前或在调度程序恢复它们之后覆盖处理器状态,从而破坏了保证 4。第三,所有不受信任的异常处理程序被分配的优先级低于任何受信任的处理程序.此限制通过消除对可信处理程序将处理器状态溢出到影子堆栈的需要来提高性能。最后,当不受信任的异常处理程序调用安全 API 时,不受信任的处理程序必须首先临时提高其优先级,以防止其他不受信任的处理程序抢占。安全 API 函数检查异常优先级是否被提高。这两个限制确保来自不受信任异常处理程序的不受信任代码不能破坏受信任代码的堆栈数据并使用受信任代码绕过安全保证(例如,使非特权内存可执行)。
Kage的异常处理机制还检查潜在的堆栈溢出。这种检查的原因是一个微妙的竞争条件,其中另一个异常处理程序可以在堆栈指针递减到内核堆栈末尾之后但在下一个存储指令触发硬件故障之前抢占当前处理程序。在这种情况下,ARMv7-M 在异常条目上的自动寄存器溢出机制可能会覆盖特权内存。
kage compiler
使用并增强了Silhouette来有效隔离不受信任的组件,并在不受信任的代码上强制执行返回地址的完整性和控制流完整性。
kage将返回地址存储在影子栈(3.5.1),通过结合存储强化转化(3.5.2),CFI(3.5.3),内存区域配置(3.4.1)。Kage保证返回地址始终保存到影子栈,受影子栈保护并可以在影子栈中正确检索,以此来提供返回地址的完整性。
Kage使用CFI转换来保证间接函数调用将始终分支到函数的开头。下面将描述这些转化,以详细了解如何强制控制流和返回地址完整性。
Shadow Stack Transformation
Kage使用Sihouette的影子栈变换来变换每个不受信任函数的序言和尾声。当进入一个函数时,返回地址被保存到一个受保护的影子栈中,从函数返回时,系统使用影子栈的返回地址而不是常规堆栈。影子堆栈驻留在特权内存中,影子栈插桩被认为是受信任的代码,每个任务和不受信任的内核都使用单独的影子栈。
Store Hardening Transformation
Kage使用Sihouette的存储强化转换将不受信任代码中的所有存储指令转换为ARMv7-M的非特权存储指令。当于适当的MPU配置结合使用时,此转换提供地址空间内隔离,防止不受信任的代码修改特权内存。
存储强化允许Kage访问每个函数序言中的影子栈,而不需要以前工作进行昂贵的硬件特权模式更改。
CFI Instrumentation
使用CFI来防止前向控制流劫持,具体来说,对于间接函数调用,Kage在合法目标函数的开头插入CFI标签,并在所有间接调用站点插桩,以验证目标在运行时是否具有正确的标签。CFI标签只分配给不受信任的代码中的函数,这些代码是地址获取的或对其他编译单元可见的。例如这使得不受信任的代码无法通过间接函数调用跳转到受信任的代码。
全局唯一标签生成。基于标签的CFI要求使用的字节序列不会出现在任何可执行内存的其他位置。而Sihouette编译器不强制执行此要求,Kage则是通过一种新颖的标签生成方案部分解决了此限制。
全局唯一性保证由两部分组成:
- ARMv7-M上的指令是一个或两个半字节并且在半字节边界处对齐。因此CFI标签的编码不得为编译器可能生成的指令的任何部分加上别名,Kage通过选择由两个不同的半字节组成的CFI标签来避免这种情况,这些半字节都是ARM上未定义的指令编码。
- 编译器可能会巧合的将常量数据嵌入CFI标签相同的代码中那个,因此Kage不允许将数据嵌入到受信任和不受信任的代码段中。
Code Scanner
为了确保编译的二进制代码不会违反Kage的安全保证,Kage包含一个静态二进制代码扫描器。如果扫描器发现违规,会提醒开发人员。
Kage的扫描器禁止不受信任的代码使用特权CPS和MSR指令,因为这些指令可以更改重要寄存器的值,如CONTROL寄存器和堆栈指针。但是在不受信任的代码中MSR指令更改APSR或BASEPRI寄存器是被允许的。更改APSR只会影响条件指令的执行,更改BASEPRO只会禁用或启用不受信任的异常,这两者都不会影响Kage的安全保证。
可信内核需要MSR指令和CPS指令,因此代码扫描器允许可信内核将这些指令包含在任何操作数种。
代码扫描器还会验证不受信任的代码调用的唯一受信任的函数是安全API函数,不受信任的代码不应该能用内不受信任的内核函数。
由于Kage不会给受信任的内核函数添加CFI标签,因此代码扫描器只需要确保从不受信任代码不存在到内部受信任函数的直接函数调用即可。
Implementation
作者对Silhouette编译器进行了修改以及兼容RTOS和二进制代码扫描器的实现。
Kage RTOS是扩展的Amazon的FreeRTOS V1.4.9
作者以STM32L475 discovery board为目标,因为其存在MPU并获得FreeRTOS的正式支持。
Compiler Implementation
作者修改了379行代码以适应基于LLVM的Silhouette编译器,其中主要部分是改进了CFI,为了确保CFI标签不会嵌入到其他指令中,作者选择0xf870f871作为CFI标签,因为0xf870和0xf871都对ARM上未定义的指令进行了编码。
Kage的CFI检查将在执行间接分支时跳过CFI标签,因为CFI标签现在是未定义的指令。
作者将每个任务的堆栈和内核堆栈的大小设置为4kb,从而允许多个堆栈适应板上有限的128KB的RAM。此堆栈还允许更有效的影子栈指令。具体来说,ARM的立即存储和立即加载指令支持4KB的立即偏移,由于限制为4KB,影子栈转换不需要在访问影子栈前将影子栈的偏移量编码到空闲寄存器中。因此,Kage只需要将函数序言中的一条指令写入影子堆栈。
FreeRTOS 在代码区域中提供了一个可选的privileged_functions部分来存储特权内核函数。 Kage 使用这个部分来存储所有受信任的内核函数。一个特殊的编译器标志可以用来告诉 Kage 编译器一个 C 源文件中的所有函数都应该放在 privileged_functions 部分。当编译器编译本节中的函数时,它会跳过存储强化、影子堆栈和 CFI 转换。
Code Scanner Implementation
作者使用python的elftools库实现的代码扫描器,包含148行代码。
RTOS Implementation
作者向FreeRTOS中添加了2136行代码,实现了在ARM特权执行模式下运行任务和内核。但与FreeRTOS不同,Kage启用了MPU并利用编译器转换、运行时检测和内核修改来强制控制流和返回地址的完整性。
Trusted and untrusted components
受信任的内核组件有:
- 调度程序
- 任务管理模块
- 内核列表模块
- 受信任的动态分配和释放模块
- 特定于设备的支持模块,包括PendSV、SVC、MemManage 和HardFault
- HAL库(包括SysTick 的异常处理程序和未实现的异常处理程序的默认代码)
所有其他内核组件和所有任务都是不受信任的。
不可信内核组件包括
- 不可信列表模块
- 不可信分配模块
- 队列
- 流缓冲区
- 事件组和计时器模块
受信任和不受信任的内核组件都需要使用内核列表模块来访问就绪和挂起的任务列表,因此Kage提供两个列表模块 一个用于可信组件,一个用于不可信组件。
对于不受信任的C库,作者改造了Newlib,该库是一个为嵌入式系统设计的开源C库。
可信内核只使用了两个C库函数memset和strlen,因此在可信内核中手动添加了这两个函数的未转换实现。
FreeRTOS内核对特权全局变量使用privileged data部分。kage将此部分用于仅应由可信代码写入的内核数据。在Kage中只有调度器和任务管理数据放在这个部分。一个兼容Kage的操作系统需要为受信任的内核提供一个特权堆,为不受信任的内核和应用程序代码提供一个非特权堆。由于FreeRTOS只提供一个堆并且为所有动态分配的数据使用相同的堆。对于Kage我们将所有在可信计算库之外的内存分配调用替换为一个新的不可信内存分配组件,即一组不同的malloc和free函数使用的非特权堆内存区域。
MPU Configuration
如上表配置MPU,Kage使用目标板的7个MPU区域。
此外启用了ARMv7-M的默认背景区域,该区域禁止非特权访问上述区域中未列出的任何内存地址,并禁止在外围和系统区域中执行。
Limitations
存在几个限制:
- 继承Silhouette的并行影子栈设计,并行影子栈允许以更高的RAM使用为代价进行更高效的处理器 instrumentation。虽然这个成本对于单机来说是合理的,但它限制了Kage可以支持的任务数量。此外当前的影子栈实现还要求所有任务使用相同的堆栈大小,替代影子堆栈设计可以解决这些问题。
- 其次 kage依赖开发者正确配置异常优先级
- 最后 kage编译器不会转换内联汇编代码或手写的汇编源文件
在当前版本中,作者手动转换了所有不受信任的内联汇编blobs和汇编源文件,这增加了437行代码
performance evaluation
使用CoreMark基准评估具有实际应用程序代码的Kage,然后使用微基准探索各个组件的影响。作为基准,kage与未经修改的FreeRTOS进行比较。
作者使用LLVM9.0编译了未修改的FreeRTOS,这与Silhouette编译器所基于的版本相同,默认情况下,FreeRTOS禁用板上的MPU。
作者使用 STM32L475 板来运行所有实验。该板包含一个 ARMv7-M 微控制器,能够运行高达 80 MHz 并支持 MPU、128 KB SRAM 和 1 MB 闪存。
作者使用 FreeRTOS 的默认配置设置为以80MHz运行。由于所有的测试程序都适合板上的代码内存,因此使用 -O3 编译器优化级别来改善基线代码和 Kage 的执行时间。
对于每个基准测试,作者将每个配置运行 3 次,并记录 3 次运行的平均值。
Macrobenchmark
作者将CoreMark移植到FreeRTOS上,并修改了基准以利用内核功能,CoreMark是ARM推荐的行业标准的基准。包括常见的嵌入式操作,例如链表操作,矩阵乘法和状态机操作。
简而言之,修改后的基准测试使用多个任务执行 CoreMark 计算,这些任务被抢占和上下文切换,并通过队列将它们的输出传递给主任务。
Benchmark Setup
作者对CoreMark的移植符合coremark资源库的许可和说明,也就是说 作者只修改了名称包括portme的文件,具体地说,作者编辑了coremark的依赖架构 的源文件,以使用FreeRTOS的系统调用和kage的安全API,这样coremark的代码就可以在FreeRTOS上运行。
Coremark配置为使用多线程代码路径,以便创建多个基准任务,要求操作系统内核在任务之间进行上下文切换。
作者将所有的基准任务配置为具有相同的优先级,导致FreeRTOS使用轮回调度策略。这笔默认基于优先级的调度算法调用的上下文切换要多得多。
作者还修改了Coremark,使一个主任务初始化系统并开始执行所有执行基准计算的子任务,这个主任务还为每个子任务创建一个FreeRTOS队列,以便将其输出发送到主任务,主任务将验证输出是否正确,并测量基准计算的开始和结束时间。
结果包括创建所有任务和任务间通信队列的时间,任务进行计算的时间,在任务间上下文切换所需的时间,以及完成计算后删除所有任务的时间。
最后作者修改了malloc和free函数的调用,使基准测试使用kage的不可信任的堆分配API。
Benchmark Result
表2总结了baseline FreeRTOS、只修改了内核的Kage(即没有对不可信任代码进行任何转换)和完整的Kage系统的性能。
由于Kage需要其操作系统机制、编译器转换和代码扫描器来执行安全保证,只包括操作系统机制的单独结果,以阐明Kage的开销来源。
在这些实验中,每个基准任务的迭代次数设置为2000次,任务的数量从1个到3个不等。单任务配置使用CoreMark的单线程代码路径,它在主例程中运行基准算法,而不创建任何基准任务。对于每个配置,都运行了3次基准。
与基线FreeRTOS相比,Kage产生了5.2%的平均开销。在单线程配置中,Kage产生的开销最低,为4.6%。这是因为单线程的代码路径只使用一个主任务,而不创建任何基准任务;因此,单线程的结果没有上下文切换或任务间队列的开销。
有了两个和三个基准任务,Kage分别产生了5.5%和5.5%的开销。这一微小的增长表明,Kage通过安全API、上下文切换和无特权的任务间队列增加的开销,可能对现实世界的应用性能只有很小的影响。
Kage的开销的主要来源是对不信任的代码的转换。例如,当只启用Kage的内核机制时,平均开销为2.2%。换句话说,影子栈转换、存储加固和前沿CFI检查占了5.2%的平均开销的其余部分。请注意,Kage依靠所有这些机制来保证其安全,也就是说,这些组件都是可选的。
表3显示没有缓存时的开销结果,在没有缓存的情况下,运行更多的任务会降低基线和Kage的性能,正如预期的那样。这个结果得出结论,小幅度的加速是由于缓存造成的。
Code size results
作者还测量了三线程配置的二进制文件的代码大小,以评估Kage对代码大小的影响。由于基准任务是相同的,其他配置的代码大小也几乎是相同的。
表4显示了FreeRTOS和Kage的信任和不信任的代码大小。与基线FreeRTOS相比,Kage在代码大小上产生了49.8%的开销。然而,这个开销的大部分来自于FreeRTOS中的MPU,而不是直接来自Kage的扩展。例如,启用了MPU的同一个FreeRTOS的代码大小为66,704字节,比基线FreeRTOS大31.1%。与这个启用MPU的版本相比,Kage产生的开销只有14.2%。
Kage还产生了大量的代码大小的开销,因为它包括两个版本(一个不受信任的版本和一个受信任的版本)的C和编译器运行库函数,堆分配函数,和内核列表API函数。
Microbenchmarks
作者设计了一套微基准测量各种组件引入的额外处理器周期数,具体说,就是测量了安全API、上下文切换、异常处理和不可信任代码的周期数。
使用手写汇编和KIN1库的组合来访问周期计数器。
表5显示了安全API运行时检查的周期数。这些组件在FreeRTOS中没有对应的组件,所以没有基线数字。
安全API运行时检查产生的开销从7到106个周期不等。每个安全API函数所增加的开销取决于该函数使用的检查子集。例如,只有一个安全API函数调用106周期的MPU配置检查。
大多数安全API函数只包含一个运行时检查,即任务控制块检查或通用指针检查。三十一个安全API函数中只有九个包含多个运行时检查。最糟糕的情况是重新配置特定任务的MPU配置的安全API函数,它有任务控制块、通用指针和MPU配置的运行时检查。
表6显示了与基线FreeRTOS和启用MPU的FreeRTOS相比,其他Kage机制的性能开销。
Kage在微观基准上的开销与它在CoreMark宏观基准上的低开销形成对比。CoreMark的大量计算和缺乏系统调用是造成这种差异的主要原因。除了编译器转换的开销外,上下文切换是CoreMark中最持续产生开销的部分。
上下文切换:为了测量上下文切换的周期数,使用PendSV处理程序的开头添加了代码来重置周期计数器,并在处理程序返回前立即添加了代码来读取周期计数器
Kage的上下文切换比基线FreeRTOS增加了132个周期,主要是由于将处理器状态保存到影子堆栈的成本。这意味着每次上下文切换需要执行1.65微秒。相对于基线FreeRTOS,这是一个69.5%的开销,相对于启用MPU的FreeRTOS,这是一个51.2%的开销。启用MPU增加了FreeRTOS的上下文切换延迟(并减少了Kage的相对开销),因为内核必须从任务控制块中读取下一个任务的MPU配置并将其写入MPU控制寄存器中。
Security Evaluation
评估Kage的安全性,将所有的保护措施都开启后,攻击者可以采取的行动。
特别考虑以下问题:攻击者是否有可能以kage允许的方式操纵控制流并仍然执行有用的攻击?
Coremark应用程序基准测试中不可能进行优用的代码重用攻击。
Summary of Kage Protections
- 确保返回地址的完整性
- 限制前向控制流分支的合法目标集
- 在中断、异常和上下文切换期间保护控制数据
- 防止修改现有代码和注入新代码
kage中唯一可能的控制流劫持危险来自对前向控制流分支的操纵。这是由于kage的粗粒度CFI工具,限制合法目标及,但不阻止操纵。特别是攻击者可以操纵函数指针以分支到有效CFI标签开头的任何函数,这是基于标签的CFI工具的一个已知弱点。但可以通过影子栈保护返回地址缓解该问题
Code-Reuse Gadget Analysis
作者使用了ROPgadget,用于在两个二进制文件中搜索gadget,一个是包含FreeRTOS,CoreMark和所有必须的支持库基线;另一个是兼容Kage的系统。
Kage的安全保证使两大类代码重用gadget无法访问:
- 在受信任代码中找到的gadget,以及不在函数开头或在调用指令之后立即启动的gadget。可信代码中的gadget无法访问,因为kage不会将CFI标签添加到可信代码中的函数,并且kage的标签方案确保有效的CFI标签永远不会无意中出现在可信代码中。因此所有操纵函数指针调用的尝试都无法通过CFI检查。
- 不受信任的代码通过直接函数调用安全API,因此对于攻击者重用存在的直接调用指令来调用安全API函数的入口点是可能的。但由于安全API代码执行的检查,安全API函数中的代码不能用于绕过Kage的保证。
无法通过损坏的函数返回访问受信任代码的gadget,因为kage保证返回地址的完整性,并且受信任代码从不调用不受信任代码中的函数。
在不受信任的代码中,唯一可访问的gadget是那些从函数开头开始或者在调用指令之后立即开始的gadget。前者可以通过函数指针操作直接访问,也可以作为包含直接调用指令的较大gadget的一部分访问。
由于kage保证返回地址的完整性,因此只有当函数返回到gadget之前的调用站点并且调用站点是函数的动态调用者时,才能到达后者。
在没有控制流保护的baseline中发现了2276个gadget。
在kage兼容的二进制文件中找到1605个gadget,过滤吊无法访问的gadget后,仍有27可访问。这27个分为以下3类:
- 17个gadget从函数的开头开始,以返回给调用者的指令结束。
- 4个gadget在直接函数调用之后立即开始并以return结束
- 6个gadget在直接调用后开始,直接分支到静态地址结束,该静态地址要么在同一个函数中,要么是另一个函数的入口点。
使用这27个gadget劫持控制流不切实际,首先27个gadget都没有特权存储指令,因此也就无法使用它们破坏关键内存区域,例如用于MPU配置的内存区域。其次即可可以转移控制流到这些gadget上,也不能以任意顺序组合和执行gadget,即攻击者不能使gadget挑战到攻击者选择的另一个gadget上,从而使gadget无法拼接。
因为所有 27 个gadget都以返回、对函数的直接尾调用或直接分支到同一函数内的代码终止。由于 Kage 的返回地址完整性保证,以返回指令结束的那些不能与 27 个gadget集中的其他任意gadget拼接在一起。那些以直接尾调用或直接分支结尾的不会跳转到集合中的另一个小工具,因此也不能缝合在一起。有了这些限制,无法构成有效攻击手段。
kage中gadget数量少的主要因素有以下两个:
- 影子栈变换将pop {…, pc}转换为ldr pc, [sp, #4092],这消除了大量潜在的gadgets,而当影子栈被禁用时,发现了1828个gadget。
- FreeRTOS使用libgcc作为运行时库,而kage使用compiler-人太多存储强化版本。结果就导致在libgcc找到的许多小工具在kage中不存在。
Kage包括隔离不受信任的组件、保护控制数据免受内存错误损坏以及安全处理上下文切换中断和异常的技术。