文章目录
前言
By Jonathan Corbet
July 10, 2018
本文来自:
https://lwn.net/Articles/759423/
https://lwn.net/Articles/746551/
一、简介
1.1 简介
在许多方面,Spectre变种1(边界检查绕过漏洞)是Meltdown/Spectre系列中最让人头疼的,尽管相对难以利用。任何给定的代码库都可能存在V1问题,但是要发现和防御这些问题都很困难。静态分析可以提供帮助,但可用的工具很少,而且大多是专有的,容易产生误报。此外,在用户空间代码中缺乏高效且与架构无关的方式来解决Spectre V1问题。因此,在大多数项目中,对于寻找和修复Spectre V1漏洞的工作只进行了有限的努力。
为了改善这种情况,有人努力向GCC(GNU编译器集合)等工具添加一些防御措施。然而,这样做也会带来一些问题和代价。
记住,Spectre V1是处理器错误的分支预测的结果。给定的代码如下:
if (index < structure->array_size)
do_something_with(structure->array[index]);
处理器通常会预测条件index < structure->array_size为真,因为在正常执行中,它几乎总是成立的。然后,它会进行具有可能超出边界的索引值的代码的推测执行,例如访问array[index]。如果这种推测访问在系统的其他位置留下痕迹(例如将数据加载到缓存中),则可以利用这些痕迹泄漏在正确执行代码时应该受保护的数据。
该漏洞源于推测执行和其对系统的副作用,使得攻击者可以利用信息泄漏并绕过预期的安全措施。Spectre变种1展示了推测执行漏洞的复杂性,以及在检测和缓解方面所面临的挑战。
1.2 array_index_nospec()宏
在内核中,已 引入 array_index_nospec()宏作为一种防止此类错误推测加载的方式。然而,这些宏调用必须手动引入到某些地方,即那些已确定可能存在Spectre V1漏在内核中,已经引入了array_index_nospec()宏,作为防止此类错误推测加载的一种方式。然而,这些宏调用必须在某些地方手动引入,只有当有人确定可能存在Spectre V1漏洞时才会使用。尽管已经开始采取这样的措施,但进展缓慢。例如,在4.18-rc4内核中大约有60个array_index_nospec()宏的调用。然而,在用户空间方面的工作相对较少,原因有很多,包括缺乏类似array_index_nospec()的原语。
// linux-5.15/arch/x86/entry/common.c
static __always_inline bool do_syscall_x64(struct pt_regs *regs, int nr)
{
/*
* Convert negative numbers to very high and thus out of range
* numbers for comparisons.
*/
unsigned int unr = nr;
if (likely(unr < NR_syscalls)) {
unr = array_index_nospec(unr, NR_syscalls);
regs->ax = sys_call_table[unr](regs);
return true;
}
return false;
}
这次引入array_index_nospec()宏来自文章:Meltdown and Spectre mitigations — a February update:
Spectre变种1(也称为越界检查绕过漏洞)可能是在硬件级别上难以修复的,因此可能会存在很长时间。不幸的是,在软件级别上解决它也很困难。
最终形式的变种1补丁再次改变了接口。考虑一个可能受到绕过边界检查的推测攻击的简单代码片段:
if (index < ARRAY_SIZE)
return array[index];
保护此类对数组的引用以确保不会发生越界引用的方法是:
if (index >= ARRAY_SIZE)
return 0; /* Or whatever error value makes sense */
else {
index = array_index_nospec(index, ARRAY_SIZE);
return array[index];
}
保护宏array_index_nospec()不再实际访问数组;相反,它只是以一种阻止推测的方式操作索引值。它使用了1月中旬文章中描述的掩码技术,完全避免了较为昂贵的屏障操作。对于需要保护的操作比简单的数组访问更复杂的情况,还有另一个名为barrier_nospec()的宏,它使用屏障来阻止所有推测活动。在至少x86架构上,它的使用成本要比array_index_nospec()更高,但有时别无选择。
这些宏可以保护免受变种1攻击,但需要在正确的位置使用。识别内核中存在可利用的代码序列并不容易;内核庞大且大量使用用户空间提供的值。因此,确定何时需要使用这些宏是一个有挑战性的任务。
当前对这些新宏的实际使用相对较少。内核中的get_user()函数是一个关注的领域,因为它可以用于尝试访问内核中的任意地址;由于get_user()具有必要的边界检查以确保给定的地址指向用户空间,添加一个对array_index_nospec()的调用(更准确地说,是针对x86优化的汇编版本)足以防止问题。然而,__get_user()变体缺乏这些检查,并且在内核中有超过1000个调用点。保护__get_user()需要添加一个barrier_nospec()调用。
另一个关注的领域是系统调用表,它使用用户空间的整数值(系统调用号)进行索引。调用array_index_nospec()用于防止对该表的越界访问。此外,还对文件描述符查找、KVM代码和低级无线网络代码进行了保护。然而,没有人认为已经找到了所有潜在的可利用位置。
与此同时,有一个 arm64的补丁集 正在使用中,其中的缓解措施类似于x86的补丁。它目前的缓解措施较少,没有重复在x86代码中找到的非体系结构特定的缓解措施,但是它为futex()系统调用添加了目前在x86中不存在(也许不需要)的保护措施。
找到仍然需要变种1保护的位置可能需要相当先进的静态分析工具。迄今为止所做的工作依赖于专有的Coverity工具,并且必须应对较高的误报率。所有相关人员都希望看到一个能够完成这项工作的免费工具,但显然没有人正在开发这样的工具。这必然会减缓发现易受攻击的代码的速度,并增加引入新漏洞的速度。
Arjan van de Ven建议真正需要发生的是对当前分散在整个内核中的许多安全检查进行集中。他建议创建一个描述如下的实用函数:
copy_from_user_struct(*dst, *src, type, validation_function);
其中validation_function()将根据描述内核接口的UAPI头文件自动生成。广泛使用这样的函数将使大多数开发人员不再需要担心Spectre变种1漏洞;这可能还会改善(并不总是很好的)参数验证的状态。
1.3 __builtin_speculation_safe_value
GCC可能很快会解决最后一个问题,这要归功于Richard Earnshaw的这个 补丁集 ,它基于Chandler Carruth首次发布的一种技术。这些补丁添加了一个新的内置函数,其行为方式类似于array_index_nospec():
__builtin_speculation_safe_value(value, fallback)
在没有推测执行的情况下,该函数将简单地返回value。而当发生推测执行时,它可能仍然返回value,但也可能返回fallback值,默认为零。因此,它可以用于确保推测执行不会使用超出范围的索引值。一个简单的实现方法是无条件使用屏障来完全防止推测执行,但屏障的开销可能很大。相比之下,更高效的方法可能是在允许一般推测执行的情况下,仅限制索引值的范围。
GCC 补丁集 :
我之前发布的一系列用于应对CVE-2017-5753(Spectre变体1)的补丁引起了一些有用的反馈意见,从中可以明显看出需要重新考虑。本邮件以及接下来的补丁试图解决这些反馈意见,并提出一种新的方法来应对这种攻击形式。
原始方法存在两个主要问题:
推测边界受到了过于严格的限制,基本上它们必须表示指针的上下界限或指针偏移量。
推测约束只能覆盖紧接着的分支,这在很多情况下与现有代码的结构不太匹配。
另外一个批评是内建函数的形式不太适用于使用单个推测屏障的系统,该屏障必须等待所有先前的推测都解决。
为了解决上述问题,这些补丁采用了一种新的方法,部分基于Chandler Carruth在LLVM开发者邮件列表中的帖子((https://lists.llvm.org/pipermail/llvm-dev/2018-March/1220…),),但我们对其进行了扩展以处理函数间推测。这些补丁将问题分为两部分。
(1)第一部分是一些特定目标代码,用于跟踪推测条件通过生成的代码,提供一个内部变量,可以告诉我们CPU的控制流推测是否与数据流计算一致。这个内部变量的初始值为TRUE,如果CPU的控制流推测导致跳转到错误的代码块,变量会变为false,直到错误的控制流推测被解开为止。
(2)第二部分是引入了一个比之前简单得多的新内建函数。内建函数的基本形式现在如下:
T var = __builtin_speculation_safe_value (T unsafe_var);
当不进行推测时,内建函数返回unsafe_var。在进行推测时,如果可以证明推测流与预期的控制流不一致,则返回零。可选的第二个参数可以用于指定零以外的替代值。该内建函数可能会导致执行暂停,直到推测状态解决为止。
该补丁集包括七个补丁,如下所示:
1)引入新的内建函数__builtin_sepculation_safe_value。
2)为AArch32(arm)状态添加基本的硬屏障实现。
3)为AArch64状态添加基本的硬屏障实现。
4)添加了一个新的命令行选项-mtrack-speculation(目前无操作)。
5)当启用-mtrack-speculation时,禁用CB[N]Z和TB[N]Z指令。
6)为AArch64添加新的推测跟踪传递。
7)使用新的推测跟踪传递生成基于CSDB的屏障序列。
目前我还没有为AArch32添加推测跟踪传递。虽然有可能做到,但由于可用寄存器数量有限,这将需要对arm后端进行相当多的重组。
虽然补丁6是针对AArch64的,但我希望得到对分支边缘代码更熟悉的人的审查。由于更复杂的边缘存在一些棘手的问题,我希望能得到第二个观点,以防我忽略了重要的情况。
Add __builtin_speculation_safe_value
Arm - add speculation_barrier pattern
AArch64 - add speculation barrier
AArch64 - Add new option -mtrack-speculation
AArch64 - disable CB[N]Z TB[N]Z when tracking speculation
AArch64 - new pass to add conditional-branch speculation tracking
AArch64 - use CSDB based sequences if speculation tracking is enabled
二、Detecting incorrect speculation
这个新的内置函数是如何工作的,这一点可以让我们了解为什么它被指定为这样的方式。其实现的核心是一种技巧,用于检测错误的推测执行并防止在这种情况下发生越界访问。为了实现这一点,需要在编译器构建代码时对其进行工具化。在这个方案中,上述的if语句将被修改为类似以下的形式:
void *all_ones = ~0;
void *all_zeroes = 0;
void *correct = all_ones;
if (index < structure->array_size) {
correct = (index >= structure->array_size) ? all_zeroes : correct;
index &= correct;
do_something_with(structure->array[index]);
}
关键在于在if语句的主体中对correct的赋值:
correct = (index >= structure->array_size) ? all_zeroes : correct;
这个赋值测试了分支条件的反面是否为真;如果是这种情况,即主体在不应推测执行时正在进行推测执行,需要采取回避措施。由于如果(且仅当)发生错误的推测时,correct将被设置为零,所以回避措施可以采用将correct作为掩码与index进行按位与的形式:
index &= correct;
在正常执行时,这个操作不会改变任何内容;但是当检测到错误的推测执行时,index将被重置为零。此时,它就无法用于推测访问越界内存。
在这里可能会产生一个问题:如果在if语句中的条件被错误地预测,那么设置correct的三元表达式是否会发生相同的情况?实际上,几乎所有的体系结构都有某种形式的比较和赋值操作,它(1)是一个不带有分支的单指令,因此分支预测器不会介入,(2)在体系结构中定义为不受推测执行的影响。因此,对correct的赋值将使用非预测值进行,它将准确地指示是否发生了错误的推测执行。
请注意,correct标志只会在初始化时设置一次,但会在每个分支之后进行更新,如上所示。因此,如果需要,它将在多个分支中传递预测状态。通过足够巧妙的处理,甚至可以在函数调用之间传递这个状态。由于推测执行有时会超前于任何已知为正确的代码数百条指令,因此跟踪和传递执行状态的能力非常重要。
三、Adding support to GCC
正如上面所提到的,实现__builtin_speculation_safe_value()可以简单地通过在生成的代码中插入一个屏障来完成。但是,如果编译器还能够添加检测错误推测的能力,那么将会打开其他可能性。为此,正在考虑的GCC补丁集添加了一个新的编译选项-mtrack-speculation,用于打开这个机制。具体来说,这个 补丁 为arm64架构添加了推测追踪。如该补丁所述,一个简单的相等性测试可能会在比较设置条件码之后如下所示:
B.EQ <dst>
...
<dst>:
使用-mtrack-speculation选项,这段代码将变成以下形式:
B.EQ <dst>
CSEL tracker, tracker, XZr, ne
...
<dst>:
CSEL tracker, tracker, XZr, eq
这里,tracker是一个专门用于保存正确标志的寄存器的名称。CSEL指令 将根据条件的实际值(不进行推测)将tracker设置为自身或XZr(保存全零值的寄存器)。换句话说,它实现了我们在上面示例中看到的三元运算符。
当发生错误的推测时,这个操作将导致tracker寄存器为零。这使得它可以用于实现__builtin_speculation_safe_value();使用默认的回退值为零,将tracker寄存器与相应的值进行逻辑与操作即可。然而,在arm64架构的情况下,可以做得更好一些。当打开推测追踪时,编译器将在检测到错误的推测时简单地插入一个CSDB推测屏障。
值得一提的是,当涉及到函数调用时,情况变得更加复杂。推测执行可能涉及函数调用,因此跟踪函数调用中的错误推测非常重要。如果能够为跟踪器值全局分配一个寄存器,那将很容易,但这将需要对arm64 ABI进行重大更改。相反,堆栈指针以一种巧妙的方式用于在函数调用和返回时编码正确性状态;有关详细信息,请参阅上面链接的补丁。
总的来说,这种方法可能看起来是最佳的选择;屏障的开销很高,因此只在已知需要时执行屏障的机制是理想的。当然,推测追踪本身并不廉价。它需要分配两个寄存器来跟踪状态,并对每个分支进行工具化。尚未发布与代码相关的基准测试结果,但这种开销肯定会产生影响。这个成本足够高,以至于排除了其他有趣的想法,比如自动保护所有边界检查。
无论如何,这种推测追踪可能看起来是一种奇怪的机制;在处理器上运行的代码可以检测到处理器的错误推测,但处理器本身仍然需要一些时间来弄清楚这一点。但这就是我们所处的世界。我们能做的最好的事情就是找到一种既能保护我们的代码又能最大限度地减少成本的方式。
补丁 简介:
这个补丁是推测跟踪代码的主要部分。它添加了一个新的特定于目标的Pass,在最终的分支重组Pass之前运行(以便清理我们进行的任何新边插入)。只有在命令行上传递了"-mtrack-speculation"选项时,才会运行该Pass。
在此过程中发现的一个问题是,堆栈指针寄存器未被允许在比较指令中使用。我们依赖于它在函数调用边界上在SP和临时寄存器之间移动跟踪状态。
Speculation tracking and mitigation (e.g. CVE 2017-5753) for AArch64.
This file is part of GCC.
该Pass在最终的分支重组Pass之前扫描RTL。其目的是识别所有存在条件控制流的位置,并插入代码来跟踪任何条件分支的推测执行。
为此,我们保留一个被调用破坏的寄存器(以便可以在函数序言非常早的时候初始化它),然后每次出现条件分支时更新该寄存器。在每个条件分支处,我们生成一个使用条件选择操作的代码序列,这些操作本身不会受到推测的影响(目前忽略了可能不总是严格正确的情况)。例如,一个分支序列如下所示:
B.EQ <dst>
...
<dst>:
转换为:
B.EQ <dst>
CSEL tracker, tracker, XZr, ne
...
<dst>:
CSEL tracker, tracker, XZr, eq
由于我们从初始化为全1的tracker开始,如果在任何时候预测的控制流与架构程序行为不一致,tracker将变为零(否则不变)。
tracker的值可以在需要保护免受错误推测的值的任何时候使用。可以通过几种方式来实现,但它们都是同样的原理。对于不受信任的地址或受信任地址的不受信任的偏移量,我们可以简单地使用tracker与不受信任的值对地址进行掩码处理。如果CPU没有进行推测,或者推测正确,那么该值将保持不变,否则它将被限制为零。对于更复杂的情况,我们可以将tracker与零进行比较,并使用标志位形成一个新的选择,其中包含一个备用的安全值。
对于那些数据处理指令本身可能产生推测值的实现,架构要求CSDB指令将解决此类数据推测。因此,每次我们使用tracker来保护一个易受攻击的值时,我们还会发出一个CSDB指令:我们不需要每次更新tracker时都这样做。
在函数边界处,我们需要与调用者或被调用者传递推测跟踪状态。这很棘手,因为没有可用的寄存器来实现这样的目的,除非创建一个新的ABI。为此,我们依靠以下原则:在所有真实的程序中,堆栈指针SP在函数边界处永远不会为NULL;因此,我们可以通过在推测跟踪器本身为空时将SP清零来在SP中编码推测状态。在调用之后,我们将跟踪状态从SP恢复到tracker寄存器中。结果是,函数调用序列转换为:
MOV tmp, SP
AND tmp, tmp, tracker
MOV SP, tmp
BL <callee>
CMP SP, #0
CSETM tracker, ne
在前调用序列中需要额外的MOV指令,因为不能直接使用SP与AND指令。
函数体内的代码使用序言中的后调用序列来建立tracker,并使用尾调用序列中的前调用序列来重新编码返回的状态。
代码序列具有一个好的特性,即如果从不跟踪推测的函数调用或被调用函数,则堆栈指针将始终为非NULL,因此tracker将被初始化为我们需要的全1位值:在这种情况下,我们失去了完全跟踪推测的能力,但仍然是架构上安全的。
以这种方式跟踪推测是非常昂贵的,无论是在代码大小还是执行时间上。我们采用了一些技巧来尝试限制这一代价:
1)简单的叶子函数没有条件分支(或不使用跟踪器),它们不需要建立新的跟踪器:它们只需通过SP在调用期间传递跟踪状态。对于以尾调用结束的叶子函数也是如此。
2)同一个基本块中的连续函数调用也不需要在调用之间重新建立跟踪器。同样地,我们可以在此期间通过SP携带跟踪状态,除非在那一点上需要tracker的值。
我们在最终的分支重组之前运行该Pass,以便我们可以使用标准的边插入代码来处理大多数条件分支情况。重组Pass希望在此之后清理代码,以避免结果过于糟糕。