前言
ROP
攻击(即Return-Oriented Programming)是一种通过攻击栈上的函数返回地址的方式来执行恶意代码。这种攻击方式在过去一般没法让系统软件以异常之类的方式主动拦截。以往防止ROP
攻击是通过编译器往生成的代码里加特技实现静态防止ROP
。(比如MSVC的编译器里加一个/GUARD:CF
参数)
但在2020年中Intel的x86手册更新之后我发现Intel加入了CET
(Control-flow Enforcement Technology)技术实现动态防止ROP
攻击。很快啊,AMD也在手册里拟定了CET
的草稿。
鉴于大学英语四六级考试的简写也是CET
,因此该技术也可以戏称为大学英语四六级。
宏观框架
CET
技术的核心是往处理器里引入了影栈(Shadow Stack Pointer,以下简称ssp
)寄存器(或许也可以称之为暗栈,暗影之栈,命名逐渐中二。明修栈道,暗度陈仓,我™到底在说啥。。。),它的特点是与一般的栈只在调用与返回时同时变化。换言之,只有call
、int
、ret
、iret
指令以及异常和中断可以向影栈读写玩意。
每当出现call
指令、int
指令、中断、异常的时候,处理器除了向rsp
压栈,同时向ssp
压栈。而当ret
指令或iret
指令执行的时候,会同时从rsp
和ssp
里弹出返回地址并进行比较,如果不相等就产生执行流异常(Control-flow Exception,简称#CP
异常)。
页表变化
由于ROP
攻击的重点就是在于能成功的改写控制流,因此攻击者在了解到存在控制流守护(Control-Flow Guard,简称CFG
)时,就会试图在修改栈的同时也要去绕过CFG
。而CET
技术的核心就是在于新增的ssp
寄存器,因此攻击者若是获取到了ssp
寄存器的值,就会去修改影栈从而绕过CET
的保护。
不过显然x86的CET
工程师在设计影栈的时候就想到过这一点。对此x86的工程师就设计了一种降维打击的方式:在页表里标记该影栈的页面归属于影栈,于是使用一般的操作内存的指令写影栈就会产生异常。
当操作系统分配影栈的时候,需要将影栈页的最后一级的页表记录中将R/W
复位并将Dirty
位置位,以此标记该页属于影栈,可以说非常会节省空间了属于是。只有写入才能使得Dirty
位被置位,对于不可写入的位那就不可能把Dirty
位置位了,于是这反而成为了一种保留位组合,被x86工程师利用起来了。
影栈令牌
影栈令牌(Shadow Stack Token)是切换影栈时的校验机制。按照影栈令牌的使用规则,切换栈的步骤应当是:
-
生成影栈令牌并压入到影栈上
-
取出新影栈的令牌并校验,其中:
- 若令牌校验成功,则修改成功,处理器修改
ssp
寄存器的值
- 若令牌校验失败,则修改失败,处理器跑出
#CP
异常
-
完成切换,接下来爱干嘛干嘛
影栈令牌的结构其实很简单,如图所示:
最低位为Mode
模式位:若复位则表示它是保护模式或兼容模式影栈,即32位影栈;若置位则表示它是长模式影栈,即64位影栈。
第1位为Busy
繁忙位:若复位则表示处理器暂时没使用这个影栈;若置位则表示该影栈正在被处理器使用。
高62位为Token
域,即令牌域:该域表示影栈线性地址的高62位。
而当处理器生成影栈令牌时,会计算ssp
压入一个帧后的值的高62位作为令牌的高62位,第1位视繁忙与否进行置位或复位,最低位视运行模式进行置位或复位,最后将影栈令牌压到栈上。
也就是说,当处理器生成完一个影栈令牌后,理论上ssp == token & 0xfffffffffffffffc
。这个比较式是用于验证影栈令牌的合法性的其中一步,其他步骤视校验影栈令牌的目的而定。
影栈指令
本章节简介所有影栈相关的指令。一切影栈指令在访问内存时,皆进入原子态,故其他处理器核心及系统外设的内存访问将会被临时挂起,直到离开原子态才能继续。
clrssbsy
指令名称来自Clear Shadow Stack Busy的缩写。其作用是停用影栈寄存器。该指令具有操作数:
clrssbsy mem64
其操作数是指向影栈的栈指针的线性地址。
该指令会校验影栈令牌的合法性,除了影栈地址之外,还要校验影栈令牌的Busy
位是否置位。
若校验失败,则将RFlags.CF
置位表示执行失败。
若校验成功,则将ssp
寄存器清零,并将RFlags.CF
复位表示执行成功。
该指令是特权指令,必须在Ring0执行。
setssbsy
指令
setssbsy
指令名称来自Set Shadow Stack Busy的缩写。其作用是启用影栈寄存器。该指令没有操作数:
setssbsy
它不从某个内存地址取得影栈的栈指针,而是从PL0_SSP
这个MSR寄存器(Index=0x6A4
)里获取。
该指令会校验影栈令牌的合法性,除了影栈地址之外,还要校验影栈令牌的Busy
位是否复位。
若校验失败,则产生#CP
异常。
若校验成功,则设置新的ssp
寄存器的值为PL0_SSP
这个MSR寄存器的值,并生成Busy
置位的令牌写到影栈上。
该指令是特权指令,必须在Ring0执行。
rstorssp
指令名称来自Restore (Saved) Shadow Stack Pointer的缩写。其作用是切换影栈。该指令具有操作数:
rstorssp mem64
其操作数是指向新影栈的栈指针的线性地址。
该指令会校验新影栈的影栈令牌合法性,除了影栈地址之外,还要校验影栈令牌的Busy
是否复位,且Mode
位必须与当前模式相同。
若校验失败,则产生#CP
异常。
若校验成功,则切换ssp
到新影栈上,并将新影栈上存有的新影栈令牌替换为旧影栈令牌。
若影栈切换成功,且处理器运行在32位上,则将RFlags.CF
置位。
该指令不是特权指令,只要当前CPL启用了影栈,就可以使用该指令。
saveprevssp
指令
saveprevssp
指令名称来自Save Previous Shadow Stack Pointer的缩写。其作用是保存旧影栈以备恢复。该指令没有操作数:
saveprevssp
该指令直接使用当前ssp
的值作为旧影栈的栈指针,值得注意的是获取值的行为是弹出栈,因此会增加ssp
的值。
该指令会校验旧影栈的影栈令牌合法性,除了影栈地址之外,还要校验影栈令牌的Busy
是否置位,且Mode
位必须与当前模式相同。在此之上还要校验ssp
的对齐性。
若校验失败,则产生#GP
异常。注意这里不是#CP
异常了。
若校验成功,则生成一个旧影栈令牌并写到旧影栈上。
该指令不是特权指令,只要当前CPL启用了影栈,就可以使用该指令。
incssp
指令
incssp
指令名称来自Increment Shadow Stack Pointer的缩写。其作用是增加ssp
寄存器的值。该指令具有操作数:
incsspd reg32
incsspq reg64
该指令将寄存器的低八位作为增加的帧数量。值得注意的是处理器视低八位是无符号的,因此只能加不能减。用伪汇编来描述的话:
incsspd eax
相当于add ssp,al*4
incsspq rax
相当于add ssp,al*8
特殊情况是,若al=0
则直接add ssp,4
或add ssp,8
。具体要看incssp
指令中REX.W
是否置位。(什么?你不知道REX.W为何物?)
该指令不是特权指令,只要当前CPL启用了影栈,就可以使用该指令。
rdssp
指令
rdssp
指令名称来自Read Shadow Stack Pointer的缩写。其作用是读取ssp
寄存器的值。该指令具有操作数:
rdssp reg
用伪汇编表达其含义就是
mov reg,ssp
值得注意的是,如果处理器不支持CET技术,或者未启用CET技术,则该指令会被视为NOP指令。
wrss
指令名称来自Write to Shadow Stack的缩写。其作用是向影栈中写东西。该指令具有操作数:
wrss mem,reg
由于影栈受到页表的保护,普通的写内存指令无法修改影栈里的内容,该指令就是用来专门修改影栈的内容的。它具有的特点是:
- 只能写32位或64位,且地址必须对齐,否则产生
#GP
异常。
- 必须启用影栈写入,否则产生
#UD
异常。
- 所引用的页必须是被标记为影栈的页,否则产生
#PF
异常。
wruss
指令
wruss
指令名称来自Write to User Shadow Stack的缩写。其作用是向用户影栈中写东西。该指令具有操作数:
wruss mem,reg
相比于wrss
指令,wruss
指令的执行成功条件还要多:
- 必须在Ring0里执行,否则产生
#GP
异常。
- 所引用的页还必须得是用户模式页,否则产生
#PF
异常。
影栈相关MSR
S_CET
(MSR Index=0x6A0) 与 U_CET
(MSR Index=0x6A2) MSR
这两个MSR分别控制CET技术在Supervisor模式(CPL<3)与User模式(CPL=3)的行为。它们的位域定义是一样的:
- 第0位表示是否为该模式启用CET技术。
- 第1位表示是否为该模式启用影栈写入。
- 高62位为保留位。
原则上不要给用户态启用影栈写入能力。
PLX_SSP
(MSR Index=0x6A4-0x6A7) MSR与ISST_ADDR
(MSR Index=0x6A8) MSR
这五个MSR控制权限跃迁时如何处理ssp
寄存器的值,这要分类讨论。
原则上可以吧PLX_SSP
MSR理解为它包含CPL=X
用的ssp
的值。
代码远转移(Far Transfer)行为
这里不讨论不涉及权限跃迁的代码远转移行为,否则就是在脱离章节主旨。本章节讨论:
- 远调用(Far Call)、中断(Interrupt)与异常(Exception)产生的权限提升
- 远返回(Far Return)与中断返回(Interrupt Return)产生的权限下降
此外,远跳(Far Jump)不产生栈操作,故远跳与影栈无关。
从Ring3代码远转移
如果Ring3代码远转移时产生权限提升,处理器会将当前ssp
的值保存到PL3_SSP
MSR中,然后切换ssp
。
当执行retf
或iret
指令回到Ring3时,处理器从PL3_SSP
MSR恢复ssp
寄存器的值。
但此时处理器不会对影栈有压栈操作,故从Ring3产生的代码远转移到高权限再返回时处理器不会把返回地址与影栈相比对。
从Ring1或Ring2代码远转移
如果从Ring1或Ring2代码远转移产生权限提升,处理器会先切换ssp
的值,然后再将转移源的信息(源cs
,ssp
,lip
)压入到影栈。
相应的,当代码远转移使得权限降低到Ring1或Ring2时,处理器从影栈中弹出转移源的信息并做比对,若不相符则产生#CP
异常。若成功则切换回原来的影栈。
影栈选择子
如果用远调用跃迁到Ring0,1,2,则将PL0,1,2_SSP
MSR的值作为新ssp
的值。
如果是中断或异常跃迁到Ring0,则要看IDT表上这个中断/异常项的IST
(Interrupt Stack Table)域。
- 若
IST
域为零,则将PL0_SSP
MSR的值作为新ssp
的值。
- 若
IST
域非零,则ISST_ADDR
MSR的值作为ISST
(Interrupt Shadow Stack Table)数组地址,根据IST
域选择数组中的一个作为新ssp
的值。如图所示:
影栈令牌检查
和手动切换影栈一样,代码远转移造成权限跃迁时切换影栈也会检查影栈令牌。
当权限提升时:
- 检查新
ssp
是否在8字节边界上对齐,若不对齐则产生#GP
异常。
- 从新
ssp
处获取影栈令牌,除了检查影栈指针之外,还要检查Busy
是否复位。
- 若检查通过,将新影栈
Busy
位置位。否则产生#GP
异常。
当权限下降时:
- 检查新
ssp
是否在4字节边界上对齐,若不对齐则产生#CP
异常。
- 从当前
ssp+24
处获取影栈令牌,除了检查影栈指针之外,还要检查Busy
是否置位。
- 若检查通过,将获取的影栈令牌的
Busy
位复位。否则什么都没发生,处理器继续执行。
为什么从当前ssp+24
处取影栈令牌?答案:当远转移的高级别的时候,处理器有对新影栈会有压栈操作以保存cs
,lip
,ssp
,压入的量正好是24字节。因此当返回的时候,在影栈的24字节之上就是当前影栈的令牌。(鼠标左键三击以显示答案)
除了对齐检查这一步外,处理器执行后面的步骤会进入原子态。
任务切换
任务切换也会产生栈切换,故任务切换时也必须切换掉影栈。于是x86工程师对32位TSS做出了修改,在TSS+0x68
处增设了ssp
成员来指定任务切换后目标ssp
的值。
为啥不修改64位TSS结构体?因为AMD64直接在长模式里废掉了任务机制。
至于保存原ssp
,其行为与一般的远转移保存ssp
一致。返回时也会检查返回的目标,若不一致也一样会产生#CP
异常。
64位系统调用
syscall
和sysret
指令不会对栈产生任何操作,因此处理器不会切换影栈。虽然处理器不会切换影栈,但是会把当前ssp
保存到PL3_SSP
。
由于syscall
时处理器不会切换影栈,因此操作系统要在切换到内核栈的同时也要切换到内核影栈,可以用setssbsy
指令切换到内核影栈,只需要事先把PL0_SSP
MSR设置好即可。
在sysret
之前,操作系统也应当用clrssbsy
指令停用内核影栈,然后再用rstorssp
指令切换回用户影栈,最后再用sysret
指令返回到用户模式。
对处理器流水线的影响
先说一下处理器分支预测的基本原理吧,处理器的分支预测器预测的东西其实不只是一条分支指令的对与错,它预测的是:
- 这个指令要不要跳
- 下一条指令的具体位置(当然也包括分支指令的对错)
由于分支预测器的执行一般是放在指令的第一阶段里的,即发射指令(Issue)阶段。故在此阶段无法计算出准确的跳转位置,分支预测器只能猜。猜对了,那流水线继续执行;猜错了,把走错路的流水线刷新掉重来。
对于返回性质的指令(即ret
,retf
,iret
指令),如果处理器预测出返回地址与影栈上保存的不符,则流水线不会去取返回地址的指令。
虽然不论Intel还AMD,文档都没写流水线在预测出返回地址与影栈不符情况下的行为如何,但可以做一个简单的推测:
- 停滞流水线,直到比较完影栈与返回地址后,再决定是走到返回地址还是
#CP
异常,最后启动流水线继续执行。
- 不停滞流水线,直接走到
#CP
异常处理函数,如果猜错了就直接K&R(Kill and Restart)。
硬件虚拟化
在硬件虚拟化中,若产生状态切换(比如Host向Guest切换的VM-Entry)时,处理器需要将目标状态覆盖掉当前状态,其中就包括了ssp
寄存器。
伴随着ssp
一起被覆盖的,还有S_CET
和ISST_ADDR
这两个MSR寄存器。但不会覆盖掉U_CET
,而是顺延。故如有必要,VMM需要自己覆写U_CET
MSR。
此外,PLX_SSP
MSR也不会被处理器自动覆盖,需要VMM自己覆写。