AMD分支(Mis)预测器:设置它,然后忘记它

2022-02-23 22:13:03

在这篇博客文章中,我们将讨论AMD CPU分支预测器的一些技术细节,重点讨论它在简单条件分支中的行为。我们还讨论了这种行为与利用常见和不常见的Spectre v1小工具的关系。作为讨论的一部分,我们来看看AMD';s";管理AMD处理器投机的软件技术";白皮书[1]并评估建议的缓解措施(V1-1至V1-3)。接下来,我们实现了一个看似温和的人工幽灵v1小工具,并将其作为易于使用的任意内存泄漏原语加以利用。最后,我们复制了";对野外投机型混乱脆弱性的分析#34;论文[2]展示了在AMD CPU(与英特尔相比)上开发此类Spectre v1小工具的易用性。

本文具有高度的技术性,以图表和列表的形式提供了大量的经验数据。假设对现代CPU的微体系结构细节有中等程度的理解。在适用的情况下,对讨论的主题进行简要介绍。对你来说太多了?没问题,请查看我们的最后评论部分,了解TL;博士

为了使本文更易于阅读和消化,我们的目标是遵循类似FAQ的文档结构。讨论的主题逐渐呈现出越来越详细的内容。希望这个不寻常的博客帖子结构与所描述的技术材料配合得很好。

享受另一个关于预测失误的分支、幽灵v1小工具和一两个PoC的故事吧。

在此之前,我们在2022年1月7日至2月2日期间与AMD PSIRT团队讨论了我们的发现和观察结果。AMD得出结论,它们都是典型的Spectre v1场景,并建议按照指南[1]中的解释应用缓解措施。AMD(可以理解,并与其他芯片制造商保持一致)不想对其分支预测器的设计发表评论,我们认为,似乎也没有兴趣对报告的行为及其影响进行更彻底的分析。我们不知道这种分析是在我们的报告之前进行的,还是在报告之后进行的。

有鉴于此,我们决定亲自彻底调查这个问题,并在这里公开展示我们的研究成果。我们认为,对于本文讨论的场景,建议的缓解措施是不切实际的,因为下面的细节有望说明这些原因。

故事从一个有点意外但出人意料的观察开始。有一次我问自己:";一个可计算、无内存访问且可能无延迟的条件分支是否会被预测失误?如果可以,情况会怎样". 这些问题的答案多少已经给出了。现代CPU会对任何分支进行错误预测的原因有很多。微体系结构资源争用、分支指令错位、缓存线或页面边界拆分可能是其中的一些原因。除此之外,还有一种故意对幽灵v1小工具进行分支误训练的技术。

然而,许多供应商和安全从业人员似乎都有一个共同的误解,即Spectre v1小工具主要是关于超出范围的阵列内存访问,由具有可变计算延迟(通常是内存访问)的阵列绑定检查组成。在这里,我想到的是可计算的条件分支,它的条件操作数在通用寄存器中立即可用。这样的分支是否可能以可控的方式预测失误?它对安全有影响吗?让';让我们来看看。

我通常通过使用我最喜欢的工具KTF(Kernel Test Framework[3])实现简单的实验来开始这类研究,我在不久前创建了KTF,正是出于这样的目的。KTF使在许多不同的执行环境(裸机或虚拟化)中重新运行实验变得很容易,并配备了一些经过良好测试的基于缓存的侧通道原语。

0.mov$CACHE_LINE_ADDR,%rsi;访问延迟允许观察预测失误的内存地址1。clflush(%rsi);将缓存线从缓存层次结构中清除,以获得干净状态2。答案3。忠诚;确保缓存线真的在4之外。异或%rdi,%rdi;设置ZF=1.5。jz END_标签;如果ZF==1,则转到END_LABEL6。mov(%rsi),%rax;刷新缓存线的内存负载;它从不在架构上执行7。结束标签:8。测量缓存线地址访问时间

问题是:位置5处的“总是执行”分支有多少次会被预测失误?

我在一个100k次的循环中运行了十次代码,并统计了所有对CACHE_LINE_ADDR的低延迟访问,这表明推测执行的内存负载。这就是我得到的:

CPU:AMD EPYC 7601 32核处理器基线:200CL0,迭代次数6100000 2100000 4100000 6100000 5100000 4100000 4100000 6100000 4100000 7100000总计:48

列CL0(以及后面的实验中的CL1)表示在迭代过程中推测加载测量缓存线的次数(即预测失误的次数)主循环迭代的次数,对于十次执行中的每一次。总数是发生的所有预测失误的总和。Baseline是从缓存访问内存数据所需的平均CPU周期数。

我没想到会这样。一些不规则的事件并不令人震惊,但这是很正常的。我在这个领域度过的几年让我明白,这可能毫无意义。可能存在缓存噪音(例如,由于频繁中断或同级超线程活动)、基线计算错误、由于我测试的虚拟机的vCPU浮动而产生的假信号,或者大量其他原因破坏了实验。

为了快速排除其中一些,我倾向于添加另一个非冲突测量缓存线,并在实验运行时测量两个缓存线。如果信号没有相应且可靠地变化,则实验检测到噪声。

0.移动缓存线0地址%rsi;内存地址0,其访问延迟允许观察预测失误1。mov缓存线地址%rbx;内存地址1,其访问延迟允许观察预测失误2。clflush(%rsi)3。clflush(%rbx);清除缓存层次结构中的两条缓存线,以获得干净的状态4。第五章。忠诚;确保缓存线真的在6。异或%rdi,%rdi;设置ZF=17。jz END_标签;如果ZF==1,则转到END_LABEL8。mov(%rsi/%rbx),%rax;刷新缓存线的内存负载;它从不在架构上执行9。结束标签:A.测量缓存线地址访问时间

我在100k次迭代的循环中再次运行了这段代码两次,每次都在位置8使用不同的缓存线。这是我为%rsi得到的:

CPU:AMD EPYC 7601 32核处理器基线:200 CL0,CL1,迭代次数4,0100000 2,0100000 4,0100000 1,0100000 2,0100000 3,0100000 2,0100000 4,0100000 2,0100000 4,0100000总计:28

CPU:AMD EPYC 7601 32核处理器基线:200 CL0,CL1,迭代次数0,3100000,3100000,1100000,3100000,3100000,3100000,2100000,2100000,2100000,2100000,6100000,3100000总计:28

在这一点上,我开始怀疑可能真的出了问题,特别是因为我还禁用了循环中断(cli/sti),并确保系统处于空闲状态。

另一方面,像上面这样的条件分支在主循环执行期间至少会出现一次预测失误。在分支预测单元(BPU)状态非常干净的假设理想情况下,新遇到的条件分支通常属于静态预测结果规则。然后,它根据默认的启发式(例如,始终采用新的前向分支,始终不采用后向分支,反之亦然)决定要做什么。如果你想知道为什么一开始会有这样的分支预测,那么稍后就会有答案。

我很快在不同的CPU上重新测试了相同的代码。其中一些是英特尔,另一些是AMD,一些是服务器,还有一些是客户端。我期待着通常的结果:在不同的机器上得到不同的结果,没有任何清晰可见的图案形成。但这一次,不同寻常的事情发生了。

我测试过的所有AMD CPU都显示出与上面显示的有点类似的结果模式。然而,无论我使用的缓存线是什么,所有英特尔CPU都没有显示任何预测失误。

CPU:Intel(R)Core(TM)i9-10980XE CPU@3。00GHZ频率:3000 MHZ基线:169CL0,CL1,迭代次数0,0100000,0100000,0100000,0100000,0100000,0100000,0100000,0100000,0100000,0100000,0100000,0100000总计:0,0100000,0100000,0100000,0100000,0100000,0100000,0100000,0100000总计:0

这一点很有趣。我仔细检查了内存访问延迟基线是否合理,以及缓存oracle实现是否仍然有效。我还确保代码执行被固定在给定的逻辑CPU上。我还用一些故意不正确的基线重新运行了实验,看看是否有噪音,我做到了。

在这一点上,所有迹象都表明AMD的CPU及其预测失误有问题。

由于我们正在处理导致推测性执行内存加载的分支预测失误,我们可以在分支之后放置一条串行化指令来停止推测性执行。因此,我们可以对观察结果有更好的信心,因为不应该有任何信号。

0.移动缓存线0地址%rsi;内存地址0,其访问延迟允许观察预测失误1。mov缓存线地址%rbx;内存地址1,其访问延迟允许观察预测失误2。clflush(%rsi)3。clflush(%rbx);清除缓存层次结构中的两条缓存线,以获得干净的状态4。第五章。忠诚;确保缓存线真的在6。异或%rdi,%rdi;设置ZF=17。jz END_标签;如果ZF==1,则转到END_LABEL8。忠诚;在AMD上,lfence可以设置为dispatch Serialization mode(调度序列化模式),从而停止推测9。mov(%rsi/%rbx),%rax;刷新缓存线的内存负载;它从不执行架构。结束标签:B.测量缓存线地址访问时间

CPU:AMD EPYC 7601 32核处理器基线:200CL0,CL1,迭代次数0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000总计:0

没有预期的信号。在这一点上,我说服自己,我看到这个微不足道的条件分支的频繁分支预测失误只发生在AMD CPU上,而不是英特尔CPU上。

为了回答这个问题,我们需要了解一些有关AMD branch predictor实现的信息。但在此之前,让';让我们再做几次实验,以便更好地了解情况。

0.移动缓存线0地址%rsi;内存地址0,其访问延迟允许观察预测失误1。mov缓存线地址%rbx;内存地址1,其访问延迟允许观察预测失误2。clflush(%rsi)3。clflush(%rbx);清除缓存层次结构中的两条缓存线,以获得干净的状态4。第五章。忠诚;确保缓存线真的在6。异或%rdi,%rdi;设置ZF=17。忠诚;在AMD上,lfence可以设置为dispatch Serialization mode(调度串行化模式),从而停止推测。jz END_标签;如果ZF==1,则转到END_LABEL9。mov(%rsi/%rbx),%rax;刷新缓存线的内存负载;它从不执行架构。结束标签:B.测量缓存线地址访问时间

CPU:AMD EPYC 7601 32核处理器基线:200CL0,CL1,迭代次数6,0100000 5,0100000 7,0100000 8,0100000 12,0100000 6,0100000 9,0100000 10,0100000 8,0100000 9,0100000总数:80,61000000,7100000 0,8100000 0,9100000 0,5100000 0,7100000 0,8100000 0,8100000 0,8100000 9100000总计:75

由于在跳跃之前放置lfence,预测失误率出现了轻微但持续的增加,这有点令人惊讶。序列化是否与手头的问题有关?

0.移动缓存线0地址%rsi;内存地址0,其访问延迟允许观察预测失误1。mov缓存线地址%rbx;内存地址1,其访问延迟允许观察预测失误2。clflush(%rsi)3。clflush(%rbx);清除缓存层次结构中的两条缓存线,以获得干净的状态4。第五章。异或%rdi,%rdi;设置ZF=16。jz END_标签;如果ZF==1,则转到END_LABEL7。mov(%rsi/%rbx),%rax;刷新缓存线的内存负载;它从不在架构上执行8。结束标签:9。测量缓存线地址访问时间

CPU:AMD EPYC 7601 32核处理器基线:200CL0,CL1,迭代次数1,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 1,0100000 0,总计:3101100000,0100000 0,1100000,0100000 0,0100000 0,1100000总计:5

看起来信号仍然存在,但明显较弱。让';s然后移除sfence:

0.移动缓存线0地址%rsi;内存地址0,其访问延迟允许观察错误预测1。mov缓存线地址%rbx;内存地址1,其访问延迟允许观察错误预测2。clflush(%rsi)3。clflush(%rbx);清除缓存层次结构中的两条缓存线,以获得干净的状态4。异或%rdi,%rdi;设置ZF=1.5。jz END_标签;如果ZF==1,则转到END_LABEL6。mov(%rsi/%rbx),%rax;刷新缓存线的内存负载;它从不在架构上执行7。结束标签:8。测量缓存线地址访问时间

CPU:AMD EPYC 7601 32核处理器基线:200CL0,CL1,迭代次数0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000总计:0

根本没有信号!我没料到。毕竟,sfence指令在这里不应该那么相关;它不是串行化的,并且完全不应该影响分支预测失误。相反,假设需要sfence来确保缓存线被刷新。如果没有它,信号应该(错误地)更强。因此,至少在有和没有sfence的情况下获得的结果应该表明信号的存在。

我很好奇,这种差异是否仅仅是因为有人在场而造成的延误,还是其他什么原因造成的。因此,我决定尝试使用另一条既不是序列化也不是内存排序的指令。为此,我决定使用暂停指令。

0.移动缓存线0地址%rsi;内存地址0,其访问延迟允许观察预测失误1。mov缓存线地址%rbx;内存地址1,其访问延迟允许观察预测失误2。clflush(%rsi)3。clflush(%rbx);清除缓存层次结构中的两条缓存线,以获得干净的状态4。暂停5。异或%rdi,%rdi;设置ZF=16。jz END_标签;如果ZF==1,则转到END_LABEL7。mov(%rsi/%rbx),%rax;刷新缓存线的内存负载;它从不在架构上执行8。结束标签:9。测量缓存线地址访问时间

CPU:AMD EPYC 7601 32核处理器基线:200CL0,CL1,迭代2,0100000 1,0100000 0,0100000 1,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 2,0100000总计:9,1100000,2100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,0100000 0,1100000,010000总计:4

0.移动缓存线0地址%rsi;内存地址0,其访问延迟允许观察预测失误1。mov缓存线地址%rbx;内存地址1,其访问延迟允许观察预测失误2。clflush(%rsi)3。clflush(%rbx);清除缓存层次结构中的两条缓存线,以获得干净的状态4。第五章。异或%rdi,%rdi;设置ZF=16。jz END_标签;如果ZF==1,则转到END_LABEL7。mov(%rsi/%rbx),%rax;刷新缓存线的内存负载;它从不在架构上执行8。结束标签:9。测量缓存线地址访问时间

CPU:AMD EPYC 7601 32核处理器基线:200CL0,CL1,迭代3,0100000 2,0100000 0,0100000 1,0100000 0,0100000 3,0100000 1,0100000 1,0100000 0,0100000 0,0100000总计:11000100000 0,2100000 0,2100000 0,2100000 0,1100000,0100000 0,3100000 0,1100000,2100000,11000002100000总计:14

我困惑了一会儿,但瞥了一眼";AMD64体系结构程序员手册第3卷:通用和系统说明";[4] 描述clflush指令的部分帮助我理解。事实证明,这个问题是由弱序clflush指令引起的,这些指令与推测性内存加载有关。让';让我们引用手册:";[...] CLFLUSH指令相对于在内存上运行的其他指令是弱顺序的。处理器启动的推测性加载,或使用缓存预取指令显式指定的推测性加载,可以围绕CLFLUSH指令重新排序。这种重新排序可能会使推测预取的缓存线无效,从而无意中破坏预取操作"

显然,这就是这里发生的事情。推测负载在clflush中重新排序,并在clflush之前执行。clflush指令无意中屏蔽了信号。手册还说:

" 避免这种情况的唯一方法是在CLFLUSH指令之后使用MFENCE指令,强制CLFLUSH指令对后续内存操作进行强排序"

开始了。从现在起,我们将使用mfence指令来订购clflush指令';执行得当。

到目前为止,我一直在尝试向前移动的分支。但BPU可能会以不同的方式对待向后条件分支。让';现在让我们来回顾一下过去一直以来的分支。

与之前的实验类似,我使用以下向后条件分支代码构造,在100k次迭代的循环中运行了十次:

0.移动缓存线0地址%rsi;内存地址0,其访问延迟允许观察预测失误1。mov缓存线地址%rbx;内存地址1,其访问延迟允许观察预测失误2。clflush(%rsi)3。clflush(%rbx);清除缓存层次结构中的两条缓存线,以获得干净的状态4。第五章。jmp BRANCH_标签;到后面的分支6。返回标签:;这是向后分支目标7。jmp-END_标签;去测量8。对齐64;该对齐结构用于隔离后向9。ud2;从早期的分支中分支。这一点很重要,因为密度A.对准64;每个缓存线的分支数很重要。B.分支机构标签:C.异或%rdi,%rdi;设置ZF=1D。jz返回_标签;如果ZF==1,则转到返回标签。mov(%rsi/%rbx),%rax;刷新缓存线的内存负载;它从不执行架构。结束标签:10。测量缓存线地址访问时间

CPU:AMD EPYC 7601 32核处理器基线:200CL0,CL1,迭代次数2,0100000 2,0100000 0,0100000 1,0100000 1,0100000 1,0100000 3,0100000 1,0100000 0总计:1401100000,1100000,4100000,2100000,

......