所有程序都是从main()函数开始执行的吗?事实并非如此。如果用IDA或者HIEW打开可执行文件,我们可以看到原始入口OEP(Original Entry Point)总是指向其他的一段代码。这些代码会在启动程序之前进行一些维护和准备工作。这就是所谓的启动代码/startup-code即CRT代码(C RunTime)。
在通过命令行指令启动程序的时候,main()函数通过外来数组获取启动参数及系统的环境变量。然而,实际传递给程序的不是数组而是参数字符串。CRT代码会根据空格对字符串进行切割。另外,CRT代码还会通过envp数组向main()函数传递系统的环境变量。在Win32的GUI程序里,主函数变为了WinMain(),并且拥有自己的参数传递规格:
int CALLBACK WinMain(
_In_ HINSTANCE hInstance,
_In_ HINSTANCE hPrevInstance,
_In_ LPSTR lpCmdLine,
_In_ int nCmdShow
);
上述参数同样是由CRT代码准备的。
在程序结束以后,主函数main()会返回其退出代码。这个退出代码会被传递给CRT的 ExitProcess()函数,作为后者的一个参数。
通常来说,不同的编辑器会有不同的CRT代码。
以下列出的是MSVC 2008特有的CRT代码:
1 ___tmainCRTStartup proc near
2
3 var_24 = dword ptr -24h
4 var_20 = dword ptr -20h
5 var_1C = dword ptr -1Ch
6 ms_exc = CPPEH_RECORD ptr -18h
7
8 push 14h
9 push offset stru_4092D0
10 call __SEH_prolog4
11 mov eax, 5A4Dh
12 cmp ds:400000h, ax
13 jnz short loc_401096
14 mov eax, ds:40003Ch
15 cmp dword ptr [eax+400000h], 4550h
16 jnz short loc_401096
17 mov ecx, 10Bh
18 cmp [eax+400018h], cx
19 jnz short loc_401096
20 cmp dword ptr [eax+400074h], 0Eh
21 jbe short loc_401096
22 xor ecx, ecx
23 cmp [eax+4000E8h], ecx
24 setnz cl
25 mov [ebp+var_1C], ecx
26 jmp short loc_40109A
27
28
29 loc_401096: ; CODE XREF: ___tmainCRTStartup+18
30 ; ___tmainCRTStartup+29 ...
31 and [ebp+var_1C], 0
32
33 loc_40109A: ; CODE XREF: ___tmainCRTStartup+50
34 push 1
35 call __heap_init
36 pop ecx
37 test eax, eax
38 jnz short loc_4010AE
39 push 1Ch
40 call _fast_error_exit
41 pop ecx
42
43 loc_4010AE: ; CODE XREF: ___tmainCRTStartup+60
44 call __mtinit
45 test eax, eax
46 jnz short loc_4010BF
47 push 10h
48 call _fast_error_exit
49 pop ecx
50
51 loc_4010BF: ; CODE XREF: ___tmainCRTStartup+71
52 call sub_401F2B
53 and [ebp+ms_exc.disabled], 0
54 call __ioinit
55 test eax, eax
56 jge short loc_4010D9
57 push 1Bh
58 call __amsg_exit
59 pop ecx
60
61 loc_4010D9: ; CODE XREF: ___tmainCRTStartup+8B
62 call ds:GetCommandLineA
63 mov dword_40B7F8, eax
64 call ___crtGetEnvironmentStringsA
65 mov dword_40AC60, eax
66 call __setargv
67 test eax, eax
68 jge short loc_4010FF
69 push 8
70 call __amsg_exit
71 pop ecx
72
73 loc_4010FF: ; CODE XREF: ___tmainCRTStartup+B1
74 call __setenvp
75 test eax, eax
76 jge short loc_401110
77 push 9
78 call __amsg_exit
79 pop ecx
80
81 loc_401110: ; CODE XREF: ___tmainCRTStartup+C2
82 push 1
83 call __cinit
84 pop ecx
85 test eax, eax
86 jz short loc_401123
87 push eax
88 call __amsg_exit
89 pop ecx
90
91 loc_401123: ; CODE XREF: ___tmainCRTStartup+D6
92 mov eax, envp
93 mov dword_40AC80, eax
94 push eax ; envp
95 push argv ; argv
96 push argc ; argc
97 call _main
98 add esp, 0Ch
99 mov [ebp+var_20], eax
100 cmp [ebp+var_1C], 0
101 jnz short $LN28
102 push eax ; uExitCode
103 call $LN32
104
105 $LN28: ; CODE XREF: ___tmainCRTStartup+105
106 call __cexit
107 jmp short loc_401186
108
109
110 $LN27: ; DATA XREF: .rdata:stru_4092D0
111 mov eax, [ebp+ms_exc.exc_ptr] ; Exception filter 0 for function 401044
112 mov ecx, [eax]
113 mov ecx, [ecx]
114 mov [ebp+var_24], ecx
115 push eax
116 push ecx
117 call __XcptFilter
118 pop ecx
119 pop ecx
120
121 $LN24:
122 retn
123
124
125 $LN14: ; DATA XREF: .rdata:stru_4092D0
126 mov esp, [ebp+ms_exc.old_esp] ; Exception handler 0 for function 401044
127 mov eax, [ebp+var_24]
128 mov [ebp+var_20], eax
129 cmp [ebp+var_1C], 0
130 jnz short $LN29
131 push eax ; int
132 call __exit
133
134
135 $LN29: ; CODE XREF: ___tmainCRTStartup+135
136 call __c_exit
137
138 loc_401186: ; CODE XREF: ___tmainCRTStartup+112
139 mov [ebp+ms_exc.disabled], 0FFFFFFFEh
140 mov eax, [ebp+var_20]
141 call __SEH_epilog4
142 retn
在程序的第62行、第66行和第74行我们分别可以看到的是GetCommandLineA、setargv()和setenvp()这三个函数,从这三个函数的名称可以看出它们处理的分别是argc、argv和envp这三个全局变量。
最后,第97行的主函数main()会获取这些外部参数。
CRT中的函数名称通常都可以自然解释。例如第35行和第54行的heap_init()和ioinit()这两个函数。
堆的初始化操作是由CRT代码完成的。若在没有CRT代码的情况下调用内存分配函数malloc(),就会引发异常退出,并将看到下述错误代码:
runtime error R6030
- CRT not initialized
在C++程序中,CRT代码还要在启动主函数main()之前初始化全部全局对象。我们可以参考本书的51.4.1节。
主函数main()返回的数值会传递给cexit(),或者是在$LN32,随后会调用函数doexit()。
下面一个问题是:我们有没有可能不采用CRT呢?这是有可能的,前提是您清楚地知道自己在做什么。
我们可以通过MSVC的Linker程序的/ENTRY选项设置程序的入口点。
比如下面的这个程序代码:
#include <windows.h>
int main()
{
MessageBox (NULL, "hello, world", "caption", MB_OK);
};
选用以下的命令行来编译:
cl no_crt.c user32.lib /link /entry:main
上述指令最终生成一个大小为2560字节的可执行文件。该文件中具备标准的PE文件头,调用MessageBox的指令,其数据段声明了两个字符串,并从库文件user32.dll导入MessageBox函数。整个可执行文件没有其他的内容了。
虽然这个程序确实可以正常运行,但是这种程序无法获取WinMain()函数所需的4个参数。确切地说,程序确实可以启动起来,但是在程序启动的时候外部参数没有被准备或传递过来。
不能直接采用包括4个参数在内的主函数WinMain()的方式,而且不采用main()函数。更加精确一点来说,虽然能传递参数,但是参数不是在程序一执行时就被传递的。
另外,如果通过编译指令限定PE段向更小地址对齐(默认值是4096字节),那么.编译器将会生成尺寸更小的exe文件:
cl no_crt.c user32.lib /link /entry:main /align:16
链接器Linker将会提示:
LINK : warning LNK4108: /ALIGN specified without /DRIVER; image may not run
上述指令将生成一个长度为720字节的exe可执行文件。它可以运行于x86构架的Windows 7系统,但是却不能运行于64位的Windows 7系统(执行的时候,系统会给出错误提示)。从这里我们可以看到,虽然我们可以想办法让可执行文件变得更小一些,但是同时兼容性问题也会越来越突出。
PE(Portable Executable)格式,是微软Windows环境可移植可执行文件(如exe、dll、vxd、sys和vdm等)的标准文件格式。
与其他格式的PE文件不同的是,exe和sys文件通常只有导入表而没有导出表。
和其他的PE文件一样,DLL文件也有一个原始代码入口点OEP(就是DllMain()函数的地址)。但是DLL的这个函数通常来讲什么也不会做。
sys文件通常来说是一个系统驱动程序。说到驱动程序,Windows操作系统需要在PE文件里保存其校验和,以验证该文件的正确性(Hiew就可以验证这个校验和)。
从Vista开始,所有的Windows驱动程序必须具备数字签名,否则系统会拒绝加载它们。
每个PE文件都由一段打印“This program cannot be run in DOS mode.”的DOS程序块开始。如果在DOS或者Windows 3.1环境下运行这个程序,那么只会看到上述字符串。因为DOS及Windows 3.1 系统不能识别PE格式的文件。
模块Module:它是指一个单独的exe或者dll文件。
进程Process:加载到内存中并正在运行的程序,通常由一个exe文件和多个dll文件组成。
进程内存Process memory:每个进程都有完全属于自己的,进程间独立的,不被干扰的内存空间。 通常是模块、堆、栈等数据构成。
虚拟地址VA(Virtual Address):程序访问存储器所使用的逻辑地址。
基地址Base Address:进程内存中加载模块的首地址。
相对虚拟地址RVA(Relative Virtual Address):虚拟地址VA与基地址Base Address的差就是相对虚拟地址RVA。在PE文件表中的很多地址都是相对虚拟地址RVA。
导入地址表IAT(Import Address Table):导入符号的地址数组。PE头里的IMAGE_DIRECTORY_ ENTRY_IAT指向第一个导入地址表IAT的开始位置。值得说明的是,反编译工具IDA可能会给IAT虚构一个伪段--.idata段,即使IAT是其他地址的一部分。
导入符号名称表INT(Import Name Table):存储着所需符号名称的数组。
在开发各自的DLL动态链接库文件时,多数开发团队都有意让其他人直接调用自己的动态链接库。然而,具体到“谁的DLL到底应该加载到哪个地址”这种问题,却没有一种公开的协议或标准。
因此,当同一个进程的两个DLL库具有相同的基地址时,只会有一个DLL被真正加载到基地址上。而另外一个DLL则会分配到进程内存的某段空闲空间里。在调用后者时,每个虚拟地址都会被重新校对。
通常来说,MSVC编译成的可执行程序的基地址都是0x400000,而代码段则从0x401000开始。由此可知,这种程序代码段的相对虚拟地址RVA的首地址都是0x1000。而MSVC通常把DLL的基地址设定为0x10000000。
操纵系统可能会把模块加载到不同的基地址中,还可能是因为程序自身的要求。当程序“点名”启用地址空间分布的随机化(Address Space Layout Randomization,ASLR)技术的时候,操作系统会把其各个模块加载到随机的基地址上。
ASLR是shellcode的应对策略。shellcode都会调用系统函数。
在Windows Vista之前早期系统里,系统的DLL(如kernel32.dll,user32.dll的加载地址是已知的固定地址。在同一个版本的操作系统里,系统DLL里的系统函数地址也几乎一尘不变。也就是说,shellcode可以根据版本信息直接调用系统函数。
为了避免这个问题,地址空间分布的随机化ASLR技术应运而生。它能够将程序以及程序所需模块加载到无法事先确定的随机地址。
在PE文件中,我们通过设置一个标识来实现ASLR。这个标识的名称是:IMAGE_DLL_ CHARACTERISTICS_ DYNAMIC_BASE。
PE文件有一个子系统字段。这个字段的值通常是下列之一:
NATIVE(系统驱动程序)。
console控制台程序。
GUI(非控制台程序,也就是最常见的图文界面程序)。
PE文件还指定了可加载它的Windows操作系统最低版本号。如需查阅版本号码和Windows发行名称的完整列表,请参阅:https://en.wikipedia.org/wiki/Windows_NT#Releases
。
举个例子,MSVC 2005编译的.exe文件只能运行在Windows NT4(版本号为4.00)及以后的操作系统上。但是MSVC 2008编译调应用程序(版本号是5.00)不兼容NT4系统,只能运行于Windows 2000及以后的操作系统。
在MSVC 2012生成的.exe文件里,操作系统版本号的默认值是6.00。这种程序仅面向Windows Vista及后期推出的操作系统。但我们可以编译选项强制编译器生成支持Windows XP的应用程序。详情请参阅https://blogs.msdn.microsoft.com/vcblog/2012/10/08/windows-xp-targeting-with-c-in-visual-studio-2012/
。
所有的可执行文件都可分解为若干段(sections)。段是代码和数据、以及常量和其他数据的组织形式。
带有IMAGE_SCN_CNT_CODE或IMAGE_SCN_MEM_EXECUTE标识的段,封装的是可执行代码。
数据段的标识为IMAGE_SCN_CNT_INITIALIZED_DATA、IMAGE_SCN_MEM_READ或IMAGE_ SCN_MEM_WRITE标记。
未初始化的数据的空段的标识为IMAGE_SCN_CNT_UNINITIALIZED_DATA、IMAGE_SCN_ MEM_READ或IMAGE_SCN_MEM_WRITE。
常数数据段(其中的数据不可被重新赋值)的标识是IMAGE_SCN_CNT_ INITIALIZED_ DATA以及IMAGE_SCN_MEM_READ,但是不包括标识IMAGE_SCN_MEM_WRITE。如果进程试图往这个数据段写入数据,那么整个进程就会崩溃。
PE可执行文件的每个段都可以拥有一个段名称。然而,名称不是段的重要特征。通常来说,代码段的段名称是.text,数据段的段名称是.data,常数段的段名称.rdata(只读数据)。其他类型的常见段名称还有:
.idata:导入段。IDA可能会给这个段分配一个伪名称;详情请参考本书68.2.1节。
.edata:导出段。这个段十分罕见。
.pdata:这个段存储的是用于异常处理的函数表项。它包含了Windors NT For MIPS、IA64以及x64所需的全部异常处理信息。详情请参考本书的68.3.3节。
.reloc:(加载)重定向段。
.bss:未初始化的数据段(BSS)。
.tls:线程本地存储段(TLS)。
.rsrc:资源。
.CRT:在早期版本的MSVC编译出的可执行文件里,可能出现这个这个段。
经过加密或者压缩处理之后,PE文件section段的段名称通常会被替换或混淆。
此外,开发人员还可以控制MSVC编译器、设定任意段的段名称。有关详情请参阅:https://msdn. microsoft.com/en-us/library/windows/desktop/cc307397.aspx
。
部分编译器(例如MinGW)和链接器可以在生成的可执行文件中加入带有调试符号、或者其他的调试信息的独立段。然而新近版本的MSVC不再支持这项功能。为了便于专业人员分析和调试应用程序,MSVC推出了全称为“程序数据库”的PDB文件格式。
PE格式的section段的数据结构大体如下:
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_NAME];
union {
DWORD PhysicalAddress;
DWORD VirtualSize;
} Misc;
DWORD VirtualAddress;
DWORD SizeOfRawData;
DWORD PointerToRawData;
DWORD PointerToRelocations;
DWORD PointerToLinenumbers;
WORD NumberOfRelocations;
WORD NumberOfLinenumbers;
DWORD Characteristics;
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
上述代码摘自于:https://msdn.microsoft.com/en-us/library/windows/desktop/ms680341 (v=vs.85).aspx
。
简单的说,上述PointerToRawData(指向原始数据)就是段实体的偏移量Offset,而虚拟地址VirtualAddress就是Hiew里的RVA。
至少在Hiew中,它也可以表示为FIXUP-s。
重定位段是自MS-DOS时代起一直存在于可执行文件的实体段,几乎所有的可执行文件都有这个段。
前文介绍过,程序模块可能会被加载到不同的基地址。但是如何处理全局变量等局部共享数据呢?程序必须通过地址指针才能访问这类数据,然而程序又不可能事先知道共享数据的存储地址。为解决这个问题,人们推出了“位置无关代码/PIC”(详情请参阅67.1节)的解决方案。不过PIC用起来并不方便。
于是,人们又推出了基于“重定向表”的地址修正技术。重定向表记录了该文件被加载到不同基地址时需要修正的所有指针。
如果PE文件声明了一个地址为0x410000的全局变量,那么这个变量的寻址指令大致会是:
A1 00 00 41 00 mov eax,[000410000]
此时,模块的基地址是0x400000(编译器默认值),全局变量的相对虚拟地址RVA是0x10000。
如果这个模块被加载到首地址为0x500000的基地址上,那么这个全局变量的真实地址应当被调整为0x510000。
在上述opcode中,变量地址应当是0xA1(“MOV EAX”指令)之后的那几个字节。为了通知操作系统在重定向时正确处理该地址,PE文件的重定向表必须收录这四个字节的相对地址。
当操作系统的加载器需要把这个模块加载到不同的基地址时,它会逐一枚举重定位表中所有地址,把这些地址所指向的数据当作32位的地址指针,然后减去初始基地址(这样就能获得RVA,也就是相对虚拟地址),并加上新的基地址。
如果模块被加载于其原始的基地址,那么操作系统就不会进行重定向处理。
所有全局变量的处理过程都是如此。
重定向段的数据结构并不唯一。Windows for x86程序的重定向段一般采用IMAGE_REL_BASED_ HIGHLOW的数据结构。
另外,Hiew用暗色区域显示重定向段,如图7.12所示。
而OllyDbg会在内存中用下画线的方式标记重定向段,如图13.11所示。
我们都知道,任何可执行文件都必须或多或少地调用由操作系统提供的服务或者DLL动态链接库。
广义地说,由某个模块(一般来说是DLL)声明的所有函数,最终都会被其他模块(.exe文件或者其他DLL)中的某条指令调用,只是调用方式不同而已。
为此,每一个DLL动态链接库文件都有一个“导出/exports“表。导出表声明了该模块定义的函数名称和函数的地址。
同时每个exe文件和DLL文件另有一个“导入”表。这个表声明了执行该模块所需的函数名称、以及相应DLL文件的文件名。
在加载完可执行文件主体的.exe文件之后,操作系统加载器开始处理导入表:它会加载记录在案的DLL文件,接着在DLL的导出表里查找所需函数的函数地址,最后把这些地址写到.exe模块的IAT导入表。
由此可见,操作系统的加载器在进程的加载过程中要比对大量的函数名称。但是检索字符串的效率不会很高。后来人们引入了基于“排行榜”或者“命中率”(对应相关数据结构里的Hints或Ordinal字段)的序号表示办法、把函数名称编排为数字,以此摆脱字符串操作的低效率问题。
因此,DLL文件只需在导出表里标注内部函数的函数“序号”。这显著提升了DLL文件的加载速度 。
举例来说,调用MFC的程序就能够通过函数“序号”调用mfc*.dll动态链接库。而这种程序不再需要导入段的数据表(Import Name Table,INT)中使用字符串存储MFC的函数名称。
当我们在IDA中加载这样的程序后,IDA会询问mfc*.dll文件的路径以获取外部函数的函数名称。如果我们没有指定MFC文件的存储路径,那么函数名称会是mfc80_123之类的字符串。
导入段
编译器通常会给导入表及其相关内容分配一个单独的section段(例如.idata),但这不是一个强制规定。
导入段涉及大量的技术术语,因此理解起来特别困难。本节将通过一个典型的例子进行集中演示。
导入段的主体是数组IMAGE_IMPORT_DESCRIPTOR。它记录着PE文件要导入哪些库文件。
在其元素的数据结构中:
Name字段存储着库名称字符串的RVA地址;
图68.1 在PE范畴内与导入段相关的全部数据结构
FirstThunk字段存储的是IAT表的表指针。IAT表的每个成员元素都是由操作系统加载器解析出来的函数地址(RVA)。IDA会给这些元素添加“:_imp_CreateFileA”一类的名称标注。
由加载器解析出来的外部函数地址,至少有两种调用方法:
① 代码可通过call_imp_CreateFileA形式的指令直接调用外部函数。从某种意义上讲,导入函数的函数地址存储到全局变量的存储空间了。考虑到当前模块可能会被加载于与初始值不同的基地址上,那么只要把call指令引用的外部函数目标地址直接追加到reclos重定向表里就好了。
但是要把导入函数的函数地址全部追加到重定向段relocs里,会显著增加重定向表的数据容量。进一步来说,重定向表越大、程序的加载效率就越低。
② 另一种办法就是对每个调用点进行处理。在调用外部函数的时候,只要JMP到“重定向值+外部函数RVA”就可以调用外部函数了。这种调用方式也叫做“形实转换/thunks”。调用外部函数时,程序可以直接CALL 相应的thunk地址。这种调用方式无需进行重定位运算,因为CALL指令本身就能进行相对寻址,因此不必修正RVA地址。
编译器能够分派上述两种调用方法。如果外部函数的调用频率很高,那么链接器Linker很可能会通过thunk调用外部函数。但是在默认情况下,链接器并不创建thunk。
另外,FirstThunk字段里的函数指针数组不必在PE文件的导入地址段(Input Address Table,IAT section)中。笔者曾经编写过一个令.exe文件添加外部函数信息的PE_add_import工具(https://yurichev.com/PE_add_imports.html
)。它就曾经成功的将PE文件引用的外部函数替换为另外一个DLL文件的其他函数。此时生成的指令是:
MOV EAX, [yourdll.dll!function]
JMP EAX
FirstThunk字段存储的是外部函数第一条指令的地址。换而言之,在调用youdll.dll这类自定义动态链接库的时候,加载器把程序代码的相应位置直接替换为外部函数function的函数地址。
需要注意的是代码段一般都是只读的。因此,我们的应用程序将在代码段上增加一个标志IMAGE_ SCN_MEM_WRITE。否则的话,调用期间会出现5号错误(禁止访问)。
可能有读者会问,如果程序只调用一套DLL文件,而且这些DLL文件里所有的函数名称和函数地址保持不变,那么还能否进一步提高进程的加载速度?
答案是:可能的。实现在程序的FirstThunk数组里写入外部函数的函数地址即可。另外需要注意的是IMAGE_IMPORT_DESCRIPTOR结构体里的Timestamp字段。若这个字段有值,则加载器会判断DLL文件的时间戳是否与这个值相等。如果它们相等,那么加载器不做进一步处理,加载速度可能会快些。这就是所谓的“old-style binding(古板的绑定)”。Windows的SDK中有一个名为BIND.EXE的工具可以专门进行这项绑定设置。Matt Pietrek在其发布的《An In-Depth Look into the Win32 Portable Executable File Format》建议,终端用户应当在安装程序之后尽快进行这种时间戳绑定。
PE文件的打包/加密工具也可能会压缩/加密导入表。在这种情况下,Windows的加载器无法加载全部所需的DLL。这时,应由打包程序/加密程序负责加载外部函数。后者一般通过LoadLibrary()和GetProcAddress()来完成这项任务。
在Windows的安装程序的标准动态链接库DLL中,多数文件的导入地址表(Input Address Table,IAT)都位于PE文件的头部。这大概是出于优化的考虑而刻意设计成这样的。当运行一个exe可执行文件时,exe文件并不会被一次性地全部装载进内存(否则大型安装程序的加载速度就快得太离谱了),而是在访问过程中被分部映射到内存里。其目的就是加快exe文件的加载速度。
位于PE文件资源段里的数据无非就是图表、图形、字符串以及对话框描述等界面信息。这些资源与主程序指令分开存储,大概是为了方便实现多语言的支持:操作系统只需要根据系统的语言设置就可以选取相应的文本或图片。
而这其实也带来了一个副作用:因为PE可执行文件比较容易编辑,不具备PE文件专业知识的人也可以借助工具(ResHack等)直接修改程序资源。相关的介绍可以参见本书68.2.11节。
.NET的源程序并不会被编译成机器码,而会被编译成一种特殊的字节码。严格地说,这种.exe文件由字节码构成,并不是常规意义上的x86指令代码。但是这种程序的入口点(OEP)确实是一小段x86指令:
jmp mscoree.dll!\_CorExeMain
.NET格式的PE文件由mscoree.dll处理,它同时是.NET程序的装载器。在Windows XP操作系统之前,.net程序都是通过上述jmp指令交由mscoree.dll处理的。自Windows XP系统起,操作系统的加载器能自动识别.NET格式的文件,即使没有上述JMP指令也可以正常加载.NET程序。有关详情请参阅:
https://msdn.microsoft.com/en-us/library/xh0859k0(v=vs.110).aspx
这个段里存储了TLS数据(第65章)的初始化数据(如果需要的话)。当启动一个新的线程时,TLS的数据就是通过本段的数据来初始化的。
除此之外,PE文件规范还约定了“TLS!”的初始化规范,即TLS callbacks/TLS回调函数。如果程序声明了TLS回调函数,那么TLS回调函数会先于OEP执行。这项技术广泛应用于PE文件的压缩和加密程序。
Objdump(cygwin版),可转储所有的PE文件结构。
Hiew(可以参考本书第73章)。这是一个编辑器。
Prefile。这是一个用来处理PE文件的Python库。
ResHack。它是Resource Hacker的简称,是一个资源编辑器。
PE_add_import。这是一个小工具,利用它可以将符号加入到PE可执行文件的导入表中。
PE_patcher。一个小工具,可以用来给PE文件打补丁。
PE_search_str_refs。一个小工具,可以用来在PE可执行文件中寻找函数,这些函数可能有些字符串。
Daniel Pistelli:《.NET文件格式》:https://www.codeproject.com/articles/12585/the-net-file-format。
Windows操作系统中,结构性例外程序处理机制(Structured Exception Handling,SEH)是用来处理异常情况的响应机制。然而,它是与语言无关的,与C++或者面向对象的编程语言(Oriented Object Programming,OOP)无任何关联。本节将脱离C++以及MSVC的相关特效,单独分析SEH的特性。
每个运行的进程都有一条SEH句柄链,线程信息块(Thread Information Block,TIB)有SHE的最后一个句柄。当出现异外时(比如出现了被零除、地址访问不正确或者程序主动调用RaiseException()函数等情况),操作系统会在线程信息块TIB里寻找SEH的最后一个句柄。并且把出现异常情况时与CPU有关的所有状态(包括寄存器的值等数据)传递给那个SEH句柄。此时异常处理函数开始判断自己能否应对这种异常情况。如果答案是肯定的,那么异常处理函数就会着手接管。如果异常处理函数无法处理这种情况,它就会通知操作系统无法处理它,此后操作系统会逐一尝试异常处理链中的其他处理程序,直到找到能够应对这种情况的异常处理程序为止。
在异常处理链的结尾处有一个大家都接触过的异常处理程序:它显示一个标准的对话框,通知用户进程已经崩溃,崩溃时CPU的状态信息是什么情况,用户是否愿意把这些信息发送给微软的开发人员。
图68.2 Windows XP下的崩溃截图
图68.3 Windows XP下的崩溃细节
图68.4 Windows 7下的崩溃细节
图68.5 Windows 8.1下的崩溃截图
早些时候,这个异常处理程序叫做Dr. Watson。
另外,一些开发人员会在程序里设计自己的异常处理程序,以便收集程序的崩溃信息。这些都是通过系统函数SetUnhandledExceptionFilter()注册的异常处理函数。当操作系统遇到无法应对的异常情况时,它就会调用应用程序自己注册的异常处理函数。Oracle的RDBMS就是十分典型的一个例子:它会在程序崩溃时尽可能地转储CPU以及内存数据。
接下来,我们研究一个初级的异常处理程序。它摘自于https://www.microsoft.com/msj/0197/ Exception/Exception.aspx:
#include <windows.h>
#include <stdio.h>
DWORD new_value=1234;
EXCEPTION_DISPOSITION __cdecl except_handler(
struct _EXCEPTION_RECORD *ExceptionRecord,
void * EstablisherFrame,
struct _CONTEXT *ContextRecord,
void * DispatcherContext )
{
unsigned i;
printf ("%s\n", __FUNCTION__);
printf ("ExceptionRecord->ExceptionCode=0x%p\n", ExceptionRecord->ExceptionCode);
printf ("ExceptionRecord->ExceptionFlags=0x%p\n", ExceptionRecord->ExceptionFlags);
printf ("ExceptionRecord->ExceptionAddress=0x%p\n", ExceptionRecord->ExceptionAddress);
if (ExceptionRecord->ExceptionCode==0xE1223344)
{
printf ("That's for us\n");
// yes, we "handled" the exception
return ExceptionContinueExecution;
}
else if (ExceptionRecord->ExceptionCode==EXCEPTION_ACCESS_VIOLATION)
{
printf ("ContextRecord->Eax=0x%08X\n", ContextRecord->Eax);
// will it be possible to 'fix' it?
printf ("Trying to fix wrong pointer address\n");
ContextRecord->Eax=(DWORD)&new_value;
// yes, we "handled" the exception
return ExceptionContinueExecution;
}
else
{
printf ("We do not handle this\n");
// someone else's problem
return ExceptionContinueSearch;
};
}
int main()
{
DWORD handler = (DWORD)except_handler; // take a pointer to our handler
// install exception handler
__asm
{ // make EXCEPTION_REGISTRATION record:
push handler // address of handler function
push FS:[0] // address of previous handler
mov FS:[0],ESP // add new EXECEPTION_REGISTRATION
}
RaiseException (0xE1223344, 0, 0, NULL);
// now do something very bad
int* ptr=NULL;
int val=0;
val=*ptr;
printf ("val=%d\n", val);
// deinstall exception handler
__asm
{ // remove our EXECEPTION_REGISTRATION record
mov eax,[ESP] // get pointer to previous record
mov FS:[0], EAX // install previous record
add esp, 8 // clean our EXECEPTION_REGISTRATION off stack
}
return 0;
}
在Win32环境下,FS:段寄存器里的数据就是线程信息块(Thread Information Block,TIB)的指针。而TIB中的第一个元素正是异常处理指针链里最后一个处理程序的地址。所谓“注册”异常处理程序就是把自定义的异常处理程序的地址链接到这个异常处理指针链里。在注册异常处理程序时,需要使用一种名为_EXCEPTION_REGISTRATION的数据结构。它其实只是一个简单的单向链表,使用栈结构存储各项节点的链数据。
指令清单68.1 MSVC/VC/crt/src/exsup.inc
\_EXCEPTION\_REGISTRATION struc
prev dd ?
handler dd ?
\_EXCEPTION\_REGISTRATION ends
可见,每个节点的“handler”都是一个异常处理程序的启始地址,每个节点的“prev”字段都是上一个节点的地址指针。而最后一个节点的“prev”字段的值为0xFFFFFFFF(-1)。
在注册好我们自定义的异常处理程序以后,我们调用RaiseException()函数、触发用户异常的处理过程。异常处理程序首先检查异常代码,如果异常代码是0xE1223344,它就返回ExceptionContinueExecution。这个返回值代表“已经纠正CPU的状态”(通常通过调整EIP/ESP寄存器实现)、“操作系统可以继续执行后续指令”。如果把异常代码修改为其他值,那么处理函数的返回值则会变为ExceptionContinueSearch。顾名思义, 操作系统就会逐一尝试其他的异常处理程序—万一没有找到有关问题(并不仅仅是错误代码)的异常处理程序,我们就会看到标准的Windows进程崩溃对话框。
系统异常(system exceptions)和用户异常(user exceptions)之间的区别是什么?系统异常的有关信息是:
由WinBase.h定义的异常状态 |
在ntstatus.h里的相应状态 |
错误编号 |
---|---|---|
EXCEPTION_ACCESS_VIOLATION |
STATUS_ACCESS_VIOLATION |
0xC0000005 |
EXCEPTION_DATATYPE_MISALIGNMENT |
STATUS_DATATYPE_ |
|
EXCEPTION_BREAKPOINTSTATUS_ |
||
EXCEPTION_SINGLE_STEP |
STATUS_SINGLE_STEP |
0x80000004 |
EXCEPTION_ARRAY_BOUNDS_EXCEEDED |
STATUS_ARRAY_BOUNDS_EXCEEDED |
0xC000008C |
EXCEPTION_FLT_DENORMAL_OPERAND |
STATUS_FLOAT_DENORMAL_OPERAND |
0xC000008D |
EXCEPTION_FLT_DIVIDE_BY_ZERO |
STATUS_FLOAT_DIVIDE_BY_ZERO |
0xC000008E |
EXCEPTION_FLT_INEXACT_RESULT |
STATUS_FLOAT_INEXACT_RESULT |
0xC000008F |
EXCEPTION_FLT_INVALID_OPERATION |
STATUS_FLOAT_INVALID_OPERATION |
0xC0000090 |
EXCEPTION_FLT_OVERFLOW |
STATUS_FLOAT_OVERFLOW |
0xC0000091 |
EXCEPTION_FLT_STACK_CHECK |
STATUS_FLOAT_STACK_CHECK |
0xC0000092 |
EXCEPTION_FLT_UNDERFLOW |
STATUS_FLOAT_UNDERFLOW |
0xC0000093 |
EXCEPTION_INT_DIVIDE_BY_ZERO |
STATUS_INTEGER_DIVIDE_BY_ZERO |
0xC0000094 |
EXCEPTION_INT_OVERFLOW |
STATUS_INTEGER_OVERFLOW |
0xC0000095 |
EXCEPTION_PRIV_INSTRUCTION |
STATUS_PRIVILEGED_INSTRUCTION |
0xC0000096 |
EXCEPTION_IN_PAGE_ERROR |
STATUS_IN_PAGE_ERROR |
0xC0000006 |
EXCEPTION_ILLEGAL_INSTRUCTION |
STATUS_ILLEGAL_INSTRUCTION |
0xC000001D |
EXCEPTION_NONCONTINUABLE_EXCEPTION |
STATUS_NONCONTINUABLE_EXCEPTION |
0xC0000025 |
EXCEPTION_STACK_OVERFLOW |
STATUS_STACK_OVERFLOW |
0xC00000FD |
EXCEPTION_INVALID_DISPOSITION |
STATUS_INVALID_DISPOSITION |
0xC0000026 |
EXCEPTION_GUARD_PAGE |
STATUS_GUARD_PAGE_VIOLATION |
0x80000001 |
EXCEPTION_INVALID_HANDLE |
STATUS_INVALID_HANDLE |
0xC0000008 |
EXCEPTION_POSSIBLE_DEADLOCK |
STATUS_POSSIBLE_DEADLOCK |
0xC0000194 |
CONTROL_C_EXIT |
STATUS_CONTROL_C_EXIT |
0xC000013A |
32位错误代码的具体含义,如下图所示。
S是最高的两位(第30、31位),为基本的状态代码,一共有4种组合,分别是11、10、01以及00。它们分别代码的意义是:11代表错误,10代表警告,01代表信息,00代表成功。
U是第29位(第29位),它只有0和1两种状态,代表该异常是否属于用户侧异常。
上面我们提到的一个返回值是0xE1223344。最高的4位是0xE(1110)。这几个比特位代表:①这是属于用户态异常;②这是一个错误信息。实事求是地讲,本例这个程序与这些最高位的值没有关系;而考究的异常处理程序应当能够充分利用错误代码的所有信息。
接着,程序试图读取地址为0的内存数据。这个地址实际就是NULL指针指向的地址。访问这个地址必将导致系统错误,因为根据ISO C标准这个地址不应当存放任何数据(硬性规定)。此时操作系统会优先调用程序自己注册的异常处理程序。后者会判断错误代码是否为EXCEPTION_ACCESS_ VIOLATION,从而得知该异常是否是自己可以处理的问题。
读取地址为0的指令大致如下:
指令清单68.2 MSVC2010
...
xor eax, eax
mov eax, DWORD PTR [eax] ; exception will occur here
push eax
push OFFSET msg
call _printf
add esp, 8
...
我们的程序是不是可以实时处理这个错误以使得程序能继续执行呢?答案是肯定的。我们的异常处理程序能够修正EAX寄存器的值,让操作系统继续执行下去。这就是自定义异常处理程序的功能。字符串显示函数printf显示的数值是1234,因为执行了我们的异常处理函数之后,EAX的数值不再是0了,而是全局变量new_value的值。因此执行流程就得到了恢复。
以下就是程序执行的步骤:首先内存管理器检测出由中央处理器CPU发出的错误信息,接着CPU将此进程挂起,并在Windows的内核中检索异常处理程序的句柄。然后依次调用SEH链的handler。
本例是由MSVC 2010编译的程序。当然,我们并不能保证其他的编译程序同样会使用EAX寄存器存放该指针。
它所演示的地址替换技巧非常的精妙。我经常使用这种技术演示SEH的内部构造。不过,我还不曾使用这种技巧实时修复异常错误。
为什么SEH相关的记录存储于栈,而不是其他的地方?据说,如此一来操作系统就不需要关心这类数据的释放操作,毕竟函数结束以后这些数据都会被自动释放。但是,笔者也不难100%保证这种假说的正确性。这有点像本书5.2.4节讲到的 alloca()函数。
据说,C++语言开发环境已经能够稳妥的处理各种异常情况,只有C语言的开发人员才需要关注代码的异常处理机制。所以微软推出了一个面向MSVC的非标准C扩展。这个扩展组件不适用于C++程序。
有关详情请参阅:https://msdn.microsoft.com/en-us/library/swezty51.aspx
__try
{
...
}
__except(filter code)
{
handler code
}
除了“try-except”语句之外,MSVC还支持“try-finally”语句:
__try
{
...
}
__finally
{
...
}
前者中的“filter code”是一个用来判断是否执行“handler code(响应指令)”的表达式。如果代码太长、无法表示为一个表达式,那么就得借助于独立的过滤函数。
Windows内核就大量使用这种SEH结构。以WRK(Windows Research Kernel)的某段指令为例:
指令清单68.3 WRK-v1.2/base/ntos/ob/obwait.c
try {
KeReleaseMutant( (PKMUTANT)SignalObject,
MUTANT_INCREMENT,
FALSE,
TRUE );
} except((GetExceptionCode () == STATUS_ABANDONED ||
GetExceptionCode () == STATUS_MUTANT_NOT_OWNED)?
EXCEPTION_EXECUTE_HANDLER :
EXCEPTION_CONTINUE_SEARCH) {
Status = GetExceptionCode();
goto WaitExit;
}
指令清单68.4 WRK-v1.2/base/ntos/cache/cachesub.c
try {
RtlCopyBytes( (PVOID)((PCHAR)CacheBuffer + PageOffset),
UserBuffer,
MorePages ?
(PAGE_SIZE - PageOffset) :
(ReceivedLength - PageOffset) );
} except( CcCopyReadExceptionFilter( GetExceptionInformation(),
&Status ) ) {
下面也是一组过滤代码。
指令清单68.5 WRK-v1.2/base/ntos/cache/copysup.c
LONG
CcCopyReadExceptionFilter(
IN PEXCEPTION_POINTERS ExceptionPointer,
IN PNTSTATUS ExceptionCode
)
/*++
Routine Description:
This routine serves as a exception filter and has the special job of
extracting the "real" I/O error when Mm raises STATUS_IN_PAGE_ERROR
beneath us.
Arguments:
ExceptionPointer - A pointer to the exception record that contains
the real Io Status.
ExceptionCode - A pointer to an NTSTATUS that is to receive the real
status.
Return Value:
EXCEPTION_EXECUTE_HANDLER
--*/
{
*ExceptionCode = ExceptionPointer->ExceptionRecord->ExceptionCode;
if ( (*ExceptionCode == STATUS_IN_PAGE_ERROR) &&
(ExceptionPointer->ExceptionRecord->NumberParameters >= 3) ) {
*ExceptionCode = (NTSTATUS) ExceptionPointer->ExceptionRecord->ExceptionInformation[2];
}
ASSERT( !NT_SUCCESS(*ExceptionCode) );
return EXCEPTION_EXECUTE_HANDLER;
}
从内部来讲,SEH是由操作系统支持的异常处理扩展。但是异常处理函数属于_except_handler3 (SEH3)或_except_handler4(SEH4)。而且响应代码依赖于MSVC编译器。它需要由MSVC的库文件或者msvcr*.dll的动态链接库提供支持。SEH是由MSVC提供的一种机制,这一点至关重要。其他Win32的编译器的异常响应机制可能与SEH完全不同。
SEH3
SEH3定义了一个_except_handler3的异常处理函数,而且还对_EXCEPTION_REGISTRATION表进行了扩充、添加了scope table和previous try level的指针。在此基础上,SEH4对scope table表添加了四个值,以实现缓冲溢出保护。
scope table是一个表,它的元素都由filter表达式和handler code块的指针构成。这个表可以正确处理带有嵌套关系的多级try/except语句。
本书再次强调,操作系统只关心SEH里各节点的prev字段和handle字段,从不关心任何其他数据。函数_except_handler3的作用是读取其他的字段以及scope表的数值,并且判断什么时间执行哪个异常处理函数。
函数_except_handler3的源代码并不公开。然而,Sanos操作系统(与Win32系统部分兼容),再现了这个函数。在某种程度上说,由Sanos实现的_except_handler3与Windows系统的同名函数十分相似(请参阅其源文件/src/win32/msvcrt/except.c)。除此以外,Wine平台和ReactOS系统也开发了类似的函数。
如果filter指针是空指针NULL,那么handler指针将指向“finally”所在的代码块。
在执行过程中,堆栈中的previous try level改变了,因此函数_except_handler3能从目前的嵌套中获取信息,目的是知道使用哪个scope table表。
执行期间,栈中的previous try level字段将会发生变化。_except_handler3根据这项数据获取当前嵌套级的信息,进而判断要使用scope table中的哪个表项。每一个try块都分配了一个唯一的数作为标识,scopetable表中条目(entry)间的关系则描述了try块的嵌套关系。
SEH3: 一个try/except块的例子
#include <stdio.h>
#include <windows.h>
#include <excpt.h>
int main()
{
int* p = NULL;
__try
{
printf("hello #1!\n");
*p = 13; // causes an access violation exception;
printf("hello #2!\n");
}
__except(GetExceptionCode()==EXCEPTION_ACCESS_VIOLATION ?
EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH)
{
printf("access violation, can't recover\n");
}
}
指令清单68.6 MSVC 2003
$SG74605 DB 'hello #1!', 0aH, 00H
$SG74606 DB 'hello #2!', 0aH, 00H
$SG74608 DB 'access violation, can''t recover', 0aH, 00H
_DATA ENDS
; scope table:
CONST SEGMENT
$T74622 DD 0ffffffffH ; previous try level
DD FLAT:$L74617 ; filter
DD FLAT:$L74618 ; handler
CONST ENDS
_TEXT SEGMENT
$T74621 = -32 ; size = 4
_p$ = -28 ; size = 4
__$SEHRec$ = -24 ; size = 24
_main PROC NEAR
push ebp
mov ebp, esp
push -1 ; previous try level
push OFFSET FLAT:$T74622 ; scope table
push OFFSET FLAT:__except_handler3 ; handler
mov eax, DWORD PTR fs:__except_list
push eax ; prev
mov DWORD PTR fs:__except_list, esp
add esp, -16
; 3 registers to be saved:
push ebx
push esi
push edi
mov DWORD PTR __$SEHRec$[ebp], esp
mov DWORD PTR _p$[ebp], 0
mov DWORD PTR __$SEHRec$[ebp+20], 0 ; previous try level
push OFFSET FLAT:$SG74605 ; 'hello #1!'
call _printf
add esp, 4
mov eax, DWORD PTR _p$[ebp]
mov DWORD PTR [eax], 13
push OFFSET FLAT:$SG74606 ; 'hello #2!'
call _printf
add esp, 4
mov DWORD PTR __$SEHRec$[ebp+20], -1 ; previous try level
jmp SHORT $L74616
; filter code:
$L74617:
$L74627:
mov ecx, DWORD PTR __$SEHRec$[ebp+4]
mov edx, DWORD PTR [ecx]
mov eax, DWORD PTR [edx]
mov DWORD PTR $T74621[ebp], eax
mov eax, DWORD PTR $T74621[ebp]
sub eax, -1073741819; c0000005H
neg eax
sbb eax, eax
inc eax
$L74619:
$L74626:
ret 0
; handler code:
$L74618:
mov esp, DWORD PTR __$SEHRec$[ebp]
push OFFSET FLAT:$SG74608 ; 'access violation, can''t recover'
call _printf
add esp, 4
mov DWORD PTR __$SEHRec$[ebp+20], -1 ; setting previous try level back to -1
$L74616:
xor eax, eax
mov ecx, DWORD PTR __$SEHRec$[ebp+8]
mov DWORD PTR fs:__except_list, ecx
pop edi
pop esi
pop ebx
mov esp, ebp
pop ebp
ret 0
_main ENDP
_TEXT ENDS
END
由此可见,SEH在栈里形成了帧结构。而Scope table则是位于文件的CONST段,确实如此,scope table各字段的值确实不会发生变化。值得关注的是previous try level字段的变化过程。其初始值是0xFFFFFFF(-1)。当执行到try语句时,专有一条指令把它赋值为0。而当try语句的主体关闭时,它又被赋值为−1。我们也看到了filter以及handler code的地址。因此,我们能很容易地分析出函数中的try-except语句。
函数序言中的SEH初始化代码可能会被多个函数共享,有时候编译器会在函数序言直接调用SEH_prolog()函数,再在函数尾声处调用SEH_epilog()函数以回收栈空间。
下面我们在tracer跟踪器中运行这个例子:
tracer.exe -l:2.exe --dump-seh
上述指令的输出如下。
指令清单68.7 tracer.exe的输出
EXCEPTION_ACCESS_VIOLATION at 2.exe!main+0x44 (0x401054) ExceptionInformation[0]=1
EAX=0x00000000 EBX=0x7efde000 ECX=0x0040cbc8 EDX=0x0008e3c8
ESI=0x00001db1 EDI=0x00000000 EBP=0x0018feac ESP=0x0018fe80
EIP=0x00401054
FLAGS=AF IF RF
* SEH frame at 0x18fe9c prev=0x18ff78 handler=0x401204 (2.exe!_except_handler3)
SEH3 frame. previous trylevel=0
scopetable entry[0]. previous try level=-1, filter=0x401070 (2.exe!main+0x60) handler=0x401088 ↙
↘ (2.exe!main+0x78)
* SEH frame at 0x18ff78 prev=0x18ffc4 handler=0x401204 (2.exe!_except_handler3)
SEH3 frame. previous trylevel=0
scopetable entry[0]. previous try level=-1, filter=0x401531 (2.exe!mainCRTStartup+0x18d) ↙
↘ handler=0x401545 (2.exe!mainCRTStartup+0x1a1)
* SEH frame at 0x18ffc4 prev=0x18ffe4 handler=0x771f71f5 (ntdll.dll!__except_handler4)
SEH4 frame. previous trylevel=0
SEH4 header: GSCookieOffset=0xfffffffe GSCookieXOROffset=0x0
EHCookieOffset=0xffffffcc EHCookieXOROffset=0x0
scopetable entry[0]. previous try level=-2, filter=0x771f74d0 (ntdll.dll! ↙
↘ ___safe_se_handler_table+0x20) handler=0x771f90eb (ntdll.dll!_TppTerminateProcess@4+0x43)
* SEH frame at 0x18ffe4 prev=0xffffffff handler=0x77247428 (ntdll.dll!_FinalExceptionHandler@16)
我们可以看到SEH链含有4个处理函数/handler。
前两个handler是由源代码指定注册的异常处理函数。虽然我们的源代码只定义了一个handler,但是CRT的_mainCRTStartup()函数会自动设置一个配套的handler。后者的功能不多,至少能够处理一些与FPU有关的异常情况。有关源码可以参阅MSVC安装目录里的crt/src/winxfltr.c文件。
第三个handler是由ntdll.dll提供的SEH4,第四个handler也位于ntdll.dll,跟MSVC没什么关系,它的函数名是FinalExceptionHandler。
上述信息表明,SEH链含有三种类型的处理函数:一种是与MSVC彻底无关的自定义handler(即异常处理指针链中的最后一项),另外两种处理程序是由MSVC提供的SEH3和SEH4函数。
SEH3:两个try/except模块例子
#include <stdio.h>
#include <windows.h>
#include <excpt.h>
int filter_user_exceptions (unsigned int code, struct _EXCEPTION_POINTERS *ep)
{
printf("in filter. code=0x%08X\n", code);
if (code == 0x112233)
{
printf("yes, that is our exception\n");
return EXCEPTION_EXECUTE_HANDLER;
}
else
{
printf("not our exception\n");
return EXCEPTION_CONTINUE_SEARCH;
};
}
int main()
{
int* p = NULL;
__try
{
__try
{
printf ("hello!\n");
RaiseException (0x112233, 0, 0, NULL);
printf ("0x112233 raised. now let's crash\n");
*p = 13; // causes an access violation exception;
}
__except(GetExceptionCode()==EXCEPTION_ACCESS_VIOLATION ?
EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH)
{
printf("access violation, can't recover\n");
}
}
__except(filter_user_exceptions(GetExceptionCode(), GetExceptionInformation()))
{
// the filter_user_exceptions() function answering to the question
// "is this exception belongs to this block?"
// if yes, do the follow:
printf("user exception caught\n");
}
}
这里,我们可以看到两个try块。因此scope table会有两个元素,分别存储着各try块的相应指针。“Previous try level”字段的值会伴随着进入/退出try语句块而发生相应改变。
指令清单68.8 MSVC 2003
$SG74606 DB 'in filter. code=0x%08X', 0aH, 00H
$SG74608 DB 'yes, that is our exception', 0aH, 00H
$SG74610 DB 'not our exception', 0aH, 00H
$SG74617 DB 'hello!', 0aH, 00H
$SG74619 DB '0x112233 raised. now let''s crash', 0aH, 00H
$SG74621 DB 'access violation, can''t recover', 0aH, 00H
$SG74623 DB 'user exception caught', 0aH, 00H
_code$ = 8 ; size = 4
_ep$ = 12 ; size = 4
_filter_user_exceptions PROC NEAR
push ebp
mov ebp, esp
mov eax, DWORD PTR _code$[ebp]
push eax
push OFFSET FLAT:$SG74606 ; 'in filter. code=0x%08X'
call _printf
add esp, 8
cmp DWORD PTR _code$[ebp], 1122867; 00112233H
jne SHORT $L74607
push OFFSET FLAT:$SG74608 ; 'yes, that is our exception'
call _printf
add esp, 4
mov eax, 1
jmp SHORT $L74605
$L74607:
push OFFSET FLAT:$SG74610 ; 'not our exception'
call _printf
add esp, 4
xor eax, eax
$L74605:
pop ebp
ret 0
_filter_user_exceptions ENDP
; scope table:
CONST SEGMENT
$T74644 DD 0ffffffffH ; previous try level for outer block
DD FLAT:$L74634 ; outer block filter
DD FLAT:$L74635 ; outer block handler
DD 00H ; previous try level for inner block
DD FLAT:$L74638 ; inner block filter
DD FLAT:$L74639 ; inner block handler
CONST ENDS
$T74643 = -36 ; size = 4
$T74642 = -32 ; size = 4
_p$ = -28 ; size = 4
__$SEHRec$ = -24 ; size = 24
_main PROC NEAR
push ebp
mov ebp, esp
push -1 ; previous try level
push OFFSET FLAT:$T74644
push OFFSET FLAT:__except_handler3
mov eax, DWORD PTR fs:__except_list
push eax
mov DWORD PTR fs:__except_list, esp
add esp, -20
push ebx
push esi
push edi
mov DWORD PTR __$SEHRec$[ebp], esp
mov DWORD PTR _p$[ebp], 0
mov DWORD PTR __$SEHRec$[ebp+20], 0 ; outer try block entered. set previous try level to 0
mov DWORD PTR __$SEHRec$[ebp+20], 1 ; inner try block entered. set previous try level to 1
push OFFSET FLAT:$SG74617 ; 'hello!'
call _printf
add esp, 4
push 0
push 0
push 0
push 1122867 ; 00112233H
call DWORD PTR __imp__RaiseException@16
push OFFSET FLAT:$SG74619 ; '0x112233 raised. now let''s crash'
call _printf
add esp, 4
mov eax, DWORD PTR _p$[ebp]
mov DWORD PTR [eax], 13
mov DWORD PTR __$SEHRec$[ebp+20], 0 ; inner try block exited. set previous try level back to 0
jmp SHORT $L74615
; inner block filter:
$L74638:
$L74650:
mov ecx, DWORD PTR __$SEHRec$[ebp+4]
mov edx, DWORD PTR [ecx]
mov eax, DWORD PTR [edx]
mov DWORD PTR $T74643[ebp], eax
mov eax, DWORD PTR $T74643[ebp]
sub eax, -1073741819; c0000005H
neg eax
sbb eax, eax
inc eax
$L74640:
$L74648:
ret 0
; inner block handler:
$L74639:
mov esp, DWORD PTR __$SEHRec$[ebp]
push OFFSET FLAT:$SG74621 ; 'access violation, can''t recover'
call _printf
add esp, 4
mov DWORD PTR __$SEHRec$[ebp+20], 0 ; inner try block exited. set previous try level back to 0
$L74615:
mov DWORD PTR __$SEHRec$[ebp+20], -1 ; outer try block exited, set previous try level back to -1
jmp SHORT $L74633
; outer block filter:
$L74634:
$L74651:
mov ecx, DWORD PTR __$SEHRec$[ebp+4]
mov edx, DWORD PTR [ecx]
mov eax, DWORD PTR [edx]
mov DWORD PTR $T74642[ebp], eax
mov ecx, DWORD PTR __$SEHRec$[ebp+4]
push ecx
mov edx, DWORD PTR $T74642[ebp]
push edx
call _filter_user_exceptions
add esp, 8
$L74636:
$L74649:
ret 0
; outer block handler:
$L74635:
mov esp, DWORD PTR __$SEHRec$[ebp]
push OFFSET FLAT:$SG74623 ; 'user exception caught'
call _printf
add esp, 4
mov DWORD PTR __$SEHRec$[ebp+20], -1 ; both try blocks exited. set previous try level back to -1
$L74633:
xor eax, eax
mov ecx, DWORD PTR __$SEHRec$[ebp+8]
mov DWORD PTR fs:__except_list, ecx
pop edi
pop esi
pop ebx
mov esp, ebp
pop ebp
ret 0
_main ENDP
只要在handler中调用printf()函数的指令那里设置一个断点,就可以观测到SEH handler的添加过程。 或许SEH的内部处理机制与众不同。而这里我们可以看到scope table 包含着两个元素:
tracer.exe -l:3.exe bpx=3.exe!printf --dump-seh
指令清单68.9 tracer.exe输出
(0) 3.exe!printf
EAX=0x0000001b EBX=0x00000000 ECX=0x0040cc58 EDX=0x0008e3c8
ESI=0x00000000 EDI=0x00000000 EBP=0x0018f840 ESP=0x0018f838
EIP=0x004011b6
FLAGS=PF ZF IF
* SEH frame at 0x18f88c prev=0x18fe9c handler=0x771db4ad (ntdll.dll!ExecuteHandler2@20+0x3a)
* SEH frame at 0x18fe9c prev=0x18ff78 handler=0x4012e0 (3.exe!_except_handler3)
SEH3 frame. previous trylevel=1
scopetable entry[0]. previous try level=-1, filter=0x401120 (3.exe!main+0xb0) handler=0x40113b ↙
↘ (3.exe!main+0xcb)
scopetable entry[1]. previous try level=0, filter=0x4010e8 (3.exe!main+0x78) handler=0x401100 ↙
↘ (3.exe!main+0x90)
* SEH frame at 0x18ff78 prev=0x18ffc4 handler=0x4012e0 (3.exe!_except_handler3)
SEH3 frame. previous trylevel=0
scopetable entry[0]. previous try level=-1, filter=0x40160d (3.exe!mainCRTStartup+0x18d) ↙
↘ handler=0x401621 (3.exe!mainCRTStartup+0x1a1)
* SEH frame at 0x18ffc4 prev=0x18ffe4 handler=0x771f71f5 (ntdll.dll!__except_handler4)
SEH4 frame. previous trylevel=0
SEH4 header: GSCookieOffset=0xfffffffe GSCookieXOROffset=0x0
EHCookieOffset=0xffffffcc EHCookieXOROffset=0x0
scopetable entry[0]. previous try level=-2, filter=0x771f74d0 (ntdll.dll! ↙
↘ ___safe_se_handler_table+0x20) handler=0x771f90eb (ntdll.dll!_TppTerminateProcess@4+0x43)
* SEH frame at 0x18ffe4 prev=0xffffffff handler=0x77247428 (ntdll.dll!_FinalExceptionHandler@16)
SEH4
在遭受缓冲区溢出攻击(请参见本书第18章第2节)以后,地址表scope table的地址可能被重写。MSVC 2005编译器为SEH帧增加了一些缓冲区溢出保护,把SEH3升级成了SEH4。SEH4的scope table表的指针会与security cookie进行异或运算,然后才被写到相应的数据结构里。此外Scope table新增了一个双指针表头,这两个EH cookie指针都是security cookies的指针(GS cookies只有在编译时打开/GS参数才会出现)。EH Cookie的偏移量(offset)都是基于栈帧(EBP)的相对地址,其与security_cookie的异或运算结果会被保存在栈里,充当校验码。异常处理函数的加载过程会读取这个值并检查其正确性。
由于栈内的security cookie是一次性随机值,因此远程攻击者是无法事先预测这个值。
在SEH4中最外层的级别(previous try level)是-2,而不是SEH3的-1。
这里列出了两个由MSVC 2012编译的SEH4函数。
指令清单68.10 MSVC 2012:一个try块的例子
$SG85485 DB 'hello #1!', 0aH, 00H
$SG85486 DB 'hello #2!', 0aH, 00H
$SG85488 DB 'access violation, can''t recover', 0aH, 00H
; scope table:
xdata$x SEGMENT
__sehtable$_main DD 0fffffffeH ; GS Cookie Offset
DD 00H ; GS Cookie XOR Offset
DD 0ffffffccH ; EH Cookie Offset
DD 00H ; EH Cookie XOR Offset
DD 0fffffffeH ; previous try level
DD FLAT:$LN12@main ; filter
DD FLAT:$LN8@main ; handler
xdata$x ENDS
$T2 = -36 ; size = 4
_p$ = -32 ; size = 4
tv68 = -28 ; size = 4
__$SEHRec$ = -24 ; size = 24
_main PROC
push ebp
mov ebp, esp
push -2
push OFFSET __sehtable$_main
push OFFSET __except_handler4
mov eax, DWORD PTR fs:0
push eax
add esp, -20
push ebx
push esi
push edi
mov eax, DWORD PTR ___security_cookie
xor DWORD PTR __$SEHRec$[ebp+16], eax ; xored pointer to scope table
xor eax, ebp
push eax ; ebp ^ security_cookie
lea eax, DWORD PTR __$SEHRec$[ebp+8] ; pointer to VC_EXCEPTION_REGISTRATION_RECORD
mov DWORD PTR fs:0, eax
mov DWORD PTR __$SEHRec$[ebp], esp
mov DWORD PTR _p$[ebp], 0
mov DWORD PTR __$SEHRec$[ebp+20], 0 ; previous try level
push OFFSET $SG85485 ; 'hello #1!'
call _printf
add esp, 4
mov eax, DWORD PTR _p$[ebp]
mov DWORD PTR [eax], 13
push OFFSET $SG85486 ; 'hello #2!'
call _printf
add esp, 4
mov DWORD PTR __$SEHRec$[ebp+20], -2 ; previous try level
jmp SHORT $LN6@main
; filter:
$LN7@main:
$LN12@main:
mov ecx, DWORD PTR __$SEHRec$[ebp+4]
mov edx, DWORD PTR [ecx]
mov eax, DWORD PTR [edx]
mov DWORD PTR $T2[ebp], eax
cmp DWORD PTR $T2[ebp], -1073741819 ; c0000005H
jne SHORT $LN4@main
mov DWORD PTR tv68[ebp], 1
jmp SHORT $LN5@main
$LN4@main:
mov DWORD PTR tv68[ebp], 0
$LN5@main:
mov eax, DWORD PTR tv68[ebp]
$LN9@main:
$LN11@main:
ret 0
; handler:
$LN8@main:
mov esp, DWORD PTR __$SEHRec$[ebp]
push OFFSET $SG85488 ; 'access violation, can''t recover'
call _printf
add esp, 4
mov DWORD PTR __$SEHRec$[ebp+20], -2 ; previous try level
$LN6@main:
xor eax, eax
mov ecx, DWORD PTR __$SEHRec$[ebp+8]
mov DWORD PTR fs:0, ecx
pop ecx
pop edi
pop esi
pop ebx
mov esp, ebp
pop ebp
ret 0
_main ENDP
指令清单68.11 MSVC 2012:两个try块的例子
$SG85486 DB 'in filter. code=0x%08X', 0aH, 00H
$SG85488 DB 'yes, that is our exception', 0aH, 00H
$SG85490 DB 'not our exception', 0aH, 00H
$SG85497 DB 'hello!', 0aH, 00H
$SG85499 DB '0x112233 raised. now let''s crash', 0aH, 00H
$SG85501 DB 'access violation, can''t recover', 0aH, 00H
$SG85503 DB 'user exception caught', 0aH, 00H
xdata$x SEGMENT
__sehtable$_main DD 0fffffffeH ; GS Cookie Offset
DD 00H ; GS Cookie XOR Offset
DD 0ffffffc8H ; EH Cookie Offset
DD 00H ; EH Cookie Offset
DD 0fffffffeH ; previous try level for outer block
DD FLAT:$LN19@main ; outer block filter
DD FLAT:$LN9@main ; outer block handler
DD 00H ; previous try level for inner block
DD FLAT:$LN18@main ; inner block filter
DD FLAT:$LN13@main ; inner block handler
xdata$x ENDS
$T2 = -40 ; size = 4
$T3 = -36 ; size = 4
_p$ = -32 ; size = 4
tv72 = -28 ; size = 4
__$SEHRec$ = -24 ; size = 24
_main PROC
push ebp
mov ebp, esp
push -2 ; initial previous try level
push OFFSET __sehtable$_main
push OFFSET __except_handler4
mov eax, DWORD PTR fs:0
push eax ; prev
add esp, -24
push ebx
push esi
push edi
mov eax, DWORD PTR ___security_cookie
xor DWORD PTR __$SEHRec$[ebp+16], eax ; xored pointer to scope table
xor eax, ebp ; ebp ^ security_cookie
push eax
lea eax, DWORD PTR __$SEHRec$[ebp+8] ; pointer to VC_EXCEPTION_REGISTRATION_RECORD
mov DWORD PTR fs:0, eax
mov DWORD PTR __$SEHRec$[ebp], esp
mov DWORD PTR _p$[ebp], 0
mov DWORD PTR __$SEHRec$[ebp+20], 0 ; entering outer try block, setting previous try level=0
mov DWORD PTR __$SEHRec$[ebp+20], 1 ; entering inner try block, setting previous try level=1
push OFFSET $SG85497 ; 'hello!'
call _printf
add esp, 4
push 0
push 0
push 0
push 1122867 ; 00112233H
call DWORD PTR __imp__RaiseException@16
push OFFSET $SG85499 ; '0x112233 raised. now let''s crash'
call _printf
add esp, 4
mov eax, DWORD PTR _p$[ebp]
mov DWORD PTR [eax], 13
mov DWORD PTR __$SEHRec$[ebp+20], 0 ; exiting inner try block, set previous try level back to 0
jmp SHORT $LN2@main
; inner block filter:
$LN12@main:
$LN18@main:
mov ecx, DWORD PTR __$SEHRec$[ebp+4]
mov edx, DWORD PTR [ecx]
mov eax, DWORD PTR [edx]
mov DWORD PTR $T3[ebp], eax
cmp DWORD PTR $T3[ebp], -1073741819 ; c0000005H
jne SHORT $LN5@main
mov DWORD PTR tv72[ebp], 1
jmp SHORT $LN6@main
$LN5@main:
mov DWORD PTR tv72[ebp], 0
$LN6@main:
mov eax, DWORD PTR tv72[ebp]
$LN14@main:
$LN16@main:
ret 0
; inner block handler:
$LN13@main:
mov esp, DWORD PTR __$SEHRec$[ebp]
push OFFSET $SG85501 ; 'access violation, can''t recover'
call _printf
add esp, 4
mov DWORD PTR __$SEHRec$[ebp+20], 0 ; exiting inner try block, setting previous try level back to 0
$LN2@main:
mov DWORD PTR __$SEHRec$[ebp+20], -2 ; exiting both blocks, setting previous try level back to -2
jmp SHORT $LN7@main
; outer block filter:
$LN8@main:
$LN19@main:
mov ecx, DWORD PTR __$SEHRec$[ebp+4]
mov edx, DWORD PTR [ecx]
mov eax, DWORD PTR [edx]
mov DWORD PTR $T2[ebp], eax
mov ecx, DWORD PTR __$SEHRec$[ebp+4]
push ecx
mov edx, DWORD PTR $T2[ebp]
push edx
call _filter_user_exceptions
add esp, 8
$LN10@main:
$LN17@main:
ret 0
; outer block handler:
$LN9@main:
mov esp, DWORD PTR __$SEHRec$[ebp]
push OFFSET $SG85503 ; 'user exception caught'
call _printf
add esp, 4
mov DWORD PTR __$SEHRec$[ebp+20], -2 ; exiting both blocks, setting previous try level back to -2
$LN7@main:
xor eax, eax
mov ecx, DWORD PTR __$SEHRec$[ebp+8]
mov DWORD PTR fs:0, ecx
pop ecx
pop edi
pop esi
pop ebx
mov esp, ebp
pop ebp
ret 0
_main ENDP
_code$ = 8 ; size = 4
_ep$ = 12 ; size = 4
_filter_user_exceptions PROC
push ebp
mov ebp, esp
mov eax, DWORD PTR _code$[ebp]
push eax
push OFFSET $SG85486 ; 'in filter. code=0x%08X'
call _printf
add esp, 8
cmp DWORD PTR _code$[ebp], 1122867 ; 00112233H
jne SHORT $LN2@filter_use
push OFFSET $SG85488 ; 'yes, that is our exception'
call _printf
add esp, 4
mov eax, 1
jmp SHORT $LN3@filter_use
jmp SHORT $LN3@filter_use
$LN2@filter_use:
push OFFSET $SG85490 ; 'not our exception'
call _printf
add esp, 4
xor eax, eax
$LN3@filter_use:
pop ebp
ret 0
_filter_user_exceptions ENDP
Cookie Offset是EBP的值(栈帧栈底)与栈内EBP⊕security_cookie之间的差值。Cookie XOR Offset是EBP⊕security_cookie与栈中数值之间的差值。如果上述各值不符合下列条件,那么整个进程将会因为栈损坏而终止运行:
security_cookie⊕(CookieXOROffset +address_of_saved_EBP) == stack[address_of_saved_EBP + CookieOffset]
如果Cookie Offset的值是−2,就表示这个cookie并不存在(GScookie一般如此)。
笔者编写的tracer程序也能进行Cookies的合法性检查,有关详情请访问https://github.com/ dennis714/tracer/blob/master/SEH.c
。
在启用“/GS-”选项之后MSVC 2005编译器就会分配SEH3的函数,但是它依然会分配SEH4的CRT代码。
和大家想象的一样,每个函数都在序言中设置SEH栈帧将会降低运行速度。此外,在程序运行过程中不断调整“previous try level”字段同样会增加时间开销。然而在x64程序里,整个情况彻底不同了:所有try块的指针、filter和handler函数的指针都单独存储于可执行文件的.pdata段。操作系统根据.pdata段获取异常处理的全部信息。
我们把上一个章节的两个程序编译为x64程序,可以得到:
指令清单68.12 MSVC 2012
$SG86276 DB 'hello #1!', 0aH, 00H
$SG86277 DB 'hello #2!', 0aH, 00H
$SG86279 DB 'access violation, can''t recover', 0aH, 00H
pdata SEGMENT
$pdata$main DD imagerel $LN9
DD imagerel $LN9+61
DD imagerel $unwind$main
pdata ENDS
pdata SEGMENT
$pdata$main$filt$0 DD imagerel main$filt$0
DD imagerel main$filt$0+32
DD imagerel $unwind$main$filt$0
pdata ENDS
xdata SEGMENT
$unwind$main DD 020609H
DD 030023206H
DD imagerel __C_specific_handler
DD 01H
DD imagerel $LN9+8
DD imagerel $LN9+40
DD imagerel main$filt$0
DD imagerel $LN9+40
$unwind$main$filt$0 DD 020601H
DD 050023206H
xdata ENDS
_TEXT SEGMENT
main PROC
$LN9:
push rbx
sub rsp, 32
xor ebx, ebx
lea rcx, OFFSET FLAT:$SG86276 ; 'hello #1!'
call printf
mov DWORD PTR [rbx], 13
lea rcx, OFFSET FLAT:$SG86277 ; 'hello #2!'
call printf
jmp SHORT $LN8@main
$LN6@main:
lea rcx, OFFSET FLAT:$SG86279 ; 'access violation, can''t recover'
call printf
npad 1 ; align next label
$LN8@main:
xor eax, eax
add rsp, 32
pop rbx
ret 0
main ENDP
_TEXT ENDS
text$x SEGMENT
main$filt$0 PROC
push rbp
sub rsp, 32
mov rbp, rdx
$LN5@main$filt$:
mov rax, QWORD PTR [rcx]
xor ecx, ecx
cmp DWORD PTR [rax], -1073741819; c0000005H
sete cl
mov eax, ecx
$LN7@main$filt$:
add rsp, 32
pop rbp
ret 0
int 3
main$filt$0 ENDP
text$x ENDS
指令清单68.13 MSVC 2012
$SG86277 DB 'in filter. code=0x%08X', 0aH, 00H
$SG86279 DB 'yes, that is our exception', 0aH, 00H
$SG86281 DB 'not our exception', 0aH, 00H
$SG86288 DB 'hello!', 0aH, 00H
$SG86290 DB '0x112233 raised. now let''s crash', 0aH, 00H
$SG86292 DB 'access violation, can''t recover', 0aH, 00H
$SG86294 DB 'user exception caught', 0aH, 00H
pdata SEGMENT
$pdata$filter_user_exceptions DD imagerel $LN6
DD imagerel $LN6+73
DD imagerel $unwind$filter_user_exceptions
$pdata$main DD imagerel $LN14
DD imagerel $LN14+95
DD imagerel $unwind$main
pdata ENDS
pdata SEGMENT
$pdata$main$filt$0 DD imagerel main$filt$0
DD imagerel main$filt$0+32
DD imagerel $unwind$main$filt$0
$pdata$main$filt$1 DD imagerel main$filt$1
DD imagerel main$filt$1+30
DD imagerel $unwind$main$filt$1
pdata ENDS
xdata SEGMENT
$unwind$filter_user_exceptions DD 020601H
DD 030023206H
$unwind$main DD 020609H
DD 030023206H
DD imagerel __C_specific_handler
DD 02H
DD imagerel $LN14+8
DD imagerel $LN14+59
DD imagerel main$filt$0
DD imagerel $LN14+59
DD imagerel $LN14+8
DD imagerel $LN14+74
DD imagerel main$filt$1
DD imagerel $LN14+74
$unwind$main$filt$0 DD 020601H
DD 050023206H
$unwind$main$filt$1 DD 020601H
DD 050023206H
Xdata ENDS
_TEXT SEGMENT
main PROC
$LN14:
push rbx
sub rsp, 32
xor ebx, ebx
lea rcx, OFFSET FLAT:$SG86288 ; 'hello!'
call printf
xor r9d, r9d
xor r8d, r8d
xor edx, edx
mov ecx, 1122867 ; 00112233H
call QWORD PTR __imp_RaiseException
lea rcx, OFFSET FLAT:$SG86290 ; '0x112233 raised. now let''s crash'
call printf
mov DWORD PTR [rbx], 13
jmp SHORT $LN13@main
$LN11@main:
lea rcx, OFFSET FLAT:$SG86292 ; 'access violation, can''t recover'
call printf
npad 1 ; align next label
$LN13@main:
jmp SHORT $LN9@main
$LN7@main:
lea rcx, OFFSET FLAT:$SG86294 ; 'user exception caught'
call printf
npad 1 ; align next label
$LN9@main:
xor eax, eax
add rsp, 32
pop rbx
ret 0
main ENDP
text$x SEGMENT
main$filt$0 PROC
push rbp
sub rsp, 32
mov rbp, rdx
$LN10@main$filt$:
mov rax, QWORD PTR [rcx]
xor ecx, ecx
cmp DWORD PTR [rax], -1073741819; c0000005H
sete cl
mov eax, ecx
$LN12@main$filt$:
add rsp, 32
pop rbp
ret 0
int 3
main$filt$0 ENDP
main$filt$1 PROC
push rbp
sub rsp, 32
mov rbp, rdx
$LN6@main$filt$:
mov rax, QWORD PTR [rcx]
mov rdx, rcx
mov ecx, DWORD PTR [rax]
call filter_user_exceptions
npad 1 ; align next label
$LN8@main$filt$:
add rsp, 32
pop rbp
ret 0
int 3
main$filt$1 ENDP
text$x ENDS
_TEXT SEGMENT
code$ = 48
ep$ = 56
filter_user_exceptions PROC
$LN6:
push rbx
sub rsp, 32
mov ebx, ecx
mov edx, ecx
lea rcx, OFFSET FLAT:$SG86277 ; 'in filter. code=0x%08X'
call printf
cmp ebx, 1122867; 00112233H
jne SHORT $LN2@filter_use
lea rcx, OFFSET FLAT:$SG86279 ; 'yes, that is our exception'
call printf
mov eax, 1
add rsp, 32
pop rbx
ret 0
$LN2@filter_use:
lea rcx, OFFSET FLAT:$SG86281 ; 'not our exception'
call printf
xor eax, eax
add rsp, 32
pop rbx
ret 0
filter_user_exceptions ENDP
_TEXT ENDS
要想查看更多信息,可以参考Igor Skochinsky撰写的文章“Compiler Internals:Exceptional and RTTL(编译器内幕:例外与RTTL)”。
除了例外信息外,.pdata段还存储着几乎所有函数的起始和结束的地址。由此可见,.pdata段是自动分析的工具的重点分析对象。
Igor Skochinsky编写的文章“Compiler Internals:Exceptional and RTTL(编译器内幕:例外与RTTL)”。
Matt Pietrek编写的文章“A Crash Course on the Depths of Win32 Structured Exception Handling(Win32结构性例外进程的崩溃的深度分析)”。
在任何一个多线程的环境下,临界区段(Critical section)是保护数据一致性和操作互斥性的重要手段。临界区段保证了在同一时间内只会有一个线程访问某些数据,阻止其他进程和中断同期操作相关数据。
在Windows NT系统的数据结构中,CRITICAL_SECTION关键段的定义如下。
指令清单68.14 (Windows Research Kernel v1.2) public/sdk/inc/nturtl.h
typedef struct _RTL_CRITICAL_SECTION {
PRTL_CRITICAL_SECTION_DEBUG DebugInfo;
//
// The following three fields control entering and exiting the critical
// section for the resource
//
LONG LockCount;
LONG RecursionCount;
HANDLE OwningThread; // from the thread's ClientId->UniqueThread
HANDLE LockSemaphore;
ULONG_PTR SpinCount; // force size on 64-bit systems when packed
} RTL_CRITICAL_SECTION, *PRTL_CRITICAL_SECTION;
下面的代码描述了函数EnterCriticalSection()的工作原理。
指令清单68.15 Windows 2008/ntdll.dll/x86(开始)
_RtlEnterCriticalSection@4
var_C = dword ptr -0Ch
var_8 = dword ptr -8
var_4 = dword ptr -4
arg_0 = dword ptr 8
mov edi, edi
push ebp
mov ebp, esp
sub esp, 0Ch
push esi
push edi
mov edi, [ebp+arg_0]
lea esi, [edi+4] ; LockCount
mov eax, esi
lock btr dword ptr [eax], 0
jnb wait ; jump if CF=0
loc_7DE922DD:
mov eax, large fs:18h
mov ecx, [eax+24h]
mov [edi+0Ch], ecx
mov dword ptr [edi+8], 1
pop edi
xor eax, eax
pop esi
mov esp, ebp
pop ebp
retn 4
... skipped
在代码段中最重要的指令是BTR(及其LOCK前缀):BTR指令把第一个操作数的第0位复制给CF标识位,然后再把这个位清零。由LOCK前缀修饰的都是原子性操作,可以让CPU阻止其他的系统总线读取或修改相关内存地址。如果LockCount的第0位值是1,则将其充值重置并退出函数——CPU现在正处于临界区;否则,则表示其他线程正在占用临界区,CPU将等待相关操作结束。
等待期间运行的函数是WaitForSingleObject()。
下述代码描述了LeaveCriticalSection()函数的工作机理:
指令清单68.16 Windows 2008/ntdll.dll/x86(开始)
_RtlLeaveCriticalSection@4 proc near
arg_0 = dword ptr 8
mov edi, edi
push ebp
mov ebp, esp
push esi
mov esi, [ebp+arg_0]
add dword ptr [esi+8], 0FFFFFFFFh ; RecursionCount
jnz short loc_7DE922B2
push ebx
push edi
lea edi, [esi+4] ; LockCount
mov dword ptr [esi+0Ch], 0
mov ebx, 1
mov eax, edi
lock xadd [eax], ebx
inc ebx
cmp ebx, 0FFFFFFFFh
jnz loc_7DEA8EB7
loc_7DE922B0:
pop edi
pop ebx
loc_7DE922B2:
xor eax, eax
pop esi
pop ebp
retn 4
... skipped
XADD指令的功能是:先交换操作数的值,然后再进行加法运算。在本例中,它将LockCount与数字1的和存储于第一个操作数,同时将LockCount的初始值传递给EBX寄存器。但是EBX里的这个值也随即被后面的INC指令递增,最终与LockCount的值同步。因为它带有LOCK前缀,所以属于原子操作。这就意味着所有的其他CPU(不管是几核的)都不能同时访问那片内存区域。
LOCK前缀非常重要。不同的CPU或者CPU核心(core)可能会加载同一个进程的不同线程。若使用无LOCK前缀的指令操作临界区段的数据,很可能发生无法预料的情况。