浅谈熔断和幽灵

本文

主要针对CPU的两个漏洞熔断(Meltdown)和幽灵(Spectre),谈谈自己的理解。

版本 说明
0.1 初版发布

参考

名称 作者 来源
《谈谈幽灵和熔断两兄弟》 看雪学院-ixiaohuo 简书

专业术语与缩略语

缩写 全称 说明
OoO Out of Order 乱序执行

概述

Meltdown-and-Spectre

这两组漏洞来源于为了提高CPU性能而采用的乱序执行和预测执行技术。乱序执行是指CPU并不是严格按照指令的顺序串行执行,而是根据相关性对指令进行并行执行,最后顺序提交。预测执行是CPU根据当前掌握的信息预测某个条件判断的结果,然后选择对应的分支提前执行。

不管是乱序执行和预测执行,总之会使一些指令提前执行,当乱序执行和预测执行发生错误时,会将先前执行的指令作废,flush流水线,保证程序的正确性。但是,CPU进行flush时会作废掉load/store指令,但并不会恢复CPU缓存原始状态,而这两组漏洞正是利用了这一设计上的缺陷进行测信道攻击。

利用乱序执行的是熔断(Meltdown),利用预测执行的是幽灵(Spectre)。

重要概念

乱序执行

为了进一步提升CPU的性能,基于超标量流水技术的微架构被广泛应用于现代CPU当中,微架构可以依据指令相关性进行乱序执行。顺序提交。乱序执行可以使无依赖指令先执行,以减少指令依赖导致流水线的堵塞,这样可以尽可能保证运算单元不空闲,从而提高处理器性能,最后指令顺序提交,可以保证软件层面的执行顺序。

以下是程序指令,由于指令1/2/3之间存在依赖关系,所以乱序执行处理器中,指令4/5/6可以先于指令2/3执行。

(1)  LDR R1, [R0]
(2)  ADD R2, R1, 1
(3)  SUB R3, R2, 1
(4)  ADD R5, R4, 1
(5)  ADD R5, R4, 1
(6)  ADD R5, R4, 1

分支预测

条件分支指令通常具有两路后续执行分支。即不采取(not taken)跳转,顺序执行后面紧挨JMP的指令;以及采取(taken)跳转到另一块程序内存去执行那里的指令。是否条件跳转,只有在该分支指令在指令流水线中通过了执行阶段(execution stage)才能确定下来。

如果没有分支预测器,处理器将会等待分支指令确定了是否taken,才把下一条指令送入流水线。这种技术叫做流水线停顿或者分支延迟间隙。分支预测器会预测是否taken以及目标地址,直接将后续指令送入流水线,来避免流水线停顿造成的时间浪费。如果后来发现分支预测错误,那么流水线需要作废推测执行的指令,flush流水线。

数据缓存

由于CPU的计算速度要远远的快于存储的读写速度,为了弥补因存储速度造成的计算延迟,提升计算机整体的性能,出现了缓存、主存储器、辅助存储器的三级存储结构,其中缓存的作用是改善主存储器与CPU的速度匹配问题,往往被集成到CPU中。

CPU高速缓存,其容量远小于内存,但速度却可以接近处理器的频率。当处理器发出内存访问请求时,会先查看缓存内是否有请求数据。如果存在(hit),则不经访问内存直接返回该数据;如果不存在(miss),则要先把内存中的相应数据载入缓存,再将其返回处理器。

之所以CPU高速缓存容量远小于内存,但其仍具有很大的意义就是因为局部性原则:

  1. 空间局部性:在最近的未来要用到的信息(指令和数据),很可能与现在正在使用的信息在存储空间上是邻近的。
  2. 时间局部性:在最近的未来要用到的信息,很可能是现在正在使用的信息。

幽灵

幽灵利用的是分支预测机制。

//此段代码非严格符合某语法类型,仅为描述逻辑使用
bit [511:0] array1 [256]; //512宽度是因为cacheline大小为64Byte,256深度是因为1byte表示最大为256。
bit [7:0]   array2 [16];  //8宽度是因为作为array1的索引,深度16无特殊意义。

//program
mv  r0  #array2_size
str r0  [array2_size_addr]

function1
    //清除array1缓存区
    //将array2所有元素设置为0
endfunction

function2(input x)
    ldr r1 [array2_size_addr_addr] //为了与分支指令形成依赖,使array1的访问可以先执行。
    ldr r2 [r1] //r1 也就是array2_size_addr
    ...
    if(x < r1)
    {
        ldr r2 array2[x];
        ldr r3 array1[r2 * 512];
    }
endfunction

function3 //依次访问array1所有元素,记录返回时间。
    for i is 0 to 255
    {
        ldr r2 [array1_base_addr + i]
    }
endfunction
  • 第一步先定义array1和array2两个数组空间,同时清除array1的缓存区和将array2所有元素设置为0。
  • 第二步多次执行function2,输入符合x小于array2_size的值,训练分支预测器,使其默认为跳转taken。
  • 第三步将x设为目标地址(x大于array2_size),执行function2,此时分支指令前的ldr指令会阻碍分支指令执行,而访问array2的指令会先执行,最终会因为if条件不满足而flush流水线,但是数据已经缓存至cache。
  • 第四步执行function3,扫描array1的所有元素,记录返回时间,由于之前清除了array1的所有缓存区,function2也只是元素0添加至了缓存区,ldr指令从cache和内存拿数据有明显的时间差,换句话说,存在两个结果:
    1. 如果扫描结果只有元素0访问最快,则目标地址的数据byte值为0;
    2. 如果扫描结果有0元素和某index元素访问最快,则目标地址的数据byte值为index。

熔断

幽灵利用的是乱序执行机制。

//此段代码非严格符合某语法类型,仅为描述逻辑使用
bit [511:0] array1 [256]; //512宽度是因为cacheline大小为64Byte,256深度是因为1byte表示最大为256。

//program
function1
    //清除array1缓存区
endfunction

function2
    ldr r0 byte[x]   //x为目标地址,非法操作
    ldr r1 array1[r0 * 512];
endfunction

function3 //依次访问array1所有元素,记录返回时间。
    for i is 0 to 255
    {
        ldr r2 [array1_base_addr + i]
    }
endfunction
  • 第一步先定义array1数组空间,同时清除array1的缓存区。
  • 第二步执行function2,访问目标地址(非法),并将返回数据作为array1的index访问array1。
  • 第三步执行function3,扫描array1的所有元素,记录返回时间,由于之前清除了array1的所有缓存区,function2也只是元素0添加至了缓存区,ldr指令从cache和内存拿数据有明显的时间差,如果扫描结果为某index元素访问最快,则目标地址的数据byte值为index。

解决方案

待补充


文章原创,可能存在部分错误,欢迎指正,联系邮箱 cao_arvin@163.com。