Meltdown的分析——完整版;-)

发布者:SL7
发布于:2022-04-15 14:40

我把攻击细节放在最前面,因为这最有可能是你点这篇文章的原因。理解这个攻击需要的一些更加详细的知识在下面,也欢迎阅读。(4.15开始写,20号终于差不多写完了……)

Meltdown 攻击细节

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
  1. 首先声明缓存是2个Page大小,后面会把从内核窃取的1bit乘上4096得到的要么是0要么是1,这会导致要么buf[0]在cache里面,要么buf[4096]在cache里面。至于为什么不乘其他数,这里要求相乘后的结果离得足够远,CPU有预获取的,因此硬件会加载相内存相邻的数据到cache,离太近不就区分不了这两种情况了么?
    然后用CPU clflush指令把原先的buf[0]和buf[4096]填上junk数据
  2. 第5行可能没有必要,但是后面第8通过CPU预测执行窃取内核数据一定会发生PageFault,retired的时候会取消执行效果。要攻击成功,需要CPU预测执行(Speculative execution)几条指令至少要到第11行。而确认retired的时间越靠后,那成功的几率越大,因此需要执行一些更耗时的指令(比如除法啦)为后面争取时间。
  3. Page Fault的时间可能会被推迟,但是一定会发生。发生过后要让代码继续执行可以通过注册Page Fault Handler来继续重新获得控制权,是有方法能够在Page Fault发生后仍然继续执行15-20步的攻击步骤。
  4. 接下来就是通过CPU cycle来判断buf[0]和buf[4096]哪一个更有可能在内存里,进而推出窃取的那一个内核bit位是0还是1

Meltdown的原理

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];
  • buffer就声明了一个正常的,普通用户可以使用的内存,当然是从0开始一个虚拟内存,要被重新映射才能被MMU找到。
  • r1是某个感兴趣的虚拟地址
  • 将r1里的内容取出放到r2内
  • 取下r2的低bit位,这里只是1位
  • 因为一个bit要么是0,要么是1因此这里的r2要么是4096,要么是0
  • 可以取到buffer的0位或1位

问题1:为什么要进行这样的操作?
因为用户能得到内存里的数据却不能访问内存里真实的地址。所有指令对应的都是虚拟地址,通过pagetable来查找对应,而如果是SV39模式下,每个PTE表项会有一个权限标志位Valid来表示用户的权限,如果这一位没有被设置,那用户其实是不能越权访问的。

 

问题2:为什么在现在的场景下这样的攻击失效?
这个攻击能成功的最大前提就是内核的地址被直接映射到用户进程的内存空间里了,所以CPU的执行模式才能被猜中啊,而现在已经不是这样了。也就是当用户代码在运行时,完整的内核PTE也出现在用户程序的Page Table中,但是这些PTE的pte_u比特位没有被设置,所以用户代码在尝试使用内核虚拟内存地址时,会得到Page Fault。但这一个表项仍然存在。

这样的攻击为什么会成功呢?

这其实依赖于CPU的一些特性,一个是Speculative execution(预测执行),另一个是CPU缓存。

Speculative execution(预测执行)

(插句嘴,预测执行其实是很经典的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;
}
  • 这里r0是某个内存地址,r1是某个能够被访问的变量
  • 接下来需要要做的if其实是一个分支判断(branch prediction)选择某一个要执行的岔路口
  • 如果r1的值为1,就把r0寄存器里面的值取出来放到r2里,然后+1后再传给r3;否则把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如果是多核,很可能有不止一级缓存,有一种模式是每个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表项存在只是不能被用户访问,它对用户透明但是你不能访问。这一点就成了攻击的关键。之所以这样做,又处于对性能的考虑,同时包含内核和用户态的页表能让系统调用速度变快。

Meltdown为什么和CPU cache相关?

要更清晰地回答这个问题需要知道CPU cache使用的一些细节。要实际进行攻击还需要对CPU进行Flush and Reload。说到底,计算机底层硬件能够理解的只有0和1。那么对于任何信息(比如密码),只要一次能猜中它的某一位是0或1,算上中间某些失败,进行个几百万次的猜测就有可能彻底破解。这背后的原理就是要找到特定的代码是否使用了特定的内存地址,而某一位是0或1就决定了怎么使用内存,这样反推了某一位究竟是什么。

CPU Flush and Reload

要想知道某个函数是不是使用了特定的内存地址,这个原理其实也不复杂

1
2
3
4
5
6
cflush address:x
f()
a = rdstc
junk = xxxx
b = rdstc
b-a
  • 我们对地址x感兴趣,我们希望确保先前地址x肯定不在cache里面。Intel CPU有一条指令clflush,它接受一个内存地址并且确保这个内存地址不在缓存里面。但是即使没有这么精简的指令仍然可以做到这一点,如果知道cache是64KB,那你直接load 64KB的随机数据就好,先前cache中的数据就被冲走了。
  • 接下来想知道f()函数有没有使用x地址,那先调用它再说
  • 接下来利用的其实就是数据的就近原则,CPU从内存地址加载数据一般是会把它周围的数据也加载进去。rdstc是用来统计时间的,这里的量级都在ns级别,需要硬件级别的指令才能统计时间。
  • 第五行是加载先前扔进去flush的垃圾地址,如果f()使用了地址x,情况下x在cache里面,这个加载速度就会很快,b-a也就是几个、几十个CPU cycle,当然也有可能地址x被加载到cache里面后面又被删除或者转移,但是简单情况下如果b-a比较小,就可以认定f()使用了地址x。

Meltdown Fixed

其实通过猜测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


声明:该文观点仅代表作者本人,转载请注明来自看雪