【技术分享】探索Windows 10的CFG机制

https://p1.ssl.qhimg.com/t014e2284a3bb2ddafb.jpg

翻译:myswsun

预估稿费:260RMB

投稿方式:发送邮件至linwei#360.cn,或登陆网页版在线投稿

0x00 前言

随着操作系统开发人员一直在增强漏洞利用的缓解措施,微软在Windows 10和Windows 8.1 Update 3中默认启用了一个新的机制。这个技术称作控制流保护(CFG)。

和其他利用缓解措施机制一样,例如地址空间布局随机化(ASLR),和数据执行保护(DEP),它使得漏洞利用更加困难。毫无疑问,它将大大改变攻击者的利用技术。就像ALSR导致了堆喷射技术的出现,和DEP导致了ROP技术的出现。

为了研究这个特别的技术,我使用了Windows 10 技术预览版(build 6.4.9841)和使用Visual Studio 2015 预览版编译的测试程序。因为目前最新版的Windows 10 技术预览版(build 10.0.9926)有了一点改变,我将指出不同之处。

为了完全实现CFG,编译器和操作系统都必须支持它。作为系统层面的利用缓解措施,CFG的实现需要联合编译器、操作系统用户层库和内核模块。MSDN上面的一篇文章描述了支持CFG开发者需要做的步骤。

微软的CFG实现主要集中在间接调用保护上。考虑下面测试程序中的代码:

http://p4.qhimg.com/t0137416ea49e1cd7a0.png

图1 – 测试程序的代码

让我们看下CFG没有启用时的代码情况。

http://p0.qhimg.com/t01241774bba08c0170.png

图2 – 测试程序的汇编代码

在上图中,有一个间接调用。它的目标地址不在编译时决定,而是在运行时决定。一个利用如下:

http://p5.qhimg.com/t01fcc66ea4c6ae2cb7.png

图3 – 怎么滥用间接调用

微软实现的CFG主要关注缓解间接调用和调用不可靠目标的问题(在利用中,这是shellcode的第一步)。

不可靠的目标有明显特征:在大部分情况下,它不是一个有效的函数起始地址。微软的CFG实现是基于间接调用的目标必须是一个可靠的函数的起始位置。启用CFG后的汇编代码是怎样的?

http://p0.qhimg.com/t01f916655a6b2e3cc4.png

图4 – 启用CFG后的汇编代码

在间接调用之前,目标地址传给_guard_check_icall函数,在其中实现CFG。在没有CFG支持的Windows中,这个函数不做任何事。在Windows 10中,有了CFG的支持,它指向ntdll!LdrpValidateUserCallTarget函数。这个函数使用目标地址作为参数,并且做了以下事情:

1. 访问一个bitmap(称为CFGBitmap),其表示在进程空间内所有函数的起始位置。在进程空间内每8个字节的状态对应CFGBitmap中的一位。如果在每组8字节中有函数的起始地址,则在CFGBitmap中对应的位设置为1;否则设置为0。下图是CFGBitmap的一部分示例:

http://p3.qhimg.com/t011fd7b90465fad4fa.png

图5 – CFGBitmap

2. 将目标地址转化为CFGBitmap中的一个位。让我们以00b01030为例:

http://p7.qhimg.com/t0192ed89b6b364ae57.png

图6 – 目标地址

高位的3个字节(蓝色圈中的24位)是CFGBitmap(单位是4字节/32位)的偏移。在这个例子中,高位的3个字节相当于0xb010。因此,CFGBitmap中指向字节单元的指针是CFGBitmap的基址加上0xb010。

同时,第四位到第八位(红色圈中的)有值X。如果目标地址以0x10对齐(目标地址&0xf==0),则X为单位内的位偏移值。如果目标地址不以0x10对齐(目标地址&0xf!=0),则X|0x1是位偏移值。

在这个例子中,目标地址是0x00b01030。X的值为6。表达式0x00b01030&0xf值为0;这意味着位偏移也是6。

3. 我们看到第二步定义的位。如果位等于1,意味着间接调用的目标是可靠的,因为它是一个函数的起始地址。如果这个位为0,意味着间接调用的目标是不可靠的,因为它不是一个函数的起始地址。如果间接调用目标是可靠的,函数将不做任何事并且直接执行。如果间接调用是不可靠的,将触发异常阻止利用代码运行。

http://p8.qhimg.com/t011373c1d9e8ccffc7.png

图7 – CFGBitmap中的值

值X取自第4位到第8位(上面红圈中5位)。如果目标地址以0x10对齐(目标地址&0xf==0),X是单元中的位偏移值。如果目标地址不以0x10对齐(目标地址&0xf!=0),X|0x1是位偏移值。在这个例子中,目标地址是0x00b01030,X是6(图6中红色圈)。0x00b01030&0xf==0,因此位偏移是6。

在第二步中,位偏移是6。以图7为例,第6位(红圈)为1。意味着间接调用的目标是一个可靠的函数地址。

现在,我们已经有了CFG工作机制的基本认识。但是这个技巧带来了下面的问题:

1. CFGBitmap的位信息来自哪里?

2. 何时且怎么生成CFGBitmap?

3. 系统怎么处理不可靠的间接调用触发的异常?


0x01 深入CFG实现

我们能在PE文件(启用CFG的VS2015编译的)中发现另外的CFG信息。让我们看下PE文件中的图1的代码。这个信息能用VS2015的dumpbin.exe转储出来。在PE文件的Load Config Table部分,我们能找到下面的内容:

http://p9.qhimg.com/t016d1b3d1242c41154.png

图8 – PE信息

Guard CF address of check-function pointer:_guard_check_icall的地址(见图4)。在Windows 10预览版中,当PE文件加载时,_guard_check_icall将被修改并指向nt!LdrpValidateUserCallTarget。

Guard CF function table:函数的相对虚拟地址(RVA)列表的指针,其包含了程序的代码。每个函数的RVA将转化为CFGBitmap中的“1”位。换句话说,CFGBitmap的位信息来自Guard CF function table。

Guard CF function count:函数RVA的个数。

CF Instrumented:表明程序中启用了CFG。

在这里,编译器完成了CFG的整个工作。剩下的是OS的支持使CFG机制起作用。

1. 在OS引导阶段,第一个CFG相关的函数是MiInitializeCfg。这个进程是system。调用堆栈如下:

http://p6.qhimg.com/t01daf64c664795756a.png

图9 – 调用堆栈

MiInitializeCfg函数的前置工作是创建包含CFGBitmap的共享内存。调用时间可以在NT内核阶段1内存管理器初始化时找到(MmInitSystem)。如你所知,在NT内核阶段1的初始化期间,它调用MmInitSystem两次。第一个MmInitSystem将进入MiInitializeCfg。那么MiInitializeCfg做了什么?

http://p1.qhimg.com/t018d7acc532bd16cc0.png

图10 – 函数的主要逻辑

步骤A:注册表值来自HKEY_LOCAL_MACHINESYSTEMCurrentControlSetControlSession Managerkernel: MitigationOptions

步骤B:MmEnableCfg是一个全局变量,它被用来表示系统是否启用CFG功能

步骤C:MiCfgBitMapSection的DesiredAccess允许所有的权限;它的分配类型是“reserve”。在build 10.0.9926和build 6.4.9841中共享内存的大小是不同的。对于build 6.4.9841,它按用户模式空间大小计算。表达式是size=User Mode Space Size>>6。(>>X:右移X位)。对于build 10.0.9926,这个大小是0x3000000。CFG bitmap能表示整个用户模式空间。MiCfgBitMapSection是CFG实现的核心组件,因为它被用来包含CFGBitmap。

2. 获得压缩RVA列表信息的函数且保存到映像的Control_Area结构。

PE映像第一次加载到系统。NT内核将调用MiRelocateImage来重定位。MiRelocateImage将调用MiParseImageCfgBits。在函数MiParseImageCfgBits中,PE映像的压缩的RVA列表被计算且存储在PE映像节中的Control_Area数据结构。在系统引导期间一个PE映像只发生一次。

当PE再次加载进进程,NT内核将调用MiRelocateImageAgain。因为它的压缩的RVA列表已经保存了(且不需要再次计算),MiRelocateImageAgain不需要调用MiParseImageCfgBits保存一些进程的时间。MiParseImageCfgBits被用来计算压缩的RVA列表以便在小的空间中保存RVA列表。微软实现CFG有时间和空间的消耗。在MiRelocateImage中,它的CFG相关的部分被如下描述:

http://p3.qhimg.com/t01fcae65b0be55c2e5.png

MiParseImageCfgBits被用来计算启用CFG编译的模块的压缩的RVA列表。在深入这个函数之前,我们将看一下这个函数调用的上下文。函数MiParseImageCfgBits将在MiRelocateImage函数中调用。

函数MiParseImageCfgBits有5个参数:

a) 映像节的Control_Area结构的指针

b) 映像文件内容的指针

c) 映像大小

d) 包含PE可选头结构的指针

e) 输出压缩的CFG函数RVA列表的指针

MiParseImageCfgBits的主要工作如下:

a) 从映像的Load Config Table获得函数RVA列表

b) 使用压缩算法压缩列表,以便在小空间保存列表

c) 创建压缩的RVA列表作为输出

3. 在CFGBitmap共享内存对象被创建后,CFGBimap共享内存对象被映射来作为两种用途:

a) 用来写共享模块(.DLL文件等)的bits。这个映射是临时的;在bits写入完成后,映射将释放。通过这个映射写入的bits信息是共享的,意味着它能被操作系统内所有的进程读取。这个映射发生在MiUpdateCfgSystemWideBitmap函数中。调用堆栈如下:

http://p8.qhimg.com/t01a0050d8bfb2b9fa5.png

图11 – 调用堆栈

b) 用来写私有的bits和读取校验间接调用的bits。通过这个映射写入的bits是私有的,意味着它只能被当前进程读取。这个映射的生存周期与进程的生命周期相同。这个映射发生子MiCfgInitializeProcess中,调用堆栈如下:

http://p1.qhimg.com/t0131e56e434352170d.png

图12 – 调用堆栈

基于调用堆栈,我们知道它在一个正在初始化的进程中被映射。Build 10.0.9926和6.4.9841的映射大小是不一样的。对于6.4.9841,大小是基于用户模式空间大小计算的。表达式为size=User Mode Sapce Size>>6(>>X:右移X位)。对于10.0.9926,这个大小是0x3000000。映射的空间在进程生命周期内总是存在的。映射的基址和长度将被保存在类型为MI_CFG_BITMAP_INFO的结构体中,且地址被修改了(在6.4.9841中,基址是0xC0802144。在10.0.9926中,是0xC080214C)。我稍后将讨论怎么将私有的bits写入映射空间中。MI_CFG_BITMAP_INFO的结构如下:

http://p2.qhimg.com/t013fd136f385bd7cb4.png

4. 一旦PE映像的RVA列表准备好了且CFGBitmap节也映射了,就可以将RVA列表翻译为CFGBitmap中的bits。

http://p7.qhimg.com/t0135ed25f25b94e063.png

图13 – 更新CFGBitmap的bits

在几种不同的场景下这个过程不太一样:

在ReloadImage/ReloadImageAgain,通过 MiUpdateCfgSystemWideBitmap写入共享模块(如dll)的bits

在进程初始化阶段写入私有模块(如exe)的bits

写入VM(虚拟内存)操作的bits

写入映像和数据段的映射的bits

在深入每个场景之前,我们需要搞清楚一些背景信息。在每个进程中,包含CFGBitmap的空间被分为两部分:共享和私有。

MiCfgBitMapSection是一个共享内存对象,包含了CFGBitmap的共享的bitmap的内容。它与每个进程共享。当它在自己的虚拟内存空间中映射MiCfgBitMapSection时,每个进程看见的内容都相同。共享模块(dll等)的bitmap信息将通过3.a节描述的映射方法写入。

然而每个进程需要CFGBitmap的一部分不是被所有进程共享的。它需要私有写入一些模块的bitmap信息到CFGBitmap中。这个私有的部分将不和所有的进程共享。EXE模块的bitmap信息使用3.b节描述的方法写入。下图描述了一个通用的场景。

http://p1.qhimg.com/t01efb1b21d765df423.png

图14 – 在MiCfgBitMapSection中的共享部分的bitmap内容的3中过程和他们的私有节

a) 在ReloadImage/ReloadImageAgain中,通过MiUpdateCfgSystemWideBitmap写入共享模块(dll等)的bits。

如第2节所见,在得到压缩的函数的RVA列表并将它保存到Control_Area结构后(在build6.4.9841中: _Control_Area ->SeImageStub->[+4]->[+24h];在build10.0.9926中: _Control_Area ->SeImageStub->[+0]->[+24h]),它将调用MiSelectImageBase。这个函数是ASLR实现的核心。它返回最终选择的基址。选择的基地址对于写bit信息到CFGBitmap中非常重要。在得到基地址后,它将调用MiUpdateCfgSystemWideBitmap。

MiUpdateCfgSystemWideBitmap的主要任务是将压缩的RVA列表翻译为CFGBitmap中的“1”bit。通过这个函数写入的bitmap信息是共享的,且被操作系统所有的进程共享。这个函数只针对共享模块有效(dll文件等)。

MiUpdateCfgSystemWideBitmap有3个参数:

指向Control_Area结构的指针

映像的基址

指向压缩的RVA列表的指针

MiUpdateCfgSystemWideBitmap的主要逻辑如下:

http://p0.qhimg.com/t01f0b02443f6db4bfb.png

图15 – MiUpdateCfgSystemWideBitmap的主要逻辑

在步骤B中,它映射CFGBitmap共享内存到系统进程空间中。它不映射所有共享内存的全部大小。它转化映像的基址为CFGBitmap的偏移,且使用转化的结果作为映射的起始地址。转为过程如下:

Bitmap的偏移=基地址>>6。按这个公式,映射大小是映像大小右移6位。这个方法在映像需要重定位(ReloadImageAgain函数)的时候也会被使用。

b) 在进程初始化阶段写私有模块(exe文件等)的bits。它将调用MiCommitVadCfgBits,其是一个派遣函数。你能使用图13作为参考。它在确定的场景被调用。这个函数的前置工作是在VAD描述的空间写入bits。主要逻辑如下:

http://p7.qhimg.com/t01ab448a49090f817d.png

图16 – MiMarkPrivateImageCfgBits函数处理写入私有模块的bits

MiMarkPrivateImageCfgBits函数实现向CFG Bitmap中写入私有模块(exe等)的bit信息。当系统映射一个EXE的节或者启动一个进程时,这个函数被调用。

这个函数有2个参数:

1) Cfg信息的全局变量地址

2) 映像空间的VAD

VAD是用来描述虚拟内存空间范围的一个结构。

函数的前置工作是将输入的VAD的相关的压缩的RVA列表转化为bitmap信息,且在CFGBitmap中写入bits。主要逻辑如下:

http://p9.qhimg.com/t0103cb6d39da8f9d4c.png

图17 – MiMarkPrivateImageCfgBits的主要逻辑

在步骤A中,压缩的RVA列表能从输入的VAD关联的Control_Area结构中获得,在MiRelocateImage中保存(参见第二节)。

这个函数的主要步骤是步骤C。它实现私有写入映射的MiCfgBitMapSection32节(在3.b节有描述)。写入的私有的bits的映射是只读的。向映射的空间写入bits怎么实现?关键步骤如下:

i. 获得映射的空间地址的物理地址(PFN)

ii. 申请一个空的页表入口(PTE)并使用上步获得的物理地址填充PTE,新的PTE被映射到相同的物理页,其包含了映射的MiCfgBitMapSection32的虚拟地址。

iii. 复制结果缓冲区(图12)到新的PTE。物理页将包含结果缓冲区的内容

iv. 释放新的PTE

在上面步骤完成后,bitmap信息被拷贝到当前进程地址空间内。但是不会影响MiCfgBitMapSection。换句话说,MiCfgBitMapSection不知道bitmap改变了。其他进程也不会看到改变;新添加的bitmap信息对当前进程是私有的。

c) 写虚拟内存操作的bits。如果一个进程有虚拟内存操作,它可能会影响CFGBitmap中的bitmap的bits状态。从图13的场景看,它将调用MiMarkPrivateImageCfgBits。函数的前置工作是复制“1”或“0”页到CFGBitmap空间中。

i. 对于NtAllocVirtualMemory函数

如果一个进程调用NtAllocVirtualMemory函数来分配具有可执行属性的虚拟内存,NT内核将设置CFGBitmap中相关的位为“1”。但是如果分配的内存的保护属性有 SEC_WRITECOMBINE,NT内核将使用“0”设置bitmap。

ii. 对于MiProtectVirtualMemory函数

如果一个进程调用MiProtectVirtualMemory来改变虚拟内存的保护属性为“可执行”,NT内核将设置CFGBitmap相关位为“1”。

d) 写映像和数据段映射的bits

i. 对于映像(dll,EXE等)节的映射,如果映像不是共享的,处理过长如4.b节描述。如果是共享的,将由图13中的MiMarkPrivateImageCfgBits函数处理。它遍历映射空间中的每个页且将页地址转化为CFGBitmap中的偏移。

i. 如果CFGBitmap中的偏移不被PrototypePTE支持,相关的bits信息将被拷贝到CFGBitmap空间中。

ii. 如果CFGBitmap中的偏移已经有bitmap信息,CFGBitmap部分将改为只读。

ii. 对于数据段的映射,处理与4.c.i相同。

5. 上面提到的步骤都发生在内核模式下。但是对于用户模式,CFGBitmap需要访问LdrpValidateUserCallTarget函数,它在上一部分已经描述了。用户模式下怎么知道CFGBitmap映射的地址?当创建一个进程,NT内核调用PspPrepareSystemDllInitBlock函数来写CFGBitmap映射的地址和全局变量的长度,其数据结构是PspSystemDllInitBlock结构。PspSystemDllInitBlock是修正过的地址并且从用户模式和内核模式都能访问。

http://p3.qhimg.com/t01ac5dd24f494331c2.png

图18 – 调用堆栈

用户模式可以访问硬编码的PspSystemDllInitBlock全局变量的CFGBitmap字段。

6. 在图4中,_guard_check_icall函数指针将指向ntdll的LdrpValidateUserCallTarget。何时发生,如何发生?LdrpCfgProcessLoadConfig来完成这个工作。进程创建过程将在用户模式下调用LdrpCfgProcessLoadConfig。

http://p9.qhimg.com/t01aaf7261201b9be74.png

图19 – 在这个函数中,它将修改_guard_check_icall的值指向LdrpValidateUserCallTarget

7. 在所有的准备都完成后,如果间接调用的目标地址相关的位在CFGBitmap中不是“1”,将触发CFG。进程将采取行动处理这个异常。处理函数是RtlpHandleInvalidUserCallTarget。这个函数使用间接调用的目标为唯一的参数。函数的主要逻辑如下:

http://p0.qhimg.com/t0174c7622622eab247.png

图20 – RtlpHandleInvalidUserCallTarget的主要逻辑

函数的主要流程是校验DEP状态和触发int 29中断,这个内核中断处理例程是KiRaiseSecurityCheckFailure。它的行为是结束进程。

如果一个间接调用的目标地址的CFGBitmap中的相关的位不能访问(如超出了CFGBitmap空间),意味着目标地址是不可靠的。系统将抛出访问异常。当这个异常回到用户模式的处理函数KiUserExceptionDispatcher时,它将调用RTLDispatchException。在RTLDispatchException中,它将校验异常发生的地址。如果指令的地址能访问CFGBitmap,它将继续调用RtlpHandleInvalidUserCallTarget。

8. 如果一个进程需要自定义CFGBitmap,它能调用ntdll中的NtSetInformationVirtualMemory。在内核中函数MiCfgMarkValidEntries实现了这个功能。MiCfgMarkValidEntries以一个缓冲区和长度作为参数。缓冲区中的每个单位是8字节。头四个字节是目标地址,其想在CFGBitmap中设置相关的位,且后四个字节是设置“0”或“1”的标志。MiCfgMarkValidEntries自定义的CFGBitmap只在当前进程能看见。

9. 如果一个攻击者需要改变用户模式下的CFGBitmap的内容,是不可能的。因为CFGBitmap被映射为只读(在3.b节讨论过)。不管改内存保护属性还是向空间中写值都将失败。


0x02 CFG的弱点

当然,这个机制不是没有弱点的。我们指出了一些弱点如下:

CFGBitmap空间地址存储在修正过的地址中,其能被用户模式代码获得。这在CFG实现中讨论过。这是很重要的安全问题,但是被简单的放过了。

如果主模块没有开启CFG,即使加载的启用了CFG的模块,进程也不会受保护。

基于图20,如果一个进程的主模块禁用了DEP(通过/NXCOMPAT:NO),能绕过CFG访问处理,即使间接调用的目标地址是不可靠的。

在CFGBitmap中的每个bit在进程空间中表示8个字节。因此如果一个不可靠的目标地址少于8个字节,CFG将认为是可靠的。

如果目标函数是动态生成的(类似JIT技术),CFG的实现不能保护。这是因为NtAllocVirtualMemory将在CFGBitmap中为所有分配的可执行的内存空间设置为“1”(4.c.i描述)。通过MiCfgMarkValidEntries自定义的CFGBitmap解决这个问题是可能的。

(完)