人生第一次见到硬件层面上的漏洞,好好研究一下看看
Spectre研究笔记
背景知识
这个漏洞的成因其实是 CPU 在优化程序执行过程中所导致的。所以在研究这个问题之前,我们首先要提导致了这个漏洞产生的优化方式:
- Cache 缓存
- 分支预测
Cache 缓存
我们知道,当我们访问一个内存的时候,比如说如下的代码:
1 | int temp = array[index]; |
我们需要进行两个操作:
- 计算出当前的内存的偏移量
- 将当前偏移位置的数据取出来
当进行数据操作的时候,我们知道读写的时候速度很慢,当我们执行第二个操作的时候,程序需要发生一次内存读写:
如果我们多次进行这样的数据访问的话,那么会导致我们程序执行的时间大大增加。
为了提高程序访问数据的速度,设计者们采用了局部性原理,也就是地址相邻的数据可能会被频繁访问的特点。
每次取出数据的时候,会将当前地址周围的数据提前缓取出,存在cache
中。每次访问内存的时候,首先检查cache
中是不是含有我们查找的地址对应的数据,如果有的话,就会将当前的数据取出
如上图,此时橙色的数据块是我们查找的数据,那么此时就不会进行内存访问,而是直接从 cache 中将数据取出。这个过程比直接访问内存要快,从而提高了程序的执行速度。
分支预测
在程序执行的过程中,很多指令其实不一定要同时执行,比如下列指令:
1 | mov ecx, [edi] |
可以看到,有一个取值的操作,并且在这个指令前的两个指令与当前指令无关,所以我们完全可以打乱程序的执行顺序:
1 | mov eax, 3 |
打乱了执行的顺序之后,这些指令就能够并发的执行。
CPU的流水线工作流程如下:
整个执行的流程如下:
- 取指令(Fetch)
- 译码(Decode)
- 执行(Execute)
- 写回(Write-Back)
由于上面的指令之间不存在相互依赖关系,所以这里三个指令就能够并发执行,可以在第一个指令进行执行这个位置的时候,第二个指令在译码,第三个指令在取指,进行并发执行的时候也不会影响。
但是,遇到这类代码的时候,就没有办法进行并发执行:
1 | if(a > 10){ |
遇到了分支的时候,代码就没有办法直接进行并发执行了。因为这个时候不知道当前的代码会不会影响并发,所以这个时候就不会进行乱序执行,而是会执行分支预测。
比如将上面的判断写起来的时候如下逻辑:
1 | cmp edx, 10; edx = a |
此时cpu不知道当前的程序会进行到哪一个分支上,但是为了提高执行速度,此时的 cpu 会尝试进行分支预测,也就是通过一定的策略,猜测此时的程序会执行的位置,并且提前执行当前的指令。如果执行正确,那么就继续执行之后的指令,否则的话则将当前预执行的指令回滚,然后重新执行正确的分支
对于分支预测,有很多相关的研究,一个最简单的思路就是
对于所有的跳转,统一进行跳转
这个操作虽然很简单,但是由于一个程序存在大量循环语句,这个预测其实还是有一定的优化意义的。
为了增加命中率,又有了一种与状态机相关的预测方法
对于当前的跳转,设置一个状态机,以00表示。每次发生一次跳转,就将一个状态置为1。比如说跳转一次,则此时状态为01,再跳转一次,则状态为10。当这个状态机达到11或者10的时候,遇到分支的时候就预测其将进行跳转,否则的话则不进行分支跳转
这个算法增加了不少命中率,使得分支预测更加准确。之后在这个思想上提出了一种新的预测思想:
使用一个 Branch History Register ,记录当前的跳转情况。比如说0110表示到目前位置之前,对于分支我们第一次未发生跳转,中间跳转了2次,最后1次没有发生跳转,然后此时我们就会到一个 Pattern History Table 中查找当前的跳转模式。并且当前的跳转结果也放到学习过程,对这个状态机进行训练。
这个似乎是最近的一个比较核心的预测思路了,之后的思路都是在这个算法上进行的优化。
漏洞成因
当我们在进行分支预测的时候,程序会将分支部分的代码进行预执行.比如:
1 | if(i < index_array_size){ |
上述情况中,index_array
是一个用来存储下标的数组,那么当 i 的值大于512的时候,如果训练合理,此时的分支预测会导致程序乱序体现执行了if
语句这种的部分内容:
1 | index_array[i] * 512 |
上述的数据作为数组array2
的下标,于是会把这个地址以及周围的数据放在 cache 中。
这样的时候,index_array[i] * 512 的数据其实就泄露了。因为这个时候,array2[index_array[i] * 512]
已经被读入到cache 中。
那么这个index_array[i]
此时作为下标的形式存在了 cache 中
当然,普通的index_array大部分就是如下的样子:
1 | index_array[16] = {1,2,3,4,5,6...}; |
但是如果,这个index_array的下标如果能够被我们控制的话,我们能够选择一个敏感的位置的数据
1 | idnex_array[offset_to_kernel_function] |
那么我们就能够利用数组,将这些敏感位置的数据泄露。
现在我们把我们的目标数据作为index_array[i]
读入了cache,那么接下来我们就能够利用 cache 的特性 ———— 读取速度比内存块 这一点将数据进行猜测。这种攻击方式我们称之为侧信道攻击。
侧信道攻击
对于一些数据的猜测攻击,我们有时候可以不直接将数据本身泄露出来,而是可以选择和数据相关的参数,比如
- 访问时间
- 温度
- 声音
- 等等。。。。。。
当然不是所有的信息都能够用得上,但是只要由一点能够利用上,就有可能将我们需要的数据从看似安全的环境下取出。
这次的攻击我们利用的就是访问时间。因为此时我们的数据已经被读取到了 cache 上,因此访问的速度和原先的访问速度存在差异。那么我们这个时候就能够通过检测访问当前数组上的元素的时间差异,猜测当前我们访问的数据是不是已经存放到了 cache 中,从而得到我们当前数据的下标,实现信息泄露。
实例
这里结合着网上流传最广的 Spectre 的测试代码吗来分析。首先我们需要可能利用的数组:
1 | // 这个数组表示256个ascii码,在侧信道攻击中用于数据泄露 |
然后,我们可以伪造一个函数,大致的功能是访问array2中,以array1作为下标的数组元素,此时array1尝试访问一个我们想知道数据的地址
1 | void victim(int i){ |
利用这个函数,我们能够将数据作为下标,读入到cache中。
这之后,我们需要一个位置进行侧信道攻击,具体来说可以是如下的形式:
1 | int i = 0; |
然后我们利用一个函数,找到这个可能性最大的值,避免误差的同时,我们可以把次可能的值也列出来:
1 | j = k = -1; |
最后,我们通过检查每个字符出现的次数,决定此时的侧行道攻击是否成功:
1 | if (results[j] >= (2 * results[k] + 5) || (results[j] == 2 && results[k] == 0)) |
作为估算,我们此时可以把估算的命中次数一类的值都记录下来,方便调试学习:
1 | // 0下标为相似度最高的数据,1为次高的数据 |
完整的代码参考:
https://github.com/Eugnis/spectre-attack
自己思考的一些QA
Q1 关于预测代码,有一段的内容为:
1 | if( i < index_array_size ){ |
既然是利用了缓存,从地址&array2[index_array[i] * 512]
周围的数据读入缓存,再利用侧信道攻击进行数据泄露,猜测这个index_array[i] 指向的数据内容从而进行数据泄露。那么为什么我们不能够直接写成下列的内容:
1 | if( i < index_array_size ){ |
这样的话我们从index_array[i]
中读取的数据应该也会直接写入缓存,此时不是也能够猜测缓存吗?
A1 这个时候,我们知道index_array[i]
会被读入到缓存里面,但是利用侧信道攻击的话,只能够猜测当前array2
的数组的下标值,而没办法猜测当前的数组的值。所以只能将当前需要查询的数据放入数组下标,才能够利用侧信道攻击。
Q2 关于预测代码的一段:
1 | mix_i = ((i * 167) + 13) & 255; |
这段为什么不能够顺序访问呢?
A2 因为这个时候,如果我们顺序访问的话,CPU 会预测下一步的位置,从而让我们的侧信道访问时间受到干扰,于是这里打乱访问的顺序,从而能够强化侧信道攻击的效果。
参考网站:
https://en.wikipedia.org/wiki/Branch_predictor