【问题标题】:The inner workings of Spectre (v2)Spectre (v2) 的内部工作原理
【发布时间】:2019-06-29 16:05:09
【问题描述】:

我已经阅读了一些有关 Spectre v2 的内容,显然您得到了非技术性的解释。 Peter Cordes 有更深入的explanation,但它并没有完全解决一些细节。注意:我从未执行过 Spectre v2 攻击,因此我没有动手经验。我只阅读了有关该理论的内容。

我对 Spectre v2 的理解是您做出了间接分支错误预测,例如 if (input < data.size)。如果间接目标阵列(我不太确定细节——即为什么它与 BTB 结构分开)——在解码时重新检查间接分支的 RIP——不包含预测,那么它将插入新的跳转 RIP(分支执行最终将插入分支的目标 RIP),但目前它不知道跳转的目标 RIP,因此任何形式的静态预测都不起作用。我的理解是,它总是会预测新的间接分支不会被采用,当端口 6 最终计算出跳转目标 RIP 和预测时,它将使用 BOB 回滚并使用正确的跳转地址更新 ITA,然后更新本地和全局分支历史寄存器和相应的饱和计数器。

黑客需要训练饱和计数器以始终预测采取哪些,我想,他们通过在循环中多次运行if(input < data.size) 来做到这一点,其中input 设置为确实小于data.size(相应地捕获错误)并在循环的最后一次迭代中,使input 大于data.size(例如1000);间接分支将被预测并跳转到发生缓存加载的 if 语句主体。

if 语句包含secret = data[1000](包含秘密数据的特定内存地址 (data[1000]) 旨在从内存加载到缓存),然后将推测性地分配给加载缓冲区。前面的间接分支还在分支执行单元中等待完成。

我相信前提是需要在加载缓冲区因错误预测而被刷新之前执行加载(分配一个行填充缓冲区)。如果它已经被分配了一个行填充缓冲区,那么什么也做不了。没有取消行填充缓冲区分配的机制是有道理的,因为行填充缓冲区在将其返回到加载缓冲区之后必须在存储到缓存之前挂起。这可能会导致行填充缓冲区变得饱和,因为不是在需要时解除分配(将其保留在那里以加快其他加载到同一地址的速度,但在没有其他可用行缓冲区时解除分配)。在接收到刷新不会发生的信号之前,它将无法解除分配,这意味着它必须停止执行前一个分支,而不是立即使行填充缓冲区可用于存储另一个逻辑核心。这种信号机制可能难以实现,也许他们没有想到(幽灵之前的想法),如果分支执行需要足够的时间来挂行填充缓冲区导致性能影响,它也会引入延迟,即如果data.size 在循环的最终迭代之前被有意从缓存 (CLFLUSH) 中刷新,这意味着分支执行可能需要多达 100 个周期。

我希望我的想法是正确的,但我不能 100% 确定。如果有人有什么要补充或更正的,请做。

【问题讨论】:

  • 如您所知,Spectre v1 和 v2 利用 BP,细节在于 BP 的工作方式。我的印象是 v2 通过毒化 BP(通过利用并非所有 jmp 地址都用于在 BTB 中索引的事实)而不是通过错误训练它们(通过使用有效输入调用函数)来工作)。当 CPU 需要刷新管道时,它可能会等待所有正在执行的微指令(即已经分派)完成(包括需求负载),我认为它不会中途停止微指令,因为可能会有一些状态管理。所以分派负载就足够了。

标签: x86 intel cpu-architecture branch-prediction spectre


【解决方案1】:

感谢 Brendan 和 Hadi Brais,在阅读了您的答案并最终阅读了幽灵论文之后,现在很清楚我的想法哪里出了问题,我有点混淆了两者。

我部分描述了 Spectre v1,它通过将跳转的分支 history (即if (x < array1_size))误训练到一个幽灵小工具来绕过边界检查。这显然不是间接分支。黑客通过调用包含具有合法参数的幽灵小工具的函数来启动分支预测器 (PHT+BHT),然后使用非法参数调用以将array1[x] 带入缓存来实现此目的。然后他们通过提供合法参数来重新设置分支历史记录,然后从缓存中刷新array1_size(我不确定他们是如何做的,因为即使攻击者进程知道array1_size 的 VA,也无法刷新该行,因为 TLB包含该进程的不同 PCID,因此必须以某种方式将其驱逐,即填充该虚拟地址处的集合)。然后,它们使用与以前相同的非法参数进行调用,因为array1[x] 在缓存中,但array1_size 不在,array[x] 将快速解析并开始加载array2[array1[x]],同时仍在等待array1_size,这会加载一个在array2 中的位置基于任何x 处超出array1 边界的秘密。然后攻击者用有效值 x 调用函数并乘以函数调用(我假设攻击者必须知道array1 的内容,因为如果array2[array1[8]] 导致更快的访问,他们需要知道array1[8] 的内容因为这是秘密,但该数组肯定必须包含每个 2^8 位组合权利)。

另一方面,Spectre v2 需要第二个攻击进程,该进程知道受害进程中 indirect 分支的虚拟地址,以便它可以毒害 目标 并替换它与另一个地址。如果攻击过程包含一个跳转指令,该指令将驻留在 IBTB 中与受害者间接分支相同的集合、方式和标记中,那么它只会训练该分支指令以预测被占用并跳转到恰好是受害者进程中的小工具。当受害进程遇到间接分支时,来自攻击程序的错误目标地址在 IBTB 中。至关重要的是,它是一个间接分支,因为通常在解码时检查由于进程切换导致的错误,即如果分支目标与该 RIP 的 BTB 中的目标不同,则它会刷新在它之前获取的指令。这不能用间接分支完成,因为它直到执行阶段才知道目标,因此这个想法是选择的间接分支取决于需要从缓存中获取的值。然后它跳转到这个小工具的目标地址等等。

攻击者需要知道受害者进程的源代码来识别一个小工具,并且他们需要知道它将驻留的 VA。我认为这可以通过可预测地知道代码的加载位置来完成。例如,我相信 .exe 通常在 x00400000 加载,然后在 PE 标头中有一个 BaseOfCode。


编辑:我刚刚阅读了 Spectre 论文的附录 B,它为 Spectre v2 提供了一个不错的 Windows 实现。

作为概念验证,我们构建了一个简单的目标应用程序,该应用程序提供计算密钥和输入消息的 SHA1 哈希的服务。该实现由一个程序组成,该程序连续运行一个调用 Sleep(0) 的循环,从文件加载输入,调用 Windows 加密函数来计算散列,并在输入更改时打印散列。我们发现Sleep() 调用是使用来自寄存器 ebx、edi 中输入文件的数据和攻击者已知的 edx 值完成的,即两个寄存器的内容由攻击者控制。这是本节开头所述的 Spectre 小工具类型的输入条件。

它使用ntdll.dll(充满原生API系统调用存根的.dll)和kernel32.dll(Windows API),它们总是映射在ASLR方向上的用户虚拟地址空间(在.dll图像中指定) ,除了物理地址很可能是相同的,因为写入时复制视图映射到页面缓存。中毒的间接分支将在kernel32.dll 中的Windows API Sleep() 函数中,该函数似乎间接调用ntdll.dll 中的NtDelayExecution()。然后,攻击者确定间接分支指令的地址,并将包含目标地址的受害者地址的页面映射到它自己的地址空间,并将存储在该地址的目标地址更改为他们识别为驻留在某处的小工具的地址在ntdll.dll 的相同或另一个函数中(我不完全确定(由于ASLR)攻击者如何确定受害者进程在其地址空间中映射kernel32.dllntdll.dll 以定位地址Sleep() 中为受害者提供的间接分支。附录 B 声称他们使用“简单指针操作”来定位包含目标的间接分支和地址——我不确定它是如何工作的)。然后以与受害者相同的亲和力启动线程(以便受害者和错误训练线程在同一物理内核上超线程),它们自己调用Sleep() 来间接训练它,在黑客进程的地址空间上下文中现在将跳转到小工具的地址。该小工具暂时替换为ret,以便它顺利地从Sleep() 返回。这些线程还将在间接跳转之前执行一个序列,以模拟受害者在遇到间接跳转之前的全局分支历史,以完全确保分支是在合金历史中采用的。然后使用受害者的线程亲和性的补充启动一个单独的线程,该线程重复驱逐包含跳转目标的受害者的内存地址,以确保当受害者确实遇到间接分支时,它需要长时间的 RAM 访问才能解决,这允许在根据 BTB 条目检查分支目标并刷新管道之前,小工具可以提前推测。在 JavaScript 中,驱逐是通过加载到相同的缓存集(即 4096 的倍数)来完成的。在这个阶段,训练错误线程、驱逐线程和受害者线程都在运行和循环。当受害进程循环调用Sleep() 时,间接分支推测该小工具是由于黑客先前中毒的 IBTB 条目。探测线程与受害进程线程亲和性的补充一起启动(以免干扰训练错误和受害分支历史)。当调用Sleep() 时,探测线程将修改受害进程使用的文件头,从而导致这些值驻留在ebxedi 中,这意味着探测线程可以直接影响存储在ebxedi。示例中分支到的幽灵小工具将存储在[ebx+edx+13BE13BDh] 中的值添加到edi,然后在存储在edi 中的地址处加载一个值,并将其与一个进位相加到dl。这允许探测线程学习存储在[ebx+edx+13BE13BDh] 中的值,就好像它选择了一个原始的edi 0,然后在第二个操作中访问的值将从虚拟地址范围0x0 – 0x255 加载,此时间接分支会解决,但副作用已经存在。攻击进程需要确保它已将相同的物理地址映射到其虚拟地址空间中的相同位置,以便使用定时攻击探测探测阵列。不确定它是如何做到的,但在 Windows 中,AFAIK 需要映射受害者在该位置打开的页面文件支持的部分对象的视图。要么这样,要么它会操纵受害者调用带有负 TC ebx 值的幽灵小工具,例如 ebx+edx+13BE13BDh = 0=1、...、=255 并以某种方式计时。这也可能通过使用 APC 注入来实现。

【讨论】:

  • 一个大的memcpy(尤其是如果你避免使用 NT 存储)会破坏大量缓存,至少在 L1 / L2 中是这样。 (在 L1 中混叠很容易,因为索引来自页面偏移量)。英特尔 L3 因为 IvyBridge 使用自适应替换策略,所以潜在地循环一个巨大的阵列并不会破坏所有 L3。 blog.stuffedcow.net/2013/01/ivb-cache-replacement
  • @PeterCordes 我更新了针对具体示例的答案(附录 B)。我不确定一些微妙的事情。
  • 您的摘要看起来不错。在同一个物理内核上攻击另一个用户空间进程(而不是内核)可能是最好的情况,因为您可以将错误训练代码映射到完全相同的虚拟地址。 (而不是试图找到一个与内核中的分支别名的地址。我猜想用(高半)内核地址训练 IBTB 意味着你会在另一个错误预测的阴影下做这件事,可能使用 call/ret,所以你的训练代码实际上并没有出错。我没有研究如何实际攻击没有缓解的内核的细节。)
  • 哦,使用超线程进行攻击可能意味着内核 Spectre 缓解根本没有帮助,就像如果您通过管道或其他东西将数据提供给另一个进程时它会跨上下文切换一样。 VM 管理程序应该将物理核心的两个/所有逻辑核心都提供给同一个来宾的另一个原因。理论上,IBTB 条目可以按物理内核标记,以避免跨线程混叠,您可能会认为这会损害性能。或者可能使用 PCID;这可能会让同一进程的线程共享 IBTB 条目,这可能是好是坏取决于工作负载。
  • re:将相同的物理页面映射为攻击目标:是的,您需要这样的时间侧通道来寻找一个高速缓存行是热的而其他缓存行是冷的。但是,相反,您可以启动所有缓存行并寻找因冲突未命中而被驱逐的缓存行。在 L1d 中,这只需要在任何页面内具有相同的偏移量。如果您可以可靠地检测 L1d 命中与未命中,则一切就绪(可能使用 RDTSC,但信噪比不高)。在 L2 中,Nehalem .. Broadwell 有 256k 8 路 L2,因此 32kiB 的物理偏移量为同一个集合。大页面 + 一些试验/错误?
【解决方案2】:

对于分支,有些类似于jc .somewhere,CPU 只需要真正猜测分支是否会被采用,就能够推测出猜测的路径。但是,有些分支就像jmp [table+eax*8],可能有超过 40 亿个可能的方向,在这些情况下,CPU 需要猜测目标地址才能推测出猜测的路径。因为分支的类型非常不同,CPU 使用的预测器类型也非常不同。

对于 Spectre,有一个“元模式”——攻击者使用推测执行来欺骗 CPU 将信息留在某物中,然后从某物中提取该信息。 “某事”有多种可能性(数据缓存、指令缓存、TLB、分支目标缓冲区、分支方向缓冲区、返回堆栈、写入组合缓冲区……),因此幽灵有许多可能的变体(而不仅仅是2018 年初公开的“众所周知的前两个变体”)。

对于 Spectre v1(其中“某物”是数据缓存),攻击者需要某种方法来欺骗 CPU 将数据放入数据缓存中(例如加载,然后根据第一次加载的值进行第二次加载) ,可以推测性地执行)和某种提取信息的方法(刷新缓存中的所有内容,然后使用加载所花费的时间来确定数据缓存的状态如何更改)。

对于 Spectre v2(其中“某物”是用于类似 jc .somewhere 的指令的分支方向缓冲区),攻击者需要某种方法来欺骗 CPU 将数据放入分支方向缓冲区(例如加载然后分支这取决于负载,可以推测性地执行)和某种提取信息的方法(预先将分支方向缓冲区设置为已知状态,然后使用分支花费的时间量来确定分支方向的状态如何缓冲区已更改)。

对于所有许多可能的幽灵变体,唯一重要的(用于防御)是“某物”可以是什么(以及如何防止信息进入“某物”,或刷新/覆盖/销毁信息进入“某物”)。其他一切(攻击多种可能幽灵变体中任何一种的代码的多种可能实现之一的具体细节)都不重要。

幽灵的模糊历史

最初的 Spectre(v1,使用缓存时序)于 2017 年被发现,并于 2018 年 1 月公开宣布。它就像一个大坝决堤,很快其他一些变体(例如 v2,使用分支预测)紧随其后。这些早期的变化引起了很多宣传。在那之后的大约 6 个月左右的时间里,发现了多个其他变体,但没有得到太多的宣传,而且很多人没有(并且仍然没有)意识到它们。到 2018 年的“下半年”,人们(例如我)开始忘记哪些变体已被证明(通过“概念证明”实现)以及哪些仍未被证明,一些研究人员开始尝试列举可能性并建立命名约定为他们。到目前为止,我见过的最好的例子是“瞬态执行攻击和防御的系统评估”(参见https://arxiv.org/pdf/1811.05441.pdf)。

但是,“大坝墙上的洞”不是很容易堵塞的东西,而且(随机猜测)我认为我们需要几年时间才能假设所有可能性都已被探索(我认为缓解的需求永远不会消失)。

【讨论】:

  • 最初的 Spectre/Meltdown 论文确实提到缓存读取时间只是最佳边信道,而不是唯一的。修改微架构状态然后在攻击过程中将其读取出来并不能涵盖所有的侧通道。来自另一个超线程的 ALU 计时是另一种可能性。我想你需要一个与数据相关的性能计算,比如div。对秘密数据进行分支需要一段时间才能很好地预测,但您可以重复 Spectreing 相同的数据,直到您可以信任已经嘈杂的定时侧通道的输入。
  • @PeterCordes:你说得对——超线程/SMT 是幽灵的一种可能变体(我认为还没有人适当考虑过)。请注意,在同一内核中将信息从一个逻辑 CPU 泄漏到另一个逻辑 CPU(不依赖于推测,因此不是幽灵的变体)已经完成/证明(导致至少一个操作系统 OpenBSD 拒绝使用超线程/SMT)。
  • @Brendan 使用端口压力作为隐蔽通道已在网络安全循环中报告。在没有太多热情/恐惧的情况下受到欢迎,因为与第一个 Spectres 相比,这确实不是什么新鲜事。
【解决方案3】:

有时,术语“BTB”被统称为分支预测单元使用的所有缓冲区。然而,实际上有多个缓冲区,所有这些缓冲区都在每个周期中用于进行目标和方向预测。具体来说,BTB用于对直接分支进行预测,ITB(indirect target buffer)用于对除return之外的间接分支进行预测,RSB用于对return进行预测。 ITB 也称为 IBTB 或间接目标阵列。所有这些术语都被不同的供应商和研究人员使用。通常,BTB 用于在其他缓冲区未命中时对各种分支指令进行初始预测。但后来预测器了解了更多关于分支的信息,其他缓冲区开始发挥作用。如果同一间接分支的多个动态实例都具有相同的目标,则也可以使用 BTB 代替 ITB。当同一个分支有多个目标并且专门为处理这些分支而设计时,ITB 会更加准确。请参阅:Branch prediction and the performance of interpreters — Don't trust folklore。第一个实现独立 BTB 和 ITB 结构的 Intel 处理器是 Pentium M。后来的所有Intel Core 处理器都有专用的 ITB。

Spectre V1 漏洞利用基于使用攻击者程序训练 BTB,因此当受害者执行一个别名为相同 BTB 条目的分支时,处理器被欺骗以推测性执行指令(称为小工具)以泄漏信息。 Spectre V2 漏洞利用类似,但基于训练 ITB。这里的关键区别在于,在 V1 中,处理器错误地预测了分支的 direction,而在 V2 中,处理器错误地预测了分支的 目标(并且,如果一个有条件的间接分支,方向也是因为我们希望它被采用)。在解释、JIT 编译或使用动态多态性的程序中,可以有许多间接分支(除了返回)。一个特定的间接分支可能永远不会打算去某个位置,但是通过错误训练预测器,它可以跳转到我们想要的任何地方。正是出于这个原因,V2 非常强大;无论小工具在哪里,无论程序的有意控制流在哪里,您都可以选择其中一个间接分支并使其推测性地跳转到小工具。

请注意,通常静态直接分支的目标的线性地址在程序的整个生命周期内保持不变。只有一种情况可能并非如此:动态代码修改。所以至少在理论上,可以基于 target 对直接分支的错误预测来开发 Spectre 漏洞利用。

关于 LFB 的回收,我不太明白你在说什么。当错过 L1D 的加载请求将数据接收到 LFB 时,数据会立即转发到流水线的旁路互连。需要有一种方法来确定哪个加载 uop 请求了这些数据。返回的数据必须带有负载的 uop ID 标记。 RS 中等待数据的微指令的来源表示为负载的微指令 ID。此外,需要将保存加载uop的ROB条目标记为已完成,以便可以将其退役,并且在SnB之前,需要将返回的数据写入ROB。如果在管道刷新时,未取消 LFB 中的未完成加载请求,并且如果加载 uop ID 被重用于其他 uop,则当数据到达时,它可能会被错误地转发到管道中当前存在的任何新 uop,从而破坏微架构状态。所以需要有一种方法来确保在任何情况下都不会发生这种情况。通过简单地将所有有效的 LFB 条目标记为“已取消”,就可以取消未完成的负载请求和管道刷新上的推测性 RFO,这样数据就不会返回到管道。但是,数据可能仍会被提取并填充到一级或多级缓存中。 LFB 中的请求由行对齐的物理地址标识。可能还有其他可能的设计。

我决定进行一项实验,以确定 LFB 何时在 Haswell 上被释放。以下是它的工作原理:

Outer Loop (10K iterations):

Inner Loop (100 iterations):
10 load instructions to different cache lines most of which miss the L2.
LFENCE.
A sequence of IMULs to delay the resolution of the jump by 18 cycles.
Jump to inner.

3 load instructions to different cache lines.
LFENCE.
Jump to outer.

为此,需要关闭超线程和两个 L1 预取器,以确保我们拥有 L1 的所有 10 个 LFB。

LFENCE 指令确保我们在正确预测的路径上执行时不会用完 LFB。这里的关键思想是每次外部迭代都会错误预测一次内部跳转,因此可以在 LFB 中分配多达 10 个位于错误预测路径上的内部迭代负载。请注意,LFENCE 可防止分配来自以后迭代的负载。几个循环后,内部分支将被解析并发生错误预测。管道被清除,前端被重新引导以获取并执行外循环中的加载指令。

有两种可能的结果:

  • 已为错误预测路径上的负载分配的 LFB 作为管道清除操作的一部分立即释放,并可供其他负载使用。在这种情况下,不会出现由于 LFB 不可用而导致的停顿(使用 L1D_PEND_MISS.FB_FULL 计算)。
  • LFB 仅在负载得到维修时才被释放,无论它们是否在错误预测的路径上。

当内层跳转后外层循环加载3次时,L1D_PEND_MISS.FB_FULL的实测值约等于外层迭代次数。这是每个外部循环迭代的一个请求。这意味着当正确路径上的三个负载被发送到 L1D 时,来自错误预测路径的负载仍然占用 8 个 LFB 条目,从而导致第三个负载的 FB 满事件。这表明 LFB 中的加载仅在加载实际完成时才被解除涂层。

如果我在外循环中放入的负载少于两个,则基本上不会出现 FB full 事件。我注意到一件事:外部循环中每增加三个负载,L1D_PEND_MISS.FB_FULL 就会增加大约 20K,而不是预期的 10K。我认为正在发生的事情是,当第一次向 L1D 发出加载 uop 的加载请求并且所有 LFB 都在使用时,它会被拒绝。然后,当 LFB 可用时,加载缓冲区中的两个未决加载被发送到 L1D,一个将在 LFB 中分配,另一个将被拒绝。因此,每增加一次负载,我们就会得到两个 LFB 完整事件。但是,当外循环中有三个负载时,只有第三个会等待 LFB,因此每次外循环迭代都会得到一个事件。本质上,加载缓冲区无法区分一个 LFB 可用还是两个 LFB;它只知道至少有一个 LFB 是空闲的,因此它会尝试同时发送两个加载请求,因为有两个加载端口。

【讨论】:

    猜你喜欢
    • 2011-08-29
    • 2015-08-21
    • 2014-01-22
    • 1970-01-01
    • 2018-07-28
    • 2022-05-21
    • 2019-12-28
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多