给程序员解释Spectre和Meltdown漏洞
我犹豫了很久要不要写这篇东西。理论上我当然不想鼓励攻击行为,所以更好的方法是不要讨论它。问题是这玩意儿论文都出来了,不讨论它似乎又是掩耳盗铃。所以,不轻不重地讨论一下吧。
Spectre和Meltdown是缓冲时延旁路攻击的两种实际攻击方法。
什么叫旁路(Side Channel)攻击呢?就是说,在你的程序正常通讯通道之外,产生了一种边缘特征,这些特征反映了你不想产生的信息,这个信息被人拿到了,你就泄密了。比如你的内存在运算的时候,产生了一个电波,这个电波反映了内存中的内容的,有人用特定的手段收集到这个电波,这就产生了一个旁路了。基于旁路的攻击,就称为旁路攻击。
缓冲时延(Cache Timing)旁路是通过内存访问时间的不同来产生的旁路。假设你访问一个变量,这个变量在内存中,这需要上百个时钟周期才能完成,但如果你访问过一次,这个变量被加载到缓冲(Cache)中了,下次你再访问,可能几个时钟周期就可以完成了。这样,如果我攻击一个对象(比如一个进程,或者内核),要得到其中某个地址ptr的内容,我只要和它共享一个数组,然后诱导它用ptr的内容访问这个数组,然后我检查这个数组的访问时间,我就可以知道ptr的值了。
你一定觉得这是不可能的,对吗?在Cache Timing Side Channel刚被提出来的时候,大家也是认为出问题的机会是很小的,只是理论上有效而已——直到Spectre和Meltdown被提出来……
这个事情坏就坏在现代的CPU基本上都支持指令预执行。比如,下面这段代码:
if(condition) do_sth();
你以为condition不成立,do_sth就不会执行,但从内存中把condition读出来,可能要几百个时钟周期,CPU闲着也是闲着,于是,它好死不死,它偷偷把do_sth()给它执行了!CPU本来想得好好的:我先偷偷执行着,如果最终condition不成立,我把动过的寄存器统统放弃掉就可以了。
问题是,部分CPU在执行do_sth()的时候,如果有数据被加载到Cache中了,它是不会把它清掉的(因为这个同样不影响功能),这样就制造了一个“不同”了,旁路就产生了。
现在我们来看看Meltdown是怎么构造的。假设我现在在用户态执行一个程序,我可以在程序中制造这样一段代码:
raise_exception();access(probe_data[data*4096]);
其中,raise_exception()表示制造一个异常,比如你除零错,或者访问非法地址之类的。后面那个数组是我自己创建,我可以通过访问另一个一样大的数组一类的手段,导致这个数组的Cache全部被清掉。这样,理论上我访问这个数组的每个成员的时间都应该要数百时钟周期。
然后data是内核的一个地址(我想攻击的那个地址),理论上这个地址我是没有权限访问的,但第一句话产生一个异常后,系统已经陷入到内核了。又“照理说”,access那一句是不应该执行的,但CPU又把它预执行了,这样,数组probe_data中的其中一个成员就被Load进Cache了。
等一个异常中内核返回后,我检查一下probe_data每个成员的加载速度(如果data指向一个字节,这个数组只要256项(乘4096是为了让cache隔开而已),我就足以偷到内核中的一个字节了)。然后重复这个过程,我就可以读出内核中的所有数据,包括你的root密码了。
按Meltdown论文的说法,他们在Intel的CPU上可以用五百多K每秒的速度Dump内核映像!
还是那句话,惊喜不惊喜?
为了解决这个问题,Linux上现在提出的解决手段是KPTI,内核和用户态不共享页表,每次你异常、IO、系统调用,都要换一个页表,这个对效率的影响是显而易见的。网上有数据说综合影响30%。
现在我们来看看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的特定下标的数据Cache被点亮,如果我有办法访问一次array_b的全部内容,index1的内容就被我抠出来了。
要营造这样一种情形,其实是相当不容易的。但如果你的程序在执行我写的代码呢?比如Linux Kernel执行EBPF(Spectre的论文就是用这种手段构造这种攻击的),或者你的浏览器执行别人网页上的JavaScript呢?
你以为你的eBPF和Java Script已经经过了安全检查了,肯定不会访问你的重要数据的,结果,指令预执行把你给出卖了。
Spectre有两种变体,一种依靠默认的预执行行为,一种是利用预执行的BTB Aliasing漏洞在一个进程中控制预执行的行为,然后在投入对问题代码的攻击。
相比Meltdown,Spectre是个更麻烦的东西,一方面它不容易构造,大家都有侥幸心理,希望没有问题,另一方面,它的攻击面有很大,大家都冒不起这个险。最保险的方式是关掉指令预测,但部分报告说关掉这个预测,性能可以直接下降到原来的10%,一朝回到解放前。
Google提出另一个软件方案,叫retpoline,所谓“ret蹦蹦床”,它认为CPU预执行就像一个精力过度充沛的孩子,闲不下来,所以,在每个可以产生指令预执行的地方都制造一个蹦床,让CPU在那里跳,而不会往下走。这个手段其实很简单,比如你原来要这样执行的:
jmp %eaxdo_sth
现在可以改成这样:
call 2f1: pause jmp 1b2: mov %eax, (%esp) ret
跳转会在2分支上发生,而预执行会被骗到1分支上蹦跶。
这个方案要求修改编译器,然后要求你重新编译所有的代码,这个性能影响有多大,估计你也能猜到了。
这两个攻击都是深刻种在CPU设计理念中的,只要你做高速CPU,不做指令预测和预执行几乎是不可能的。但它对Intel的影响更大,因为Intel的量大,而且换代速度太慢。
按现在了解的情况,ARM平台大部分处理器都受Spectre的影响,但几乎都不受Meltdown的影响(A75例外)。为什么ARM的设计师这么机智呢?——我认为主要是有点狗屎运,他们在没有Load完Cache的时候就发现数据没有用了,所以顺手把这东西扔了。
我看ARM现在的意思,并没有打算用retpoline来解决所有程序问题。而是在编译器中提供了一个宏__builtin_load_no_speculate(),内核也提供nospec_ptr()和nospec_load(),让你基于个例来解决问题。我觉得这也合情合理,毕竟这就好像缓冲区溢出漏洞一样,既然缓冲区溢出可以基于个例来解决,为什么非要用性能影响那么大的方案?
所以,还是那句话,你不给ARM Server点个赞么?我们很可能修完这个Bug后,性能影响非常有限,喔哈哈哈哈……