给程序员解释Spectre和Meltdown漏洞

我犹豫了很久要不要写这篇东西。伦理上我当然不想鼓励攻击行为,所以更好的方法是不要讨论它。问题是这玩意儿论文都出来了,不讨论它似乎又是掩耳盗铃。所以,不轻不重地讨论一下吧。

Spectre和Meltdown是缓冲时延旁路攻击的两种实际攻击方法。

什么叫旁路(Side Channel)攻击呢?就是说,在你的程序正常通讯通道之外,产生了一种边缘特征,这些特征反映了你不想产生的信息,这个信息被人拿到了,你就泄密了。这个边缘特征产生的信息通道,就叫旁路。比如你的内存在运算的时候,产生了一个电波,这个电波反映了内存中的内容的,有人用特定的手段收集到这个电波,这就产生了一个旁路了。基于旁路的攻击,就称为旁路攻击。这个论文对这种攻击有一个归纳:csrc.nist.gov/csrc/medi。读者可以体会一下可能的攻击方法:时延,异常(Fault),能耗,电磁,噪声,可见光,错误消息,频率,JTag等等,反正你运行总是有边缘特征的,一不小心这个边缘特征就成了泄密的机会。

缓冲时延(Cache Timing)旁路是通过内存访问时间的不同来产生的旁路。假设你访问一个变量,这个变量在内存中,这需要上百个时钟周期才能完成,但如果你访问过一次,这个变量被加载到缓冲(Cache)中了,下次你再访问,可能几个时钟周期就可以完成了。这样,如果我攻击一个对象(比如一个进程,或者内核),要得到其中某个地址ptr的内容,我只要和它共享一个数组,然后诱导它用ptr的内容作为下标访问这个数组,然后我检查这个数组每个成员的访问时间,我就可以知道ptr的值了。

你一定觉得这是不可能的,对吗?在Cache Timing Side Channel刚被提出来的时候,大家也是认为出问题的机会是很小的,只是理论上有效而已——直到Spectre和Meltdown被提出来(其实之前已经有人用这种方法来对内核地址随机化(KSLR)进行攻击了,Spectre和Meltdown只是综合利用了预执行的漏洞而已)……

这个事情坏就坏在现代的CPU基本上都支持指令预执行。比如,下面这段代码:

if(condition)
   do_sth();

你以为condition不成立,do_sth就不会执行,但condition存在内存上,从内存中把condition读出来,可能要几百个时钟周期,CPU闲着也是闲着,于是,它好死不死,它偷偷把do_sth()给它执行了!CPU本来想得好好的:我先偷偷执行着,如果最终condition不成立,我把动过的寄存器统统放弃掉就可以了。

问题是,大部分CPU在执行do_sth()的时候,如果有数据被加载到Cache中了,它是不会把它清掉的(因为这个同样不影响功能),这样就制造了一个“不同”了,旁路就产生了。

现在我们来看看Meltdown是怎么构造的。假设我现在在用户态执行一个程序,我可以在程序中制造这样一段代码:

raise_exception();
access(probe_data[data*4096]);

其中,raise_exception()表示制造一个异常,比如你除零错,或者访问非法地址之类的。后面那个数组是我(攻击程序)自己创建,我可以通过访问另一个一样大的数组一类的手段,导致这个数组的Cache全部被清掉。这样,理论上我访问这个数组的每个成员的时间都应该要数百时钟周期。

然后data是内核的一个地址(我想攻击的那个地址。另,为了避免部分人误会,严格来说,ptr的值是内核的一个地址,而data=*ptr),理论上这个地址我是没有权限访问的,但第一句话产生一个异常后,系统已经陷入到内核了。又“照理说”,access那一句是不应该执行的,但CPU又把它预执行了,这样,数组probe_data中的其中一个(下标等于data的)成员就被Load进Cache了。

等异常从内核返回后,我检查一下probe_data每个成员的加载速度(如果data的大小是字节,这个数组只要256项(乘4096是为了让cacheline隔开而已),我就足以偷到内核中的一个字节了)。然后重复这个过程,我就可以读出内核中的所有数据,包括你的root密码了。

按Meltdown论文的说法,他们在Intel的CPU上可以用五百多K每秒的速度Dump内核映像!

还是那句话,恐怖不恐怖?惊喜不惊喜?

为了解决这个问题,Linux上现在提出的解决手段是KPTI(通用技术称为Kaiser),内核和用户态不共享页表,每次你异常、IO、系统调用,都要把内核页表重新装进来。

现在我们来看看Spectre攻击。Meltdown只能从用户态攻击内核,Spectre攻击就灵活多了,它可以攻击任何有缺陷的对象。它要求被攻击的对象里面有如下Pattern的代码:

if(index1<array_a_size) {
  index2=array_a[index1];
  if(index2 < array_b_size);
     value = array_b[index2];
}

我们可以看到,理论上,如果index1越界,后面的代码不会被执行。但按预执行理论,即使index1超出了array_a_size的范围,它还是会“预执行”,一旦这个预执行被执行,我就可以通过控制index1的长度,让array_b的特定下标的数据Cacheline被点亮,如果我有办法访问一次array_b的全部内容,index1的内容就被我抠出来了。

要营造这样一种情形,其实是相当不容易的。既要有这样的Pattern,还要让攻击程序有办法访问array_b。但如果你的程序在执行我写的代码呢?比如Linux Kernel执行EBPF(Spectre的论文就是用这种手段构造这种攻击的),或者你的浏览器执行别人网页上的JavaScript呢?

你以为你的eBPF和Java Script已经经过了安全检查了,肯定不会访问你的重要数据的,结果,指令预执行把你给出卖了。

Spectre有两种变体,一种依靠默认的预执行行为,一种是利用预执行预测算法的BTB Aliasing漏洞在攻击程序中控制预执行的行为,然后再投入对问题代码的攻击。

相比Meltdown,Spectre是个更麻烦的东西,一方面它不容易构造,大家都有侥幸心理,希望没有问题,另一方面,它的攻击面又很大,大家都冒不起这个险。最保险的方式是关掉指令预测,但部分报告说关掉这个预测,性能可以直接下降到原来的10%,一朝回到解放前。

现在一般公司用的方法是半人工检查有没有这种模式,同时在不同区域切换的时候清空BTB(避免攻击程序训练被攻击程序),x86的IBPB和IBRS,ARM的IC IALLU等,都是这种手段,但暂时来说,性能损失都是相当大的。

对于BTB攻击,Google提出另一个软件方案,叫retpoline,所谓“ret蹦蹦床”,它认为CPU预执行就像一个精力过度充沛的孩子,闲不下来,所以,在每个可以产生指令预执行的地方都制造一个蹦床,让CPU在那里跳,而不会往下走。这个手段其实很简单,比如你原来要这样执行的:

jmp %eax
do_sth


现在可以改成这样:

call 2f
1:
  pause
  jmp 1b
2:
  mov %eax, (%esp)
  ret
  do_sth

跳转会在2分支上发生(ret不使用BTB),而预执行会被骗到1分支上蹦跶。但其实我认为这个方案在很多CPU上是有问题的,参考这里:Is retpoline really safe?。因为这个方案仅仅保护绝对地址跳转,不保护相对地址跳转,但BTB训练是可以训练相对地址的。如果把所有相对地址都修改为ret调用,这个成本就不见得低了。


另外,这个方案要求修改编译器,然后要求你重新编译所有的代码(请注意,你不是只有C代码),这个移植成本和性能成本有多大,估计你也能猜到了。但相对来说,它比起关闭指令预测,还是快多了。


这些攻击都是深刻种在CPU设计理念中的,只要你做高速CPU,不做指令预测和预执行几乎是不可能的。AMD开始认为自己免疫,估计现在不敢这么说了。ARM处理器也好不到哪里去。因为整个行业的芯片设计者都没有考虑过这个问题,所以,中不中招,完全是个运气问题。但总得来说,它对Intel的影响更大,因为Intel的量大,而且换代速度更慢一些。


而且,我认为,很多的芯片设计师并没有很严肃看待这件事,预执行为CPU性能提升带来很很多红利。他们很辛苦对预测执行进行优化,这些优化都没有考虑Cache加载带来的影响。他们一点都不想放弃这些红利。所以,很多设计师的思路还停留在如何补救Spectre和Meltdown引起的漏洞上。


但只要Cache还在发生变化,新的漏洞会被陆续发现,比如最新提出的:Skyfall 和 Solace。(参考:skyfallattach.com


所以,做CPU的同学们严肃点,别指望可以糊弄过去。这个问题也不是软件可以解决的:你们想象一下,我写一段代码,已经给CPU说了,“如果如何如何,就不要如何如何”,结果你CPU告诉我,“逻辑上,我会这么控制你的执行流程,但设计上,麻烦你考虑一下,如果我不这样执行,你的Cache可能会变哦,所以请你再考虑一下Cache上的影响,会不会导致你泄密好吗?”——你给我这样写程序试试?一两个关键函数这样搞还马虎接受,所有软件写作的时候都要这样来考虑?你干脆别提供指令集算了。对99%的软件来说,预执行和Cache都是透明的好不好?你们十年来取得的成就很了不起,但和安全比起来,呵呵……呵呵。

编辑于 2018-01-25

文章被以下专栏收录