现代处理器使用<span>分支预测</span>
和<span>推测执行</span>
来最大限度地提高性能。例如,如果分支的目标取决于<span>正在读取的内存值</span>
,CPU将尝试猜测目标并尝试提前执行。当内存值最终到达时,CPU要么<span>丢弃</span>
,要么<span>提交</span>
推测计算。投机逻辑在执行方式上是不可信的,可以访问受害者的<span>内存和寄存器</span>
,并可以执行具有可观副作用的操作。幽灵攻击包括诱使受害者<span>投机性地执行在正确程序执行期间不会发生的操作</span>
,并通过<span>侧通道</span>
将受害者的机密信息泄露给攻击者。
注:幽灵攻击有很多变体,比如 Spectre Variant 1/2/3/3a/4、L1TF, Foreshadow (SGX)、MSBDS, Fallout、TAA, ZombieLoad V2等^1^, 这里只介绍 spectre v1, 其他几个变体暂不涉及。
思考下面这个程序,可否在不输入正确密码情况下通过验证?(答案有很多,比如:24个1)
int main() {
int ret = 0;
char def_password[8] = "1234567";
char save_password[8] = {0};
char password[8] = {0};
while(true) {
printf("please input password: ");
scanf("%s",password);
memset(save_password,0,sizeof(save_password));
if (strcmp(password, def_password)) { // 比较是否和密码一致
printf("incorrect password!\n\n");
} else {
printf("Congratulation! You have passed the verification!\n");
strcpy(save_password, password);
break;
}
}
return ret;
}
这里对数组的访问没有检查下标是否合法,但是更进一步的思考下,加上长度检查就一定安全了吗?
实际上,软件即使没有漏洞,数组访问时都加了下标有效性检查,也是不一定是安全的。考虑一个例子,<span>其中程序的控制流依赖于位于外部物理内存中的未缓存值。由于此内存比CPU慢得多,因此通常需要几百个时钟周期才能知道该值</span>
。这时候,CPU会通过<span>投机执行</span>
把这段空闲时间利用起来,从安全的角度来看,投机性执行涉及以可能不正确的方式执行程序。然而,由于CPU的设计了通过将不正确的投机执行的结果恢复到其先前状态来保持功能正确性,因此这些错误以前被认为是安全的。
具体的,比如下面的代码片段(完整的在论文^2^最后):
if (x < array1_size)
y - array2[array1[x] * 4096]
假设变量 x 包含攻击者控制的数据,为了确保对 array1 的内存访问的有效性,上面的代码包含了一个 if 语句,其目的是验证x的值是否在合法范围内。接下来我们将介绍一下攻击者如何绕过此if语句,从而从进程的地址空间读取潜在的秘密数据。
首先,在初始错误训练阶段,攻击者使用有效输入调用上述代码,从而训练分支预测器期望 if 为真。接下来,在漏洞攻击阶段,攻击者在 array1 的边界之外调用值 x 的代码。CPU不会等待分支结果的确定,而是猜测边界检查将为真,并已经推测性地执行使用恶意x访问<span>array2[array1[x]*4096]</span>
的指令。
请注意,从 array2 读取的数据使用恶意x将数据加载到依赖于 array1[x] 的地址的缓存中,并进行映射,以便访问转到不同的缓存行,并避免硬件预取效应。当边界检查的结果最终被确定时,CPU 会发现其错误,并将所做的任何更改恢复到其标称微体系结构状态(nominal microarchitectural state)。但是,对缓存状态所做的更改不会恢复,因此攻击者可以分析缓存内容,并找到从受害者内存读取的越界中检索到的潜在秘密字节的值。
攻击的消减方法主要有以下几个:
关于使用编译器(如毕昇编译器)进行 spectre v1 的消减在LLVM社区^4^已有针对函数级别的方案,使用方法如下:
为单个函数添加属性.
void f1() __attribute__((speculative_load_hardening)) {}
为代码片段添加函数属性.
#pragma clang attribute push(__attribute__((speculative_load_hardening)), apply_to = function)
void f2(){};
#pragma clang attribute pop
添加编译选项, 整体使能幽灵攻击防护
-mllvm -antisca-spec-mitigations=true
-mllvm -debug-only=aarch64-speculation-hardening # 查看调试信息
下面简单介绍一下编译器(如毕昇编译器)的消减原理^3^。
原始代码:
if (untrusted_value < limit)
val = array[untrusted_value];
// Use val to access other memory locations
生成的汇编大概是这样:
CMP untrusted_value, limit
B.HS label
LDRB val, [array, untrusted_value]
label:
// Use val to access other memory locations
消减后:
if (untrusted_value < limit)
val = array[untrusted_value < limit
? untrusted_value : 0];
// Use val to access other memory locations
生成的汇编大概是这样:
CMP untrusted_value, limit
B.HS label
CSEL tmp, untrusted_value, WZr, LO
CSDB
LDRB val, [array, tmp]
label:
// Use val to access other memory locations
可以看到,我们主要使用<span>CSEL</span>
+<span>CSDB</span>
(Consume Speculative Data Barrier) 两个指令组合进行消减,CSEL 指令引入一个临时的变量,如果没有投机执行,这个指令看起来是多余的,因为它还是会等于<span>untrusted_value</span>
, 在投机执行且推测错误的情况下,临时变量的值就变成了0,且 CSDB 确保 CSEL 的结果不是基于预测的。
附:编译器防护前后反汇编对比
主要代码在文件<span>AArch64SpeculationHardening.cpp</span>
, 虽然 LLVM社区^4^有很多讨论^5^,但代码一共只有七百行左右,主要有三个步骤:
启用自动插入投机安全值。
// 对于可能读写内存的指令(不止是load), 加固其相关的寄存器
MachineInstr &MI = *MBB.begin();
if (MI.mayLoad())
BuildMI(MBB, MBBI, MI.getDebugLoc(),
TII->get(Is64Bit ? AArch64::SpeculationSafeValueX
: AArch64::SpeculationSafeValueW))
.addDef(Reg)
.addUse(Reg);
其中:如果全是load到 GPR(通用寄存器),就对寄存器加固,否则对地址加固,因为 mask load 的值预计会导致更少的性能开销,因为与 mask load 地址相比,load 仍然可以推测性地执行。但是,mask 只在 GPR寄存器上很容易有效地完成,因此对于load到非GPR寄存器中的负载(例如浮点load),mask load 的地址。
将消减代码添加到函数入口和出口(初始化)。
for (auto Entry : EntryBlocks)
insertSPToRegTaintPropagation(
*Entry, Entry->SkipPHIsLabelsAndDebug(Entry->begin()));
...
// CMP SP, #0 === SUBS xzr, SP, #0
BuildMI(MBB, MBBI, DebugLoc(), TII->get(AArch64::SUBSXri))
.addDef(AArch64::XZR)
.addUse(AArch64::SP)
.addImm(0)
.addImm(0); // no shift
// CSETM x16, NE === CSINV x16, xzr, xzr, EQ
BuildMI(MBB, MBBI, DebugLoc(), TII->get(AArch64::CSINVXr))
.addDef(MisspeculatingTaintReg)
.addUse(AArch64::XZR)
.addUse(AArch64::XZR)
.addImm(AArch64CC::EQ);
将消减代码添加到每个基本块。
BuildMI(SplitEdgeBB, SplitEdgeBB.begin(), DL, TII->get(AArch64::CSELXr))
.addDef(MisspeculatingTaintReg)
.addUse(MisspeculatingTaintReg)
.addUse(AArch64::XZR)
.addImm(CondCode);
SplitEdgeBB.addLiveIn(AArch64::NZCV);
...
BuildMI(MBB, MBBI, DL, TII->get(AArch64::HINT)).addImm(0x14);
其他说明,这个方案依赖于<span>X16/W16</span>
寄存器(CSEL 要用到),如果已经被使用,则只能插入内存屏障指令:
BuildMI(MBB, MBBI, DL, TII->get(AArch64::DSB)).addImm(0xf);
BuildMI(MBB, MBBI, DL, TII->get(AArch64::ISB)).addImm(0xf);
支持软件安全技术的一个基本假设是,处理器将忠实地执行程序指令,包括其安全检查。本文介绍的幽灵攻击,它利用了投机执行违反这一假设的事实。实际攻击的示例不需要任何软件漏洞,并允许攻击者读取私有内存并从其他进程和安全上下文注册内容。软件安全性从根本上取决于硬件和软件开发人员之间对CPU实现允许(和不允许)从计算中暴露哪些信息有明确的共识。因此,虽然文中描述的对策可能有助于在短期内限制攻击,但它们只是权宜之计,因为最好有正式的体系结构保证,以确定任何特定代码构建在当今的处理器中是否安全。因此,长期解决方案将需要从根本上改变指令集体系结构。更广泛地说,安全性和性能之间存在权衡。本文中的漏洞以及许多未介绍的漏洞都来自于技术行业长期以来对最大限度地提高性能的结果。这之后,处理器、编译器、设备驱动程序、操作系统和许多其他关键组件已经进化出了复杂优化的复合层,从而引入了安全风险。随着不安全成本的上升,这些设计需要选择性修改。在许多情况下,需要改为安全性优化的^^替代实现。^^
参考
更多回帖