1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | char buf[ 8192 ] clflush buf[ 0 ] clflush buf[ 4096 ] <some expensive instruction like divide> r1 = <a kernel virtual address>; r2 = * r1; r2 = r2 & 1 ; r2 = r2 * 4096 ; r3 = buf[r2]; <handle the page fault from r2 = * r1> a = rdstc r0 = buf[ 0 ] b = rdstc r1 = buf[ 4096 ] c = rdstc if b - a < c - b low bit probabaly is 0 |
CVE-2017-5754(Meltdown)虽然已经被修复,而距离它的发生也已经过了几年了。但是研究这个漏洞仍然能够增加对操作系统内存管理的理解,而下一个这样的漏洞在那里?仍然令人期待;-)
OS为本质上就是代替User操纵底层硬件的一个软件,它需要做到很重要的一点就是隔离。对很多个用户的隔离,对底层硬件的隔离,隔离是所有安全的基础。Metldown漏洞其实就是有人在猜测硬件CPU的执行细节,一般来说硬件的细节被隐藏,是不能够被上层用户知道的。
1 2 3 4 5 6 7 | / / core of Metltdown attack char buf[ 8192 ]; r1 = <a kernel virtual address>; r2 = * r1; r2 = r2 & 1 ; r2 = r2 * 4096 ; r3 = buf[r2]; |
问题1:为什么要进行这样的操作?
因为用户能得到内存里的数据却不能访问内存里真实的地址。所有指令对应的都是虚拟地址,通过pagetable来查找对应,而如果是SV39模式下,每个PTE表项会有一个权限标志位Valid来表示用户的权限,如果这一位没有被设置,那用户其实是不能越权访问的。
问题2:为什么在现在的场景下这样的攻击失效?
这个攻击能成功的最大前提就是内核的地址被直接映射到用户进程的内存空间里了,所以CPU的执行模式才能被猜中啊,而现在已经不是这样了。也就是当用户代码在运行时,完整的内核PTE也出现在用户程序的Page Table中,但是这些PTE的pte_u比特位没有被设置,所以用户代码在尝试使用内核虚拟内存地址时,会得到Page Fault。但这一个表项仍然存在。
这其实依赖于CPU的一些特性,一个是Speculative execution(预测执行),另一个是CPU缓存。
(插句嘴,预测执行其实是很经典的CS提升性能的方法啊,如果能够被预测,那就可以让大概率事件发生时的响应加快,但就是往往没法预测准啊……)
1 2 3 4 5 6 7 8 9 | / / example of speculation execution r0 = <something>; r1 = valid; if (r1 = = 1 ){ r2 = * r0; r3 = r2 + 1 ; } else { r3 = 0 ; } |
这个逻辑是很简单的,但是站在CPU的角度上,第3行对应的load指令,可能会使2Ghz的CPU消耗掉数百个CPU cycle,而这每一个cycle都可以执行一个指令的。因此branch prediction就是在得到r1并且在对r1做判断得出结果之前,提前执行5、6、8步,哪怕它目前没有得到足够多的信息来做判断,CPU其实也是在赌。
CPU的赌有一些很有趣的地方,它在提前执行的5、6步的时候保存在临时寄存器上,只有当它确实赌对了之后才会让这几步操作真正生效。而r0是一个有效的地址还好说,但在提前预测执行的情况下,即使r0无效或者权限pte_u没有被设置也会被取出执行,Metldown attack在ARM上并不能攻击成功,猜测可能就是在预测执行的时候也是判断了权限.他也不能产生Page Fault,万一if要执行的是第8行呢?hhhhhhh~~
CPU判断自己有没有赌对,也就是执行是不是正确的时刻被叫做retired,如果retired执行正确先前的每一步都会生效,但如果赌错,先前的执行会被抛弃。而且就在这个简单的例子里面,CPU也赌了两件事,if要选一条执行,如果执行5\6,r0一定要有效。
Intel的AMD没有披露太多的细节,同时CPU所做的这一切其实都是透明的。当第4行的retired返回的时候,如果预测失败,所有的执行都会被回滚,你不应该看到你不该看到的东西。Mirco-Architecture的细节保密,但是很多人都对它有兴趣,因为这关乎性能,当然也关乎安全。不过写编译器的人应该知道的多一点,因为很多编译器的优化不就基于CPU的特性么?
CPU如果是多核,很可能有不止一级缓存,有一种模式是每个CPU都有自己的L1(Level 1第1级缓存)、L2缓存,然后公用L3。其中L1是虚拟内存地址,但是L2是内存物理地址。L1是最快最小的缓存,L1命中可能只要几个CPU cycle,如果查不到,L2缓存命中需要几十个CPU cycle,还找不到那只有去RAM了那就需要几百个CPU cycle了。
这有个问题:TLB和MMU是在哪一级上的?我认为它是与L1 cache并列的。如果你miss了L1 cache,你会查看TLB并获取物理内存地址。MMU并不是一个位于某个位置的单元,它是分布在整个CPU上的。
不过在L1、L2里面内核空间和用换空间切换的时候(先前)是没有更换Page Table的,L1Cache甚至没有被清空,kernel的PTE表项存在只是不能被用户访问,它对用户透明但是你不能访问。这一点就成了攻击的关键。之所以这样做,又处于对性能的考虑,同时包含内核和用户态的页表能让系统调用速度变快。
要更清晰地回答这个问题需要知道CPU cache使用的一些细节。要实际进行攻击还需要对CPU进行Flush and Reload。说到底,计算机底层硬件能够理解的只有0和1。那么对于任何信息(比如密码),只要一次能猜中它的某一位是0或1,算上中间某些失败,进行个几百万次的猜测就有可能彻底破解。这背后的原理就是要找到特定的代码是否使用了特定的内存地址,而某一位是0或1就决定了怎么使用内存,这样反推了某一位究竟是什么。
要想知道某个函数是不是使用了特定的内存地址,这个原理其实也不复杂
1 2 3 4 5 6 | cflush address:x f() a = rdstc junk = xxxx b = rdstc b - a |
其实通过猜测CPU等硬件的执行模式来进行攻击早就存在,而且过去那么多年为CPU执行的很多炫酷指令也未必能保证安全。但是Meltdown让人发现这种攻击手段能够破坏操作系统的隔离性,这种漏洞的利用确实非常精彩。
通过先前的分析修复手段至少有2种。
一种是KAISER,现在是Linux中被称为KPTI的技术(Kernel page-table isolation)。这个想法很简单,也就是不将内核内存映射到用户的Page Table中,在系统调用时切换Page Table。那通过先前的方式根本不可能猜到内核数据,因为切换页表肯定会刷新Cache和内存。刚才代码里代表内核虚拟内存地址的r1寄存器不仅是没有权限使用了,简直没有意义了。现在内核虚拟内存地址不会存在于cache中,甚至都不会出现在TLB中。
还有一种是修复硬件,就是CPU进行Speculative execution的时候也要核对权限,那也没法加载内核数据了。
参考资料:https://pdos.csail.mit.edu/6.828/2020/readings/meltdown.pdf