这一篇在干嘛?

这篇文章是 ACM TECS 2022 年的一篇综述《Survey of Control-flow Integrity Techniques for Real-time Embedded Systems》的中文讲解。它系统梳理了一类叫控制流完整性(Control-Flow Integrity,CFI)的防御技术——专门用来阻止攻击者”劫持”程序执行流程。综述的特殊视角是:把 CFI 搬到算力弱、内存小、还有硬性时间底线的嵌入式设备(比如汽车的防抱死刹车控制器、工厂里的 PLC)上,开销扛得住吗?怎么设计才扛得住?如果你是嵌入式开发者、对二进制安全感兴趣,或者想知道”为什么安全方案不能直接抄 PC 那一套”,这篇非常适合你。

阅读导航为什么嵌入式设备的控制流会被劫持 · 控制流攻击的两大流派:代码注入与代码复用 · CFI 到底是什么 · 后向边防御:栈金丝雀与影子栈 · 前向边防御:CFG 与标签检查 · 嵌入式系统上的 CFI 实现:从 Silhouette 到 TrustZone · 寄存器式影子栈:μRAI 与 Zipper Stack · 实时嵌入式系统的 CFI:安全与调度的博弈 · 开放挑战与全文总结 · 自测题

为什么嵌入式设备的控制流会被劫持

先从一个反直觉的事实说起:你家里那个看起来人畜无害的智能灯泡,曾经被安全研究人员用来发起分布式拒绝服务攻击(DDoS,Distributed Denial-of-Service)。灯泡本身被攻击造成的影响顶多是经济损失,但试想一下——如果攻击者能远程控制灯泡以特定频率闪烁,对光敏性癫痫患者来说就可能是人身伤害。而比灯泡严重得多的案例是 Stuxnet(震网蠕虫):这是 2010 年被发现的一个专门攻击 SCADA(Supervisory Control and Data Acquisition,监控与数据采集)系统的蠕虫,它感染了核反应堆控制系统,对伊朗的核设施造成了实质破坏。更讽刺的是,许多被感染的系统是”物理隔离”(air-gapped)的——根本没有连接外网,病毒是通过运维人员插入的 U 盘传播的。

这说明一个关键问题:攻击面不只是网络接口。只要存在任何能让攻击者向系统写入数据的通道(U 盘、蓝牙、Wi-Fi、传感器输入),控制流劫持攻击就有机会发生。

嵌入式系统为什么格外脆弱

综述里对”嵌入式系统”(Embedded Systems)的定义很宽泛:处理单元是 ARM Cortex-M、Cortex-R 这类微控制器(Microcontroller,MCU),或者从这类架构演化的定制架构,内存和处理器性能远低于桌面/服务器级硬件,通常为了压缩尺寸、重量、功耗和成本(业内简称 SWaP)而精心裁剪。举几个例子感受一下这种”弱”:

  • 一辆现代汽车有超过 100 个电子控制单元(Electronic Control Unit,ECU),每个 ECU 里是主频比服务器处理器低一个数量级的 MCU。它们控制的东西从车载娱乐系统一直到防抱死刹车系统(ABS)——后者一旦失效可能直接威胁乘客生命。
  • 这些系统的软件部署后往往常年不更新,因为部署位置难以触达。漏洞被发现后很难打补丁,对攻击者来说是躺在那里的”长肥的羊”。
  • 车联网、蓝牙、Wi-Fi 等接口越来越多,原本封闭的系统正在被一根根网线”连出去”。

还有一个软件层面的根本原因:嵌入式系统几乎都是用 C/C++ 这类内存不安全语言写的。由于缺少昂贵的内存管理单元(MMU),内存模型是平坦的(flat memory model),栈、代码、数据混在同一个线性地址空间里,缓冲区溢出(Buffer Overflow)漏洞极其常见。攻击者只要找到一个”往数组里写超出容量的数据”的 bug,就可能改写相邻内存里的关键数据——其中最致命的就是函数的返回地址。

实时嵌入式系统:多了一层”时间红线”

综述进一步区分了实时嵌入式系统(Real-time Embedded Systems):不但资源受限,还必须在截止时间(deadline)之前完成计算。防抱死刹车的多个控制回路每秒必须执行若干次,一旦错过 deadline,哪怕计算结果全对,后果也可能致命。这个”时间红线”给 CFI 设计带来了独特的难题,综述归纳为四条:

  1. 算力弱,复杂防御跑不动。复杂 CFI 引入的开销可能直接打破实时保证。通用服务器可以接受”性能降 30% 换更强安全”,实时系统往往不行。
  2. 缺高级硬件特性。很多为通用系统设计的防御依赖虚拟内存等机制,而低端 MCU 根本没有 MMU,防御方案必须做硬件/软件层面的变通(workaround)。
  3. 必须评估可调度性(Schedulability)。实时系统需要证明”加了 CFI 之后任务仍能赶上各自的 deadline”,但绝大多数 CFI 论文根本不讨论这一点。
  4. 功耗几乎没人管。很多嵌入式设备靠电池或太阳能供电,而 CFI 机制(尤其是频繁访问内存的影子栈)对功耗的影响在几乎所有已有工作中都是空白。

这四条是全文的暗线:后面每讲一种 CFI 技术,都可以拿它们来检验——这种方案在资源受限 + 实时的场景下到底行不行。

控制流攻击的两大流派:代码注入与代码复用

要理解 CFI,得先理解它防的是什么。控制流攻击(Control-flow Attacks)的目标是:篡改程序运行时的控制信息——也就是决定程序”下一步去哪执行”的数据——把执行流重定向到攻击者指定的位置。因此这类攻击也叫代码重定向攻击(Code Redirection Attacks)。

攻击的靶子:返回地址

最经典的控制信息就是函数调用的返回地址。以 ARM 架构的 MCU 为例:函数调用时,返回地址被保存在栈帧(stack frame)的 LR 字段或链接寄存器(Link Register,LR)里。函数返回时执行 BX LR 之类的指令,处理器就跳到 LR 里的地址继续执行。LR 的内容就是控制信息。

现在假设这个函数里有一个缓冲区溢出漏洞——比如一个写用户输入的静态数组没有做边界检查。攻击者就可以用超长的输入把栈帧里的返回地址覆盖掉,换成他想要的任意地址。函数返回时,被污染的值被弹出到 LR,处理器就”忠实地”跳向攻击者的目标。

正常流程:  main() → 调用 func() → func 返回 → 回到 main 中正确的下一条指令
被劫持后:  main() → 调用 func() → 溢出改写返回地址 → func 返回 → 跳到攻击者的地址

流派一:代码注入攻击(Code Injection)

最直接的利用方式分两步:(a) 借着溢出把恶意代码写进某块可执行的内存(比如栈),(b) 把返回地址改成恶意代码的起始位置,函数一返回就执行恶意代码。这就是代码注入攻击。

好消息是,这一派现在基本被防住了。它的隐含前提是”栈可执行”(writable 且 executable),所以只要让可写的内存不可执行——即 W⊕X 策略(Write XOR Execute)——攻击就失效了。现代处理器普遍带有内存保护单元(Memory Protection Unit,MPU),让系统设计者轻松实现”可写不可执行”,甚至连 ARM Cortex-M 这类低端 MCU 也有相应防御方案。所以综述明确把代码注入排除在讨论范围之外,聚焦更棘手的下一派。

流派二:代码复用攻击(Code Reuse)

W⊕X 普及后,攻击者换了思路:不注入新代码,而是复用程序里已有的代码片段。这就是代码复用攻击,其中最臭名昭著的是返回导向编程(Return-Oriented Programming,ROP)。

ROP 的核心操作是:在程序二进制里搜索以 ret 指令结尾的短代码片段(称为 gadget),然后利用溢出漏洞精确布置栈上的返回地址链,让函数每次返回都”串”起一个 gadget,最后拼出一个完整的恶意逻辑。攻击者根本不需要写一行新代码,全部素材都来自受害程序自身。后续研究还发展出了更狠的变体——甚至不需要 ret 指令,改用间接跳转/函数调用的目标来串 gadget(这已经触及”前向边”了,后面会讲)。

常见坑:以为"有 W⊕X 就安全了"

很多初学者觉得代码注入死了,嵌入式设备加个 MPU 就高枕无忧。错。代码复用攻击(ROP 及其变体)完全不需要可执行的可写内存——它用的全是程序自己代码段里的合法指令。W⊕X 只能挡住低级的注入攻击,挡不住复用攻击。也正因为如此,才需要 CFI 这种更本质的防御。

通关标准:理解"控制信息"

读到这里,你应该能不查资料地回答:什么是控制信息?返回地址为什么是控制信息?代码注入和代码复用的本质区别是什么(一句话:注入需要写入可执行内存,复用只用已有代码)?如果都能答上,这一节就通了。

CFI 到底是什么

控制流完整性(Control-Flow Integrity,CFI)的定义一句话就能说清:确保程序在运行时不偏离合法的控制流路径的一类防御技术的总称。它针对的是最强威胁模型——假设攻击者已经绕过了其他所有防御、成功渗透进系统,此时 CFI 是最后一道防线:你可以进来,但你不能让程序执行”不该走的路”。

CFI 并非纯学术玩具,它已经进入工业界主流:Clang 编译器内置了控制流违规检测,微软的 Windows 从 8.1 起就有自己的 CFI 实现——Control Flow Guard(CFG)。

要精确讨论 CFI,需要先建立一个核心模型:把程序所有合法的控制流转移(哪些指令可以从哪跳到哪)画成一张控制流图(Control-Flow Graph,CFG)。图上的每条边都是”合法转移”。CFI 机制做的事,就是在运行时检查每一次转移是否都在图上——不在就报警/终止。

这张图上的边分两类,这也是理解全部 CFI 文献的钥匙:

  • 后向边(Backward-edge):函数调用与返回形成的边,尤其是 ret 指令的跳转目标。ROP 攻击利用的就是后向边——它疯狂串接返回序列。
  • 前向边(Forward-edge):间接跳转和间接函数调用形成的边,即那些”目标地址在运行时才确定”的转移。不需要 ret 的 ROP 变体攻击的就是前向边。

为什么这么分?因为防御这两类边的技术难度和手段完全不同:后向边好防(历史可查,“回到哪”是已知的),前向边难防(“接下来去哪”要预测)。综述的整个章节结构就是围绕这两类边展开的:先讲通用基础技术(第 3 章),再讲嵌入式系统上的实现(第 4 章),最后讲考虑实时约束的设计(第 5 章)。

另外约定一下术语:综述里说的”性能开销”(performance overhead)指 CFI 机制带来的 CPU 周期数增加;“内存开销”(memory overhead)指代码和数据内存的增加。这一点在后面对比数字时要记住——不同论文用的基准测试和硬件并不相同,只能在相同测试条件下做定量比较,其余时候只能定性比。

后向边防御:栈金丝雀与影子栈

所有控制流攻击的第一步都是渗入——找到并利用一个初始漏洞(最常见的就是缓冲区溢出)。后向边防御针对的就是”溢出改写返回地址”这一步。两种最基础、也最重要的是栈金丝雀(Stack Canary)和影子栈(Shadow Stack),它们是后面所有嵌入式 CFI 机制的积木。

栈金丝雀:在返回地址前埋一道哨

栈金丝雀的做法是在栈帧里、返回地址和局部变量之间插入一个特殊值(见下面图 1(b))。攻击者要从缓冲区一路溢出写到返回地址,必然先覆盖金丝雀值。函数返回前检查一下金丝雀是否原样,就能发现溢出是否发生过——就像矿井里带金丝雀下井,鸟死了说明毒气来了。

但金丝雀有几个天然弱点:

  1. 如果金丝雀值是固定的(不随机)或随机性不够,攻击者可以读出或猜出它,然后”连着它一起正确覆盖”。
  2. 它挡不住攻击者改写金丝雀之前的局部变量。攻击者依然可以通过污染局部变量影响函数行为。
  3. 它只能”检测”,不能精确告诉你返回地址是否被改——它是间接信号。

影子栈:给返回地址存一份备份

影子栈更硬核:在一块(假定的)攻击者碰不到的内存区域里,为每个函数调用额外保存一份返回地址副本(见图 1(c))。函数调用时更新副本,函数返回前把真实返回地址和副本对比,不一致就说明被攻击了。

它和金丝雀的本质区别是:金丝雀是”报警器”,影子栈保存了正确值本身,所以不但能检测,还能(在部分实现里)用副本恢复正确的返回地址。

一个自然的疑问是:既然影子栈更强,为什么不直接用它?答案是开销。Dang 等人专门在 x86 处理器上用 SPEC CPU2006 基准测试量化过两种影子栈实现的开销:

  • 传统影子栈(traditional):有自己的独立栈指针,只存返回地址。每次 push/pop 都要额外维护自己的栈指针(至少多一条指令),平均性能开销 9.69%,最坏 52.5%
  • 并行影子栈(parallel):与主栈共用同一个栈指针,只是基址不同,通过跳过栈帧中其他数据来记录返回地址。由于两栈偏移一致,访问对应位置只需换一下栈基址寄存器(一条指令搞定),平均开销降到 3.51%,最坏 19.6%。
  • 额外检查返回地址本身还要再加约 0.8%
  • 作为对比,栈金丝雀的平均开销只有 2.54%

结论很清晰:并行影子栈是性能敏感场景的最佳选择,金丝雀最便宜但保护最弱。不过这里埋着一个大前提——“攻击者无法访问或修改影子栈所在的内存”。在桌面系统上这个假设可以用虚拟内存等机制勉强成立,但在没有 MMU 的低端 MCU 上,这个假设几乎无法直接成立——这正是嵌入式 CFI 设计的最大难点,下一节展开。

图1:三种栈布局对比:(a) 无防御的函数栈帧;(b) 使用栈金丝雀的后向边 CFI;(c) 并行影子栈

图 1:(a) 没有任何防御时的函数栈帧,缓冲区溢出可以直接吞掉返回地址;(b) 金丝雀方案——在返回地址与局部变量之间插入哨兵值;(c) 并行影子栈——把返回地址复制到独立的影子栈区域,返回前核对。

前向边防御:CFG 与标签检查

前向边攻击是后向边攻击的”逻辑延伸”:随着后向边防御普及,攻击者转向间接跳转和间接函数调用的目标(综述里称为 POI,Points of Interest)。改写函数指针或间接调用的目标地址,就能不需要任何 ret 指令串起 gadget 链。

前向边 CFI 普遍更难,原因很朴素:回看历史容易,预测未来难。函数返回时,“它该回到哪”是刚发生的事实,可查;而一次间接调用”该调用谁”需要预测所有合法可能性。

粒度:细粒度 vs 粗粒度

理论上能做到”预测所有合法的分支起点-终点组合”的前向边 CFI 称为细粒度(fine-grained)CFI。合法组合就是 CFG。 pioneer 工作是 Abadi 等人的方案:用二进制静态分析工具 Vulcan 生成 CFG,运行时据此判断每条边是否合法。

实施细粒度 CFI 最常用的手段是标签(labeling):给所有可能被攻击者利用的前向边位置(间接分支点)和所有合法目标(函数入口)打上唯一 ID,并在函数序言(prologue)里插入检查代码。发生间接调用时,源标签与目标标签匹配才放行。图 2 展示的正是这个流程。

图2:使用标签与内嵌 CFG 强制前向边 CFI

图 2:标签检查示意——每个间接分支点带源标签,每个函数入口带目标标签,只有标签匹配(即该边存在于 CFG 中)的调用才被放行。

但细粒度 CFI 有两个现实难题,在嵌入式场景下尤其致命:

  1. 存储和执行 CFG 的内存开销。低端 MCU 上一张完整的 CFG 加上每次分支的检查代码,内存和性能都不便宜。
  2. 源代码可得性。很多实时嵌入式系统跑的是专有遗留软件,源代码拿不到(或协议不允许插桩),静态分析根本没法做,CFG 无从生成。

于是就有了粗粒度(coarse-grained)CFI:放宽检查——比如只要求”跳到任何函数入口都合法”,而不是”这次调用恰好能调到这个函数”。开销小了,但代价是出现盲区:攻击者可以跳到”在细粒度视角下非法”的目标。已有研究证明粗粒度 CFI 可以被拼接 gadget 的方式绕过。

综述还提到一个有趣的折中思路——BBB-CFI:不构建完整 CFG,而是把二进制切分成基本块(basic block,只有一个入口一个出口的指令块),运行时只保证”不允许跳进块的中间”,把基本块作为最小的合法跳转单位。这不需要源代码,也不需要完整 CFG。

另一个更先进的方向是上下文敏感(context-sensitive)CFI,代表是 PathArmor:记录运行时的控制流转移序列,然后用 CFG 做深度优先式的路径验证——不仅检查”这条边合法”,还检查”在当前执行历史下这条边合法”。这能抓住”控制流弯曲”(Control-Flow Bending)这类高级攻击:转移的起点和终点都合法,但放在上下文里是错的。可惜 PathArmor 依赖特殊的硬件架构特性来记录控制流转移,低端 MCU 上没有这种硬件,直接用不了。

顺带一提,综述在 3.3 节区分了一类”看起来像 CFI 但不是”的工作:软错误(soft error)检测。宇宙射线导致内存位翻转(bit-flip)也会造成控制流偏转,有一批工作用类似手段(基本块签名比对)检测这种非恶意错误。区别在于:软错误是随机一次性的,而攻击者的重定向发生在分支/返回点且有规律可循(比如必然先有溢出),而且”控制流弯曲”这种上下文级攻击是软错误检测抓不住的。综述只聚焦对抗性攻击,不涵盖软错误。

常见坑:把 CFI 想成"万能护盾"

两个常见误解:一,以为细粒度 CFI 不可攻破——实际上细粒度 CFI 也被证明可绕过(利用静态分析无法完美刻画编程习惯的缺陷),所以才需要上下文敏感方案;二,以为 CFI 能防所有攻击——面向数据的攻击(Data-Oriented Programming,DOP)完全不改变控制流,只篡改程序数据(比如循环计数器),任何 CFI 都防不住它。CFI 的防护边界是”执行流”,仅此而已。

嵌入式系统上的 CFI 实现:从 Silhouette 到 TrustZone

理论讲完,进入综述的主菜:这些技术在资源受限的嵌入式设备上怎么落地?核心矛盾在这一节开头就点明了——影子栈的前提”攻击者碰不到影子栈”在低端 MCU 上不成立。没有 MMU、没有虚拟内存,怎么把影子栈藏起来?

Silhouette:用 MPU 和”存储加固”藏影子栈

Silhouette 是面向 ARMv7-M 架构(ARM Cortex-M 系列的底层架构)的方案,同时支持后向边(影子栈)和前向边(标签)CFI,目标是裸机(bare-metal,无 RTOS)代码库。

它的关键洞察是利用 ARMv7-M 的两级特权(特权/非特权)和 MPU。但有个障碍:MPU 的配置可以被任何特权代码改写——而大多数 RTOS(如 FreeRTOS)默认让任务和内核都以特权级运行(避免特权切换的开销)。攻击者一旦渗透进特权代码,直接重配 MPU,影子栈就裸奔了。

Silhouette 的解法叫存储加固(store hardening):编译时把除了允许写影子栈(和硬件抽象层 HAL 代码)之外的所有存储指令,替换成非特权存储变体。非特权存储无论当前执行特权级如何,都会受 MPU 访问规则约束——即使攻击者在特权级,也写不进 MPU 保护起来的影子栈区域。影子栈本身采用前文说的并行影子栈实现。

实测数据(两个嵌入式标准基准套件 CoreMark-Pro 和 BEEBS):最大性能开销分别 4.9%24.8%,代码内存开销 8.9% 和 2.3%,几何平均性能开销仅 1.3%3.4%。相当亮眼。

另一条技术路线是软件故障隔离(Software Fault Isolation,SFI):把地址空间划成故障域(fault domain),编译时给 load/store 指令插桩,越界访问就触发故障处理。Silhouette 的一个变体用 SFI 保护影子栈——所有存储指令插桩,除了影子栈操作代码,其他写入影子栈的尝试一律被拦。代价是性能更高:几何平均开销 2.2% 和 10.2%,所以作者结论是存储加固更优。不过综述提醒,如果换成 Aweke 和 Austin 的轻量级 SFI(只用 150 行可信代码配置 MPU,MiBench 上开销仅 1%),情况可能不同——可惜基准不同没法直接比。

在 Silhouette 基础上,Kage 把这套思路扩展到了 RTOS 环境:修改 FreeRTOS,引入可信内核/不可信任务的划分。不可信任务代码经过存储加固编译(存储指令全部变成非特权变体),写不了可信内存;内核代码保持特权级可访问一切;任务需要的 RTOS 基础设施(锁、队列等)通过一个安全 API暴露,API 会审查参数防止不可信代码篡改内核里的控制信息。实测:运行 1~3 个 CoreMark 基准任务的多任务负载时,Kage 内核比基线 FreeRTOS 平均只慢 5.2%

控制流锁定(CFL):懒人式防御

前面讲的方案都是前后向边分开处理,控制流锁定(Control-Flow Locking,CFL)则用一套机制同时覆盖两类边,并引入了一个重要概念——懒检查(lazy CFI),用”检测延迟”换”性能开销”。

CFL 的原理(见图 3):仿照标签方案的思路,给每个合法的调用/跳转目标分配唯一键值(key,本质上代表 CFG 里的一条合法边;目标包括间接调用、jmp、ret——它是在 x86 上实现的)。然后在二进制里插桩:

  • 每个合法控制流转移起点前插入 lock 操作:把键值存进一个键缓冲区(假设该缓冲区只有 lock/unlock 子程序能改)。
  • 控制流到达合法终点后立即执行 unlock:校验键值,匹配则清零放行。
  • 下次转移发生前,先检查键缓冲区是否残留非零值——如果有,说明上一次”lock 之后没有对应的 unlock”,即控制流跳到了非法位置,攻击实锤。

图3:控制流锁定的操作流程,全程只使用 lock/unlock 操作

图 3:CFL 的 lock/unlock 配对机制——合法路径上每次转移都”上锁-开锁”配对成对出现,一旦中途偏航,残留的非零键值会在下一次转移点暴露攻击。

CFL 的精妙之处:它不仅阻止非法跳到合法转移点,还能自动回溯检测”最近历史上跳到非法位置”的情况,不需要影子栈那样的额外运行时内存。性能上,在 SPEC CPU2000 上最大开销 21%,优于 Abadi 等人细粒度 CFI 的 31%

但”懒”是有代价的:从攻击发生到被下一个 lock 点抓到之间存在检测盲窗。对工业控制系统这种场景——MCU 正控制着执行器(actuator)——如果攻击者在被检测到之前已经发出了控制指令,灾难照样发生。不过综述指出,懒本身不是原罪,关键是没有机制约束”懒”的边界——这是第 6 章开放挑战”有界懒惰”(Bounded Laziness)的伏笔。

CFI CaRE 与 TZmCFI:把影子栈藏进 TrustZone

Silhouette 用软件技巧绕硬件限制,下一组方案直接搬出了更强的硬件——ARM TrustZone。TrustZone 让处理器支持两个隔离的执行域:安全域(secure)和非安全域(non-secure),各有独立地址空间,安全域对非安全域有监督权。这天然就是藏影子栈的好地方。

CFI CaRE 的设计(见图 4):原始二进制只在非安全域执行,所有函数调用/返回指令被替换成超级调用(SVC,Supervisory Call),SVC 会唤起一个运行在特权上下文的分支监视器(branch monitor)。监视器根据 SVC 的参数判断这次是分支还是返回,然后调用安全域代码更新影子栈。TrustZone 边界保证非安全域代码永远看不到也改不了影子栈。

图4:CFI CaRE 插桩后的代码,分支与返回指令都被替换为 SVC 调用

图 4:CFI CaRE 的插桩方式——所有分支/返回指令改写为 SVC 陷入分支监视器,由安全域统一维护影子栈。

CFI CaRE 还有一个被综述特别强调的工程优点:SVC 替换只覆盖分支/返回指令本身,不破坏原始二进制布局。这对嵌入式系统意义重大——很多实时嵌入式系统跑的是严格按内存预算构建的专有二进制,常规插桩(随意插入指令、改变布局)可能直接毁掉与硬件的兼容性。代价:需要额外的分支监视器空间;性能开销也不便宜——在 ARM Cortex-M23 上用 Dhrystone 基准测得开销 13%~513%,flash 内存增加 14.5%

CFI CaRE 支持中断(用跳板 trampoline——中断入口处的一小段代码,先调用安全域把返回地址存进影子栈),但不支持嵌套中断。这是个真实漏洞:如果攻击者控制的高优先级中断在低优先级中断的跳板存好返回地址之前触发,就能改写返回地址;等低优先级中断继续跑时,跳板存进去的已经是被污染的值。而嵌套中断在 RTOS 系统里很常见——定时器 tick 和外设中断完全可能同时来。

TZmCFI 补上了这个洞:同样用 TrustZone 藏影子栈,但扩展出异常影子栈(exception shadow stack)支持嵌套中断——修改跳板,让每个中断的跳板在执行中断主体前,先完成所有低优先级中断待处理的影子栈事务。这样即使攻击者控制了中断主体,也动不了影子栈里的中断返回地址副本。代价是可观的:支持 FreeRTOS 时性能开销最高 84%;嵌套中断场景下,插桩后的中断执行时间从 30 周期涨到 132~236 周期——最多 550% 的中断延迟增加。这个数字直观说明:中断频繁的实时系统上,这类方案的代价很敏感。

再往外看,还有更激进的硬件方案。Intel 的 CET(Control-Flow Enforcement)在 Tiger Lake 处理器上提供了影子栈和前向边 CFI 的原生硬件支持,但那是桌面级处理器,超出本文范围。HCFI 则干脆造新 ISA:在 SparcV8 的 Leon3 核的流水线里加新阶段做影子栈等 CFI 操作,FPGA 实现测得开销小于 1%——性能最优,但代价是彻底的硬件改造。综述的判断是:在嵌入式这种更抠资源的场景里,大规模硬件改造是否可行还不好说,现阶段 TrustZone 路线更现实。

借外部处理器做 CFI

还有一类思路是不在主芯片上做检查:利用片外/独立处理资源。比如用独立监控模块跟踪程序计数器检测偏离(Abdi 等人)、面向汽车系统利用车规 ECU 里常见的硬件安全模块(HSM)检测异常路径(SecMonQ)、或用很多嵌入式系统都有的可信平台模块(TPM)存放 CFG 并周期性度量(RTTV)。它们的本质仍是应用第 3 章的基础技术,只是把”检查者”挪到了另一个处理器上。综述对它们只做简要介绍,因为引入外部资源后很难和自包含方案公平比较。

这一节的小结

嵌入式 CFI 方案演进的主线很清晰:要么绕硬件限制(Silhouette 的存储加固),要么借更强硬件(TrustZone),要么改硬件(HCFI、Zipper Stack)。但综述点出一个通病——所有这些方案本质上还是”校验分支源地址和目标地址”的老一套,原理上没有突破;而且每个方案都和特定硬件深度绑定,性能数据互相之间没法直接比。更关键的通病是:没有任何一个方案讨论了开销对实时性的影响。这正好引出下一节。

寄存器式影子栈:μRAI 与 Zipper Stack

前面所有方案的影子栈都在内存里——也因此需要费尽心机保护这块内存。有没有可能干脆不要内存里的影子栈?这就是”影子栈替换”(shadow stack replacement)路线,代表是 Zipper Stack、PACStack 和 μRAI。它们的共同思路:把整条影子栈”折叠”进一个寄存器值里。

Zipper Stack:用 MAC 链代替影子栈

Zipper Stack 最激进——直接改 CPU 架构。它引入两个专用寄存器:

  • key register(键寄存器):存一个进程启动时随机初始化的秘密密钥;
  • top register(顶寄存器):存一个不断滚动的值。

每次函数调用时,top register 的旧值随真正的返回地址一起压入主栈;同时用密钥对(top register 旧值 + 返回地址)计算一个消息认证码(Message Authentication Code,MAC——用于验证消息来源真实性的密码学原语),结果存回 top register。函数返回时反向操作:从栈里弹出上一次的 MAC,用返回地址和它重新计算 MAC,与 top register 当前值比对——匹配则返回地址可信。

这套 MAC 链的防护逻辑在于:攻击者想改返回地址而不被发现,必须先改 top register——但它是专用寄存器,应用代码碰不到,只在调用/返回时由硬件自动更新。整条影子栈被压缩成了一个寄存器值。

三个依赖条件:(a) MAC 算法足够抗碰撞;(b) 算法够快——每次调用至少算两次 MAC,作者论证硬件实现可以单周期完成;(c) 密钥不泄露。Zipper Stack 在 RISC-V CPU 的 FPGA 实现上测得 SPEC CINT 2000 开销仅 1.86%。综述评价:这种深度定制架构在商业上可能难普及,但嵌入式领域为特定应用(如国防)定制架构并不罕见;而且这个思路很快就能在通用硬件上实现了——见下面的 PACStack。

PACStack 用现成的 ARM 指令做了几乎一样的事:ARMv8.3-A 引入的指针认证扩展(Pointer Authentication,PA)提供 pac 指令,能为指针生成指针认证码(Pointer Authentication Code,PAC——本质就是 MAC,存在指针旁边)。PACStack 用链式寄存器存 PAC 链,每次调用用”链寄存器旧值 + 返回地址”生成新 PAC,返回时反向验证。SPEC CPU2017 测试上几何平均开销 2.75%(SPECrate)/ 3.28%(SPECspeed)。随着 ARMv8.3-A 落地量产芯片,MAC 式影子栈替换很快不再是纸上谈兵。

μRAI:让返回地址”不可改”

μRAI 走了另一条更务实、能在当前一代 COTS(commercial off-the-shelf,商用现货)硬件上跑的路。它的目标不再是”验证返回地址”,而是返回地址完整性(Return Address Integrity,RAI)——让返回地址根本不可被攻击者修改,从”事后验证”变成”事前免疫”。

机制(见图 5):它要求处理器完全让出一个寄存器作为状态寄存器(State Register,SR),永不溢出到内存。编译器把所有间接分支改写成直接分支(目的地硬编码在指令里的分支),并做如下插桩:

  1. 每个函数调用点分配唯一的函数键(Function Key,FK)。调用前,SR 与该调用点的 FK 做 XOR,得到该调用路径的函数 ID(Function ID,FID)。
  2. 函数体执行。返回时,查询函数查找表(Function Lookup Table,FLT)——表里存着”所有可能调用本函数的 FID 及对应返回点”。SR 的当前值与表中哪个 FID 匹配,就返回到哪个位置。
  3. 返回后 SR 再 XOR 同一个 FK,恢复原值。

图5:μRAI 的操作流程——返回时通过 SR 寄存器与 FID 表完成"影子栈"式的校验

图 5:μRAI 的调用-返回流程。函数 A 有多个调用点调用 B,每个调用点用自己的 FK 翻转 SR;B 返回时查 FLT 匹配 FID 决定返回位置,再 XOR 恢复 SR——等效于用寄存器实现了一个影子栈。

整个”函数返回机制”完全实现在代码内存里(FID 表烧在 flash 里,受 DEP 保护,攻击者改不了),处理器栈上那个天然可写的返回地址被 μRAI 彻底弃用。这就是”完整性”的来源——不再保护一块可写内存,而是把关键控制信息搬进不可写的内存。

在 ARM Cortex-M4 开发板上的实测:CoreMark 最大性能开销 8.1%,平均只有 0.1%——与最好的影子栈方案相当。但内存代价大:平均需要 34.6% 的额外 flash 空间存放插桩代码和 FLT。

μRAI 还有两个值得注意的点:

  1. 它是综述里第一个明确处理中断的机制。中断随时可能打断影子栈操作,必须显式处理。μRAI 对中断处理程序插桩:中断硬件自动压栈的返回地址被保存到 MPU 藏起来的安全内存——在中断场景下它又不得不造了个小影子栈。支持中断是走向 RTOS 多线程调度的重要一步。
  2. 它不提升前向边防御。攻击者可以用分支操作不断重定向执行,根本不让代码跑到 FID 表就完成攻击。综述的批评很中肯:μRAI、Zipper Stack 这类方案相比常规影子栈,只有性能/内存上的取舍,没有额外的安全保证,却要求大量代码库修改或定制编译器。

实时嵌入式系统的 CFI:安全与调度的博弈

终于到了综述的核心命题:实时嵌入式系统的 CFI。前面所有方案共享一个盲区——从不讨论”加了 CFI,任务还能不能赶上 deadline”。而实时系统恰好有三个可被 CFI 利用的独特性质:

  1. 周期性可预测:任务在固定的周期性时间区间里执行,行为时序可以预期——异常行为理论上能从时序上看出来。
  2. 系统故意低负载:出于安全考虑,实时系统通常配置了比峰值需求更多的算力(不追求 100% 利用率),存在大量空闲时间(slack)。CFI 开销理论上可以往空闲时间里塞。
  3. 可调度性可分析:存在成熟的可调度性测试(schedulability test)判定”任务集在给定调度算法下是否全都能赶上 deadline”。

遗憾且重要的是:综述考察的所有既有实时 CFI 工作,没有一个真正利用了第 2 点(空闲时间摊销)。下面看现有的几条路线做到了哪一步。

RECFISH:第一个大规模可调度性研究

RECFISH 是 RTOS 感知(RTOS-aware)的 CFI 方案,面向专为关键实时应用设计的 ARM Cortex-R 处理器(和 Cortex-M 一样没有 MMU、有专门的缓存机制保可预测性,但也不支持 TrustZone)。它的机制本身是前面技术的组合拳:用 MPU 强制 DEP(任务跑非特权级,RTOS 跑特权级,攻击者渗透进任务也改不了 MPU);为每个任务配置影子栈(MPU 保护,只有特权代码能改);修改 FreeRTOS 调度器,任务切换时同步更新影子栈;二进制插桩加函数序言标签(前向边);影子栈操作像 CFI CaRE 一样通过 SVC 调用特权处理代码。

机制不新鲜,评估才是它的贡献——它是第一个系统研究”CFI 对实时负载影响”的工作:

  • CoreMark 上性能开销 21%
  • 微基准:调度器上下文切换从 120 周期涨到 159 周期;函数序言/尾声(含标签检查和影子栈操作)从 19 周期暴涨到 275 周期。
  • 大规模可调度性实验:随机生成数百万合成任务集(利用率 0.1%~90%、不同周期、不同间接分支密度),把 CFI 开销计入任务的最坏情况执行时间(Worst-Case Execution Time,WCET)后做可调度性判定。结果:总计能成功调度 85% 的 600 万个任务集(覆盖 5760 种参数组合)。
  • 但规律很严峻:任务少、单任务利用率高、间接分支稀疏时表现好;一旦任务多、间接分支频繁(每 10³~10⁵ 周期一次的量级),最多可达 30% 的系统利用率直接变得不可调度

结论有正有负:好消息是影子栈+标签这类”标准 CFI”确实能用于很大范围的多线程实时系统;坏消息是重负载、高频调用的系统会被 CFI 开销挤出可调度范围。

用安全性换调度率:选择性开关 CFI

Hao 等人的工作面对 RECFISH 揭示的困境给出了一个思路:别给所有任务实例都开 CFI。他们针对 ROP 防御,把系统里每个任务的执行实例(称为 job)作为粒度,通过穷举搜索找出”在不破坏可调度性的前提下,最多能有多少 job 开着 CFI 检查”的组合;同时给出了运行时更快的近似算法——实验显示在利用率较低(≤0.6)时近似逼近最优。可调度性研究还发现:任务集利用率超过 0.8 后,加 CFI 检查会让可调度率断崖式下跌——和 RECFISH 的观察互相印证:任务集越”重”,CFI 的挤压越致命。

代价不言自明:只有一部分运行时代码被真正检查了。聪明的攻击者(尤其是知道”哪些 job 不检查”的选择逻辑的攻击者)可以专挑盲窗下手。综述提到可以用任务随机化(如 TaskShuffler)缓解,但随机化本身也被证明可被精心构造的攻击击破。这本质上是一个”承认不完美、做安全-调度权衡”的方案

时序偏离检测:把”超时”当攻击信号

前面方案都在”内存里做校验”,Bellec 等人的时序偏离(Timing Deviation)方案换了个维度——利用实时系统可预测性本身。核心假设:攻击者发动控制流攻击是为了执行恶意动作,这必然消耗额外执行时间;而实时系统的任务时间参数是精确已知的,异常的时间增长就是攻击的指纹(见图 6)。

图6:以执行时间(MID)为指标检测控制流攻击

图 6:时序偏离检测示意——把任务代码划分为区域(region),每个区域有自己的 MID 上限,运行时监控各区域耗时,超出阈值即报警。

具体机制:把单任务代码切分成区域(region,可嵌套但不重叠),已知任务的 WCET,就能给每个区域分配自己的 WCET 上限,称为最大内部时长(Maximal Inner Duration,MID,不含子区域的 MID)。所有覆盖任务代码的区域的 MID 之和等于任务 WCET。再定义最大攻击窗口(Maximal Attack Window,MAW)——一组被监控区域中最大的 MID。设计目标是在内存(存区域边界和运行时度量)与性能约束下,搜索一组区域:完整覆盖任务代码,且 MAW 最小。配套的自定义硬件架构负责感知进出区域并持续计时,一旦在区域内的耗时超过 MAW 就判定攻击。

局限也很清楚:检测有延迟——和 CFL 的懒惰问题同源,攻击者可能在被检测到之前已经造成破坏;而且需要大规模架构改造。但综述给了它很高的定位:它是把实时可预测性真正用于 CFI 的起点,尽管是起点而非终点。

ECFI:传统 CFI 与时序的合流

最后是 ECFI,面向工业控制系统的计算单元——可编程逻辑控制器(Programmable Logic Controller,PLC)。它是一个折中方案:对有指针调用的代码用细粒度 CFI,否则用粗粒度;同时利用硬实时系统的高可预测性,检测”执行时间突增”来决定何时需要做 CFI 检查;执行期间把控制流数据捕获到全局影子栈,然后在低优先级进程里异步检查——这就是”异步 CFI”名字的由来。它同时用到了传统手段和时间维度,可以看作是 RECFISH 式机制与时序监控的合体。

实时 CFI 的总结

综述在这一节末尾的观察很尖锐:

  1. 考虑时序约束的技术严重稀缺
  2. 没有任何技术处理系统过载情形,也没有利用空闲时间(slack)摊销 CFI 开销的方案——把 CFI 操作推迟到 slack 区间执行,本可以显著降低在线开销,但没人做。
  3. Hao 等人的选择性方案在安全上是残缺的(只检查部分代码),Bellec 等人的时序方案有检测延迟问题。真正”既完整又实时友好”的 CFI 依然是空白
  4. ECFI 隐式信任调度器的完整性——而在更强的威胁模型里,攻击者可能篡改系统定时器扭曲调度器的时间感(相关防御如 TimeSeal)。这是个被忽视的软肋。

开放挑战与全文总结

综述最后给出了一张分类总表(表 2)和两组开放挑战。先把全景拼起来:

分类技术与一句话总结
标准实现:在不同架构上应用标准 CFI 技术Silhouette——ARM Cortex-M 上的影子栈+二进制标签;RECFISH——ARM Cortex-R 上的影子栈+标签
设计变更:利用标准控制流端点的非标准技术CFL——懒惰式控制流评估,前后向边一套机制;μRAI——用 XOR 把影子栈折叠进单个寄存器;Zipper Stack——定制硬件用 HMAC 把影子栈折叠进单个寄存器
现代硬件架构:利用新处理器特性CFI CaRE——影子栈藏进 ARM TrustZone;TZmCFI——支持嵌套中断(RTOS 感知)的 TrustZone 影子栈;PACStack——用 ARM 指针认证(ARMv8.3-A)把影子栈折叠进单寄存器
底层原理:用非标准原理检测控制流偏离时序偏离——用定制硬件检测代码段 WCET 违规;ECFI——面向 PLC,运行时检测时序违规

再补一张关键机制的全景对照(由综述表 1 整理):

机制前向-细粒度前向-粗粒度后向边机制要点
BBB-CFI基本块强制——仅需二进制,无需 CFG
PathArmor上下文敏感——需要架构支持
Silhouette影子栈+标签
控制流锁定 CFL懒惰检查 + 影子栈替代
μRAI / Zipper Stack / PACStack基于寄存器的影子栈替代(μRAI 还处理中断)
CFI CaRE / TZmCFI基于 ARM TrustZone 的影子栈;TZmCFI 支持嵌套中断、威胁模型更强
HCFI新 ISA,影子栈操作融入处理器流水线
RECFISH对 RTOS 应用常见 CFI 技术的大规模可调度性研究
选择性 CFI(Hao 等)搜索可开启 CFI 的任务 job 数量以保可调度性
时序偏离检测用超出预算的计算时间检测控制流偏离
ECFI面向硬实时 PLC 的 CFI,检测执行时间异常增长

(空格表示该机制不覆盖此类边。)

实时社区的开放挑战

  1. 有界懒惰(Bounded Laziness)。懒惰机制(CFL、时序检测)的问题不是懒,而是威胁模型没有表达”攻击者最迟什么时候能造成伤害”。实时系统天然有一组离散且已知的时间点必须产生输出——比如 I/O 控制器周期性扫描传感器。那么 CFI 完全可以推迟:转移事件先记录,在系统发出执行器命令等输出之前完成验证。这样既保留了”在伤害发生前抓住攻击者”的 CFI 本质,又把检查开销从在线路径上挪走了。这是综述认为最有潜力的方向——现有所有技术都在控制流转移的当下内联检查,白白抬高 WCET。
  2. 多线程/多核调度。把有界懒惰扩展到多核系统:可以让部分核专职执行 CFI 操作。已有把安全操作抽象为”安全任务”研究其可调度性的框架(Contego),但没有显式考虑安全操作的完成时限。
  3. 确定 CFI 相关的负载属性。上述方向都需要回答:CFI 操作自身的 WCET 是多少?它如何融入变周期任务、混合关键性等实时模型?尤其对依赖历史控制流数据的上下文敏感 CFI,运行时数据量可变,WCET 很难准确刻画。

通用挑战

  1. 功耗。所有被综述的 CFI 设计没有一个报告功耗。对电池供电、野外部署的设备(想想心脏起搏器!),影子栈这种高频访存机制对功耗的影响必须是设计输入。综述建议研究粗粒度 CFI 与功耗的关系(检查少→功耗低),以及用 DVFS(动态电压频率调整)配合懒惰调度形成”功耗-可调度性协同设计”。
  2. 可移植性。嵌入式硬件碎片化严重(ARM、RISC-V、MIPS、定制架构并存),且 ARM 的高度模块化让厂商随意增删特性。Silhouette 那种绑死 ARMv7-M 特性的方案难以移植。需要尽量纯软件、最少硬件依赖的可移植 CFI 设计——但开销如何,还是未知数。
  3. 高级 CFI 与未来威胁。实时嵌入式系统上还没有任何上下文敏感 CFI(能防”控制流弯曲”的那类),这是空白。更大的空白是面向数据攻击(DOP)——不改控制流、只改数据(如循环计数器)的攻击,本文所有 CFI 都防不住;时序检测或许能间接发现,但前提是攻击者不超 MAW。数据攻击已被证明能影响程序输出、泄露隐私,值得嵌入式社区警惕。

结语

综述的结论很冷静:CFI 在高端系统已经工业落地,但在资源受限嵌入式系统上的设计大多停留在学术阶段,部分方案的性能开销不可接受。硬件约束带来的开销无法消除,但懒惰性这类”用检测速度换开销”的思路,配合实时系统天然的时序可预测性,可能正是打开局面的钥匙。对初学者来说,这篇综述给出的最重要的心智模型是:安全不是免费的,尤其在连”按时跑完”都困难的小设备上——每一分安全都是用性能、内存、功耗或检测延迟换来的,好的设计就是把这些交换做到最优。

通关标准

读完本文,你应该能独立回答:① 后向边和前向边攻击分别是什么,防御手段为何不同?② 影子栈为什么在低端 MCU 上”藏不住”,Silhouette、CFI CaRE、μRAI 分别用什么思路解决?③ RECFISH 的实验揭示了 CFI 与实时调度之间的什么矛盾?④ “有界懒惰”为什么被认为是实时 CFI 最有前景的方向?四题全过,这篇综述你就真正消化了。

自测题

此文件夹下有0条笔记。