这篇文章将讲述“幽灵 ”漏洞的实现细节,该漏洞是针对AMD、ARM和Intel 处理器的CPU。攻击的原理可以在b站 这里看到,这里附录里有poc源代码 的链接,也可以一边看文章一边看代码 。
第5和第8行包含了用于 Flush+Reload 缓存攻击的 rdtscp 和 clflush 文件。这些指令并不是在所有的处理器上都是可用的,例如,用于读取时间戳记计数器的rdtscp,通常只用于最新的cpu上,更常见的是rdtsc,但它是非序列化的,意味着cpu可能会将其序列化,所以对于时序攻击,它会和一个序列化的比如cpuid这样的命令一起使用。如果rdtscp和rdtsc都不可以用,那还有别的选择(在下一篇博文中我将会讲解在ARM处理器中的使用细节)。
第21行的代码中array1 代表了位于攻击者和受害者之间的共享内存空间。我们并不关心这个空间里面的内容,它的大小可以随意指定,此处为160字节。
array1被两个未使用的数组unused1和unused2包围着:这些数组有助于确保我们命中不同的缓存行。在许多处理器上(例如Intel i3,i5,i7,ARM Cortex A53等),L1高速缓存每行有64个字节。
第25行就是我们的秘密内容了,这个秘密内容只有受害者知道,也是攻击者尝试获取的内容。
在poc中,攻击者和受害者共享相同的进程,这样便于简化代码。事实上,受害者和攻击者共享一个内存空间,并且攻击者可以调用位于29到35行的victim_function()函数。
27到35行就是问题所在的victim_funtion()函数,第27行是确保在编译期间victim_funtion()函数不会被编译器删除。Victim_funtion()函数在 Spectre paper 的第四部分有详细的描述。一旦x的值超过了array1_size,处理器就会执行下列步骤:
1、 读array1_size的值。这会导致缓存丢失。
2、 当处理器读取array1_size的值时,由于缓存丢失,它是long型的,分支预处理器认为if语句的条件判断为true,然而这次推断是错误的。
3、 推测性的读取array1[x]的内容,缓存命中的缘故所以速度会很快。
4、 读取array2[array1[x] * 512] ,由于缓存丢失,它会是long型的。
5、 当步骤4的读取尚未结束时,步骤1的结果返回了,处理器这才意识到之前的if推断是错误的,并退回之前的状态,但是并没有清除缓存。
Spectre paper 中提到了k*256,其中k是array1[x](即array1[x]*256),而代码中为什么却是array1[x]*512呢?这是因为乘法因子必须要是高速缓存行的大小,对于大多数英特尔处理器而言,正如我们前面所说的,每行64字节,即64×8=512。不同的处理器这个值也是不同的。
第43行开始是readMemoryByte() 函数,它的功能是推测位于给定地址中的值。无论字节数是多少(此例中是256 byte), 函数将测试使用Flush + Reload缓存攻击的方法访问该值所需的时间。
所有的时间值都存储在results表中,最后仅有2个最优值被返回。
如下代码所示,第52、53行仅仅初始化了result表,不存在缓存攻击。
缓存攻击是在第54到89行实现的。首先,进行清除工作,我们刷新整个array2表的内容,这个表是和攻击者共享的,并且需要能够存储256个不同的缓存行,每个缓存行大小是512位。
接下来62到77行的代码,是多次调用victim_function()函数,这一部分的代码执行顺序是:
第一步,刷新缓存行
第二步,确保刷新工作已经完成,并且处理器没有将它序列化。注释中提到,intel 和AMD处理器中的序列化指令mfence也可以替代这个功能,其实cpuid指令也可以实现。
第三步,是计算x的值,这几行有点难以理解:
第四步,调用victim_function()函数
不必逐行去理解第三步涉及的代码,printf打印输出一下就能看见程序运行的结果:
连续五次生成一个较小的x,让victim_function(x)函数执行分支语句,让分支预处理器误认为就改执行这个分支语句,在经过五次这样的训练之后,进程就会在第六次也这样执行代码。
在错误的执行了Victim_function()函数中的分支语句后,我们就知道访问缓存中给定字节的值需要的时间,这也是缓存攻击的核心(第79到89行):
正如注释中所说,我们不是简单地猜测访问序列中每个字节的时间,而是将它们混合在一起,以便处理器无法猜测接下来要访问哪个字节,然后优化访问。这个部分由第82行处理。
然后,第83行, addr = &array2[mix_i * 512] 计算要检查的缓存行的地址。接下来的84到86行,我们定时访问这个缓存行中的每一个值。如果这个速度很快,那就是缓存命中。如果访问速度很慢,那它就是一个缓存缺失。如果是缓存命中,这就意味着之前的缓存行在调用victim_function()函数时被访问过。然而之前调用victim_function()函数时是认为x大于array1_size ,在执行期间,处理器访问了array1[x]数组,可是索引x却是远远大于数组的长度,这就越界读取了内存中的内容。索引x可以调整来读取之前写到“secret”字段中内容,例如,假设处理器读取到了secret字段内容’Magic’的’M’,那么array1[x]=’M’,第33行,处理器就可以越界访问array2[‘M*512’]。
再来看一下第33行的victim_function()函数:
当处理器访问到array2['M'*512],它就缓存了’M’的行号。到这里可以知道,像mix_i = array1[x] = 'M'这样的形式就能知道secret字段里面的值了。我们将把它记录下来,result[‘M’]就成为一个缓存命中了:
CACHE_HIT_THRESHOLD的测试中可以清楚知道,低于这个值是缓存命中,超过这个值就不是。CACHE_HIT_THRESHOLD的值是多次测试后获得的。为什么它要测试 mix_i != array1[tries % array1_size]?可能是为了排除这个索引,因为它通常会提供更多的缓存命中,因为tries是当前的索引值。
readMemoryByte 函数剩下的部分就很简单了:在缓存命中选择最可能的2个字节值。
[注意]传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!