2023年1月

什么是内存腐败

当堆内存位置的内容由于编程行为而被修改,超出了原始程序构造的意图时,计算机程序就会发生内存腐败,也可以叫内存破坏;这被称为违反内存安全。内存腐败的最可能原因是编程错误。当腐败的内存内容稍后在该程序中使用时,它要么导致程序崩溃,要么导致奇怪的程序行为。Windows系统上近10%的应用程序崩溃是由于堆腐败造成的。像C和C++这样的现代编程语言具有显式内存管理和指针运算的强大功能。这些特性是为开发高效的应用程序和系统软件而设计的。但是,错误地使用这些功能可能会导致内存腐败错误。

在win32里,当程序损坏分配程序对堆的视图时,就会发生堆损坏。结果可能是相对良性的,并导致内存泄漏(有些内存没有返回到堆中,之后程序无法访问),或者可能是致命的,并导致内存错误,通常是在分配器内部。内存错误通常发生在分配程序中,当堆损坏后分配程序处理一个或多个空闲列表时。

内存腐败是最难处理的编程错误之一,原因有二:

  • 内存腐败的根源和表现形式可能相距甚远,很难将因果联系起来。
  • 症状出现在不寻常的情况下,使得很难持续地再现错误。

产生的原因

内存腐败错误大致可分为四类:

  • 使用未初始化内存:未初始化内存的内容被视为垃圾值。使用这些值可能会导致不可预测的程序行为。未初始化状态是一个常见的编程错误,可能会导致数小时的调试。基本上,未初始化状态是指已成功分配但尚未初始化为可供使用的状态的内存块。内存块可以从简单的本机数据类型(如整数)到复杂的数据块。
  • 使用非自有内存:通常使用指针访问和修改内存。如果此类指针是空指针、悬挂指针(指向已释放的内存)或指向当前堆栈或堆边界之外的内存位置,则它指的是程序当时未拥有的内存。使用这样的指针是一个严重的编程缺陷。访问这样的内存通常会导致操作系统异常,这些异常通常会导致程序崩溃(除非正在使用合适的内存保护软件)。
  • 使用已分配内存以外的内存(缓冲区溢出):如果在循环中使用数组,且终止条件不正确,则可能意外操作数组边界以外的内存。缓冲区溢出是计算机病毒利用的最常见的编程缺陷之一,在广泛使用的程序中会导致严重的计算机安全问题(如返回libc攻击、堆栈崩溃保护)。在某些情况下,程序也可能在缓冲区启动之前错误地访问内存。
  • 堆内存管理错误:内存泄漏和释放非堆或未分配的内存是堆内存管理错误导致的最常见错误。

在win32里,下面情况可能发生:

  • 程序试图释放内存
  • 程序在被释放后试图分配内存
  • 堆早在释放内存块之前就已损坏
  • 故障发生在随后的内存块上
  • 使用连续内存块
    当使用连续块时,写在边界之外的程序可能会损坏分配器关于它正在使用的内存块的信息,以及分配器对堆的视图。视图可以包括在使用块之前或之后的内存块,并且可以或不可以对其进行分配。在这种情况下,在不相关的分配或释放内存的尝试过程中,分配器可能会发生故障。
  • 你的程序是多线程的
    多线程执行可能导致错误发生在与实际损坏堆的线程不同的线程中,因为线程交错请求分配或释放内存。
  • 内存分配策略改变
    在特定内存分配策略中工作的程序,当分配策略以次要方式改变时,可能会中止。

在c++语言编程里,具体有如下原因:

  • 缓冲区溢出(上溢或下溢),这是最常见的原因;
  • 强制转换到一个错误的类型
  • 未初始化的指针
  • 错误使用. 和 ->
  • 错误使用& 和 .
  • delete & new 和 delete[] new[] 不配套
  • 缺少或者不完整的拷贝构造
  • 指向已回收内存的指针
  • 重复delete同一块内存
  • 多重基类,但是没有虚析构函数

表现形式

根据被破会的内存位置,程序会有不同的表现形式,常见就是程序崩溃掉。如果被破坏的是是堆/堆段/堆块等管理边界结构,那么在win32里,一般会触STATUS_HEAP_CORRUPTION

异常,此时的异常结构EXCEPTION_RECORD成员值一般如下:

EXCEPTION_RECORD:  
ExceptionAddress:异常地址
ExceptionCode: c0000374
ExceptionFlags: 00000001
NumberParameters: 1
Parameter[0]: 导致此异常的最终函数

0xC0000374就是堆内存腐败的异常代码,他定义如下

file:..../winnt.h#define STATUS_HEAP_CORRUPTION           ((DWORD   )0xC0000374L)   

Shim是微软系统中一个小型函数库,用于透明地拦截API调用,修改传递的参数、自身处理操作、或把操作重定向到其他地方。Shim主要用于解决遗留应用程序在新版Windows系统上的兼容性问题,但Shim也可用于其他方面。例如上周微软紧急推出针对微软Office Powerpoint 0day漏洞(CVE-2014-6352)”的Fix It,其中就采用了Shim技术,用于修复存在安全缺陷的函数。

一、什么是Shim

Shim是微软极少使用的四字母单词之一,也不是某种形式的缩写。它是英语单词Shim的引申含义。Shim是一个工程术语,描述为了让两个物体更好地组装在一起而插入的一块木头或金属。在计算机编程中,shim是一个小型的函数库,用于透明地拦截API调用,修改传递的参数、自身处理操作、或把操作重定向到其他地方。Shim也可以用来在不同的软件平台上运行程序。

二、shim如何工作

 

Shim架构实现了一种API钩子,而Windows API是通过一组DLL来实现的。Windows系统上的每个应用程序导入这些DLL,并在内存中维护一个存储调用函数地址的表(导入表)。由于Windows函数的地址位于一个表中,Shim直接把导入表中的地址替换为shim DLL中的地址。通常,应用程序没有意识到请求被重定向到一个Shim DLL而不是Windows系统,而Windows系统也没意识到请求并非来自应用程序(因为Shim DLL刚好也位于应用程序的进程中)。

在这个例子中,两个主体分别是应用程序和Windows系统,而shim是能够两者更好协作的附加代码,如下所示

shim代码被注入,并能够修改发向Widows系统的请求、从Windows系统返回的响应或全部,

尤其是,shim利用链接的特性将API调用重定向至替换的代码-Shim。通过导入表(IAT)实现调用外部的二进制文件。因此,调用Windows的函数类似于:

 

 

 你可以修改IAT表中已解析的Windows函数地址,然后替换为指向shim中替代函数的指针,如图4所示。

 

 

静态链接的DLL重定向发生在应用程序启动的时候。你也可以通过拦截GetProcAddressAPI调用来重定向动态链接的DLL文件。

三、为什么使用Shim

 

你无需访问源代码就可以修复应用程序,或甚至不需要修改应用程序。你只需承担极少的管理开销(针对Shim数据库),然而你通过这种方式可以修复数量相当可观的应用程序。缺点是支撑不足,因为大部分供应商不支持经Shim修复的应用程序。你不能够应用Shim来修复所有的应用程序。在软件供应商已经倒闭关门、软件已被淘汰而不予支持、或只购买一段时间的授权的情况下,人们可能考虑对应用程序进行Shim修复。

例如,最经常使用的Shim是version-lie(版本欺骗)Shim。为了实现这个Shim,我们拦截应用程序用于判断Windows版本的几个API。正常情况下,请求能够直接发送到Windows系统,并能够给予真实的回复。使用了Shim之后,向应用程序回复一个伪造的Windows版本(例如,Windows XP 而不是 Windows 7)。如果应用程序只能运行在Windows XP上,通过这种方式就能够让应用程序误以为自己运行在正确的操作系统上。(通常用来解决兼容性问题)。

你可以利用shim玩很多花样,例如:

1.ForceAdminAccess shim试图欺骗应用程序相信当前用户是本地管理员用户组,即使实际情况并不是如此。(如果你不是一个本地管理员用户,尽管你可能使用了其他的技巧来解决这个问题,如UAC文件和注册表虚拟化,许多应用程序还是启动失败)。Shim如何实现版本检查是相当简单明了。例如shim拦截shell32.dll中IsUserAnAdminAPI调用。shimmed修复后的函数(相对于实际的API,修复后的函数具有极佳的性能)只是简单返回True。

2.WrpMitigation shim欺骗应用程序的安装程序相信可以写入被WRP保护的文件。如果你试图一个被WRP保护的文件,shim首先创建一个新的临时文件,标记为文件句柄关闭后立即删除,然后返回临时文件的句柄冒充为实际被保护的文件。

3.CorrectFilePaths shim可以把文件从一个位置重定向到另一个位置。因此,你如果有一个程序试图写入c:\myprogramdir(不能利用UAC文件和注册表虚拟化来自动解决),你可以把运行时修改的文件重定向到每个用户的位置。这样就允许你作为标准用户运行,同时又不放松ACL。

Freebuf科普

WRP(Windows资源保护)对系统稳定性有重要影响的文件进行隐秘拷贝,但是存储的位置变成了%Windir%\WinSxS\Backup,依靠Access Control List(访问控制列表,ACL)为系统提供实时保护,在
WRP的管理下允许对被保护的资源进行写入只授权给了TrustedInstaller,即使是系统管理员也没有权限。

注意:因为Shim代码运行在用户模式的程序进程中,所以你不能使用shim来修复内核模式的代码。例如,你不能利用shim来解决设备驱动或其他内核模式代码的兼容性问题。(例如,一些反病毒软件、防火墙以及反间谍软件代码运行内核模式下)

四、何时使用Shim

1.从已破产的供应商获得的应用程序:既然供应商已经倒闭,技术支持自然无从说起。然而,因为源码不能获得,shim是解决兼容性问题的唯一选择。

2.内部开发的应用程序:虽然大部分用户更倾向于让自己开发的应用程序本身可以解决兼容性问题,但有些场景中时间上并不允许这么做。团队也许不能在新版Windows部署计划之前解决所有的兼容性问题,因此他们可能选择利用shim修复应用程序,同时shim不能修复的部分就需要修改应用程序的源代码。

3.供应商将要发布一个兼容性的版本,但当前的技术支持不够:当现有的应用程序既不是关键业务也不是很重要,一些用户使用Shim作为临时解决方案。理论上讲,用户可以等到兼容性的版本发布,但会阻碍整个部署计划。在兼容版本可用之前,先为用户提供一个Shim修复过的且能正常运转的应用程序不失为两全之计。

五、创建一个应用程序兼容性的Shim

如果你试图在Windows 7运行专为2000或XP创建的应用程序,并出现了问题,你可能总是需要在你的机器上开启兼容模式。然而,如果创建了Shim,你也可在其他机器上运行这个程序,而不需要每次手动开启兼容模式。Shim是体积小且只需运行一次,常常和机器上的特定应用程序联系在一起。

ACT是应用程序兼容性工具包(Application Compatibility Toolkit),可以从这里下载:

一旦我们从“开始”菜单->”微软应用程序兼容性工具包”->”兼容性管理工具”( Compatibility AdministratorTool)启动。

 

在“New Database”点击右键:

 

 选择“Application Fix”,然后从下面的对话框中选择需要修复的应用程序:

①输入需要修复的程序
②输入供应商名称
③浏览可执行程序的位置

什么是TEB

TEB(Thread Environment Block,线程环境块)

线程环境块中存放着进程中所有线程的各种信息

这里我们了解到了TEB即为线程环境块, 进程中每一条线程都对应着的自己的“TEB”。

TEB的访问方法

ntdll.NtCurrentTeb() 函数用来返回当前线程的TEB结构体指针

什么是SEH?


SEH( Structured Exception Handling , 结构化异常处理 )

结构化异常处理(SEH)是Windows操作系统提供的强大异常处理功能。而Visual C++中的__try{}/__finally{}和__try{}/__except{}结构本质上是对Windows提供的SEH的封装

本文是Matt Pietrek在1997年月10月的MSJ杂志Under The Hood专栏上发表的文章。中断和异常在DOS时代是整个系统的灵魂,但Windows已将其隐藏到了系统深处。Matt Pietrek详细剖析了Windows下的中断和异常及其处理机制以及内核模式与用户模式代码之间调用的问题。作者还提供了一个比较有意思的实验程序。

你可能感觉一切都好。但当你写了一些新代码并运行它时才知道你被感觉欺骗了!又出现了令人烦恼的访问违规(Access Violation)。你可能也看到了那个令人害怕的代码0xC0000005,也就是STATUS_ACCESS_VIOLATION。0xC0000005是如何表示“刚刚出错了”的,以及Win32®是如何支持不同类型的异常的,这些并不为很多人所知。在本月的专栏中,我要挖掘Win32下的异常以及它们是如何与硬件异常相关联的。在讨论硬件时主要针对的是Intel x86平台。
如果你曾经为Windows® 3.x编写过程序或者编写过MS-DOS®
extender,你一定遇到过0xD这个异常(一般保护性错误,简称为GPF)。你也可能看到过其它错误,例如非法指令错误(异常6)。这些代码并不是人工赋予的。任何Intel手册上都说过,这些异常代码是CPU用来通知各种问题或事件用的。在Win32中你看不到这些代码,因为Windows®
NT,这个Win32操作系统家族的旗舰产品,被设计运行于多种平台上。它没有简单地让Alpha或MIPS版本的Windows NT使用Intel
CPU的异常代码。
相反,Win32使用它自己的一套代码系统来表示各种异常。在任何给定的Win32平台上,系统把相应的CPU的异常代码映射到一个或多个通用的Win32异常代码上。例如,Intel

CPU上的异常代码0xD可能变成STATUS_ACCESS_VIOLATION(0xC0000005)。同样,异常代码0xD也可能变成Win32的STATUS_PRIVILEGED_INSTRUCTION(0xC0000096)异常。底层的硬件异常决定了它应该被映射到哪个Win32异常上。
让我们从CPU异常和中断出发,开始我们的Win32异常之旅。异常(Exception)和中断(Interrupt)是一种手段,
当正在执行代码时CPU通过它切换到一个完全不同的代码路径上以处理一些外部的刺激或条件。中断通常是由外部的刺激引起的,例如按下了一个键。而异常则是代码或数据中的条件导致处理器生成的。CPU试图读取一个没有物理内存映射到的地址时会产生异常,这是最经典的一个异常的例子。
Intel
CPU保留了32个中断/异常号以处理各种情形。图1是一些常用的代码。它们中很多意义很清楚,但是还有很多你没有遇到过(至少是在运行本专栏的样例程序之前)。MS-DOS上的老手可能奇怪竟然列出的INT
5H不是打印屏幕,INT
8H也不是计时器中断。这是为什么?图1的描述是Intel对异常和中断的定义。但不幸的是,在Intel迅猛发展之前,MS-DOS的作者已经把其中的一些中断号用作其它用途。结果导致当程序员使用BOUND指令时竟然意外到得到了屏幕的输出内容!
图1:Intel定义的异常和中断
代码        定义
00        除法错
01        调试异常(单步和硬件调试)
02        不可屏敝中断(NMI)
03        断点中断
04        溢出中断(INTO)
05        越界中断
06        非法指令
07        协处理器不可用
08        异常嵌套
0A        非法任务状态段(TSS)
0B        段不存在
0C        堆栈错误
0D        一般保护性错
0E        页错误

为了简单起见,本专栏以下的部分中我就用异常来代表异常或中断。就像我前面说的,中断和异常在技术上是不同的。另外,异常可以被进一步分成故障(Fault)、自陷(Trap)和终止(Abort)。我不想在这里对它们做详细描述,你可以简单地认为它们是一样的。

当异常发生时,CPU挂起当前的执行路径,把控制权交给异常处理程序。CPU把标志寄存器(EFLAGS)、代码段寄存器(CS)、指令指针寄存器(EIP)压入堆栈以保护当前的执行状态。接着,根据异常代码查找事先设计好的处理这个异常的程序的地址,并把控制权转交给它。实际上,异常代码就是中断描述符表(Interrupt
Descriptor Talbe,IDT)的索引,而中断描述符表指出异常应该交由谁处理。

IDT是Intel CPU使用的基本数据结构,它由多达256个中断描述符组成,每个长为8字节。中断描述符表由操作系统创建和维护,因此虽然被理解为是CPU的数据结构,但它也受到操作系统的控制。如果操作系统把IDT搞错了,那整个系统立马崩溃。

在大多数操作系统上,包括基于Win32的系统,IDT被放在高特权级内存上,低特权级的应用程序根本不能访问它。这与实模式的MS-DOS程序有很大不同,在那里,应用程序通常替换中断向量表(IDT在实模式下的一种版本)。由于多个基于MS-DOS的程序、驱动程序、TSR(终止并驻留程序)缺乏协调,导致MS-DOS系统和16位的Windows系统特别不稳定。在最新的32位操作系统上,CPU严格限制对IDT的访问,相应地增加了稳定性。然而Win32设备驱动程序(高特权级)可以访问IDT,并且可以修改它在IDT中的相应项。

现在让我们回到异常发生时的情形。CPU把异常号作为索引获取8字节的描述符。在描述符中包括各种域。图2显示的是中断描述符的一种简化形式。注意,对于每个异常来说,都有一个相应的异常处理程序地址(CS:EIP),控制权就是要转到这个地址。图3显示了GPF(异常0xD)发生时的事件顺序。
图2:中断描述符
  
图3:异常发生时的事件顺序
  
要是在平时,到这里我一定会写一个能显示IDT内容的试验程序。但不幸的是(至少对于我来说),应用程序不能访问IDT。这是因为在Win32下,应用程序运行在Ring
3,这是最低的特权级。Win32操作系统内核运行在Ring
0(内核或管理模式),这是最高的特权级。同时,关键的操作系统数据结构,例如IDT,只能通过Ring 0的代码进行访问。(Ring
1和2在Win32中没有使用。从80286开始起它们就存在,但据我所知,还没有人使用这些特权级。)
既然我不能写一个可以读取IDT的程序,那就拿一些其它资料吧。图4是用SoftICE/NT的IDT命令得到的前30个中断描述符表项。SoftICE作为Ring 0下的驱动程序运行,所以它对IDT有读/写权。
图4:SoftICE的IDT命令输出结果
Int        Type        Sel:Offset        Attributes        Symbol/Owner
IDTbase=80036400 Limit=07FF
0000        IntG32        0008:8013C354        DPL=0        P        _KiTrap00
0001        IntG32        0008:8013C49C        DPL=3        P        _KiTrap01
0002        IntG32        0008:0000137E        DPL=0        P       
0003        IntG32        0008:8013C764        DPL=3        P        _KiTrap03
0004        IntG32        0008:8013C8B8        DPL=3        P        _KiTrap04
0005        IntG32        0008:8013C9F4        DPL=0        P        _KiTrap05
0006        IntG32        0008:8013CB4C        DPL=0        P        _KiTrap06
0007        IntG32        0008:8013D068        DPL=0        P        _KiTrap07
0008        TaskG        0050:000013D8        DPL=0        P       
0009        IntG32        0008:8013D3A8        DPL=0        P        _KiTrap09
000A        IntG32        0008:8013D4A8        DPL=0        P        _KiTrap0A
000B        IntG32        0008:8013D5CC        DPL=0        P        _KiTrap0B
000C        IntG32        0008:8013D8BC        DPL=0        P        _KiTrap0C
000D        IntG32        0008:8013DABC        DPL=0        P        _KiTrap0D
000E        IntG32        0008:8013E468        DPL=0        P        _KiTrap0E
000F        IntG32        0008:8013E7D4        DPL=0        P        _KiTrap0F
0010        IntG32        0008:8013E8D4        DPL=0        P        _KiTrap10
0011        IntG32        0008:8013E9E8        DPL=0        P        _KiTrap11
0012        TaskG        00A0:8013E7D4        DPL=0        P       
0013        IntG32        0008:8013E7D4        DPL=0        P        _KiTrap0F
0014        IntG32        0008:8013E7D4        DPL=0        P        _KiTrap0F
0015        IntG32        0008:8013E7D4        DPL=0        P        _KiTrap0F
0016        IntG32        0008:8013E7D4        DPL=0        P        _KiTrap0F
0017        IntG32        0008:8013E7D4        DPL=0        P        _KiTrap0F
0018        IntG32        0008:8013E7D4        DPL=0        P        _KiTrap0F
0019        IntG32        0008:8013E7D4        DPL=0        P        _KiTrap0F
001A        IntG32        0008:8013E7D4        DPL=0        P        _KiTrap0F
001B        IntG32        0008:8013E7D4        DPL=0        P        _KiTrap0F
001C        IntG32        0008:8013E7D4        DPL=0        P        _KiTrap0F
001D        IntG32        0008:8013E7D4        DPL=0        P        _KiTrap0F
001E        IntG32        0008:8013E7D4        DPL=0        P        _KiTrap0F
001F        IntG32        0008:8013E7D4        DPL=0        P        _KiTrap0F
0020        Reserved        0008:00000000        DPL=0        NP       
0021        TrapG16        00C7:00000696        DPL=3        P       
0022        Reserved        0008:00000000        DPL=0        NP       
0023        Reserved        0008:00000000        DPL=0        NP       
0024        Reserved        0008:00000000        DPL=0        NP       
0025        Reserved        0008:00000000        DPL=0        NP       
0026        Reserved        0008:00000000        DPL=0        NP       
0027        Reserved        0008:00000000        DPL=0        NP       
0028        Reserved        0008:00000000        DPL=0        NP       
0029        Reserved        0008:00000000        DPL=0        NP       
002A        IntG32        0008:8013B8A6        DPL=3        P        _KiGetTickCount
002B        IntG32        0008:8013B990        DPL=3        P        _KiCallbackReturn
002C        IntG32        0008:8013BAA0        DPL=3        P        _KiSetLowWaitHighThread
002D        IntG32        0008:8013C65C        DPL=3        P        _KiDebugService
002E        IntG32        0008:8013B440        DPL=3        P        _KiSystemService
002F        IntG32        0008:8013E7D4        DPL=0        P        _KiTrap0F

首先看到的是,Windows NT 的IDT中所有的异常处理程序地址都在0x80000000之上。0x80000000之上的地址被Windows
NT保留用于特权级(Ring
0)访问。尽管从图上看可能不明显,但是确实几乎所有的异常处理程序地址都在NTOSKRNL.EXE中,它是Windows NT中运行于Ring
0的核心组件。由于我事先已经从NTOSKRNL的DBG文件中加载了调试符号,所以SoftICE查找异常处理程序地址并且找到了大部分异常处理程序的名称。前0x20个异常被一系列名字为_KiTrap00,_KiTrap01等的例程处理。“Ki”代表内核中断(Kernel
Interrupt)。

还有一个应该注意的是IDT中的描述符特权级(Descriptor Privilege
Level,DPL)域。它指定了允许调用特定软件中断的最低特权级。例如,INT 2EH可以被从Ring 3(最低特权级)到Ring
0(最高特权级)中任何一级调用。同样,用于断点的INT 3H,也可以被Ring 3及更高特权级的代码调用。

从0x2A到0x2E的异常被NTOSKRNL.EXE中的其它例程处理。例如,在我1996年八月的文章“Poking Around Under
the Hood: A Programmer’s View of Windows NT 4.0”,我讲到了Ring
3级的应用程序代码传递控制权到Ring 0级的系统代码以完成诸如创建一个新进程之类的特殊操作的机制,那就是调用INT 2E。INT
2E被系统DLL,例如NTDLL.DLL、USER32.DLL和GDI32.DLL从Ring
3调用。看一下IDT的0x2E这一项,你会看到它的地址指向NTOSKRNL中的_KiSystemService函数。正是这个函数把控制权转到了相应的代码。

INT 2EH之后,在前面的表中接下来最经常使用使用的中断当属INT
2BH。这个中断在IDT中的项的名称叫_KiCallbackReturn,这个名字提示了它的作用。当Ring 3的回调函数被Ring
0的代码调用后,需要一种回到Ring 0的调用者中的方法。INT
2BH正用于此目的。这方面的一个典型例子是调用SetWindowsHookEx来安装的Windows钩子回调函数。用户功能中的真正实现部分在Ring
0的WIN32K.SYS驱动程序中,正是它调用了在Ring 3中的钩子回调函数。当回调函数执行完毕,系统执行一个INT 2BH返回到Ring
0。

关于中断讲的已经够多了。那异常怎么样呢,特别是像访问违规之类令人讨厌的异常?处理器级别最经常出现的两个异常是异常0xD(GPF)和0xE(页错误)。从CPU产生这些异常到你的应用程序得到机会处理它们这段时间内,操作系统把异常代码改成它喜欢的更一般的代码。

假设你想运行下面这个有错误的程序,它试图把2写到内存偏移0处:
int main()
{
    *(int *) 0 = 2;
}
正如你所料,偏移0不是一个可用的程序地址。例如,在Windows
NT中,内存的第一个4KB页面被标记为“不存在”,用以阻止使用NULL指针的程序问题。试图写这个地址将引发一个页错误(异常0xE)。看一下上面的IDT图,你会看到这个异常是由NTOSKRNL.EXE中的_KiTrap0E处理的。

我已经多次在调试器中跟踪到_KiTrap0E的代码中,但这个代码相当复杂,想全面描述得另用一篇文章才行。眼下,只要知道Ring
0的_KiTrap0E代码检查各种各样的特殊条件就足够了。因此,KiTrap0E调用了IRETD指令把控制权传到了Ring
3的NTDLL开头的KiUserExceptionDispatcher函数中。我在这里不讲KiUserExceptionDispatcher,因为我已经在我的文章“A
Crash Course on the Depths of Win32 Structured Exception
Handling”(MSJ,1997年一月)中详细讲了这个函数。重点是要知道KiUserExceptionDispatcher被告知异常代码是0xC0000005(STATUS_ACCESS_VIOLATION),并不是由CPU产生的那个异常代码0xE。

像0xC0000005之类的Win32异常代码是哪里来的?答案可以在Win32 SDK或你的C++编译器中的WINERROR.H头文件中找到。几乎在最上面,你会看到一个注释:
//  Values are 32 bit values layed out as follows:
继续读这个注释,你就会知道,最高的两位(位31和30)代表严重程度。接下来的位(29)表示定义者。位28是保留的。高位字中剩下的12位是设备代码。低位字(位0到15)是异常代码。

比较有趣的一点是,Win32的Last
Error代码也是通过用位域来分类信息的。因此,你会知道像0x80010002(RPC_E_CALL_CANCELED)之类的错误代码来自哪里。顺便说一下,使用严重程度,定义者和设备位域并不是起源于Windows
NT。IBM的OS/2使用了相同的机制,它是在20世纪80年代后期合并分别由Microsoft和IBM完成的操作系统的工作的一个副产品。

回到异常中,看一下严重程度位,位31和30。值0代表成功,1代表信息,2代表警告,3(两个位均置位)代表错误。一个致命的异常相当于一个错误,因此任何32位的致命异常代码最高的两位都是置位的。接下来的两个位,定义者和保留位,通常都被设置为0,因为很少使用它们。

仅仅知道上面那些有限的异常代码构造方面的知识,你就能推断出致命异常代码都是以0xC开头的。因此,遇到像0xC0000005(STATUS_ACCESS_VIOLATION)和0xC000001D(STATUS_ILLEGAL_INSTRUCTION)之类的异常代码,你知道它们就属于这一类。比这严重程序低一些的异常,也就是警告,它的严重系数是2,因此你看到类似0x80000003(STATUS_BREAKPOINT)和0x80000004(STATUS_SINGLE_STEP)之类的代码,你知道它们就属于这一类。在WINNT.H中搜索STATUS_可以找到一份相当完整的可能的异常代码列表。当你看这个列表时要记住,并不是支持Win32的每一个处理器都可以生成所有Win32异常代码。

在写这个专栏时,我到底能导致多少个Win32异常引起了我的兴趣。我对操作系统到底能赋予我有意导致的许多错误什么样的异常代码也充满好奇。为了帮助解决这些问题,我写了一个能以各种方式产生处理器错误并且报告它们被映射到的Win32异常代码的程序框架。这就是我的GenException程序(见图5)。
图5 GenException.CPP
//==========================================
// Matt Pietrek
// Microsoft Systems Journal, October 1997
// FILE: GenException.CPP
// 使用命令行CL GenException.CPP编译
//==========================================
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <stdio.h>
#include <float.h>
#include <assert.h>

typedef void (* PFNGENERATEEXCEPTION)(void);
void GenerateSTATUS_BREAKPOINT( void )
{
    __asm   int 3   // 普通的断点指令
}
void GenerateSTATUS_SINGLE_STEP( void )
{
    // 这比使用硬件断点寄存器生成int 1更容易
    __asm   int 1
}
void GenerateSTATUS_ACCESS_VIOLATION( void )
{
// 通过读取地址在2GB以上的内存来产生
// 一个页错误(异常代码0xE)
    int i = *(int *)0xFFFFFFF0;
}
void GenerateSTATUS_ILLEGAL_INSTRUCTION( void )
{
    __asm _emit 0x0F    // 无效指令导致产生异常0xD
    __asm _emit 0xFF
}
void GenerateSTATUS_ARRAY_BOUNDS_EXCEEDED( void )
{
    DWORD arrayBounds[2] = { 10, 48 };

    __asm   mov eax, 12
    __asm   bound eax, arrayBounds  // 这条BOUND指令运行正常
    __asm   mov eax, 7
    __asm   bound eax, arrayBounds  // 这条BOUND指令会产生异常0x5
}
void UnmaskFPExceptionBits( void )
{
    unsigned short cw;

    __asm   fninit      // 初始化数值协处理器
    __asm   fstcw [cw]
    cw &= 0xFFE0;       // 关闭大部分异常位(除了精度异常)
    __asm   fldcw [cw]

}
void GenerateSTATUS_FLOAT_DIVIDE_BY_ZERO( void )
{
    double a = 0;
   
    a = 1 / a;
    __asm fwait;        
}
void GenerateSTATUS_FLOAT_OVERFLOW( void )
{
    double a = DBL_MAX;

    a *= a;
    __asm fwait;
        
}
void GenerateSTATUS_FLOAT_STACK_CHECK( void )
{
    unsigned a;

    __asm   fistp [a]
    __asm   fwait;
        
}
void GenerateSTATUS_FLOAT_UNDERFLOW( void )
{
    double a = DBL_MIN;
   
    a /= 10;
    __asm fwait;
        
}
void GenerateSTATUS_INTEGER_DIVIDE_BY_ZERO( void )
{
    // 除以0导致异常0x0
    int i = 0;
    i = 2 / i;
}
void GenerateSTATUS_INTEGER_OVERFLOW( void )
{
    __asm   mov eax, 07FFFFFFFh     // 带符号数的最大值
    __asm   add eax, 2              // 结果 = 0x80000001 -> 溢出!
    __asm   into                    // 产生异常0x4
}
void GenerateSTATUS_PRIVILEGED_INSTRUCTION( void )
{
    // HLT指令只能在ring 0下执行
    __asm   hlt
}
void GenerateSTATUS_STACK_OVERFLOW( void )
{
    DWORD myArray[512];
   
    // “无穷”递归导致堆栈溢出
    GenerateSTATUS_STACK_OVERFLOW();
}
DWORD GetExceptionNumber( PFNGENERATEEXCEPTION pfn )
{
    DWORD exceptionCode = 0;
    __try
    {
        pfn();  
    }
    __except( exceptionCode = GetExceptionCode(), EXCEPTION_EXECUTE_HANDLER )
    {
    }   
    return exceptionCode;
}
#define SHOW_EXCEPTION( x )                                 \
    dwExceptionNumber = GetExceptionNumber( Generate##x );  \
    printf( "%X %s\n", dwExceptionNumber, #x );             \
    assert( dwExceptionNumber == x );
int main(int argc, char *argv[])
{
    DWORD dwExceptionNumber;
   
    SHOW_EXCEPTION( STATUS_BREAKPOINT )
    SHOW_EXCEPTION( STATUS_SINGLE_STEP )
    SHOW_EXCEPTION( STATUS_ACCESS_VIOLATION )
    SHOW_EXCEPTION( STATUS_ILLEGAL_INSTRUCTION )
    SHOW_EXCEPTION( STATUS_ARRAY_BOUNDS_EXCEEDED )
   
    UnmaskFPExceptionBits();
    SHOW_EXCEPTION( STATUS_FLOAT_DIVIDE_BY_ZERO )

    UnmaskFPExceptionBits();
    SHOW_EXCEPTION( STATUS_FLOAT_OVERFLOW )

    UnmaskFPExceptionBits();
    SHOW_EXCEPTION( STATUS_FLOAT_STACK_CHECK )

    UnmaskFPExceptionBits();
    SHOW_EXCEPTION( STATUS_FLOAT_UNDERFLOW )

    SHOW_EXCEPTION( STATUS_INTEGER_DIVIDE_BY_ZERO )
    SHOW_EXCEPTION( STATUS_INTEGER_OVERFLOW )
    SHOW_EXCEPTION( STATUS_PRIVILEGED_INSTRUCTION )

    SHOW_EXCEPTION( STATUS_STACK_OVERFLOW );
   
    return 0;
}

GetException程序的代码被分成三部分。第一部分是一系列函数,它们的名字以Generate开头,后面是它们要产生的Win32异常的名字。例如,GenerateSTATUS_ILLEGAL_INSTRUCTION引起一个非法指令异常。第二部分是GetExceptionNumber函数。它使用Win32结构化异常处理(SEH)来确定各个GenerateXXX函数引起的Win32异常代码,并且将这个异常代码返回它的调用者。GetExceptionNumber函数带有一个参数,这个参数是指向它要调用的GenerateXXX函数的指针。

GenException.CPP的最后一部分是main函数。它是一系列C++预处理器宏的调用,这个宏被我命名为SHOW_EXCEPTION。对SHOW_EXCEPTION的每一次调用就会产生一个Win32异常。SHOW_EXCEPTION带一个预定义的异常名称(例如STATUS_ACCESS_VIOLATION),然后将它合成一个与其相应的GenerateXXX函数的调用。我使用SHOW_EXCEPTION宏来省略大量模板代码,这些模块代码只有实际调用的异常代码不同。通过使用预处理器符号粘贴(preprocessor
token pasting)和字符串化(stringizing)宏,这一行

SHOW_EXCEPTION( STATUS_BREAKPOINT )
被扩展成:
dwExceptionNumber = GetExceptionNumber( GenerateSTATUS_BREAKPOINT );
printf( "%X %s\n", dwExceptionNumber, "STATUS_BREAKPOINT" );
assert( dwExceptionNumber == STATUS_BREAKPOINT );

在写GetException时,一些异常非常容易产生,例如STATUS_ACCESS_VIOLATION。创建那些不常见的异常也很重要,例如STATUS_ILLEGAL_INSTRUCTION。许多情况下,我不得不借助于内联汇编。两个比较好的例子是CPU异常4和5,它们分别由INTO指令和BOUND指令产生。我不详细讲述各种异常是如何产生的,GenException.CPP代码中包含了许多相关注释。

生成浮点异常需要一些技巧,因为Win32初始化浮点单元时不会产生异常。我不得不明确关闭协处理器控制字中的某些位来产生浮点异常,像STATUS_FLOAT_DIVIDE_BY_ZERO。如果你对此好奇,可以看UnmaskFPExceptionBits函数,它包含了处理那些位的代码。因为在执行浮点指令时,只有执行到实际出错指令的下一条指令时才引发异常,因此我使用__asm
fwait指令强制在一个有意出错的指令后引发一个异常。

可能GetException不是你曾经运行过的程序中最令人兴奋或最有用的程序,但是我相信你一定能从如何产生各种Win32异常中受到启发。在大多数情况下,CPU生成一个异常0xD,然后Win32异常处理程序分析这个代码并构造一个更有意义,更加明确的异常代码。我的目的是描述这些机制,解释硬件级别和操作系统级别的异常,并且向你展示它们之间的联系。