
现代处理器通过一种名为“推测执行”的赌博来实现惊人的速度,它们猜测程序将要执行的路径并提前执行指令。然而,这种提升性能的策略创造了一个“瞬态执行”的阴影领域——这些操作被执行了,但从未正式提交到程序的最终状态。本文深入探讨了这一现象的深刻双重性,揭示了当那些看似被丢弃的瞬态指令留下了可观察的痕迹时,所产生的关键安全缺口。通过探究其核心原理和机制,我们将揭示这只“机器中的幽灵”如何导致像 Meltdown 和 Spectre 这样毁灭性的漏洞。随后,在“应用与跨学科关联”一节中,我们将考察这一硬件特性如何颠覆从算法性能到操作系统安全的方方面面,迫使整个计算堆栈进行一次协同的重新思考。
要理解瞬态执行的世界,我们必须首先领会现代处理器核心所达成的一项基本交易——一项以速度之名订立的契约。想象一位在高压厨房中工作的主厨。为了准时呈上一桌复杂的多道菜大餐,厨师不可能等到汤被喝完才开始准备主菜。他们会提前工作,在开胃菜还在炖煮时,就开始为烤肉切菜。这种并行性就是流水线处理器的精髓。但我们的厨师更聪明。菜单上有一个选择:惠灵顿牛排或素食千层面。等待食客点餐太慢了。于是,厨师知道 95% 的食客会点惠灵顿牛排,便做出了一个有根据的猜测,并开始准备它。这就是推测执行。
如果猜测正确,就能节省大量时间。厨房的运作效率将惊人地高。如果猜错了,厨师必须丢弃半成品的惠灵顿牛排,迅速转向制作千层面。这有成本——浪费了精力和食材——但赌赢的次数远比赌输的次数多。现代中央处理器 (CPU) 正是如此。当它们遇到一个条件分支(代码中的“if-then-else”)时,它们不会干等着。它们使用一个复杂的分支预测器来猜测程序将走哪条路径,并推测性地执行该路径下的指令。其性能增益非同小可;例如,将预测器的错误率从 20% 降低到仅 5%,就能显著缩短程序的总执行时间,因为一次错误猜测的代价(预测错误惩罚)远大于一次正确猜测所节省的时间。正是这种对性能的不懈追求,才催生了推测执行。这是一种制胜策略,但这个策略有一个微妙而深远的后果。
要掌握瞬态执行的本质,你不能将处理器视为一个单一实体,而应看作两个共存的独立世界。一个是架构师的世界,另一个是工程师的世界。
架构状态 () 是程序员所看到的世界,由指令集架构 (ISA) 定义。这是一个秩序井然、逻辑严谨的世界。指令一条接一条地执行,结果被保存,程序以一个可预测的、顺序的故事进行。这就像一场由观众观看的、排练完美的戏剧:每个演员都按顺序说出台词,场景按时切换,叙事连贯。
而微架构状态 () 则是工程师的世界。它是戏剧的后台——一个受控的混乱领域。在这里,指令乱序执行,结果被 juggling(灵活处理),多个推测路径可能被同时探索。舞台工作人员(执行单元)正疯狂地搬运道具(数据),演员们(指令)则在提示到来之前早已在侧翼准备就绪。从像 Flynn 分类法这样的形式化视角来看,一个同时推测执行两条不同程序路径的处理器在后台可能看起来像一台多指令流多数据流()机器。但由于观众只能看到那条唯一正确的、被提交到最终故事中的路径结果,因此其架构视图仍然是一台简单的单指令流单数据流()机器。
处理器最神圣的承诺是,后台的混乱绝不会破坏台上的戏剧。这个保证被称为精确异常。如果一条指令导致错误——比如演员在舞台上绊倒了——戏剧会在那一刻精确停止。架构状态被冻结,仿佛所有先前的指令都已完美完成,而失败的指令及其后的所有指令从未发生过。这种清理是绝对的。当发现分支预测错误或外部中断到达时,所有推测性工作——那些半成品的惠灵顿牛排——都会被毫不客气地从内部结构(如重排序缓存 ROB)中丢弃,这个过程称为冲刷流水线。从架构上看,就好像推测性工作从未存在过一样。
那么,如果架构状态总是保持纯净,问题出在哪里呢?问题在于,后台完成的工作,即使被丢弃,也不是无声的。它会留下痕迹。厨师扔掉了牛排,但平底锅还是热的。切牛肉用过的刀现在是脏的。这些挥之不去的影响,就是对微架构状态的改变。
这些就是侧信道。一个站在厨房外的攻击者看不到被丢弃的食材(机密数据)。但他们可以设计巧妙的方法来测量留下的痕迹。例如,他们可以要厨师刚刚用过的那个平底锅。如果立即就递过来,那它肯定就在附近,甚至可能还是温的。如果花了一段时间,说明厨师得去橱柜里取。通过对这个简单请求计时,攻击者就能了解到一些关于厨师隐藏的、推测性行为的信息。
在 CPU 中,最著名且被广泛利用的微架构痕迹留在数据缓存中。缓存是一个小型的、超高速的存储器,CPU 在其中存放最近使用过的数据。当处理器推测性地执行一次内存加载时,它会获取数据并将其放入缓存,以加速后续访问。关键在于,当推测路径被冲刷时,架构上的结果被丢弃,但缓存通常不会回滚。数据像幽灵一样留在那里。攻击者随后可以对自己的内存访问进行计时。一次快速的访问意味着数据在缓存中(缓存命中),而一次缓慢的访问则意味着数据不在(缓存未命中)。这种时间差异,即所谓的缓存侧信道,让攻击者能够了解在瞬态执行期间哪些内存位置被访问过。
这就是漏洞的核心:瞬态指令虽然永不退役(retire),但仍然可以修改微架构状态(),而这些修改可以被观察到,从而泄露本应保密的信息。
瞬态执行攻击并非千篇一律。通过观察它们两个最著名的变体,可以最好地理解它们,这两种变体以根本不同的方式利用了“后台的混乱”。
Meltdown 是一个纯粹由“不耐烦”导致的漏洞。在计算机中,一个基本的安全规则是用户程序与操作系统核心(内核)之间的隔离。在低特权模式(ring 3)下运行的用户程序被禁止读取受高特权模式(ring 0)保护的内核内存。
想象一下,一个用户程序中的指令试图从一个受保护的内核地址读取数据。从架构上讲,这是非法的,必须引发一个故障。但如果 CPU 在其乱序、推测的匆忙中,在权限检查完全完成之前就执行了这次加载呢?这正是在受 Meltdown 影响的处理器中发生的情况。在短暂的瞬间,出现了一个竞争条件:数据从内存中被取出,并可供后续的瞬态指令使用,而此时处理器的安全电路还未发出警报。
当然,架构的承诺得到了维护。当该指令试图退役时,CPU 发现它被标记为故障,于是冲刷该操作,并引发一个页错误异常。程序看到的是它应该看到的行为:一个保护错误。但为时已晚。在推测性数据获取和架构性故障之间的微小窗口期内,依赖于该秘密内核数据的瞬态指令已经使用了它——例如,用它来访问一个数组中的某个位置。这个动作在数据缓存中留下了一个清晰的足迹,攻击者随后可以测量这个足迹。因此,Meltdown 是对一个故障指令上延迟的权限检查的利用;它不需要欺骗分支预测器,只需要一次非法的加载即可。
相比之下,Spectre 是一种欺骗 CPU 滥用其自身预测能力的攻击。它不涉及执行本质上非法的指令;相反,它胁迫处理器去推测性地执行一段完全合法的指令序列,但在一个本不该执行的上下文中。
最著名的变体——边界检查绕过(Bounds Check Bypass),针对的是访问数组的代码。一个安全的程序在访问 array[i] 之前会检查索引 i 是否在数组边界内。这个检查是一个条件分支。攻击者可以通过重复使用有效索引调用该函数来“训练”CPU 的分支预测器。然后,在攻击时,他们提供一个越界索引。被训练所迷惑的分支预测器会猜错,并推测该索引是在边界内的,从而瞬态执行从 array[i] 的加载。
这个 i 可以由攻击者控制,并且其本身可以来源于机密数据。越界访问从受害者的内存中读取了一块机密数据,而后续的瞬态指令使用该机密数据去访问第二个由攻击者控制的数组,在缓存中留下足迹。当 CPU 最终解析该分支并意识到自己的错误时,它会冲刷掉推测性工作。但缓存已被修改,秘密也已泄露。因此,Spectre 是对控制流预测错误的利用。它的工作原理是找到并操纵受害者地址空间中的一个“gadget”——一段有用的代码——并欺骗 CPU 用恶意输入来瞬态执行它。
这些攻击的成功取决于处理器流水线内部一场精密的竞赛。泄露信息的瞬态指令必须在分支预测错误或故障被解决、流水线被冲刷之前,完全执行并留下其微架构痕迹。
有人可能会认为,如果一条瞬态指令依赖于一个非常慢的操作,比如整数除法,它赢得这场竞赛的可能性就会降低。然而,情况更为微妙。一个瞬态 gadget 得以执行的“机会窗口”取决于该 gadget 可以运行的时间点与流水线冲刷发生的时间点之间的时间差。如果分支解析本身也依赖于同一个长延迟操作,那么攻击路径和清理信号就会被一同延迟。这个机会窗口不一定会变大;它甚至可能缩小或保持不变,这取决于微架构内部错综复杂的数据依赖和控制路径。这凸显了瞬态执行漏洞不仅仅关乎推测,更关乎处理器深处纳秒级事件的精确时序。
我们如何防御那些利用高性能设计本质的攻击呢?我们不能简单地关闭推测执行而不牺牲数十年的性能成果。解决方案是提供更细粒度的控制——在代码的关键点竖起“藩篱”。
推测藩篱 (speculation fence) 是一种特殊的指令,在流水线中充当红灯。当处理器遇到一个藩篱时,它被禁止推测性地执行任何后续指令,直到所有更早的、不确定的操作(如条件分支)完全解析完毕。在流水线中,这意味着将较年轻的指令保持在解码阶段,防止它们在错误的路径上进入执行或内存阶段。
这提供了一种直接而有效的缓解措施。为了挫败 Spectre 的边界检查绕过攻击,编译器可以在边界检查分支之后、内存访问之前插入一个加载藩篱 (LFENCE)。这告诉 CPU:“在任何情况下,都不要执行这次加载,直到你完全确定分支预测是正确的。”类似地,为了防止另一种变体,即加载操作推测性地绕过一个更早的、对同一地址的存储操作,可以插入一个推测性存储绕过屏障 (SSB barrier),以强制加载等待存储完成。这些藩篱允许程序员和编译器在敏感代码段中有选择地用少量性能换取安全保证,从而恢复架构世界与微架构世界之间壁垒的完整性。
归根结底,瞬态执行现象是执行与退役解耦所带来的深远后果。它源于架构师所承诺的简单、顺序的世界 () 与工程师所构建的复杂、混乱的现实 () 之间的二元性。即使是一个假设的、能够乱序提交结果到架构状态的处理器,也无法改变这个基本事实。只要执行能够领先于最终验证,在微架构状态中留下痕迹,来自后台的低语的潜在可能性就依然存在。这是一场秩序与混乱、性能与安全之间美丽而复杂的舞蹈,它将继续定义未来多年处理器设计的前沿。
在窥探了瞬态执行错综复杂的机制之后,我们可能会感到一丝惊奇。这是一种源于对性能不懈追求的特性,它允许处理器洞察未来,在甚至不确定指令是否在正确路径上时就执行它们。这就像拥有一个惊人地敏捷和主动的助手。你正要开口从书房要一本书,话音未落,助手已经根据你之前的请求猜到你想要哪本,冲过去把它准备好了。当猜测正确时,速度令人叹为观止。
但如果猜错了呢?助手意识到错误,匆忙将书放回原处。看起来似乎没有造成任何伤害。架构状态——你最终正式拿到的那本书——是正确的。但即使只是片刻,取错书的行为也留下了痕迹。桌子上留下了它放过的淡淡压痕,书架上的灰尘被轻微扰动。这就是瞬态执行的世界:一个计算的幽灵,一个稍纵即逝的微架构变化,就可能泄露秘密。这种双重性——既是卓越的性能技巧,又是微妙的安全缺陷——已在整个计算机科学领域掀起涟漪,迫使我们重新发现那些我们曾以为泾渭分明的学科之间深刻的联系。
首先,让我们欣赏瞬态执行在其预期角色中的绝顶聪明之处:让事情变得更快。考虑一个简单的任务:在一个庞大的、已排序的列表中查找一个数字。计算机科学家会立刻指出二分搜索是最高效的算法。它有得到保证的对数时间复杂度 ,意味着它能在大约 20 步内在一个包含一百万个条目的数组中找到一个项目。另一种方法,跳跃搜索,则不那么出名。它以固定的步幅跳跃遍历数组,一旦越过目标,就向后进行线性扫描。它的复杂度更差,约为 。
在纸面上,二分搜索是无可争议的冠军。但在现代处理器上,这场竞赛就没那么简单了。二分搜索是混乱的;它的内存访问在数组中不可预测地到处跳跃,导致 CPU 等待从主内存中获取数据时出现长时间延迟。相比之下,跳跃搜索则非常可预测。它的主循环以规则的、顺序的步幅访问内存。具有推测执行能力的处理器看到这个模式后会想:“啊哈!我知道你下一步要去哪里了!”它会在数据被请求之前,就开始将下一个内存位置预取到其高速缓存中。这种推测性工作,这种洞察力,极大地减少了内存延迟。结果是惊人的:对于某些大型数组,“较慢”的跳跃搜索在现实世界中实际上可以胜过“较快”的二分搜索。这是一个绝佳的例子,说明了对硬件行为的深刻理解如何颠覆我们纯粹的算法直觉。瞬态执行不仅是运行代码;它从根本上改变了算法竞争的性能格局。
正是这种实现性能魔法的机制,也成为了其危险的根源。核心问题在于计算领域最神圣的契约之一的瓦解:指令集架构 (ISA) 与微架构之间的抽象屏障。ISA 是程序员眼中的世界——一个由寄存器、内存和依次执行的指令组成的世界。微架构则是其背后纷繁复杂的现实,由流水线、预测器和缓存等构成,以实现这一切。我们长期以来相信,只要微架构能产生正确的最终架构结果,其内部的混乱就是它自己的事。瞬态执行以一种惊人的方式证明了这个假设是错误的。
最直接的后果是,简单而普遍的编程结构可能变得具有泄漏性。考虑一个标准的边界检查:if (index array_size) { ... }。这是一个控制依赖,一个确保程序只访问其应有权限内存的看门人。但是,一个经过数百万次有效索引实例训练的分支预测器,可能会推测性地假设即使在索引无效时,这个检查也会通过。在短短几纳秒内,处理器会冲过这个关卡,执行内部的代码,这可能涉及使用一个秘密值来访问一个数组。这次推测性访问在处理器的缓存中留下了足迹。即使在处理器意识到错误并从架构上冲刷该操作之后,缓存状态仍然被改变了。恶意程序随后可以通过计时内存访问来探测缓存,发现该足迹,并反向工程出制造它的秘密。简单的 if 语句,这个逻辑的基石,已被武器化为一个泄露信息的 "gadget"。
冯·诺依曼架构是现代计算的一个基本概念,它指出指令和数据共同存在于同一内存中。我们很少思考其含义,但瞬态执行以一种诡异的方式将它们带到了最前沿。因为代码和数据不仅共享内存,还共享缓存,一次推测性的数据加载可能会干扰随后的指令获取。
想象一下,攻击者精心布置内存,使一个依赖于秘密的数据地址与他们想要计时的某段代码的地址发生冲突。对该数据地址的推测性加载将把代码从共享缓存中驱逐出去。当攻击者稍后尝试执行那段代码时,处理器会发现它不在缓存中,导致长时间的延迟。被当作数据操纵的秘密值,投下了一个在代码执行时间上可观察到的阴影。这是 CPU 内部一种“幽灵般的超距作用”,数据世界在指令世界上留下了鬼魅般的指纹,而这一切都由内存的统一性与执行的推测性所促成。
也许最令人担忧的是,瞬态执行可以在操作系统的最基本安全边界上打洞。处理器有特权级别,通常是一个受高度保护的“监管者”或“内核”模式和一个受限的“用户”模式。这种分离是系统安全的基石,防止常规程序干扰操作系统或彼此。然而,一些推测执行攻击可以欺骗处理器,使其使用用户级攻击者提供的地址来瞬态执行内核级指令。在短暂的瞬间,一个用户程序可以推测性地从内核最机密的内存中读取数据,在缓存中留下痕迹,这些痕迹随后可被分析。
这一原理也适用于其他预测性结构。例如,处理器使用返回栈缓冲区 (RSB) 来预测 RET 指令的目标。通过操纵调用栈,攻击者可以使 RSB 不同步,导致它提供一个错误的返回地址。CPU 随后可能会推测性地“返回”到攻击者选择的一个 gadget,瞬态执行它,并在预测错误被捕获之前可能泄露信息。处理器为速度而设计的自身预测机制,变成了颠覆的管道。
这些漏洞的发现是一个分水岭。它揭示了硬件、操作系统、编译器、算法这些清晰的抽象层次,其实并非如此分明。解决或至少管理这个问题,需要所有这些学科之间前所未有的协同努力。
几十年来,编译器的任务是将人类可读的代码翻译成高效的机器指令,很大程度上忽略了 CPU 的微架构细节。那个时代结束了。现代编译器编写者现在必须像安全工程师和硬件架构师一样思考。
一个强大的工具是推测屏障。编译器现在可以插入特殊指令(如 x86 上的 lfence),告诉处理器:“停下。在所有先前工作完成之前,不要执行此点之后的任何内容,即使是推测性的。”在关键的边界检查之后放置这样一个屏障,可以有效地关闭 Spectre 式攻击的机会窗口。
另一种更深刻的方法是生成数据无关代码。编译器可以将代码转换成访问所有可能的位置,而不是基于一个秘密访问单个内存位置,并使用无分支的算术掩码来选择正确的值。内存访问的模式变得与秘密无关,时序信道也随之消失。
即使是经典的优化也必须被重新评估。边界检查消除 (BCE),即编译器证明一个循环的访问总是安全的,从而移除冗余的 if 检查,曾经是一个纯粹的性能胜利。现在,它有了安全维度。移除分支也消除了其被错误预测的可能性,这是好事!这意味着 BCE 可以是一种强大的缓解措施,但它也突显了编译器现在不仅要分析代码的语义正确性,还要分析其微架构安全影响。
瞬态执行的影响一直延伸到计算机科学的理论基础。例如,Peterson 算法是确保两个并发线程之间互斥的一个经典、优雅的算法。在顺序一致性的理想化模型下,它是可证明正确的。然而,在具有弱内存排序和推测执行的现代处理器上,它会失败。一个线程可以推测性地读取共享变量的陈旧值,导致它错误地认为可以进入一个已被另一个线程占用的临界区。使其工作的唯一方法是插入显式的内存屏障,这迫使硬件尊重算法逻辑所依赖的顺序。
这延伸到硬件级的原子原语。加载链接/条件存储 (LL/SC) 指令对是无锁数据结构的基本构建块。然而,一个处理器核心上的推测性存储可以发送一个一致性消息,使另一个核心从加载链接操作中持有的“预留”失效,导致其后续的条件存储失败。一个核心的瞬态、非提交行为对另一个核心产生了真实的、可感知的影响,使本已困难的多处理器编程世界变得更加复杂。
瞬态执行所做的不仅仅是创造了一类新的安全漏洞;它粉碎了我们对计算堆栈舒适的分层看法。它揭示了一个深刻、微妙、有时甚至诡异的世界,其中我们算法的逻辑与执行它们的硅片物理现实之间存在着相互作用。通过迫使硬件架构师、操作系统设计者、编译器编写者和算法理论家共同面对这些挑战,它锻造了一种对我们所构建系统全新的、更整体的理解。那个能猜到我们一举一动的助手可能偶尔会犯错,但在此过程中,它让我们对自己房子的本质有了前所未有的了解。