组会纪要

2022-10-06 组会纪要

Posted by 王海静 on October 6, 2022

Holistic Control-Flow Protection on Real-Time Embedded Systems with Kage

问题

为了降低成本、简化软件设计,很多嵌入式系统不使用通用处理器(general-purpose processors),而是使用微控制器(microcontroller);基于微控制器的嵌入式系统上的大多数软件用C语言开发,会让设备程序面临内存安全威胁。

目前的防御手段由于存储资源、硬件资源限制,很难有效地隔离安全关键数据。Silhouette和µRAI提出了更有效的保护安全关键数据的机制,但是他们关注的是裸机设备,没有办法应用在实时操作系统上。本文关注有RTOS(real-time os)的嵌入式设备。

针对RTOS的整体控制流保护的几个挑战:

  1. 微控制器没有内存管理单元MMU,因此所有应用任务和内核共享相同的物理内存空间,无内存隔离
  2. 除了返回地址和函数指针之外,上下文切换时存储的处理器状态和包含控制数据的内核数据结构也需要保护
  3. 上下文切换、中断、异常,使控制流复杂化,需要仔细处理

本文贡献

  1. 设计了Kage,为基于微控制器的有RTOS的嵌入式系统提供有效、整体上的控制流保护。Kage保护返回地址和包含控制数据的内核数据结构。
  2. 构建了一个Kage的原型系统,包含一个基于FreeRTOS的嵌入式OS,一个基于Silhouette的编译器,和一个二进制代码扫描器。
  3. 性能评估:STM32L475开发板,利用CoreMark benchmark和microbenchmarks进行测试。
  4. 安全性增强评估:返回地址完整性 (RAI),控制流完整性 (CFI),中断、异常处理、上下文切换时的控制数据保护。

背景知识

ARMv7-M

ARMv7-M,包括ARM Cortex-M产品系列,与X86和ARM Cortex-A产品系列的通用处理器不同,是为资源有限、节能和低成本的微控制器设计的。

  1. 处理器模式和非特权级存储指令

    • 两个硬件特权级别:特权模式和非特权模式

    • 支持一组特殊的非特权存储指令,这些指令总是执行非特权级别的访问权限,而不考虑处理器当前的执行模式

  2. 内存保护

    • 不提供MMU,也不支持虚拟内存,所有内存区域、外设和处理器的控制寄存器都使用同一地址空间

    • 为了强制执行访问控制策略,ARMv7-M提供了一个内存保护单元(MPU)作为一个可选的特性

    • MPU允许开发者定义起始地址和内存保护区域的长度以及每个区域的访问许可。不同硬件可定义的区域数量不同,本文用的开发板支持8个区域

  3. 异常处理

    • ARMv7-M在执行异常处理程序时,会自动在堆栈上存储当前处理器状态的一个子集,并在异常处理程序返回时自动恢复它。如果需要,异常处理程序将负责保存其他寄存器。

    • 嵌套:在一个异常处理程序运行时,若发生异常,ARMv7-M允许进行异常链接。如果新异常的优先级高于当前异常,则新异常将优先于当前异常处理;否则,新异常将继续等待处理,直到当前异常的处理程序返回。

cortex-m处理器架构实现了多个特性,保证了OS设计的方便和高效。例如:

  • 影子栈指针。有两个栈指针可用,MSP用于OS内核以及中断处理,PSP则用于应用任务。
  • SysTick定时器。位于处理器内部的简单定时器,使得同一个嵌入式OS可用在多种cortex-m微控制器上。
  • SVC和PendSV异常。这两种异常对于嵌入式OS中的操作非常重要,如上下文切换的实现等。
  • 非特权执行等级。可以利用其实现一种基本安全模型,限制某些应用任务的访问权限。特权和非特权等级的分离还可同MPU一起使用,进一步提高嵌入式系统的健壮性。
  • 排他访问。排他load和store指令用于OS中的信号量和互斥操作。

RTOS

当嵌入式系统需要实时性能时,开发人员通常会用实时操作系统(RTOS)。RTOS的功能因目标系统的不同而差别很大,本文关注的是为微控制器设计的实时操作系统。其中一个例子是Amazon的FreeRTOS,它将FreeRTOS内核与连接到Amazon网络服务的库结合起来。

FreeRTOS可以在只有kb节内存的系统上运行,同时仍然提供强大的特性,如实时调度、软件计时器和共享队列。 FreeRTOS的基本抽象与桌面操作系统有很大的不同应用程序代码被划分为一组任务(大致相当于桌面进程中的一个线程)。对于每个任务,FreeRTOS维护一个任务控制块来存储任务的重要数据(如堆栈指针和MPU配置)。

FreeRTOS调度程序会在不同任务之间进行切换,以满足预定义的时间约束。调度程序确保处理器将始终执行准备好的最高优先级任务。

设计

威胁模型

  1. 系统:

    • 采用基于ARMv7-M的单核微控制器,其MPU支持至少七个区域。

    • 这种处理器的特性问题:由于ARMv7-M微控制器不支持虚拟内存,所有任务与内核都在相同的内存区域执行。且为了提升性能、降低编程复杂度,任务和内核默认在同一特权级执行。因此,任务中的内存错误就会直接导致系统崩溃。

  2. 攻击者:尝试劫持系统的控制流

    • 攻击者可以访问不可信代码中的内存错误,并利用其操纵存储在内存中的任何控制数据,包括任务和内核的返回地址、间接分支、函数指针、上下文切换或异常处理时保存的处理器状态。不可信代码包括所有的应用任务、这些任务可以访问的库以及部分内核。

    • 对于标准C库和编译器运行时库,如libgcc和compiler-rt,我们假设这些库不会发生内存安全错误,但攻击者可能会利用不可信代码中的内存安全错误来劫持转移到这些库的控制流,从而使用常规的store指令破坏特权级内存。

    • 我们的威胁模型关注代码注入攻击和代码重用攻击,不考虑非控制数据攻击。

Kage提供的安全保障

Guarantee 1 返回地址完整性RAI:返回指令总是branch to函数序言中保存的合法返回地址。(Kage使用为每个任务提供的影子栈存储返回地址)

Guarantee 2 控制流完整性CFI:间接函数调用总是转移到函数的开始。(编译时CFI插桩代码的插入)

Guarantee 3 在上下文切换中,应用任务保存的处理器状态将始终与把任务从CPU中删除时的处理器状态相同。

Guarantee 4 中断和异常中保存的处理器状态永远不会出问题。当从一个中断或异常返回时,加载到处理器上的处理器状态与中断异常之前的处理器状态相匹配。

Guarantee 5 中断向量表的位置和内容不能被不可信代码修改。

Guarantee 6 能被不可信代码写的存储区域是不能被执行的。反之亦然。

整体架构

kage包括三个组件:

  • 一个基于微控制器的RTOS:为内核和每个应用任务提供受保护的影子栈;保护安全关键数据不被内存错误篡改;

  • 一个编译器:提供有效的地址内空间隔离,通过把所有不可信代码中的store指令转换成ARMv7-M的非特权级store指令;把返回地址保存到受保护的影子栈;添加前向边控制流完整性检查;

  • 一个二进制代码扫描器:检查编译器生成的二进制文件是否包含可以绕过Kage安全保障的代码序列。

符合Kage标准的OS

Kage的核心是一个嵌入式RTOS。我们的设计假设使用与FreeRTOS相同的任务模型,携带任务数据,包括任务栈指针,存储在任务控制块中。kage把代码分成可信和不可信的部分,把存储区域分成特权级和非特权级区域。所有的控制数据,除了不可信代码中用的函数指针,都存储在特权级内存区域中。仅可信部分能直接写到特权级内存区域中,除非不可信成分中的函数序言(function prologues)够存储返回地址到一个特权级区域。

所有任务和大部分内核组件都是不可信的。可信组件仅限于那些必须访问特权级内存区域的东西。

为了减轻控制流被恶意地从不可信代码中重定向到标准C库或编译器运行时库这种情况,kage提供了两个分别编译后的库版本。版本1被不可信代码使用,版本2不可被更改,被可信内核使用。改过的库函数(版本1)不能写到特权级内存区域中,因此不能重写控制数据。

image-20221020211543408

特权级和非特权级内存

表2说明了kage的非特权级和特权级存储区域。不可信代码仅能写到非特权级,可信代码都能写。这些访问限制由Silhouette的store hardening tansformation(存储强化转换)实现,将不可信代码中的所有store指令转换为ARMv7-M的非特权store指令,与适当的MPU配置结合使用,就可以提供地址空间内隔离,防止不可信代码修改特权内存。

kage的非特权级内存区域包括:①非特权级别初始化的全局数据,②非特权级别未初始化的全局数据,③非特权内核堆栈,④任务堆栈,⑤非特权堆区域。

特权级内存区域包括所有的影子栈,所有的控制数据(除了函数指针在不可信代码中的),和其他安全关键数据结构(例如任务控制块和调度程序数据结构)。

kage为可信和不可信代码提供分离的堆,为可信和不可信的区域提供分离的动态内存分配和释放函数。可信的allocation函数从特权级堆区域分配内存,不可信代码使用不可信的allocation函数管理非特权堆中的内存。所有任务栈都位于非特权级内存区域,Kage限制不可信代码只能写当前任务的栈。这个限制可以在上下文切换中保护控制数据,为G3提供保障。为了检测栈溢出和下溢,特权区域围绕每个任务栈。

system region也是特权级,因为它包括内存映射的系统寄存器(memory-mapped system register)。(保证了G5:中断向量表的位置和内容不能被不可信代码修改)

外围和设备区是特权级。

read-only数据区域:包含中断向量表(保证了G5)、可信和不可信的代码区(唯一可执行)(保证了G6:能被不可信代码写的存储区域是不能被执行的,反之亦然)

image-20221021105510815

安全API

可信内核提供一套安全API供不可信的内核组件和任务使用。这些API允许不可信代码进行任务管理,执行调度相关操作和访问HAL(hardware abstract layer,硬件抽象层)。这些操作经常需要访问特权内存区域。安全API就保证了非特权代码在执行这些操作的时候不违反Kage的安全保障。

安全API函数分为三大类:

  1. 为所有不可信代码设计的函数(例如延迟一个任务,删除一个任务,恢复一个任务),
  2. 只能被不可信内核使用的函数(例如提高一个任务的执行优先级,延迟一个任务去等待一个事件的发生,在一个事件发生后恢复一个任务),
  3. 被不可信的异常处理程序使用的函数(例如在异常返回后从delay恢复一个任务)。

安全API-设计原则:

  1. 安全API不能重写控制数据,除非这些控制数据不再使用(比如删除一个任务后)。保证了G1G3G4。
  2. 安全API不能重写或使Kage用于保护的硬件配置失效(比如使MPU失效,更改kage的内存访问许可,更改异常优先级,或重写中断向量表灯)。控制异常优先级保证了G4。保护中断向量表的完整性保证了G5。
  3. 安全API函数必须把新的控制数据写到特权级内存区域中(比如在创建新任务的时候),如一个影子栈或者任务控制块,以保证G1G3G4。

安全API-运行时检查:

  1. 安全API检查所有的指针参数。对于指向任务控制块的指针,API根据指向有效任务控制块的指针表检查指针。对于其他指针,API验证其指向一个非特权级内存区域。
  2. 该API包括允许不受信任的代码提供新的MPU配置的功能,同时检查新的配置是否违反了Kage的基本MPU策略。
  3. API确保只有系统初始化序列(在系统启动时运行)才能调用任务创建的API函数。
  4. API函数要求不受信任的异常处理程序在调用安全API函数之前暂时提高执行优先级,防止其他不受信任的异常处理程序抢占执行。
上下文切换

当发生任务间上下文切换或发生异常时,内核需要将处理器状态存储到内存中。由于此状态包含控制流数据,因此Kage必须保护已保存的状态,以保证G3。任务的堆栈指针也应被保护起来,以保证G1。

由于Secure API将其帧放在调用任务的堆栈上,因此Kage必须防止对该帧数据的恶意操作。

Kage通过特权内存区域、MPU配置、PendSV处理程序的组合来提供这些保护。(PendSv:可悬挂异常)

调度程序组件检查在上下文切换之前,先前的任务是否溢出其堆栈。

在调度程序组件将控制传输回处理程序后,Kage将从适当的影子栈恢复为下一个任务保存的处理器状态。

Kage的PendSV处理程序重新配置了MPU,以允许对下一个任务的堆栈区域的无特权写入访问,并且不允许对前一个任务的堆栈区域进行访问。通过不允许对其他任务的堆栈区域的非特权写入访问,Kage确保了一个任务不会干扰其他任务的堆栈数据。

Kage可以防止不可信代码在上下文切换的中间执行,从而确保在处理程序返回之前,恢复到任务栈的处理器状态不会被损坏。

嵌套的处理:首先,只有在处理了所有不可信的异常之后,才会恢复MPU配置。其次,在保存和恢复处理器状态时,调度程序会临时将其优先级设置为最大的可配置优先级,从而防止其他不受信任的异常处理程序抢占它。ARMv7-M需要三个指令来提高优先级。为了防止在三个指令的小窗口中发生另一个不可信的异常,调度程序首先使用一条指令(CPS)来禁用所有异常,直到完成其优先级的提高。再次,所有不受信任的异常处理程序被分配的优先级都低于受信任处理程序的优先级。最后,当一个不受信任的异常处理程序调用安全API时,该不受信任的处理程序必须首先临时提高其优先级,以防止其他不受信任的处理程序抢占。安全API函数会检查异常优先级。

编译器

Kage利用并增强了基于LLVM的编译器Silhouette,以有效地隔离不受信任的组件,并在不受信任的代码上强制执行RAI和CFI。Kage将返回地址存储在一个影子栈中。通过结合存储强化转换 the store hardening transformation、CFI插桩和相应的存储区域配置,Kage保证返回地址将始终被保存到、被保护在影子栈中,并从影子栈中正确检索,从而提供返回地址完整性(G1)。Kage还使用CFI转换来保证一个间接的函数调用将始终转移到一个函数的开头(G2)。

影子栈转换

Kage使用Silhouette的影子栈转换来转换每个不可信函数的prologue和epilogue。当进入一个函数时,返回地址将被保存到一个受保护的影子栈中。当从函数返回时,系统将使用来自影子栈的返回地址。影子栈位于特权内存中,并且影子栈插桩代码被认为是受信任的代码。 每个任务和不受信任的内核都使用单独的影子栈。

存储强化转换

Kage使用Silhouette的store hardening pass将不受信任代码中的所有存储指令转换为ARMv7-M的非特权存储指令。当与适当的MPU配置结合使用时,此转换提供了地址内空间隔离,防止不受信任的代码修改特权内存。存储强化允许Kage在每个函数序言中访问影子栈,而无需更改硬件特权模式。

CFI插桩

使用CFI插桩来防御前向边控制流劫持。对于间接函数调用,Kage在合法目标函数的开始处插入CFI标签,并在所有间接调用点插入插桩代码,以验证目标在运行时具有正确的标签。并非所有函数都会被插入标签。CFI标签只分配给不可信代码中的地址获取或对其他编译单元可见的函数。这样,不受信任的代码就不可能通过一个间接的函数调用跳转到受信任的代码中。

全局唯一的标签生成:基于标签的CFI要求用于CFI标签的字节序列不出现在可执行内存中的其他任何地方,必须是全局唯一的。 kage的全局唯一性保证由两部分组成:首先,由于ARMv7-M上的指令是一个或两个/半字长,并且在半字边界处对齐,因此CFI标签的编码不能为编译器可能生成的指令的任何部分的别名。Kage通过选择一个CFI标签来避免这种情况,这些标签包含两个不同的半字 half-words(ARMv7-M上未定义的指令编码)。其次,由于编译器可能会碰巧将与CFI标签相同的常量数据嵌入到代码中,故Kage不允许将数据嵌入到受信任或不受信任的代码段中。

二进制代码扫描器

为了确保编译后的二进制代码不违反Kage的安全保障,Kage设计了一个静态二进制代码扫描器。如果代码扫描器发现了违规行为,它会提醒开发人员。代码扫描器禁止不受信任的代码使用特权级别的CPS和MSR指令。但是,更改APSR或BASEPRI寄存器的MSR指令在不可信的代码中是被允许的;更改APSR只会影响条件指令的执行,而更改BASEPRI只会禁用或启用不可信的异常,这两者都不会影响Kage的安全保证。受信任的内核需要CPS和MSR指令,因此代码扫描器允许可信内核使用。代码扫描程序还验证不可信任代码调用的唯一受信任函数是安全API函数。内部受信任的内核函数不能被不受信任的代码调用。

实现

RTOS

我们的操作系统原型为AWS FreeRTOS添加了2136行代码。与默认的FreeRTOS一样,Kage在ARMv7-M的特权执行模式下同时运行任务和内核。与FreeRTOS不同的是,Kage支持MPU,并利用编译器转换、运行时检测内核修改来执行CFI和RAI。

可信/不可信组件的设置

可信的内核组件:调度程序、任务管理模块、内核列表模块、可信的动态内存分配和释放模块、设备特定的支持模块(包括PendSV、SVC、MemManage和HardFault的异常处理程序)、HAL库(包括SysTick的异常处理程序和未实现的异常处理程序的默认代码)。

所有其他内核组件和所有任务都是不受信任的。不可信的内核组件包括:不可信的列表模块、不可信的分配模块、队列、流缓冲区、事件组和计时器模块。

可信和不可信的内核组件都需要使用内核列表模块来访问准备就绪和待定的任务列表。因此,Kage提供了两个列表模块,一个用于可信的组件,另一个用于不可信的组件。

MPU配置

Kage使用开发板的7个MPU区域。此外,Kage启用了ARMv7-M的默认背景区域default background region,它禁止对上述区域中未列出的任何内存地址的非特权访问,并禁止在外围设备和系统区域中执行。MPU配置分别覆盖了32 KB和96 KB的两个不连续的硬件RAM区域。

image-20221021111236481

编译器

基于LLVM的silhouette编译器,改进了CFI。为了确保CFI标签没有嵌入到其他指令中,文中选择了0xf870f871作为CFI标签;0xf870和0xf871都在ARMv7-M上编码一个未定义的指令。将每个任务的栈和内核栈的大小设置为4 KB(适应于开发板上有限的128 KB的RAM)。ARMv7-M的即时存储和即时加载指令支持最多4 KB的即时偏移。在将堆栈大小(以及影子栈偏移量)限制为4 KB的情况下,在访问影子栈之前,影子栈转换不需要将影子栈偏移量编码到空寄存器中。因此,Kage只需要函数序言中的一条指令来写入影子栈。FreeRTOS在代码区域中提供了一个privileged_functions节,以存储特权内核函数。可以使用一个特殊的编译器标志来告诉Kage编译器,C源文件中的所有函数都应该放在privileged_functions部分中。

二进制代码扫描器

用Python的elftools库将Kage的代码扫描器实现为一个Python脚本。 代码扫描器扫描不受信任的代码,以寻找可能破坏的指令编码,从而破坏Kage的安全保证。 包含148行代码。

局限性

  1. kage当前的实现继承了silhouette的并行影子栈设计。并行影子栈允许以更高的RAM使用为代价进行更高效的处理器插桩。虽然这种成本对于Silhouette所关注的单层裸机应用程序是合理的,但它限制了Kage可以支持的任务数量。
  2. 当前的影子栈实现还要求所有任务使用的堆栈大小相同。
  3. Kage依赖于开发人员来正确地配置异常的优先级。
  4. Kage编译器不转换内联汇编代码或手写的汇编源文件;可以通过在汇编程序中实现编译器转换,在Kage的未来版本中来解决这个问题。当前的原型是手工转换了所有不受信任的内联汇编块和汇编源文件。

性能评估

由于没有针对具有实时内核的应用程序的开源基准测试,因此本文将CoreMark移植到AWS FreeRTOS和Kage上,并修改了基准测试以利用FreeRTOS的内核特性。CoreMark是由ARM 推荐的一个行业标准的基准测试。CoreMark包括常见的嵌入式操作,如链表操作、矩阵乘法和状态机操作。简而言之,我们修改的基准测试使用多个抢占和上下文切换的任务,并通过队列将输出通信到一个主任务。

表2总结了基线FreeRTOS、仅进行内核修改的Kage(即不转换任何不可信任的代码)和完整的Kage系统的性能。

image-20221021110759237

表3显示了没有高速缓存的结果。在没有缓存的情况下,运行更多的任务会降低基线和Kage的性能。

image-20221021110820286

表4显示了FreeRTOS和Kage的受信任和不可信任代码的大小。与基准FreeRTOS相比,Kage在代码大小上产生了49.8%的开销。这些开销的大部分来自于启用FreeRTOS中的MPU,而不是直接来自Kage的扩展。例如,启用了MPU的相同FreeRTOS的代码大小为66,704字节,比基准FreeRTOS大31.1%。与这个启用mpu的版本相比,Kage的开销仅为14.2%。

image-20221021110840402

表5显示了安全API运行时检查的周期计数。这些组件在FreeRTOS中没有等价的组件,因此没有基线数字。

image-20221021110902592

表6显示了与启用MPU后的基线FreeRTOS和FreeRTOS相比,其他Kage机制的性能开销。

image-20221021110907930