intel:spectre&Meltdown侧信道攻击(一)——cpu乱序执行指令&volatile功能
只要平时对安全领域感兴趣的读者肯定都听过spectre&Meltdown侧信道攻击,今天简单介绍一下这种攻击的原理( https://www.bilibili.com/video/av18144159?spm_id_from=333.788.b_765f64657363.1 这里有详细的视频介绍,墙裂推荐)。
1、CPU顺序执行指令
众所周知,程序是由一条条指令顺序排列构成的,老的cpu也是逐行执行指令;随着cpu的技术发展(著名的摩尔定律),cpu执行指令的速度越来越快,和内存读写速度的差距越来越大。理论上,CPU执行一条指令耗时纳秒左右,但从内存读一次数据需要耗时约100纳秒(参考这里:https://zhuanlan.zhihu.com/p/24726196),理论上相差了百倍,业界称为冯诺依曼瓶颈;这个速度差异不解决,CPU执行速度再快都没用;由此衍生出了近年来CPU的一个重要特性:乱序执行
2、乱序执行:如下所示,比如有个if分支。正常情况下,如果MEM==0才会执行if分支;但CPU由于速度百倍于内存,等把这块内存的数据读出来,下面那4条指令早就执行完了,所以先不判断if条件是否成立,CPU会先执行这4条指令;等前面的指令执行完毕,轮到执行if 的时候,才会从内存读mem的数据,然后判断是否为0. 如果是,说明if这个分支本来就该执行,由于前面已经执行完毕,所以整个效率大大提升;退一步说,就算if条件不成立,cpu白执行了4条指令,这时只需要回退(主要是寄存器的值)即可,和以前的顺序执行比也没啥损失。这种提前预测分支执行的方法截至目前至少看起来没啥损失,还有一定的概率提升整体效率了!这个就是业界所谓的speculative execution!
3、缓存cache
前面说了,CPU执行指令的速度和从内存读数据的速度差了百倍。如果每次执行指令前都要从内存读取数据,CPU会闲死的;为了解决这个问题,衍生出了cpu另一个非常重要的功能:缓存;第一次从内存读取数据后,cpu会先把这些数据存放在内部缓存。下次再需要用这些数据时,不会立刻从内存读,而是先看看自己的缓存种是否已经有了,没有才会继续去内存读取;此种思想方法也能在一定程度上提升程序的执行效率和速度,但问题也随之而来:
cpu的缓存是否有该数据,直接影响了从内存读取该数据的效率,理论上差了近百倍,这个差异是非常明显的,这就给黑客留下了“把柄”;
4、meltdown和side-channel attack
(1)利用时间差,可以做的攻击有很多,先举一个通俗易懂的栗子:暴力猜密码
比如我设置了一个登陆密码“cnblogs”,用户输入密码后,后台验证的逻辑是逐个字符比对。比如用户输入djgnyd,第一个字符就不对,直接返回false;比如用户输入cjhtsf,第一个字符对了,再继续比对第二个,结果发现第二个错了,再次返回。这就给了黑客可乘之机:多次随机输入密码,利用不同的返回时间差猜测输入的密码是否正确!这就是业界俗称的side-channel attack;
(2)meltdown和side-channel attack
接下来介绍本文的重点:利用spectre和meltdown读取任意内存的数据,而不受任何权限限制;计算机的整个内存种,我们自己程序能用的只是一部分,还有很多内存是不能用的,比如部分内核的内存、其他进程的内存、其他虚拟机的内存(以下简称victim memory),操作系统会通过各种机制确保我们的程序无法访问(比如保护模式下的0~3环+CPL/DPL/RPL等机制控制、操作系统对内存rwx属性的管理)。如果不慎读到这些内存,windows会弹出c000005内存访问错误;如下:
(2.1)假设蓝色是我们能正常使用的内存,红色是无法使用的victim内存;我们在蓝色区域开辟一个数组A(绿色表示),只有两个元素A[0]和A[1]; 通过A指针访问这两个元素是ok的;但是要想通过A+X越界直接访问victim内存是不行的,cpu或操作系统会直接阻止,并抛出异常或弹框报错;
(2.2)同时继续再在蓝色区域开辟一个instrument 的数组,该数据所有数据一律不能存放cpu缓存(后面会解释原因);
(2.3)接下来最关键的点来了:if(xxx) access Instrument[A[x]]
如果直接执行A[x],由于越界到victim内存,会被终止;但这个指令在if条件分支,cpu的乱序执行特性会先不判断if条件是否成立,而是直接access Instrument[A[x]];此时也不会检查A[x]是否越界,而是直接读取该内存数据;假如读取的数据是4,那么Instrument[A[x]] = G,这个G会被存到CPU缓存;然后又从Instrument0开始一致读取到7,判断哪个数据读取的速度最快(上面有解释:没在缓存的只能从内存读,理论速度差了近百倍),很明显第4个单元的耗时最断,反推出A[x]=4,导致该victim的内存数据泄露;
有读者肯定会问:就算是预测分支乱序执行,为啥不检查A[x]是否越界了?整个攻击最核心的点就在这里啊!个人猜测:这和是cpu、操作系统的分工不明确导致的;cpu提前预测分支指令执行,这时if条件是否成立还不知道了,又怎么去判断A[x]是否越界了? 所以cpu spectaculative设计人员把这个验证的事情甩给了操作系统内核,希望正常执行到该命令时操作系统内核能检查一下是否越界,如果有,再kill程序、抛出异常;但等到操作系统验证时为时已晚,access Instrument[A[x]] 这条指令已经被执行,对应内存的数据已经被读取到了cpu的缓存. 就算if条件不成立,回退的是寄存器的值,cpu缓存的值还是存在的.......
这是核心的js代码:如果操作系统或浏览器没打补丁,理论上用户在浏览网页的时候,黑客可以通过这种方式从内存读取所有数据,导致密码等敏感数据泄露;
5、最后怎么避免自己的程序乱序执行代码了?VS里面把这里设置成已启用就行;
6、简化的代码如下(精华都在注释了),几个核心的函数:
(1)_mm_clflush:清除内存某个单元在cpu中的L1、L2、L3所有的缓存
(2)__rdtscp:开始计时
(3)_mm_mfence:后面的指令只能顺序执行
#include <intrin.h> #include <stdio.h> #include <Windows.h> #define SHIFT_NUMBER 0x0C #define PAGE_SIZE 4096 #define BLOCK_SIZE (1 << SHIFT_NUMBER)//0x1000,一个页 /* (LPBYTE)_aligned_malloc(256 * BLOCK_SIZE, PAGE_SIZE)这里申请了256个页;用flush函数 将这256个页在cpu中的缓存失效 详细解释可以看这了:http://scc.qibebt.ac.cn/docs/optimization/VTune(TM)%20User's%20Guide/mergedProjects/analyzer_ec/mergedProjects/reference_olh/instruct32_hh/vc31.htm https://zhuanlan.zhihu.com/p/141144249 */ void CacheLineFlush(LPBYTE lpArray, UINT index) { _mm_clflush(&(lpArray[index << SHIFT_NUMBER])); } void CacheLineFlush_all(LPBYTE lpArray) { for (UINT i = 0; i < 256; i++) CacheLineFlush(lpArray, i); } /************************************************************************/ /* 统计各个块的访问速度,并返回最快的那个块的索引 */ /************************************************************************/ BYTE GetAccessByte(LPBYTE lpArray) { UINT64 speed[256]; UINT64 start, min; UINT index, junk; BYTE result; //为min赋初始值 min = 0; //测试访问速度 for (int i = 0; i < 256; i++) { //获取array[index]的地址;也就是页的索引 index = i << SHIFT_NUMBER; //mfence指令用于序列化内存访问,即让乱序执行无效化。 //后面的指令必须在前面的内存读写完成后再开始发射执行。 _mm_mfence(); //记录开始周期 start = __rdtscp(&junk); junk = *(LPDWORD)(&lpArray[index]);//记录读取每个单元的时间 _mm_mfence(); speed[i] = __rdtscp(&junk) - start; //如果是初始值,或者比当前值还小 if ((min == 0) || (speed[i] < min)) { min = speed[i]; result = (BYTE)i; } } return result; } /************************************************************************/ /* 用index作为数组索引,访问array的某个元素 */ /************************************************************************/ BYTE AccessArray(LPBYTE lpArray, UINT index) { return lpArray[index << SHIFT_NUMBER];//左移12位,效果相当于乘以4096;那么可以把index看成是页的索引,这里访问index指向页开头的第一个字节; } int main() { //假定的kernel内存 BYTE kernel[4]; //array是用户可控制的内存 LPBYTE array; kernel[0] = 0x55; kernel[1] = 0xAA; kernel[2] = 0xF0; kernel[3] = 0x0F; /* 分配256个页,作用相当于视频中的instrument;为什么是256了?内存中每个最小单元是1字节,能表示从0~255一共256个数; 后续会挨个读取这256个页开头的第一个字节,如果速度快,说明cpu里面已经有缓存了,kernel单元(也就是视频中的victim单元) 大概是是这个数; */ array = (LPBYTE)_aligned_malloc(256 * BLOCK_SIZE, PAGE_SIZE); /* 实现原理: kernel假定是受保护的内存数据 (本demo里可访问)。 array为用户可控制的一个数组,一共分为256个块,每个块的大小为2的整数倍: (1 << SHIFT_NUMBER)。 然后从kernel中读取一个byte,以这个byte为索引,去访问array所对应的块。 之后立刻循环读取一遍array的各个块,如果之前访问成功了,那么对应的块应该还在缓存中,对应的访问时间要少很多。 统计各个块的访问周期数,最快的块,他的索引就是受保护的那个byte。 CacheLineFlush_all 函数用于把整个数组从缓存中清除出去,这样不至于污染访问速度。 AccessArray 函数用于以一个索引去访问一个数组。 GetAccessByte 函数用于测试数组的各个块的访问速度,返回最快的那个块的索引。 */ CacheLineFlush_all(array);//清空自己申请的256页在cpu的缓存,避免影响后续的读取计时 AccessArray(array, kernel[0]);//这里为了突出重点说明侧信道攻击,并没有用if分支预测执行,而是直接用kernel的元素做下标访问,让这个数写入cpu缓存 printf("Access fastest: 0x%02X\n", (DWORD)GetAccessByte(array));//看看哪个内存单元读取的速度最快,由此反推出kernel元素的值 CacheLineFlush_all(array); AccessArray(array, kernel[1]); printf("Access fastest: 0x%02X\n", (DWORD)GetAccessByte(array)); CacheLineFlush_all(array); AccessArray(array, kernel[2]); printf("Access fastest: 0x%02X\n", (DWORD)GetAccessByte(array)); CacheLineFlush_all(array); AccessArray(array, kernel[3]); printf("Access fastest: 0x%02X\n", (DWORD)GetAccessByte(array)); getchar(); return 0; }
7、为了直观感受分支预测的“功效”,这里举个java的例子,如下:
public class BranchPrediction { public static void main(String args[]) { long start = System.currentTimeMillis(); for (int i = 0; i < 100; i++) { for (int j = 0; j <1000; j ++) { for (int k = 0; k < 10000; k++) { } } } long end = System.currentTimeMillis(); System.out.println("Time spent is " + (end - start)); start = System.currentTimeMillis(); for (int i = 0; i < 10000; i++) { for (int j = 0; j <1000; j ++) { for (int k = 0; k < 100; k++) { } } } end = System.currentTimeMillis(); System.out.println("Time spent is " + (end - start) + "ms"); } }
代码分别执行两个嵌套的循环,循环总次数都是一样的,不同的是每层循环的次数不同。第一个嵌套是越往内循环次数越多,第二个嵌套是越往内循环次数越少!同样的都是10亿次循环,耗费的时间如下:
Time spent in first loop is 5ms
Time spent in second loop is 15ms
时间差了3倍!为什么差异会这么大了?分支预测策略最简单的一个方式自然是“假定分支不发生”。对应到上面的循环代码,就是循环始终会进行下去。在这样的情况下,上面的第一段循环,也就是内层 k 循环 10000 次的代码。每隔 10000 次,才会发生一次预测上的错误。而这样的错误,在第二层 j 的循环发生的次数,是 1000 次。最外层的 i 的循环是 100 次。每个外层循环一次里面,都会发生 1000 次最内层 k 的循环的预测错误,所以一共会发生 100 × 1000 = 10 万次预测错误。上面的第二段循环,也就是内存 k 的循环 100 次的代码,则是每 100 次循环,就会发生一次预测错误。这样的错误,在第二层 j 的循环发生的次数,还是 1000 次。最外层 i 的循环是 10000 次,所以一共会发生 1000 × 10000 = 1000 万次预测错误。
8、另一个cpu乱序执行的例子,在这里:https://www.bilibili.com/video/BV1H5411L7aG?p=4 马士兵的代码,写的挺好的!
(1)简单解释一下代码:核心部分就是两个线程内部的run方法了:分别都是两句赋值的语句;假如这两行赋值语句都是顺序执行,没有任何乱序执行,那么x和y的组合应该是(0,1)(1,0)(1,1)的组合,绝对不可能是(0,0)!
public class T04_Disorder { private static int x = 0,y=0; private static int a= 0,b=0; public static void main(String[] args) throws Exception { int i=0; for (; ;) { i++; x=0;y=0; a=0;b=0; Thread one = new Thread(new Runnable() { @Override public void run() { a=1; x=b; } }); Thread other = new Thread(new Runnable() { @Override public void run() { b=1; y=a; } }); one.start();other.start(); one.join();other.join(); String result = "第"+i+"次("+x+","+y+")"; if (x==0 && y==0) { System.err.println(result); break; } } } }
我是执行到第187w次的时候遇到了乱序执行的(0,0)对!
为了代码执行安全,怎么防止cpu乱序了? 以JVM为例,在java代码层面可以增加volatile关键词,那么在cpu硬件层面是怎么做的了?lock锁!另一个java层代码执行同步的关键字synchronized底层也是通过lock来实现的!
(2)马老师另一个经典的DLCsingleton案例,代码如下:
package singleton; public class Mgr06 { private volatile static Mgr06 INSTANCE; private Mgr06() { } public static Mgr06 getInstance() { if (INSTANCE == null) { synchronized (Mgr06.class) { if (INSTANCE == null) { try { Thread.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } INSTANCE = new Mgr06(); } } } return INSTANCE; } public void m() { System.out.println("m"); } public static void main(String[] args){ for (int i = 0; i < 100; i++) { new Thread(()-> // 输出实例的哈希值 System.out.println(Mgr06.getInstance().hashCode()) ).start(); } } }
如果没有cpu的乱序执行,INSTANCE完全不需要加volatile,只用synchronized+两次if检查就够了!但是cpu有乱序的功能,不排除极个别超高并发(比如双11、618时电商网站的商品库存)时线程的代码被cpu乱序执行,让第一个线程执行INSTANCE = new Mgr06()这个生成obj的代码时顺序紊乱,导致第二个线程调用getInstance时第一个if判断INSTANCE不等于null,直接返回临时生成的INSTANCE值,所以组好在INSTANCE前面加上volatile,防止cpu乱序执行代码!
PS:因为代码加了syncronized,所以cpu乱序并不影响第一个进入临界区的线程。在第一个线程还未退出临界区之前,其他线程如果同时调用getInstance方法,可能导致得到错误的INSTANCE值(第一个if判断出错),所以这里只会影响后续的线程,不会影响第一个进入临界区的线程!
9、既然cpu乱序执行可能带来无法预料的后果,怎么防止cpu乱序执行了? 解铃还须系铃人:既然乱序是cpu导致的,阻止乱序执行自然也需要cpu支持!
(1)x86架构的cpu提供了三条内存屏障(memory barrier)指令:
- sfence: store| 在sfence指令前的写操作当必须在sfence指令后的写操作前完成;
- lfence:load | 在lfence指令前的读操作当必须在lfence指令后的读操作前完成;
- mfence:modify/mix | 在mfence指令前的读写操作当必须在mfence指令后的读写操作前完成。
当然,除了以上3条,用lock这种原子指令锁定某块内存不让其他核访问也是可以的(俗称full barrier)!比如锁定某条指令的地址,所有的核都无法读取该指令,当然没法乱序执行该指令了!
(2)除了x86,另一个arm架构当然也提供了汇编级别的内存屏障指令防止乱序,如下:
- dmb:数据存储器隔离,数据内存屏障指令,Data Memory Barrier。DMB指令保证: 仅当 所有在它前面的存储器访问操作都执行完毕后,才提交(commit)在它后面的存储器访问操 作。其它数据处理指令等可以越过 DMB 屏障乱序执行。
- dsb:数据同步隔离,数据同步屏障指令,Data Synchronization Barrier。比DMB严格: 仅 当所有在它前面的存储器访问操作都执行完毕后,才执行在它后面的指令(亦即任何指令都 要等待存储器访问操作)
- isb:指令同步隔离,指令同步屏障指令,Instruction Synchronization Barrier。最严格:它会清洗流水线,以保证所有它前面的指令都执行完毕之后,才执行它后面的指令。即ISB 屏 障之前的指令保证执行完,屏障之后的指令直接flush掉再重新从Memroy中取指。
参考:https://www.freebuf.com/articles/system/159811.html 一步一步理解CPU芯片漏洞:Meltdown与Spectre
https://meltdownattack.com/ 官网
https://www.bilibili.com/video/av18144159?spm_id_from=333.788.b_765f64657363.1 15分钟读懂英特尔熔断幽灵漏洞-Emory
https://www.fortinet.com/blog/threat-research/into-the-implementation-of-spectre (中文翻译:https://zhuanlan.zhihu.com/p/33635193)Spectre 攻击详解(详细的demo代码)
https://bbs.pediy.com/thread-224040.htm 简短的demo代码,非常适合入门学习原理
https://www.cnblogs.com/zenny-chen/archive/2013/03/28/2986527.html 与Cache相关的控制
https://bbs.pediy.com/thread-254288.htm spectre跨进程泄露敏感信息
https://www.bilibili.com/video/BV1H5411L7aG?p=4 synchronized关键字和字节码原语