0.概述

本文将介绍一下Windows上的CFG防护技术。
主要会讲一个 demo,自己动手写一个有CFG保护的程序,然后跟踪调试。
接着会调试 CFG 在IE , Edge上出现的情况。
最后提一些曾经所使用的bypass方法。

1.CFG简介

微软在Windows10和Windows8.1Update3(2014年11月发布)系统中已经默认启用了一种新的机制- Control Flow Guard (控制流防护)。
这项技术通过在间接跳转前插入校验代码,检查目标地址的有效性,进而可以阻止执行流跳转到预期之外的地点, 最终及时并有效的进行异常处理,避免引发相关的安全问题。
简单的说,就是在程序间接跳转之前,会判断这个将要跳转的地址是否是合法的。
如下:
① 没有CFG保护

可以看到有段 call ecx 的间接调用,而ecx 中地址,由:
mov eax , [ebp+var_8]
mov ecx , [eax+edx]
得: ecx = [ [ebp+var_8] + edx ]
注:下面还有一个 call j_RTC_CheckEsp ,这个RTC_CheckEsp 函数是用来检查堆栈是否平衡的。简单的说,函数在调用之前会把 esp 保存在edi/esi 等寄存器中;当函数调用完之后,RTC_CheckEsp 会去检查这个时候的esp值与之前保存在edi/esi 中的值是否一致;不一致说明esp被改动了,堆栈上存在数据溢出,就会丢出一个错误。
②启用 CFG 保护:

这里的间接调用是最后一句 call [ebp+var_14] 。在这个调用之前,可以看到:
mov ecx , [ebp+var_14]
call ds: guard_check_icall_fptr
这个里先把下面要调用的地址[ebp+var_14] 放到了 ecx 里面,然后去调用 guard_check_icall_fptr;而 guard_check_icall_fptr 就是CFG保护开启才有的保护函数;这个函数里面,将会去判断 ecx 这个地址里的调用函数是不是一个合法的函数。
然后有 RTC_CheckEsp ,最后才是间接调用。

2.demo

2.1 环境&工具&源码

前面了解了CFG的大概情况,那么这里通过自己写一个简单的程序,进行调用调试分析。
环境与工具:windows 10 pro , Visual Studio 2017 , windbg , IDA pro
源码: 出自这里

typedef int(*fun_t)(int);

int foo(int a)
{
    printf("hellow world %d\n",a);
    return a;
}
class CTargetObject
{
public:
    fun_t fun;
};
int main()
{
    int i = 0;
    CTargetObject *o_array = new CTargetObject[5];
    for (i = 0; i < 5 ; i++)
        o_array[i].fun = foo;
    o_array[0].fun(1);  
    return 0;
}

编译之前,我们要开启CFG保护。一般情况下,CFG是没有启用的。可以在如下位置设置:

需要注意的是,高版本的Visual Studio 才有这个选项,笔者使用的是2017。
另外一个问题,编译可能出现下面的错误:

这是说 /guard:cf 选项与 /ZI模式不兼容。我们可以在如下位置把/ZI修改一下,改为None:

最后编译一下工程就行了。

2.2 dumpbin & IDA

编译完成后,查看一下是否真正开启了CFG;使用VS自带的dumpbin.exe 查看一下 header 和 LoadConfig 部分。
命令: dumpbin.exe /headers /loadconfig CFGtest.exe

红框处,说明成功开启CFG。
继续向下看Load Config 部分:

• Guard CF address of check-function pointer:_guard_check_icall_fptr的地址,在调试的时候可以发现它其实是指向ntdll!LdrpValidateUserCallTarget。
• Guard CF address of dispatch-function pointer:在VS2015编译出来上是个保留字段,直译是保护调度函数指针,在IDA中可看到代码就一句 jmp rax 。
• Guard CF function table: RVA列表的指针,其包含了程序的代码。每个函数的RVA将转化为 CFGBitmap中的“1”位。CFGBitmap 的位信息来自Guard CF function table。
• Guard CF function count: RVA的个数。

当然也可以用IDA验证一下:
在编译启用了CFG的模块时,编译器会分析出该模块中所有间接函数调用可达的目标地址,并将这一信息保存在Guard CF Function Table中。
Guard CF function table 表,一个有1C = 28个rva


IDA中:0x0040D000

我们去看一下 _guard_check_icall_fptr:

这里的j_nullsub_1 最终是指向ntdll!LdrpValidateUserCallTarget,在ntdll中,IDA再跟进就看不到什么了。

3.原理

在调试demo之前,有必要先了解一下CFG的验证过程。
它会以我们将要跳转的目标函数地址作为自己的参数,并进行以下的操作:

  1. 访问CFGbitmap,它表示在进程空间内所有函数的起始位置。在进程空间内每8个字节的状态对应CFGBitmap中的一位。如果函数的地址是合法有效的,那么这个函数对应在CFGbitmap的位置会被设置位1。
  2. 进行函数地址校验,会把这个要校验的地址通过计算转换为CFGbitmap中的一位。
    计算的过程,以一个实例来说明:
    Mov ecx, 0x00b613a0 
    Mov esi , ecx  
    Call _guard_check_icall_fptr 
    call esi
    上面这段汇编代码,间接跳转的地址是: 0x00b613a0。把这个地址放到了ecx,和 esi中,然后调用 _guard_check_icall_fptr函数。
    在这个函数之中,操作如下:

目标地址 :
0x00b613a0 = ‭00000000 10110110 00010011 10100000‬ (b)
首先取高位的3个字节:00000000 10110110 00010011 = 0x00b613
那么CFGbitmap的基地址加上 0x00b613 就是指向一个字节单元的指针。
这个指针最终取到的假设是:
CFGBitmap :0x10100444 = 0001 0000 0001 0000 0000 0100 0100 0100(b)
接着判断目标地址是否以0x10对齐:地址 & 0xf 是否等于 0 ;

这个偏移就是该函数在CFGbitmap中第20位的位置上,若这个位置是1,说明该函数调用是合法有效的,反之则不是。
那么:
CFGBitmap :0x10100444 = 0001 0000 0001 0000 0000 0100 0100 0100(b)
第20位加粗的 1 ,说明这个调用合法有效。

4.执行流程的跟踪

4.1 正常情况

Windbg版本:6.12.0002.633 AMD64/X86
将前面写的demo程序载入windbg后,下断点。

断点的位置,结合IDA计算一下,打在如下位置:
X86 : 00b60000 + 21ce = 00b621ce

运行程序,停在断点位置:

这里可以看到 ecx 就是函数间接调用的地址,下面就是 Call dword ptr(00b6e000)
先去看看这个ecx 的地址 0x00b613a0 是不是demo中我们说写的那个函数。
查看 00b631a0 :

偏移 0x2130:

可以看到 hellow world 的字符串,说明0x00b631a0 确实是一个正常的间接调用。
那么,继续执行

跟进: call dword ptr [CFGtest2+0xe000 (00b6e000)]

来到了前面讲到的:ntdll!LdrpValidateUserCallTarget

0x77289be0 处的 edx 中,存放的就是 验证CFGbitmap的基本指针,即:
edx = 0x00b80000
eax = ecx =  0x00b613a0   
shr  eax >> 8 = 0x0000b613 //这里右移8位,就相当于取高3字节。
edx = [ 0x00b80000 + 0x0000b613*4 ] = 0x10100444 
所以:CFGbitmap = 0x10100444 ,也就是前面讲原理说到的值。

下面,来验证:

eax = ecx = 0x00b613a0   ->   eax  >> 3 = 0x0016c274 
test cl ,0Fh    //   0xa0  &  0x0F  = 0  
ZF = 0           // ZF 为0 ,jne不跳转,说明以0x0f对齐
bt edx, eax 
/*
bt 指令把指定的位传送给CF标志寄存器,这里的意思就是把 edx 的某一位放到CF里,这个某一位由eax的值确定,那eax的值呢?就是 eax mod 0x20 的值。
为什么是mod 0x20呢,这是根据目的操作数来确定的,也就是edx, edx 是32位寄存器,0x20 = 32 (d)。
也就是说eax的值需要mod上对应的寄存器位数。且目的操作数只能是16/32位通用寄存器或存储单元
为什么eax 右移3位,然后用bt 指令就能达到取最低字节的前5位 作为偏移值的效果呢?
eax = 0x00b613a0   
0000 0000 1011 0110 0001 0011 1010 0000
eax >> 3  // 最低字节的最后8位移走3位 ,剩下5位
0000 0000 0001 0110 1100 0010 0111 0100
这剩下的5位,相当于二进制5个1: 11111  =  31 (十进制) ,就做到了 eax对 0x20 取模,模值就是偏移值。
所以:
shr eax, 3
bt  edx, eax

这2条指令就做到了取最低字节的前5位的作为偏移值,并放入CF标志寄存器中,然后通过一个jae跳转实现对函数地址的合法性进行检查。CF = 0,就说明地址不合法,跳转到异常处理去;反之就正常返回。
*/

4.2 错误处理

让程序断在相同的位置:

修改ecx : 00b613b0

修改ecx 为00b613b0 之后,可以发现下面的情况:

也就是说当正常的地址00b613a0,被改为 00b613b0的时候,虽然这不是一个合法的地址,但是在CFG检查的时候,第一次CF标志位被设为0,通过jae跳转到到了0x77289bfe Or eax , 1

但是,根据我们前面的分析,当地址被修改为 00b613a0 时,这不是一个正常的函数地址。CFG函数检查之后,CF标志位被置0,接下来应该是抛出异常。但根据windbg调试来看,并不是这样的流程。而是跳转到0x77289bfe ,然后继续执行:

Or eax, 1
// eax  = 0x0016c276
// eax = 0x0016c276 or 1 = 0x0016c277
bt  edx, eax          - >  CF = 0
jae 跳转

这里为什么会在进行一次or 1的操作呢?
后面笔者参考了很多其他资料,得出下面的结论(仅个人理解,若出现错误望大家斧正):

4.3 int 29

当我们提供一个错误的地址,我们尝试跟踪完整的中断过程,看看CFG是怎么做的。
地址:0x77289c07

跟进 ntdll!RtlpHandleInvalidUserCallTarget:

继续 ntdll!NtQueryInformationProcess:

ntdll!ZwQueryInformationProcess+0x1f (7726e90f):

最终调用的函数层次比较深,用下图进行说明:

最后中断在:
0x77289d10 int 29h

5.Edge-x64 / IE 11(32/64)

下面就简单的调试下CFG在Edge,IE11下的情况。

5.1 Edge x64

使用dumpbin /headers /loadconfig xxx.exe ,查看Edge开启的保护:


在IDA的情况下:


下断点跟踪:

从上图看汇编代码,可以发现与我们的demo程序的情况是一致的,区别就是64位的系统,地址的第9-63位被用于在CFGbimap中检索一个qword,第3-10位被用于(模64)访问qword中某一指定位:

5.2 IE32

调试时候先找一个有CFG保护的函数,确定一下函数名,然后下断点:

笔者找的红框的2个函数,下面用initterm_e 说明就行。
iexplore!_initterm_e 的情况如下:

进行简单的计算一下:

iexplore!_onexit:
eax = ecx = 0x73e18630
…
edx = 0x80080082 =1000 0000 0000 1000 0000 0000 1000 0010
eax >>  3 = 0x0e7c30c6 
0x0e7c30c6 mod 0x20 = 0x00000006
CF = 0

同样,这里也会比对CFGbitmap中的2位。道理如同上面所讲,这里不再赘述。

5.3 IE64

依然查看它的保护情况:18 h = 24d

在有CFG保护的位置下断,由于我是直接开启iexplore.exe,能断下的函数只有下图bsearch的位置,其他在开启IE过程中,都断不下来,不过并不影响什么:

跟踪情况:

从上面的跟踪可以知道,无论是demo,Edge,或是 IE ,最终都会来到ntdll中实现CFG检查,无论是32位还是64位,检查过程都是一致的。

6.bypass部分

这里bypass只列出一部分,也会简单的说明一下。

6.1 CVE-2015-0311

利用这个CVE可以先达到任意内存读写。
然后Adobe Flash Player二进制文件中有很多间接调用,但是都有CFG保护。
到目前为止,CFG在保护这个Adobe Flash Player二进制文件中的29,000多个间接调用方面做得非常出色。
但是需要找一个不受CFG保护的间接调用。
Flash Player包含一个即时(JIT)编译器,该编译器将虚拟机字节码转换为本地代码以提高执行速度。由JIT编译器生成的代码包括间接调用,
并且由于此代码是在运行时生成的,这意味着其中的间接调用不受CFG的保护。

首先有一个Vector容器,且长度很大,这个长度将允许我们从进程的地址空间内的任何内存地址读/写。
然后有一个ByteArray对象,我们的ROP链存储在Vector的第一个元素。
这个ByteArray对象+ 8的位置是一个指向VTable对象的指针
VTable_object 里面又包含很多其他指针。
在 VTable_object + 0xD4 处有一个指针,指向MethodEnv对象
MethodEnv对象第一个双字是一个指向它自己的的vtable的指针,而第二个双字是一个函数指针。
通过观察发现 MethodEnv+4处的指针,是间接调用实现的。取消引用了存储在VTable_object + 0xD4 处的 MethodEnv 对象的指针
也就是说,本应该是 通过 VTable_object + 0xD4 + 偏移 去调用 MethodEnv 第二个双字处的函数指针,然后去使用。
可以从图上看到,它是通过mov mov 然后取到了这些需要调用的位置。
此间接调用来自Flash JIT编译器生成的代码,通过调用ByteArray对象上的toString()方法,可以可靠地触发这种无保留的间接调用。

6.2 BlackHat 2015

CustomHeap::Heap对象 , 改写guard_check_icall_fptr,指向合适的函数。

利用jscript9中的CustomHeap::Heap对象将 guard_check_icall_fptr 变成可读写的:

CustomHeap::Heap::FreeAll 为每个 Bucket 对象调用 CustomHeap::Heap::FreeBucket
CustomHeap::Heap::FreeBucket 遍历 Bucket 的双向链表,为每个节点的 CustomHeap::Page 对象调用CustomHeap::Heap::EnsurePageReadWrite <1,4>。
CustomHeap::Heap::EnsurePageReadWrite <1,4>。
用以下参数调用 VirtualProtect :主要是 VirtualProtect ,可以把一个只读页面改写成可读可写的。
有4个参数如下:
lpAddress: CustomHeap::Page 对象的成员变量address
dwSize: 0x1000
flNewProtect: PAGE_READWRITE   // 请求的保护方式
flodlProtect:                 // 保存旧的保护方式
将内存页面标记为PAGE_READWRITE ,就能达到修改CFG函数的ptr指针的目的。从而绕过CFG。

patch:补丁如下图

微软引入了一个新的函数HeapPageAllocator::ProtectPages。
这个函数是VirtualProtect的一个封装,在调用VirtualProtect之前对参数进行校验,如下:
检查lpAddress是否是0x1000对齐的;
检查lpAddress是否大于Segment的基址;
检查lpAddress加上dwSize是否小于Segment的基址加上Segment的大小;
检查dwSize是否小于Region的大小;
检查目标内存的访问权限是否等于指定的(通过参数)访问权限;
任何一个检查项未通过,都会调用CustomHeap_BadPageState_fatal_error抛出异常而终止进程

6.3 MS16-063

微软在MS16-063 中发布的补丁:修复 jscript9.dll 与 TypedArray 和 DataView 相关的内存损坏漏洞。
这个漏洞简单来说: 就是先泄漏 vftable 的基地址,构建一个包含里 shellcode 的 rop 链, 用假的返回地址覆盖 vftable 的地址。然后 call Uint8Array.subarray ,就执行了 shellcode
但是在windows10 里面, 调用 vftable 前会检查这个地址是不是合法的,我们之前替换这个地址,所以当然不合法。就会抛出异常。那么绕过的方法:

CFG 是不会保护堆栈上的数据的
现在,要绕过CFG检查。 使用的函数是 RtlCaptureContext,它有个指向Context结构体的参数。
因为Context转储了所有寄存器,且输入值仅是一个缓冲区的指针。
因为 TypedArray 中 0x7c 处有 RtlCaptureContext 的地址, 0x20 处是 TypedArray 指向的真实数据的指针。
通过 TypedArray 去泄露 RtlCaptureContext 的地址,由于 RtlCaptureContext 在 ntdll 中存在,进而泄露 ntdll 的地址。
执行次操作的默认路径是使用的vtable地址,它是一个指向jscript9.dll的指针,这个指针往回走0x1000自己,可以找到MZ头,继而查到 kernelbase.dll 的导入表。然后做同样的操作,获得 kernelbase.dll 基地址

简单的说: 使用 TypedArray 对象 泄露 RtlCaptureContext 的地址 -> Context结构体 -> 转储了所有寄存器
根据 RtlCaptureContext -> 泄露 ntdll 的基地址 -> kernelbase.dll 的基地址  -> 泄露堆栈地址  ->  覆盖自己的函数返回地址。
泄露堆栈地址,改写返回地址。

6.4 MFC40.dll

采用 stack pivot 绕过 CFG
先让程序加载没有使用CFG保护的 dll -> MFC40.dll
通过 PEB 去寻找 MFC40.dll 的基地址
构造 rop 链
通过 mv.subarray(pivotGadgetAddr) 执行到rop

7. 参考文献

1.如何绕过Windows 10的CFG机制
http://www.freebuf.com/articles/system/126007.html
2.探索Windows 10的CFG机制
https://www.anquanke.com/post/id/85493
3.使用最新的代码重用攻击绕过执行流保护
https://bbs.pediy.com/thread-217335.htm
4.About CVE-2015-0311
https://www.coresecurity.com/blog/exploiting-cve-2015-0311-part-ii-bypassing-control-flow-guard-on-windows-8-1-update-3
http://www.freebuf.com/vuls/57925.html
5.http://blog.nsfocus.net/win10-cfg-bypass/
6.Bypassing Control Flow Guard in Windows 10
https://improsec.com/blog/bypassing-control-flow-guard-in-windows-10
https://improsec.com/blog/bypassing-control-flow-guard-on-windows-10-part-ii
7.敏感的API绕过CFG
https://blog.trendmicro.com/trendlabs-security-intelligence/control-flow-guard-improvements-windows-10-anniversary-update/

源链接

Hacking more

...