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实现主要集中在间接调用保护上。考虑下面测试程序中的代码:
图1 - 测试程序的代码
让我们看下CFG没有启用时的代码情况。
图2 - 测试程序的汇编代码
在上图中,有一个间接调用。它的目标地址不在编译时决定,而是在运行时决定。一个利用如下:
图3 – 怎么滥用间接调用
微软实现的CFG主要关注缓解间接调用和调用不可靠目标的问题(在利用中,这是shellcode的第一步)。
不可靠的目标有明显特征:在大部分情况下,它不是一个有效的函数起始地址。微软的CFG实现是基于间接调用的目标必须是一个可靠的函数的起始位置。启用CFG后的汇编代码是怎样的?
图4 – 启用CFG后的汇编代码
在间接调用之前,目标地址传给_guard_check_icall函数,在其中实现CFG。在没有CFG支持的Windows中,这个函数不做任何事。在Windows 10中,有了CFG的支持,它指向ntdll!LdrpValidateUserCallTarget函数。这个函数使用目标地址作为参数,并且做了以下事情:
1. 访问一个bitmap(称为CFGBitmap),其表示在进程空间内所有函数的起始位置。在进程空间内每8个字节的状态对应CFGBitmap中的一位。如果在每组8字节中有函数的起始地址,则在CFGBitmap中对应的位设置为1;否则设置为0。下图是CFGBitmap的一部分示例:
图5 – CFGBitmap
2. 将目标地址转化为CFGBitmap中的一个位。让我们以00b01030为例:
图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,意味着间接调用的目标是不可靠的,因为它不是一个函数的起始地址。如果间接调用目标是可靠的,函数将不做任何事并且直接执行。如果间接调用是不可靠的,将触发异常阻止利用代码运行。
图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部分,我们能找到下面的内容:
图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。调用堆栈如下:
图9 – 调用堆栈
MiInitializeCfg函数的前置工作是创建包含CFGBitmap的共享内存。调用时间可以在NT内核阶段1内存管理器初始化时找到(MmInitSystem)。如你所知,在NT内核阶段1的初始化期间,它调用MmInitSystem两次。第一个MmInitSystem将进入MiInitializeCfg。那么MiInitializeCfg做了什么?
图10 – 函数的主要逻辑
步骤A:注册表值来自HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\kernel: 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相关的部分被如下描述:
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函数中。调用堆栈如下:
图11 – 调用堆栈
b) 用来写私有的bits和读取校验间接调用的bits。通过这个映射写入的bits是私有的,意味着它只能被当前进程读取。这个映射的生存周期与进程的生命周期相同。这个映射发生子MiCfgInitializeProcess中,调用堆栈如下:
图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的结构如下:
4. 一旦PE映像的RVA列表准备好了且CFGBitmap节也映射了,就可以将RVA列表翻译为CFGBitmap中的bits。
图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节描述的方法写入。下图描述了一个通用的场景。
图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的主要逻辑如下:
图15 – MiUpdateCfgSystemWideBitmap的主要逻辑
在步骤B中,它映射CFGBitmap共享内存到系统进程空间中。它不映射所有共享内存的全部大小。它转化映像的基址为CFGBitmap的偏移,且使用转化的结果作为映射的起始地址。转为过程如下:
Bitmap的偏移=基地址>>6。按这个公式,映射大小是映像大小右移6位。这个方法在映像需要重定位(ReloadImageAgain函数)的时候也会被使用。
b) 在进程初始化阶段写私有模块(exe文件等)的bits。它将调用MiCommitVadCfgBits,其是一个派遣函数。你能使用图13作为参考。它在确定的场景被调用。这个函数的前置工作是在VAD描述的空间写入bits。主要逻辑如下:
图16 – MiMarkPrivateImageCfgBits函数处理写入私有模块的bits
MiMarkPrivateImageCfgBits函数实现向CFG Bitmap中写入私有模块(exe等)的bit信息。当系统映射一个EXE的节或者启动一个进程时,这个函数被调用。
这个函数有2个参数:
1) Cfg信息的全局变量地址
2) 映像空间的VAD
VAD是用来描述虚拟内存空间范围的一个结构。
函数的前置工作是将输入的VAD的相关的压缩的RVA列表转化为bitmap信息,且在CFGBitmap中写入bits。主要逻辑如下:
图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是修正过的地址并且从用户模式和内核模式都能访问。
图18 – 调用堆栈
用户模式可以访问硬编码的PspSystemDllInitBlock全局变量的CFGBitmap字段。
6. 在图4中,_guard_check_icall函数指针将指向ntdll的LdrpValidateUserCallTarget。何时发生,如何发生?LdrpCfgProcessLoadConfig来完成这个工作。进程创建过程将在用户模式下调用LdrpCfgProcessLoadConfig。
图19 – 在这个函数中,它将修改_guard_check_icall的值指向LdrpValidateUserCallTarget
7. 在所有的准备都完成后,如果间接调用的目标地址相关的位在CFGBitmap中不是“1”,将触发CFG。进程将采取行动处理这个异常。处理函数是RtlpHandleInvalidUserCallTarget。这个函数使用间接调用的目标为唯一的参数。函数的主要逻辑如下:
图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解决这个问题是可能的。