2023年1月

转载https://www.anquanke.com/post/id/85493

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解决这个问题是可能的。

首先获取阻塞线程的调用栈

0:002> kb
ChildEBP RetAddr Args to Child
00edfdd8 7c90e9c0 7c8025db 0000026c 00000000 ntdll!KiFastSystemCallRet
00edfddc 7c8025db 0000026c 00000000 00000000 ntdll!ZwWaitForSingleObject+0xc
00edfe40 7c802542 0000026c ffffffff 00000000 kernel32!WaitForSingleObjectEx+0xa8
00edfe54 6640114a 0000026c
ffffffff 00813190 kernel32!WaitForSingleObject+0x12
[...]

传递给WaitForSingleObject的第一个参数是该线程正在等待的线程的句柄(前提条件:我们正在等待一个线程,而不是另一个同步对象)。

我可以通过!handle获取更多信息

0:002> !handle 0000026c f
Handle 0000026c
Type Thread
Attributes 0
GrantedAccess 0x1f03ff:
Delete,ReadControl,WriteDac,WriteOwner,Synch
Terminate,Suspend,Alert,GetContext,SetContext,SetInfo,QueryInfo,SetToken,Impersonate,DirectImpersonate
HandleCount 7
PointerCount 10
Name
Object specific information
Thread Id b94.ff4
Priority 3
Base Priority -16

现在我们找到了线程b94.ff4

 

简介

STATUS_FATAL_APP_EXIT,值为0x40000015。代表的意思是"致命错误,应用退出"。它定义在 ntstatus.h头文件里,如下:

//
// MessageId: STATUS_FATAL_APP_EXIT
//
// MessageText:
//
// {Fatal Application Exit}
// %hs
//
#define STATUS_FATAL_APP_EXIT            ((NTSTATUS)0x40000015L)    // winnt

触发条件

应用关闭期间,应用程序产生了未处理的运行时异常。如果您自己不处理这些运行时异常,则实际上某些运行时异常会被默认处理,而这些默认处理程序中的有一些会调用abort()。默认情况下,就是中止调用:

_call_reportfault(_CRT_DEBUGGER_ABORT, STATUS_FATAL_APP_EXIT, EXCEPTION_NONCONTINUABLE);

abort是一个通用的终止-它不知道是什么特定的异常促使它被调用,因此出现了通用的“未知软件异常”消息。常见的情况是是通过pure call异常-调用未实现的纯虚拟调用。

异常结构填充

ExceptionAddress: 0x0f3db2b2{msvcr120.dll!abort(void),Line90}
ExceptionCode: 40000015//错误代码
ExceptionFlags: 00000001
NumberParameters: 0//附加参数个数,根据经验来看,abort函数里引发的一般都是0

VMMap是一个很好的系统内部工具,它可以可视化特定进程的虚拟内存,并帮助理解内存的用途。它有线程堆栈、映像、Win32堆和GC堆的特定报告。有时,VMMap会报告不可用的虚拟内存,这与可用内存不同。下面是32位进程(总共有2GB虚拟内存)的VMMap报告示例:

 

 

这种“不可用”的内存从何而来,为什么不能使用?Windows虚拟内存管理器具有64KB的分配粒度。当直接用VirtualAlloc分配内存并要求小于64KB(比如16KB)时,VirtualAlloc返回64KB边界上的地址。然后分配前四页(16KB),其余48KB标记为未使用。无法通过执行另一个分配来获取此内存,因为VirtualAlloc将始终返回驻留在64KB边界上的地址。那么,这个内存是不可用的。
VMMap中的fragmentation视图使问题更加明显。在下面的屏幕截图中,黄点是4KB区域,可以分配和使用,而灰色矩形是60KB区域,不能使用。当整个地址空间都是这些不可用的区域时,你得到的虚拟内存就没有你想象的那么多了。

 

 

幸运的是,很容易找到违规分配的来源。本质上,我们正在寻找分配大小小于64KB(或者更好:不能被64KB平均整除)的VirtualAlloc调用。您可以使用VMMap本身(它具有跟踪模式)跟踪这些分配,也可以附加WinDbg并设置断点:

0:000> bm kernelbase!VirtualAlloc* "r $t0 = poi(@esp+8); .if (@$t0 % 0x10000 != 0) { .printf \"Unusable memory will emerge after allocating %d bytes\", @$t0; kb 4 } .else { gc }"
  1: 76c03e8a          @!"KERNELBASE!VirtualAllocExNuma"
  2: 76bcd532          @!"KERNELBASE!VirtualAlloc"
  3: 76c03e66          @!"KERNELBASE!VirtualAllocEx"
0:000> g
Unusable memory will emerge after allocating 4096 bytes
ChildEBP RetAddr  Args to Child              
00defd48 010a4e34 00000000 00001000 00003000 KERNELBASE!VirtualAlloc
00defe2c 010a5f25 00000000 00000000 7eaa7000 FourKBLeak!allocate_small+0x34
00deff18 010a6989 00000001 012ba870 012bae98 FourKBLeak!main+0x35
00deff68 010a6b7d 00deff7c 7529919f 7eaa7000 FourKBLeak!__tmainCRTStartup+0x199

此断点确保传递给VirtualAlloc的分配大小可被64KB整除。否则,断点将停止并打印出有问题的分配和调用堆栈;否则,它将继续执行。这将使捕获小分配的源变得非常容易,并有望修复它们。

真是个不可思议的巧合。仅隔几天,我就要解决两个与嵌套异常处理程序有关的问题。具体来说,导致堆栈溢出的嵌套异常的无限循环。这是一个非常致命的组合。堆栈溢出对于调试来说是一个极其严重的错误;嵌套异常意味着异常处理程序遇到了一个异常,这是不可能的;更糟糕的是,堆栈损坏也在幕后发生。请继续阅读以了解诊断嵌套异常的技巧,以及首先可能导致这些异常的原因。

案例1:VC异常过滤器中的读取错误

客户机有多个转储文件供我查看,它们都显示了一个非常疯狂的模式。应用程序中会发生异常,这是完全可以预料和处理的异常。但是,它不会被正常处理,而是会导致无限的嵌套异常级联,最终导致进程崩溃,出现堆栈溢出。为了简洁起见,这里有一个简短的图片:

0:000> kcn 10000
... <repeated hundreds more times>
19e9 <Unloaded_Helper.dll>
19ea NestedExceptions1!exception_filter
19eb NestedExceptions1!trigger_exception
19ec MSVCR120D!_EH4_CallFilterFunc
19ed MSVCR120D!_except_handler4_common
19ee NestedExceptions1!_except_handler4
19ef ntdll!ExecuteHandler2
19f0 ntdll!ExecuteHandler
19f1 ntdll!KiUserExceptionDispatcher
19f2 <Unloaded_Helper.dll>
19f3 NestedExceptions1!exception_filter
19f4 NestedExceptions1!trigger_exception
19f5 MSVCR120D!_EH4_CallFilterFunc
19f6 MSVCR120D!_except_handler4_common
19f7 NestedExceptions1!_except_handler4
19f8 ntdll!ExecuteHandler2
19f9 ntdll!ExecuteHandler
19fa ntdll!KiUserExceptionDispatcher
19fb <Unloaded_Helper.dll>
19fc NestedExceptions1!exception_filter
19fd NestedExceptions1!trigger_exception
19fe MSVCR120D!_EH4_CallFilterFunc
19ff MSVCR120D!_except_handler4_common
1a00 NestedExceptions1!_except_handler4
1a01 ntdll!ExecuteHandler2
1a02 ntdll!ExecuteHandler
1a03 ntdll!KiUserExceptionDispatcher
1a04 NestedExceptions1!trigger_exception
1a05 NestedExceptions1!main
1a06 NestedExceptions1!__tmainCRTStartup
1a07 NestedExceptions1!mainCRTStartup
1a08 kernel32!BaseThreadInitThunk
1a09 ntdll!__RtlUserThreadStart
1a0a ntdll!_RtlUserThreadStart

在前面的调用堆栈中,很明显exception_filter试图调用卸载的DLL(Helper.DLL)中的函数。这又会导致一个异常(很可能是访问冲突),该异常会将控制权转移到异常过滤器,而我们在嵌套的异常循环中处于非常深的位置。顺便说一下,如果您知道要查找什么,那么很容易跟踪异常链。以下是几帧的kb输出:

006deb54 00a85706 00000000 00000000 00000000 <Unloaded_Helper.dll>+0x6666665e
006dec28 00a85eab 006dec4c 0f4e3924 00000000 NestedExceptions1!exception_filter+0x26
006dec30 0f4e3924 00000000 00000000 00000000 NestedExceptions1!trigger_exception+0x6b
006dec44 0f4e9268 006deda0 006dedf0 00000001 MSVCR120D!_EH4_CallFilterFunc+0x12
006dec7c 00a866d2 00a90000 00a81041 006deda0 MSVCR120D!_except_handler4_common+0xb8
006dec9c 7794c881 006deda0 006df734 006dedf0 NestedExceptions1!_except_handler4+0x22
006decc0 7794c853 006deda0 006df734 006dedf0 ntdll!ExecuteHandler2+0x26
006ded88 7794c6bb 006deda0 006dedf0006deda0 ntdll!ExecuteHandler+0x24
006ded88 58b3115e 006deda0 006dedf0 006deda0 ntdll!KiUserExceptionDispatcher+0xf
006df0d4 00a85706 00000000 00000000 00000000 <Unloaded_Helper.dll>+0x6666665e
006df1a8 00a85eab 006df1cc 0f4e3924 00000000 NestedExceptions1!exception_filter+0x26
006df1b0 0f4e3924 00000000 00000000 00000000 NestedExceptions1!trigger_exception+0x6b
006df1c4 0f4e9268 006df324 006df374 00000001 MSVCR120D!_EH4_CallFilterFunc+0x12
006df1fc 00a866d2 00a90000 00a81041 006df324 MSVCR120D!_except_handler4_common+0xb8
006df21c 7794c881 006df324 006df734 006df374 NestedExceptions1!_except_handler4+0x22
006df240 7794c853 006df324 006df734 006df374 ntdll!ExecuteHandler2+0x26
006df30c 7794c6bb 006df324 006df374 006df324 ntdll!ExecuteHandler+0x24
006df30c 00a85e8f 006df324 006df374 006df324 ntdll!KiUserExceptionDispatcher+0xf
006df744 00a86068 00000000 00000000 7ebab000 NestedExceptions1!trigger_exception+0x4f
006df818 00a86a79 00000001 00c37b88 00c35178 NestedExceptions1!main+0x28
006df868 00a86c6d 006df87c 76dc919f 7ebab000 NestedExceptions1!__tmainCRTStartup+0x199
006df870 76dc919f 7ebab000 006df8c0 77960bbb NestedExceptions1!mainCRTStartup+0xd
006df87c 77960bbb 7ebab000 35ed4a97 00000000 kernel32!BaseThreadInitThunk+0xe
006df8c0 77960b91 ffffffff 7794c9d2 00000000 ntdll!__RtlUserThreadStart+0x20
006df8d0 00000000 00a812d5 7ebab000 00000000 ntdll!_RtlUserThreadStart+0x1b

 突出显示的值(ntdll!ExecuteHandler的第二个参数)是上下文记录,前面的值是异常记录。您可以使用.cxr和.exr命令在WinDbg中检查它们:

 0:000> .exr 006df324 E

xceptionAddress: 00a85e8f (NestedExceptions1!trigger_exception+0x0000004f)

ExceptionCode: c0000005 (Access violation)

ExceptionFlags: 00000000

NumberParameters: 2

Parameter[0]: 00000001

Parameter[1]: 00000000

Attempt to write to address 00000000

0:000> .cxr 006dedf0

eax=cccccccc ebx=00000000 ecx=00000000 edx=00000000 esi=006df0dc edi=006df1a8 eip=58b3115e esp=006df0d8 ebp=006df1a8 iopl=0 nv up ei pl nz na pe nc cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00010206

<Unloaded_Helper.dll>+0x6666665e: 58b3115e ?? ???

0:000> .exr 006deda0

ExceptionAddress: 58b3115e (<Unloaded_Helper.dll>+0x0006666665e)

ExceptionCode: c0000005 (Access violation)

ExceptionFlags: 00000010

NumberParameters: 2

Parameter[0]: 00000008

Parameter[1]: 58b3115e

Attempt to execute non-executable address 58b3115e

这表明原始异常是trigger_exception函数中的访问冲突,但它被另一个访问冲突所掩盖,该访问冲突是由从卸载的DLL执行代码引起的。

但最初的问题比卸载的DLL更微妙。在实际应用中,exception_filter实际上是由VisualC++处理的异常过滤器,用于处理C++异常:msvcr*!__InternalCxxFrameHandler。不知何故,它会触发嵌套异常,异常代码是0xc0000060xc0000006 (IN_PAGE_ERROR: in-page I/O error) 和 I/O error code 0xc000020c (STATUS_CONNECTION_DISCONNECTED)。该嵌套异常会将控制权再次传输到__InternalCxxFrameHandler,并会一次又一次地命中相同的嵌套错误。

有了这些信息,我们开始调查。导致嵌套异常的实际内存访问是一个数据结构传递给了 __InternalCxxFrameHandler处理程序,它存储在二进制文件中,并包含由C++编译器发出的异常处理信息。此信息不经常访问-仅在发生异常时才需要。

最后一个难题是应用程序是从一个网络驱动器运行的,这个驱动器被大量的机器访问,给服务器的网络连接带来了很大的负载。因此,工作流程如下:

  1. 应用程序的初始化路径早期发生异常
  2. 还发生连接错误,导致应用程序二进制文件所在的网络驱动器暂时断开连接
  3. VisualC++异常过滤器需要访问存储在应用程序二进制文件中的数据结构,但数据结构尚未从网络驱动器缓存在RAM中,因为只有在异常发生时才访问该数据结构。
  4. 异常筛选器随后失败,并出现I/O错误,该错误再次将控制权传递给异常筛选器-导致异常的嵌套循环

通过编写访问大型只读全局数据结构(只读全局数据也存储在应用程序二进制文件中)的异常过滤器,并从网络驱动器运行示例应用程序,我能够重建此问题。当我在导致异常之前禁用了网络适配器时,我们得到了上面描述的症状。

我们是怎么解决这个问题的?事实证明,VisualC++有一个名为/SWAPRUN的链接器设置(具体地说,/SWAPRUN:NET),它可以用来指示系统在执行之前将应用程序二进制加载到内存中。这意味着应用程序不可能开始运行,但由于连接问题,二进制文件的某些部分会突然变得不可用。显然,首先最好避免连接问题,但是考虑到网络本身是不可靠的。

案例2:导致堆栈溢出的堆栈损坏

第二个案例展示了导致堆栈溢出的嵌套异常链。唯一的区别是堆栈也已损坏。下面是堆栈底部的一些帧:

0:000> kn
... <repeated hundreds more times>
f04 002be8c4 7794c881 0xcccccccc
f05 002be8e8 7794c853 ntdll!ExecuteHandler2+0x26
f06 002be9b0 7794c6bb ntdll!ExecuteHandler+0x24
f07 002be9b0 cccccccc ntdll!KiUserExceptionDispatcher+0xf
f08 002becfc 7794c881 0xcccccccc
f09 002bed20 7794c853 ntdll!ExecuteHandler2+0x26
f0a 002bede8 7794c6bb ntdll!ExecuteHandler+0x24
f0b 002bede8 cccccccc ntdll!KiUserExceptionDispatcher+0xf
f0c 002bf134 7794c881 0xcccccccc
f0d 002bf158 7794c853 ntdll!ExecuteHandler2+0x26
f0e 002bf224 7794c6bb ntdll!ExecuteHandler+0x24
f0f 002bf224 010b51d5 ntdll!KiUserExceptionDispatcher+0xf
f10 002bf674 cccccccc NestedExceptions2!trigger_exception+0x65
f11 002bf748 010b5cd9 0xcccccccc
f12 002bf798 010b5ecd NestedExceptions2!__tmainCRTStartup+0x199
f13 002bf7a0 76dc919f NestedExceptions2!mainCRTStartup+0xd
f14 002bf7ac 77960bbb kernel32!BaseThreadInitThunk+0xe
f15 002bf7f0 77960b91 ntdll!__RtlUserThreadStart+0x20
f16 002bf800 00000000 ntdll!_RtlUserThreadStart+0x1b

堆栈上的0xCCCCCC地址看起来像是堆栈损坏的必然征兆。实际上,您可能已经有了一个有效的假设:堆栈已被0xcccccccc损坏,ntdll中的异常处理代码以某种方式尝试从地址0xcccccccc执行代码。这会导致嵌套异常,我们的情况与case#1相同。如果我们查看异常记录,我们可以确认。(第一个异常记录是根本原因,第二个异常记录是由于无效的异常处理程序造成的):

0:000> .exr 002bf23c 
ExceptionAddress: 010b51d5 (NestedExceptions2!trigger_exception+0x00000065)
 ExceptionCode: c0000005 (Access violation)
 ExceptionFlags: 00000000
NumberParameters: 2
 Parameter[0]: 00000001
 Parameter[1]: 00000000
Attempt to write to address 00000000
0:000> .exr 002bee00 
ExceptionAddress: cccccccc
 ExceptionCode: c0000005 (Access violation)
 ExceptionFlags: 00000010
NumberParameters: 2
 Parameter[0]: 00000000
 Parameter[1]: cccccccc
Attempt to read from address cccccccc

但也有一些微妙之处。为什么ntdll试图从无效地址0xcccccccc执行代码?答案是,在32位Windows应用程序中,异常过滤器的地址存储在堆栈中,作为名为异常注册记录的数据结构的一部分。如果该结构已损坏,则ntdll中的异常处理代码可能会尝试执行无效地址,认为它是指向异常筛选器的指针。
但这实际上是一个相当严重的安全漏洞!如果攻击者可以覆盖异常注册记录,然后触发异常,则可以执行任意代码。事实上,开发异常注册记录是克服Visual C++ 2003(/GS标志)中引入的堆栈防御的方法之一。
幸运的是,Windows有一些技巧。首先,Visual C++ 2003引入了一个链接器标志,称为。当此标志可用时,链接器在二进制文件中嵌入所有有效异常处理程序的目录。在运行时,Windows可以检查即将运行的异常处理程序是否在有效异常处理程序列表中。如果不是,它将直接拒绝执行无效的异常处理程序,并停止进程-这比尝试执行未知的异常处理程序要安全得多。

其次,Windows Vista SP1和Windows Server 2008引入了另一种系统范围的保护机制SEHOP(结构化异常处理覆盖保护)。默认情况下,在客户端操作系统(Windows Vista、Windows 7、Windows 8)上禁用SEHOP,在服务器操作系统(Windows server 2008和Windows server 2012)上启用SEHOP。SEHOP是一个相当简单的防御:当它打开时,每个线程在链的开头都有一个虚拟的异常注册记录。当发生异常时,ntdll中的异常处理代码将验证当前异常处理程序链是否以该虚拟异常注册记录终止。如果没有,则系统假设异常处理程序链已被篡改,并终止进程。

当这些防御打开时,上述情况根本不可能发生。将阻止将控件传输到0xCCCCCC,就像它是异常处理程序一样,并且该进程将突然终止。因此,我们可以得出结论,发生此崩溃的系统不符合现代安全指南:应用程序应该使用/SAFESEH编译,并且系统应该启用SEHOP。顺便说一下,从Windows7开始,您可以使用ImageFileExecutionOptions注册表项为单个进程配置SEHOP,而不会影响系统的其余部分。
例如,下面是使用/SAFESEH编译二进制文件时发生的情况(注意,启用软件DEP的/NXCOMPAT标志也是必需的):

:000> kn
 # ChildEBP RetAddr 
00 008aee54 779c625f ntdll!NtWaitForMultipleObjects+0xc
01 008af2b8 779c5e38 ntdll!RtlReportExceptionEx+0x3eb
02 008af314 779e81bf ntdll!RtlReportException+0x9b
03 008af394 7798b2e3 ntdll!RtlInvalidHandlerDetected+0x4e04 008af3ec 7797734a ntdll!RtlIsValidHandler+0x13f1a
05 008af484 7794c6bb ntdll!RtlDispatchException+0xfc
06 008af484 01284ef5 ntdll!KiUserExceptionDispatcher+0xf
07 008af8d4 cccccccc NestedExceptions2!trigger_exception+0x65
WARNING: Frame IP not in any known module. Following frames may be wrong.
08 008af9a8 012858c9 0xcccccccc
09 008af9f8 01285a0d NestedExceptions2!__tmainCRTStartup+0x199
0a 008afa00 76dc919f NestedExceptions2!mainCRTStartup+0xd
0b 008afa0c 77960bbb kernel32!BaseThreadInitThunk+0xe
0c 008afa50 77960b91 ntdll!__RtlUserThreadStart+0x20
0d 008afa60 00000000 ntdll!_RtlUserThreadStart+0x1b
0:000> !analyze -v
... <removed for brevity>
DEFAULT_BUCKET_ID: APPLICATION_FAULT
PROCESS_NAME: NestedExceptions2.exe
ERROR_CODE: (NTSTATUS) 0xc00001a5 - An invalid exception handler routine has been detected.
EXCEPTION_CODE: (NTSTATUS) 0xc00001a5 - An invalid exception handler routine has been detected.
... <removed for brevity>

重要的是,不存在嵌套异常的无限循环。进程立即终止,并调用Windows错误报告。另一方面,当为特定进程启用SEHOP时,它会阻止无效的异常处理程序执行。在一个典型的WER转储文件中,结果显示为原始异常(本应正常处理)最终未处理:

0:000> !analyze -v

... FAULTING_IP: NestedExceptions2!trigger_exception+65 002c51d5 c705000000002a000000 mov dword ptr ds:[0],2Ah

EXCEPTION_RECORD: ffffffff -- (.exr 0xffffffffffffffff)

ExceptionAddress: 002c51d5 (NestedExceptions2!trigger_exception+0x00000065)

ExceptionCode: c0000005 (Access violation)

ExceptionFlags: 00000008

NumberParameters: 2

Parameter[0]: 00000001

Parameter[1]: 00000000 Attempt to write to address 00000000 ...

BUGCHECK_STR: APPLICATION_FAULT_NULL_POINTER_WRITE_SEHOP

PRIMARY_PROBLEM_CLASS: NULL_POINTER_WRITE_SEHOP

DEFAULT_BUCKET_ID: NULL_POINTER_WRITE_SEHOP ...

FAILURE_BUCKET_ID: NULL_POINTER_WRITE_SEHOP_c0000005_NestedExceptions2.exe!trigger_exception

BUCKET_ID: APPLICATION_FAULT_NULL_POINTER_WRITE_SEHOP_nestedexceptions2!trigger_exception+65

ANALYSIS_SOURCE: UM FAILURE_ID_HASH_STRING: um:null_pointer_write_sehop_c0000005_nestedexceptions2.exe!trigger_exception ...

但有一个很好的暗示是SEHOP参与了其中——分析中“SEHOP”这个词出现了多次。我不确定是否有更好的方法来确定SEHOP的参与,但这对我来说已经足够了

结论

首先,在处理嵌套异常的无限循环时,不要惊慌。您需要标识启动链的原始异常,然后查找重复模式。链中某个点上的异常筛选器/处理程序必须已失败,并且一系列控制传输将返回到同一个异常筛选器。
其次,结构化异常处理是一种易受攻击的机制,特别是在32位应用程序中。确保使用编译器和操作系统提供的所有保护:SafeSEH、DEP和SEHOP。