Intel CET 缓解措施深度研究
0x00 TL;DR
上⼀篇⽂章中已经简单介绍过了 CET 的基本原理和实际应⽤的⼀些技术,站在防守⽅的视⻆下,CET 确实是⼀个能 ⽐较有效防御 ROP 攻击技术的措施。那么在攻击者的视⻆来看,研究清楚 CET 的技术细节,进⽽判断 CET 是否是⼀ 个完美的防御⽅案,还是存在⼀定的局限性,则是攻击⽅的重中之重。
本⽂由浅⼊深地讲述 CET 的实现细节,最后提出⼏个理论可⾏的绕过⽅案,供研究者参考。
0x01 Shadow Stack Overview
上⼀篇⽂章已经⼤概对 CET 做了个基本概念介绍,所以就不重复,直接说重点。
Shadow Stack PTE
Shadow Stack 本质上是块内存⻚,属于新增的⻚类型,因此需要增加⼀个新的⻚属性来标识 Shadow Stack。PTE 中的⼀些未有被 CPU 定义的,也有保留给操作系统使⽤的,例如第 0 位的 Present 就由 CPU 标识⻚是否分配。Linux 操作系统没有将所有保留位都使⽤掉(⽤于别的⽤途),但是其他操作系统则没有剩余可⽤的保留位了,因此从 Linux 中取⼀个未使⽤的位,不太可取。
这⾥Linux 采⽤了复⽤很少使⽤的⻚状态(写时复制的状态):write=0, dirty=1。当 Linux 需要创建写时复制 write=0, dirty=1 的⻚时,⽤软件定义的_PAGE_COW 代替_PAGE_DIRTY,创建 shadow stack 时,则使⽤write=0, dirty=1。这就将两者区分开来了:
Shadow Stack
Management Instructions
为了保证 shadow stack 的独特性,CET 专⻔设计了独有的汇编指令。普通的指令(MOV, XSAVE...)将不被允许操 作 shadow stack。
这⾥重点说 SAVEPREVSSP、RSTORSSP。Linux 环境下,会存在栈切换的情况(系统调⽤、信号处理...),为了保 证 shadow stack 的正常运作,数据栈切换后 shadow stack 也需要相应切换,因此就会⽤到这两个指令。
下图为执⾏RSTORSSP 指令前后的 shadow stack 状态变化。执⾏的操作为先将 SSP 指针指向 new shadow stack 的 ‘restore token’,即 0x4000。然后⽤current(old) shadow stack 的地址做‘new restore token’替换掉‘restore token’,⽤于后续的 SAVEPREVSSP 指令使⽤。
下图为执⾏SAVEPREVSSP 指令前后的变化。执⾏的操作为将前⾯设置的‘new restore token’压⼊previous shadow stack 中,并将标志位置 0。然后将 SSP 指针加 1。
⾄此,就完成了 shadow stack 切换的整个过程。
0x02 Shadow Stack Implementation
这⾥不提及 Shadow Stack 的普遍情况(⻅上⼀篇⽂章),只研究 Shadow Stack 在⼀些特殊场景下的实现,在这些 场景中光申请 Shadow Stack⻚后做 push/pop 操作是不够的,往往需要更复杂的实现。
Signal
⼀般⽤户需要对某个信号做⾃定义的特殊处理时,就会⽤到信号。对应的函数为 signal()、sigaction():
当捕获信号到执⾏信号处理函数再到恢复正常执⾏的整个过程中,会经历进程挂起、Ring0 和 Ring3 间的切换、上下⽂切换等操作,这都需要 shadow stack 作出相应的变化,否则就会出现不可知的异常。下图是信号处理期间进程的变化。
以 signal 函数举例,在 glibc 中它的具体实现为下⾯所示,最终会调⽤rt_sigaction 去注册信号。
再看 CET 的实现,它在 __setup_rt_frame 函数中添加了 shadow stack 相关的操作函数, __setup_rt_frame 函 数会在信号处理过程中被调⽤,即上⾯信号处理期间进程变化的图中②的期间:
上⾯新增的 setup_signal_shadow_stack 函数,参数 restorer 即为前⾯ __libc_sigaction 函数中提到的 __NR_rt_sigreturn 系统调⽤,且该参数后续会被 push 到 shadow stack 中去作为新的函数返回地址。
相应地,再看 __NR_rt_sigreturn 系统调⽤的实现,该调⽤会在上⾯信号处理期间进程变化的图中④执⾏,CET 也在该处做了相应的改动:
从上⾯ rt_sigreturn 新增代码结合 __setup_rt_frame 新增代码可知,两者是相互配合的:⼀个负责创建 restore token 并在 shadow stack 设置返回地址,另⼀个则负责校验 restore token 并设置新的 ssp,以此来兼容在 信号处理过程中数据栈切换、上下⽂切换的场景。
⾄于为什么要在创建 restore token 后设置 shadow stack 返回地址,是因为在信号处理过程中执⾏完 sa_handler⽤户⾃定义函数后,紧接着就会执⾏sa_restorer 所设置的函数,因此在 CET 场景下需要在 shadow stack 设置相应的返回地址。
Fork
调⽤fork 后,存在两种情况:
1. ⼦进程和⽗进程分别有⾃⼰的⼀块内存,不共享;
2. ⼦进程和⽗进程共享同⼀块内存,为 vfork。
因此,在 shadow stack 场景下,需要对 fork 系统调⽤做特殊处理。fork 调⽤链如下:
CET 在 copy_thread 函数中添加了相关代码:
从上⾯新增的代码可知,CET 针对 fork 系统调⽤过程增加了创建新的 shadow stack 的部分,以兼容 fork 后⽗⼦进程 不共享内存的情况。同时也对 vfork 后⽗⼦进程共享内存的情况做了处理,使得不创建新的 shadow stack 以兼容相应场景。
Ucontext
ucontext 涉及到协程相关的技术,该技术和系统调⽤在 R3、R0 间的切换⽐较类似。但是该技术作⽤于⽤户态,⽬ 的是给⽤户态程序提供更快的切换效果,以及使得⽤户态的代码能够更加灵活。在⽤户态层⾯实现上下⽂切换。常⽤的函数为 getcontext/setcontext:
setjmp/longjmp 的技术原理和实现和 ucontext 类似,就不提及了。getcontext/setcontext 具体实现都在 glibc 中。ucontext 协程技术涉及到上下⽂切换的场景,也会存在数据栈切换的情况,因此,shadow stack 也需要做出相应 的动作。
先看 shadow stack 在 getcontext 中的改动,先⽤ __NR_arch_prctl 系统调⽤获取当前 shadow stack 的基地址,其 次将其保存在 SSP_BASE_OFFSET 寄存器中,随后保存 shadow stack 基地址、ssp 值在 ucontext 结构体中,供后续 setcontext 使⽤:
再来看 setcontext 中的改动,校验 getcontext 保存的 ucontext 中的 shadow stack 基地址和 ssp,再恢复,达到切换 回上⽂状态的⽬的:
上⾯getcontext/setcontext 的场景,是在同⼀块 shadow stack 中实现切换,因为进程并没有创建新的数据栈。此外,makecontext 会创建⼀个新的数据栈,开辟⼀个新的上下⽂,和上⾯的场景⼜有些许不同,makecontext 和 setcontext 也都做了相应的改动,由于篇幅原因不过多叙述,读者⾃⾏阅读源码即可,技术原理都是⼀样的。
0x03 CET Bypass
CET 在多场景下的实现还是相对复杂的,需要软件层⾯做相应的配合,因此在复杂的设计实现层⾯,是否有可能存 在绕过 CET 的可能性呢?本⼩节提出⼏个理论可⾏的⽅案供研究者参考。
Overwrite Function
该⽅法⽐较简单粗暴,篡改结构体中的函数指针来控制执⾏流。假设现有如下代码:
调⽤结构体函数(1)处的汇编代码如下:
此时有间接 call,IBT 机制会起作⽤,call rax 后⼀条指令必须为 ENDBR64。
如果此时拥有任意读写的能⼒,就可以篡改结构体 str1 的 test 函数指针为 over_write(2)即可改变执⾏流。且此时 over_write 函数的⼊⼝点也是 ENDBR64,即可绕过 IBT 的检查:
IBT 机制会给绝⼤部分函数体的⼊⼝点添加 ENDBR 指令,因此这种⽅法还是可⾏的,实际测试:
扩展⼀下,还可以利⽤JOP 去做。例如使⽤以下序列,也可以绕过 CET:
但是这种 JOP 序列实际上是⽐较稀少的,难找到。
Migrate Shadow Stack by RSTORSSP
这种⽅案利⽤了 CET 新增的指令来做⽂章。前⾯已经介绍过了 RSTORSSP,⽤于 shadow stack 的切换,那么如果切 换到的是攻击者伪造的 shadow stack 呢?
整个过程⽐较简单,步骤如下:
1. 构造⼀块可控内存;
2. 在可控内存中事先构造好返回地址,后续作为 shadow stack 使⽤;
3. 将内存转变为 shadow stack;
4. 构造 ROP;
5. ROP 利⽤rstorssp 将原 shadow stack 迁移到伪造的 shadow stack 中;
6. ROP 执⾏system。
CET 针对 mmap 和 mprotect 都做了相应的改动,在 mmap 中主要增加了⼀个 VMA_FLAG 为 VM_SHADOW_STACK 的 属性,在 mprotect 中除了 PROT_READ/PROT_WRITE 外增加了 PROT_SHADOW_STACK(有⼀点是 PROT_WRITE 和 PROT_SHADOW_STACK 不能同时使⽤,即只读),这两者是互相对应的关系。简单编写了这种⽅案的 demo:
调试效果如下,可⻅当前已经将 shadow stack 切换到事先伪造的内存⻚中,且返回地址也篡改得和数据栈返回地址 相同,为 0x41414141:
最终,RIP 也能成功执⾏到控制的执⾏流:
不过这种⽅法在实际场景中构造的要求⽐较⾼,局限性⽐较⼤。
当然了,还有更粗暴的⽅法,CET 新增指令还有⼀个 WRSS 的指令,该指令可以直接在 shadow stack 中写数据。但 是该指令需要在 CPU 上做使能操作,⽬前笔者阅读的源码暂时还没有使能,就不赘述了。
0x04 Summary
CET 与以往软件实现的 CFI 不同,它从硬件侧寻找解决⽅案,在底层就将 ROP 掐断,对于软件 CFI 来说从性能、缓解效果⻆度来说都有着极⼤的提升。有得必有失,底层的变动必然会撬动上层随之变化,想要将这⼀缓解措施真正实 施落地,还有着很⻓的⼀段路要⾛。笔者略浅地研究了⼀番 CET 当前的实施进展,提出了部分攻防⽅向上的想法, 供后续研究者参考。我相信在不远的将来,CET 的落地会给攻防带来很⼤的变化,到时候⼜将摩擦出怎样的⽕花?让我们⼀起期待吧。
0x05 Reference
https://github.com/yyu168/linux_cet/commit/72367656271aba4d29a25b38232e680ab9231a26
https://ty-chen.github.io/linux-kernel-signal/ https://code.woboq.org/userspace/glibc/sysdeps/unix/sysv/linux/sigaction.c.html#__libc_sigaction
https://man7.org/linux/man-pages/man2/signal.2.html
https://code.woboq.org/userspace/glibc/sysdeps/unix/sysv/linux/x86_64/getcontext.S.html#137
https://code.woboq.org/userspace/glibc/sysdeps/unix/sysv/linux/x86_64/setcontext.S.html#197
https://man7.org/linux/man-pages/man3/getcontext.3.html
https://lore.kernel.org/lkml/776fb081217145f4a488f7bca3e16eab@AcuMS.aculab.com/
https://github.com/hjl-tools/linux/commit/280503098ea762b3100edb30d60489a030d4abca
版权声明: 本文为 InfoQ 作者【腾讯安全云鼎实验室】的原创文章。
原文链接:【http://xie.infoq.cn/article/1f22ce08ad9e643373eb871d8】。未经作者许可,禁止转载。
评论