前言
继续Meltdown && Spectre安全漏洞的相关分析,本文关注V2 patch中引入的retpoline技术。
一、Retpoline
Retpoline是针对Spectre V2漏洞(CVE-2017-5715)提出的规避方案。
Spectre V2漏洞利用CPU的间接分支预测(indirect branch predictor)功能,通过事先训练分支,让分支预测器去影响受害者进程,然后通过侧信道的方式来获取受害者进程的信息。
其实这个变种2的漏洞利用是非常困难的,Jann Horn的利用其实也是在一个老版本的kvm上,按照 Linus的说法是利用Spectre是”fairly hard”。
目前有两种方案来缓解Spectre漏洞,即硬件方案和软件方案。
- 硬件方案就是IBRS + IBPB, 直接在硬件层面阻止投机执行 (speculative execution),当然,这会导致性能很低,所以IBRS没有进入内核。
- 软件方案主要就是retpoline了, 因为 性能影响较低,最终得以进入内核主线。
Retpoline的基本原理
retpoline是 “return” 和 “trampoline”,也就是在间接跳转的时候用return指令添加了一个垫子。
在间接指令跳转时,在原有代码中加入不会被执行到的死循环代码,于时在CPU执行间接跳转指令(JMP/CALL)时,进行预测执行时,只能预取到被加入的死循环代码,不会预取到内核中的其他数据,从而防止了数据泄露,规避掉了Spectre V2。
由于几乎没有添加额外的执行流程(添加的死循环代码永远不会被执行),所以对性能影响比较小,官方给出的数据是:0.5%-2%。
原理比较简单,一些关键点:
- 只针对间接跳转指令[2]。即JMP和CALL指令。
- 对于C代码,包括内核代码和应用程序代码。需要借助新的编译器选项-mindirect-branch=thunk-extern,通过新的编译器(gcc)来实现死循环代码的插入和跳转。
- 对于内核中的汇编代码。由于不能借助编译器完成,则只能自己实现,其原理跟编译器中的类似,如前面所述。本文主要关注这部分内容。
ret指令的预测跟jmp和call不太一样,ret依赖于Return Stack Buffer(RSB)。跟indirect branch predictor不一样的是,RSB是一个 先进后出的stack。当执行call指令时,会push一项,执行ret时,会pop一项,这很容易由软件控制,比如下面的指令系列:
__asm__ __volatile__(" call 1f; pause;"
"1: call 2f; pause;"
"2: call 3f; pause;"
"3: call 4f; pause;"
"4: call 5f; pause;"
"5: call 6f; pause;"
上图显示了retpoline的基本原理,即用一段指令代替之前的简介跳转指令,然后CPU如果投机执行会陷入一个死循环。
下面分析一下jmp间接跳转指令如何被替换成retpoline的指令。
在这个例子中,jmp通过rax的值进行间接跳转,如果没有retpoline,处理器会去询问indirect branch predictor,如果之前有攻击者去训练过这个分支,会导致CPU执行特定的一个gadget代码。下面看看retpoline是如何阻止CPU投机执行的。
“1: call load_label”这句话把”2: pause ; lfence”的地址压栈,当然也填充了RSB的一项,然后跳到load_label;
“4: mov %rax, (%rsp)”,这里把间接跳转的地址(*%rax)直接放到了栈顶,注意,这个时候内存中的栈顶地址和RSB里面地址不一样了;
如果这个时候ret CPU投机执行了,会使用第一步填充在RSB的地址进行,也就是”2: pause ; lfence”,这里是一个死循环;
最后,CPU发现了内存栈上的返回地址跟RSB自己投机的地址不一样,所以,投机执行会终止,然后跳到*%rax。
下面看看call指令被替换成retpoline的指令之后如何工作。
- 首先从”1: jmp label2”跳到”7: call label0”;
- “7: call label0”将”8: … continue execution”的地址压入了内存栈以及RSB中,然后跳到label0;
- “2: call label1”将”3: pause ; lfence”的地址压入了内存栈以及RSB中,然后跳到lable1;
这个时候内存栈和RSB如下:
- “5: mov %rax, (%rsp)” 这里把间接跳转的地址(*%rax)直接放到了栈顶,注意,这个时候内存中的栈顶地址和RSB里面地址不一样了;
- “6: ret”.如果这个时候ret CPU投机执行了,会使用第3步填充在RSB的地址,”3: pause ; lfence”. 这是一个死循环;
- 最后,CPU发现了内存栈上的返回地址跟RSB自己投机的地址不一样,所以,投机执行会终止,然后跳到*%rax;
这个时候内存栈和RSB如下
当间接地址调用(*%rax)返回的时候,通过RSB和内存中地址继续执行步骤2的压的地址,也就是8那里。
二、部署
由于大部分的间接跳转都是由编译器产生的,所以需要编译器的支持,目前最新的gcc已经支持了-mindirect-branch=thunk选项用于替换间接指令为retpoline系列。下面看看一个简单的例子:
#include <stdio.h>
#include <stdlib.h>
typedef void (*fp)();
void test()
{
printf("indirect test\n");
}
int main()
{
fp f = test;
f();
}
上面是一个典型的间接跳转。
# gcc -mindirect-branch=thunk test.c -o test
# objdump -d test
...
00000000004004d8 <main>:
4004d8: 55 push %rbp
4004d9: 48 89 e5 mov %rsp,%rbp
4004dc: 48 83 ec 10 sub $0x10,%rsp
4004e0: 48 c7 45 f8 c7 04 40 movq $0x4004c7,-0x8(%rbp)
4004e7: 00
4004e8: 48 8b 55 f8 mov -0x8(%rbp),%rdx
4004ec: b8 00 00 00 00 mov $0x0,%eax
4004f1: e8 07 00 00 00 callq 4004fd <__x86_indirect_thunk_rdx>
4004f6: b8 00 00 00 00 mov $0x0,%eax
4004fb: c9 leaveq
4004fc: c3 retq
00000000004004fd <__x86_indirect_thunk_rdx>:
4004fd: e8 07 00 00 00 callq 400509 <__x86_indirect_thunk_rdx+0xc>
400502: f3 90 pause
400504: 0f ae e8 lfence
400507: eb f9 jmp 400502 <__x86_indirect_thunk_rdx+0x5>
400509: 48 89 14 24 mov %rdx,(%rsp)
40050d: c3 retq
40050e: 66 90 xchg %ax,%ax
...
我们可以看到间接跳转已经被retpoline的指令系列所替换。 当然,如果是一些内嵌汇编的间接跳转,则需要自己手动去增加retpoline序列。
在Linux内核中,是通过一个内核命令行参数来决定是否开启retpoline的,如果开启则内核在启动时动态替换指令。这样最大限度的减小了内核的性能损耗。