唐凌 发表于 2021-2-9 19:30:30

【处理器】大学英语四六级:一种动态防止ROP攻击的新技术

本帖最后由 tangptr@126.com 于 2021-11-18 00:50 编辑


# 前言
`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)是切换影栈时的校验机制。按照影栈令牌的使用规则,切换栈的步骤应当是:
1. 生成影栈令牌并压入到影栈上
2. 取出新影栈的令牌并校验,其中:
        - 若令牌校验成功,则修改成功,处理器修改`ssp`寄存器的值
        - 若令牌校验失败,则修改失败,处理器跑出`#CP`异常

3. 完成切换,接下来爱干嘛干嘛

影栈令牌的结构其实很简单,如图所示:

最低位为`Mode`模式位:若复位则表示它是保护模式或兼容模式影栈,即32位影栈;若置位则表示它是长模式影栈,即64位影栈。
第1位为`Busy`繁忙位:若复位则表示处理器暂时没使用这个影栈;若置位则表示该影栈正在被处理器使用。
高62位为`Token`域,即令牌域:该域表示影栈线性地址的高62位。
而当处理器生成影栈令牌时,会计算`ssp`压入一个帧后的值的高62位作为令牌的高62位,第1位视繁忙与否进行置位或复位,最低位视运行模式进行置位或复位,最后将影栈令牌压到栈上。
也就是说,当处理器生成完一个影栈令牌后,理论上`ssp == token & 0xfffffffffffffffc`。这个比较式是用于验证影栈令牌的合法性的其中一步,其他步骤视校验影栈令牌的目的而定。

# 影栈指令
本章节简介所有影栈相关的指令。一切影栈指令在访问内存时,皆进入原子态,故其他处理器核心及系统外设的内存访问将会被临时挂起,直到离开原子态才能继续。

## `clrssbsy`指令
`clrssbsy`指令名称来自Clear Shadow Stack Busy的缩写。其作用是停用影栈寄存器。该指令具有操作数:
```Asm
clrssbsy mem64
```
其操作数是指向影栈的栈指针的线性地址。
该指令会校验影栈令牌的合法性,除了影栈地址之外,还要校验影栈令牌的`Busy`位是否置位。
若校验失败,则将`RFlags.CF`置位表示执行失败。
若校验成功,则将`ssp`寄存器清零,并将`RFlags.CF`复位表示执行成功。
该指令是特权指令,必须在Ring0执行。

## `setssbsy`指令
`setssbsy`指令名称来自Set Shadow Stack Busy的缩写。其作用是启用影栈寄存器。该指令没有操作数:
``` Asm
setssbsy
```
它不从某个内存地址取得影栈的栈指针,而是从`PL0_SSP`这个MSR寄存器(`Index=0x6A4`)里获取。
该指令会校验影栈令牌的合法性,除了影栈地址之外,还要校验影栈令牌的`Busy`位是否复位。
若校验失败,则产生`#CP`异常。
若校验成功,则设置新的`ssp`寄存器的值为`PL0_SSP`这个MSR寄存器的值,并生成`Busy`置位的令牌写到影栈上。
该指令是特权指令,必须在Ring0执行。

## `rstorssp`指令
`rstorssp`指令名称来自Restore (Saved) Shadow Stack Pointer的缩写。其作用是切换影栈。该指令具有操作数:
```Asm
rstorssp mem64
```
其操作数是指向新影栈的栈指针的线性地址。
该指令会校验新影栈的影栈令牌合法性,除了影栈地址之外,还要校验影栈令牌的`Busy`是否复位,且`Mode`位必须与当前模式相同。
若校验失败,则产生`#CP`异常。
若校验成功,则切换`ssp`到新影栈上,并将新影栈上存有的新影栈令牌替换为旧影栈令牌。
若影栈切换成功,且处理器运行在32位上,则将`RFlags.CF`置位。
该指令不是特权指令,只要当前CPL启用了影栈,就可以使用该指令。

## `saveprevssp`指令
`saveprevssp`指令名称来自Save Previous Shadow Stack Pointer的缩写。其作用是保存旧影栈以备恢复。该指令没有操作数:
```Asm
saveprevssp
```
该指令直接使用当前`ssp`的值作为旧影栈的栈指针,值得注意的是获取值的行为是弹出栈,因此会增加`ssp`的值。
该指令会校验旧影栈的影栈令牌合法性,除了影栈地址之外,还要校验影栈令牌的`Busy`是否置位,且`Mode`位必须与当前模式相同。在此之上还要校验`ssp`的对齐性。
若校验失败,则产生`#GP`异常。注意这里不是`#CP`异常了。
若校验成功,则生成一个旧影栈令牌并写到旧影栈上。
该指令不是特权指令,只要当前CPL启用了影栈,就可以使用该指令。

## `incssp`指令
`incssp`指令名称来自Increment Shadow Stack Pointer的缩写。其作用是增加`ssp`寄存器的值。该指令具有操作数:
```Asm
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为何物?](https://www.0xaa55.com/thread-16882-1-1.html))
该指令不是特权指令,只要当前CPL启用了影栈,就可以使用该指令。

## `rdssp`指令
`rdssp`指令名称来自Read Shadow Stack Pointer的缩写。其作用是读取`ssp`寄存器的值。该指令具有操作数:
```Asm
rdssp reg
```
用伪汇编表达其含义就是
```Asm
mov reg,ssp
```
值得注意的是,如果处理器不支持CET技术,或者未启用CET技术,则该指令会被视为NOP指令。

## `wrss`指令
`wrss`指令名称来自Write to Shadow Stack的缩写。其作用是向影栈中写东西。该指令具有操作数:
```Asm
wrss mem,reg
```
由于影栈受到页表的保护,普通的写内存指令无法修改影栈里的内容,该指令就是用来专门修改影栈的内容的。它具有的特点是:
- 只能写32位或64位,且地址必须对齐,否则产生`#GP`异常。
- 必须启用影栈写入,否则产生`#UD`异常。
- 所引用的页必须是被标记为影栈的页,否则产生`#PF`异常。

## `wruss`指令
`wruss`指令名称来自Write to User Shadow Stack的缩写。其作用是向用户影栈中写东西。该指令具有操作数:
```Asm
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`的值。如图所示:


#### 影栈令牌检查
和手动切换影栈一样,代码远转移造成权限跃迁时切换影栈也会检查影栈令牌。
当权限提升时:
1. 检查新`ssp`是否在8字节边界上对齐,若不对齐则产生`#GP`异常。
2. 从新`ssp`处获取影栈令牌,除了检查影栈指针之外,还要检查`Busy`是否复位。
3. 若检查通过,将新影栈`Busy`位置位。否则产生`#GP`异常。

当权限下降时:
1. 检查新`ssp`是否在4字节边界上对齐,若不对齐则产生`#CP`异常。
2. 从当前`ssp+24`处获取影栈令牌,除了检查影栈指针之外,还要检查`Busy`是否置位。
3. 若检查通过,将获取的影栈令牌的`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`指令返回到用户模式。

# 对处理器流水线的影响
先说一下处理器分支预测的基本原理吧,处理器的分支预测器预测的东西其实不只是一条分支指令的对与错,它预测的是:
1. 这个指令要不要跳
2. 下一条指令的具体位置(当然也包括分支指令的对错)

由于分支预测器的执行一般是放在指令的第一阶段里的,即发射指令(Issue)阶段。故在此阶段无法计算出准确的跳转位置,分支预测器只能猜。猜对了,那流水线继续执行;猜错了,把走错路的流水线刷新掉重来。
对于返回性质的指令(即`ret`,`retf`,`iret`指令),如果处理器预测出返回地址与影栈上保存的不符,则流水线不会去取返回地址的指令。
虽然不论Intel还AMD,文档都没写流水线在预测出返回地址与影栈不符情况下的行为如何,但可以做一个简单的推测:
1. 停滞流水线,直到比较完影栈与返回地址后,再决定是走到返回地址还是`#CP`异常,最后启动流水线继续执行。
2. 不停滞流水线,直接走到`#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自己覆写。

dearfuture 发表于 2021-2-10 10:03:59

如果没理解错, 像mov ,yyy 或 add rsp, zzz之类的指令都不会影响影栈或ssp,那有些代码混淆似乎就不能用了。
比如ret换成
push rbx
add rsp,8
ret

本来是没啥问题的。然而开启CET的话,执行到ret似乎就会触发执行流异常

唐凌 发表于 2021-2-10 14:30:14

本帖最后由 tangptr@126.com 于 2021-5-1 12:00 编辑

dearfuture 发表于 2021-2-10 10:03
如果没理解错, 像mov ,yyy 或 add rsp, zzz之类的指令都不会影响影栈或ssp,那有些代码混淆似乎就 ...

你这个例子不会,因为`ret`指令的返回地址仍然是正确的地址,前两条指令都不会修改原来`ssp`的值,并且还在`ret`前回到了原`rsp`的值。如此一来`rsp`指向的值和`ssp`指向的值仍然是一致的。

dearfuture 发表于 2021-2-10 15:07:13

本帖最后由 dearfuture 于 2021-2-10 15:08 编辑

tangptr@126.com 发表于 2021-2-10 14:30
你这个例子不会,因为`ret`指令的返回地址仍然是正确的地址,前两条指令都不会修改原来`rsp`指向的值 ...

执行完push rbx,同时更新了普通栈和影栈,此时和还是一致的。但接下来add rsp, 8 只更新了rsp, 却没有更新ssp,那么此时和就不同了啊,这时执行ret不是会触发执行流异常么?

dearfuture 发表于 2021-2-10 15:31:11

还是说我理解错了,rsp和ssp这两个寄存器的值是永远一致的?mov/add/sub rsp, XXX都会同步更新到ssp?
对影栈内容的影响必须通过call,ret,int,iret这几条指令还有异常中断(也就是通过其他方式修改普通栈的内存才会和影栈出现不一致)?

唐凌 发表于 2021-2-10 15:42:04

dearfuture 发表于 2021-2-10 15:07
执行完push rbx,同时更新了普通栈和影栈,此时和还是一致的。但接下来add rsp, 8 只更新了rsp ...

`push`指令不会操作影栈,只有call之类的能转移执行流还同时访问栈的指令才会访问影栈。
说白了那种`push+ret`性质的跳转代码会产生`#CP`异常。
如果`push`指令能修改影栈,那ROP攻击者直接插入`push`指令就可以绕过CET在`ret`时转移掉执行流了。
所以要强调的是:如果不会在返回时转移执行流到别处,就不会触发CET的保护机制。

dearfuture 发表于 2021-2-10 15:56:45

tangptr@126.com 发表于 2021-2-10 15:42
`push`指令不会操作影栈,只有call之类的能转移执行流还同时访问栈的指令才会访问影栈。
说白了那种` ...

哦哦,我傻了,把call和push搞混了。忘了push也不会影响的

dearfuture 发表于 2021-2-10 16:17:30

不过vmp之类的就是用了一大堆push ret,hook用push ret也挺常见的,以后os对这种异常的处理应该不能像软件cfg那么激进

0xAA55 发表于 2021-2-16 11:12:18

push ret操作,我感觉主要还是在实模式代码里常见。保护模式或者长模式除非手写汇编,否则编译器不会生成这样的指令。

大能猫 发表于 2021-5-11 02:17:09

push eax
ret
来代替jmp eax还是比较常见的混淆手段的,主要是可以对抗静态分析(让静态分析软件比如IDA无法分析控制流)
所以在开启这个机制的时候这种调用就是不可用的了么

唐凌 发表于 2021-5-11 18:39:41

大能猫 发表于 2021-5-11 02:17
push eax
ret
来代替jmp eax还是比较常见的混淆手段的,主要是可以对抗静态分析(让静态分析软件比如IDA无 ...

没错,这样的混淆就是不可用了。但操作系统是可以控制对特定进程不启用影栈的。
页: [1]
查看完整版本: 【处理器】大学英语四六级:一种动态防止ROP攻击的新技术