【英特尔漏洞门原理解析,保护模式是如何失效的?】

算法相关 徐 自远 765℃

【英特尔漏洞门原理解析,保护模式是如何失效的?】

前景回顾:保护模式

很多工科学生在本科时候学过英特尔8086 CPU原理,8086时代16位微机是为了执行单一的程序而设计的,即你写汇编语言可以完整访问到8086的1MB内存,也可以随便修改内存中的数。因为这机器反正也只有你一个人用,理所应当你有全部权限嘛。

从386开始,处理器不再执行单一任务了,而是多任务,比如开个word,同时挂着QQ。可毕竟物理上还是只有一个CPU,如何协调这些不同的程序呢,这时操作系统应运而生,负责调度word和QQ轮流使用CPU。

CPU为了适应多任务而提出了一种新的模式,保护模式。意思是把每个任务先要把占用的内存分清楚,不许word访问QQ的内存,也不许QQ去访问操作系统的内存。所以规定大家全都先登记到一张表里,这个表叫Global DescriptorTable (GDT),里面存放的叫“段描述符”,每个段描述符用来描述一段内存的几个要素:地址范围,代码段还是数据段,以及权限信息。权限被分为了0,1,2,3级,0级最高,3级最低,操作系统作为底层全局管理的程序,具有0级权限,而word QQ之类一般都是3级权限。

把内存“划分地盘”完毕后,保护模式由硬件强制启用,任何一个试图读写内存的程序操作都会被CPU硬件进行权限审查,如果权限不够,则会报出一个异常。

问题01

漏洞是如何让保护模式失效的

首先讲个现代处理器都具有的机制,分支预测和投机执行。为了使CPU的速度越来越快,设计采用了流水线执行,从最早的三级流水线(取指令,译码,执行)到经典的五级流水线(取指令,译码,执行,访存,回写),再到后来的14级乃至更高的流水线。一般的,流水线级数越高就会把CPU每一拍的工作划分的越细越简单,从而提高CPU主频,但有个致命的弱点就是一旦碰到分支,流水线崩溃的代价也越大。比如:

if ( x>10 ): a=b+c

else :a=b-c

假设流水线为14级,当CPU确切判断出x是不是大于10时,可能已经走到流水线7~8级了,而后面的a=b+c指令也紧跟着到了6~7级了。换句话说,判断x>10指令后面的至少6~7条指令都已经进入CPU内部。这时CPU判断出x是小于10的,那就得走a=b-c分支,此时CPU得排空流水线,把后面6~7条指令删掉,然后再重新从a=b-c往后取指令。在外界看来,这就是CPU“卡机”了。

为了减小卡机的概率,简易的单片机CPU通常引入延迟槽,让CPU在判断跳转语句后空跑几个cycle牺牲一点性能,而现代主流CPU引入分支预测,就是在流水线第一步取指令就判断if else会走哪个。那根据什么来预测呢,假设用户程序在if else的外面套了个循环,那么这个if else会被执行很多次,每次的结果都会被分支预测记录,如果发现执行else的次数较多,那下次CPU就会预测执行else分支,这就是投机执行else后面的代码,如果CPU猜对了,那流水线就不会崩溃,如果猜错,还是得从头再来。现代处理器分支预测已经成为一门独立算法,一般预测准确的概率已经可以达到90%了。如果if条件判断的东西不是来自于程序本身,而是来自程序外部,比如IO之类,if判断时间就不止7~8拍流水线了,可能是几万cycle时间,这时去投机执行显然会提高效率。

下面看一下网上广为流传的攻击漏洞代码示意图:

首先操作系统给这段攻击程序分匹配了一段内存,并在GDT表中申明,登记了攻击程序要占用哪些内存,权限肯定是最低的3级。在程序内部定义两个数组array 1和array 2,假设array 1长度为10(arr1->length),而array 2长度为400(arr2->length),当然这两个数组不能超过GDT中申明的内存范围。攻击程序首先要访问短数组array1[x],他先用了一个if判断x是不是小于10,小于10则允许访问,因为不能访问到array1的外面嘛。如果if的外面有个循环,被判断了100次之后,x<10都是成立的,CPU的分支预测就会在下次“轻信”预判x<10的。假设攻击程序在循环的第101次把x修改成了5000(图中的untrusted_offset_from_caller),这个数远远超过了array 1的长度,同时也超过了整个攻击程序在GDT表中申明的长度,也就是说,他访问到了自己内存以外的地址了,而5000这个偏移地址对应的内存,可能是0级权限才能访问的。

被分支预测欺骗后,CPU投机执行了if里面的语言,读了array1偏移5000的地址,但这时CPU仍不清楚if是不是判断正确,所以这些都是可逆的,CPU只是把结果暂存起来,并不会直接告诉攻击程序,除非最后if判断正确,且权限审查通过。但巧妙之处是攻击程序立刻把地址5000的值赋值给了value,并且又把index2=(value&1)x100+200,即如果地址5000读到value是1,则index2=300,如果value=0,则index2=200。最后利用了index2为索引去读了array 2。

到这里为止,CPU都被骗了,虽然是傻乎乎投机执行,但不管是value还是index2还是array2在index2上的值value2,都是只有CPU自己知道,攻击程序不知道,当CPU终于回过神来,发现if分支判断错误时,CPU会把这些值都从自己的暂存寄存器上全部抹掉,似乎这一切没发生过一样。但不幸的是,cache中留下了记录。

在读array 2 [index2]时,假设地址200或者300都没读过,CPU就要去内存中找,找到后把这笔数据提取到cache中,假设value=1,则地址300的值被放到了cache,并且最终赋值给value2暂存在CPU里,而地址200的值还在主存中。当CPU醒悟后,暂存的值都抹掉,重新执行,但cache中的地址300的值却没有抹掉,这么做的目的也是为了流水线性能,因为寄存器速度比较快,可以在流水线1个cycle跟着改,而cache改一下却要几十个cycle。

最后攻击程序正常读取array 2[200]和[300]两个地址,会发现200读的慢,因为要去内存读,而300读的快,因为在cache中。通过监测读速度,攻击程序就判断出越界的偏移地址5000上存的是0还是1了(CPU有公开的寄存器给程序监测读写速度)。这样攻击程序从头到尾都没读到偏移地址5000的值,但却绕过保护模式间接获得了。

问题02

漏洞能修复吗?规模有多大?真的会被攻击吗?

漏洞的核心是投机执行时CPU没做权限检查,并且投机失败后也没有清空cache。说到底,就是为了提高效率而牺牲了安全性。如果修改RTL只要修正以上两点就行,但代价必然是性能降低。笔者认为,这个漏洞存在于绝大多数具有投机执行,且流水线较深的现代处理器。但却不太可能被大量利用。首先,攻击程序必须事先知道地址array 1偏移5000上存放有自己想要的数据,如果地址不确定,读出来也不知道怎么解码。其次,这个漏洞不止要欺骗分支预测,还要保证第一个if 判断会消耗很长时间,才能让后面投机执行被完整的都执行到,这两者同时实现非常困难,很有可能投机执行了一半,就被CPU发现预测错误,停止执行了。最后,现代64位处理器都强制启用页表机制,假设内存是4G,操作系统分配每个程序都有互相独立的4G虚拟内存,并且有独立页表映射到物理地址,这也让攻击程序想找漏洞访问另一个程序的内存变的不太可能,最多只能访问到自己程序内部的一些越界结果。

http://m.toutiao11.com/group/6509335383929520648/?iid=23033451400&app=news_article×tamp=1515596831&tt_from=android_share&utm_medium=toutiao_android&utm_campaign=client_share

 

转载请注明:徐自远的乱七八糟小站 » 【英特尔漏洞门原理解析,保护模式是如何失效的?】

喜欢 (0)

苏ICP备18041234号-1 bei_an 苏公网安备 32021402001397号