Last updated on January 30, 2024 am
浅谈SEH
从去年的moectf到今年的moectf,以及平日的刷题都遇到了SEH的题目,但是一直没有系统的学习总结。正巧最近也在看《加密与解密》这本书,也看到了SEH相关内容。就借着今年的moectf来总结一下SEH吧!
SEH
什么是SEH SEH全称Structured Exception Handling,即结构化处理异常。是一种当代码产生异常提供纠错机会的机制,比如访问非法地址,发生除0错误就去修正地址、修正除数等。换句话说,也就是发生异常的时候系统会调用一个用户自定义的回调函数,此回调函数的主要作用就是修正错误,让代码重新正常运行。
不妨先看看这个回调函数样子
1 2 3 4 5 EXCEPTION_DISPOSITION __cdecl _except_handler( struct _EXCEPTION_RECORD *ExceptionRecord, void * EstablisherFrame, struct _CONTEXT *ContextRecord, void * DispatcherContext);
这个函数就是在发生异常的时候操作系统会调用的异常处理函数,它有四个参数,指示了一些与异常相关的信息。可能会有疑问,即我怎么这四个参数的值是多少呢?其实在异常发生的时候,操作系统就会自动把这个四个参数压入栈中,也因此需要__cdecl
来声明此函数的调用约定。
它的返回值是枚举类型
1 2 3 4 5 6 7 typedef enum _EXCEPTION_DISPOSITION { ExceptionContinueExecution = 0 , // 已经处理了异常,回到异常触发点继续执行 ExceptionContinueSearch = 1 , // 没有处理异常,继续遍历异常链表 ExceptionNestedException = 2 , // OS内部使用 ExceptionCollidedUnwind = 3 // OS内部使用 }EXCEPTION_DISPOSITION;
现在就来看看这四个参数是什么
函数的第一个参数是一个指向**EXCEPTION_RECORD**结构的指针,定义如下
1 2 3 4 5 6 7 8 typedef struct _EXCEPTION_RECORD { DWORD ExceptionCode; DWORD ExceptionFlags; struct _EXCEPTION_RECORD *ExceptionRecord ; PVOID ExceptionAddress; DWORD NumberParameters; DWORD ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS]; } EXCEPTION_RECORD;
函数的第二个参数是一个指向establisher帧结构的指针。它是SEH中一个至关重要的参数,但是现在可以忽略它
回调函数的第三个参数是一个指向CONTEXT 结构的指针,定义如下(该结构定义随着Windows系统不同也会改变。此结构保存异常发生时一些上下文环境
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 typedef struct _CONTEXT { DWORD ContextFlags; DWORD Dr0; DWORD Dr1; DWORD Dr2; DWORD Dr3; DWORD Dr6; DWORD Dr7; FLOATING_SAVE_AREA FloatSave; DWORD SegGs; DWORD SegFs; DWORD SegEs; DWORD SegDs; DWORD Edi; DWORD Esi; DWORD Ebx; DWORD Edx; DWORD Ecx; DWORD Eax; DWORD Ebp; DWORD Eip; DWORD SegCs; DWORD EFlags; DWORD Esp; DWORD SegSs; } CONTEXT;
回调函数的第四个参数被称为DispatcherContext。它暂时也可以被忽略。
现在我们大概知道回调函数会有四个参数,这些参数会由操作系统自动压栈,并且这些参数还会为我们提供异常的重要信息,现在还有一个问题就是,操作系统去哪里找这个回调函数?
首先我们应该想到一个问题,即异常处理函数不可能只有一个,毕竟有那么多不同的异常,每个异常都需要专门的函数去处理,因此操作系统选择把这些异常处理函数以链表的形式存储,也就是EXCEPTION_REGISTRATION_RECORD 结构,此结构定义如下
1 2 3 4 typedef struct _EXCEPTION_REGISTRATION_RECORD { struct _EXCEPTION_REGISTRATION_RECORD *Next ; PEXCEPTION_ROUTINE Handler; }EXCEPTION_REGISTRATION_RECORD;
因此只要找到这个EXCEPTION_REGISTRATION_RECORD 结构,就可以找到异常处理函数,那么这个结构在哪呢?
这就不得不提到TIB(Thread Information Block,线程信息块)了,它位于TEB(Thread Environment Block,线程环境块)的头部。TEB是操作系统为了保存每个线程的私有数据而创建的,也就是说每个线程都有自己独立的TEB。不同系统的TIB结构也会不同,但我们也不需要具体知道TIB结构,因为**_EXCEPTION_REGISTRATION_RECORD**结构总位于TIB结构头部。而在32位系统上,fs:[0]
总是指向TIB结构。
一句话说明,我们只需要去fs:[0]
就可以找到此链表了。
下面就来试试SEH吧!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 #include <windows.h> #include <stdio.h> DWORD scratch; EXCEPTION_DISPOSITION __cdecl _except_handler(struct _EXCEPTION_RECORD* ExceptionRecord, void * EstablisherFrame, struct _CONTEXT* ContextRecord, void * DispatcherContext) { unsigned i; printf ("Hello from an exception handler\n" ); ContextRecord->Eax = (DWORD)&scratch; return ExceptionContinueExecution; }int main () { DWORD handler = (DWORD)_except_handler; __asm { push handler push FS : [0 ] mov FS : [0 ] , ESP } __asm { mov eax, 0 mov[eax], 1 } printf ("After writing!\n" ); __asm { mov eax, [ESP] mov FS : [0 ] , EAX add esp, 8 } return 0 ; }
这里使用了几句简单的汇编语言,以便和之前所说的对应。实际上__try
操作也是类似的,编译器也会大概如此翻译。
再次总结一下目前已经知道的东西:在异常发生时,操作系统会去fs:[0]
找**_EXCEPTION_REGISTRATION_RECORD**结构,此结构是异常处理函数的链表,即fs:[0]
存放的是下一个异常处理函数结构的地址,fs:[4]
存放的就是当前异常处理函数地址。此链表也就是所谓的SEH链,操作需要此SEH链来遍历所有异常处理函数,来找到可以处理此异常的函数。
初探展开 如下是第二段demo,此demo中我们选择不处理异常,看看会发生什么吧!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 #include <windows.h> #include <stdio.h> EXCEPTION_DISPOSITION __cdecl _except_handler( struct _EXCEPTION_RECORD* ExceptionRecord, void * EstablisherFrame, struct _CONTEXT* ContextRecord, void * DispatcherContext) { printf ("Home Grown handler: Exception Code: %08X Exception Flags %X" , ExceptionRecord->ExceptionCode, ExceptionRecord->ExceptionFlags); if (ExceptionRecord->ExceptionFlags & 1 ) printf (" EH_NONCONTINUABLE" ); if (ExceptionRecord->ExceptionFlags & 2 ) printf (" EH_UNWINDING" ); if (ExceptionRecord->ExceptionFlags & 4 ) printf (" EH_EXIT_UNWIND" ); if (ExceptionRecord->ExceptionFlags & 8 ) printf (" EH_STACK_INVALID" ); if (ExceptionRecord->ExceptionFlags & 0x10 ) printf (" EH_NESTED_CALL" ); printf ("\n" ); return ExceptionContinueSearch; }void HomeGrownFrame (void ) { DWORD handler = (DWORD)_except_handler; __asm { push handler push FS : [0 ] mov FS : [0 ] , ESP } *(PDWORD)0 = 0 ; printf ("I should never get here!\n" ); __asm { mov eax, [ESP] mov FS : [0 ] , EAX add esp, 8 } }int main () { __try { HomeGrownFrame(); } __except (EXCEPTION_EXECUTE_HANDLER) { printf ("Caught the exception in main()\n" ); } return 0 ; }
此demo主要流程就是在函数HomeGrownFrame()
中安装一个SEH并且引发一个错误,但是我们并不处理这个错误,而是交给main()
函数中的__except
来处理。当尝试运行时,会发现如下的输出
1 2 3 Home Grown handler: Exception Code: C0000005 Exception Flags 0 Home Grown handler: Exception Code: C0000027 Exception Flags 2 EH_UNWINDING Caught the Exception in main ()
应当令人惊讶的是,_except_handler
函数似乎调用了两次,而且结果还不相同。
比较一下以“Home Grown Handler”开头的两行,就会看出它们之间有明显的区别。第一次异常标志是0,而第二次是2。这就涉及到展开(Unwinding)。实际上,当一个异常处理回调函数拒绝处理某个异常时,它会被再一次调用。但是这次回调并不是立即发生的。
当异常发生的时候,操作系统会第一次遍历**_EXCEPTION_REGISTRATION_RECORD结构链表**,直到找到处理此异常的函数,然后第二次遍历此链表直到处理此异常的结点,在这两次调用中,操作系统都会调用里面的异常处理函数(由于异常处理函数通常会设置一些过滤操作,所以并不是每次都会执行真正的修复部分),唯一的区别就是第二次调用中异常标志被设置为2。这个值被定义为EH_UNWINDING。
那为什么需要调用两次呢?实际上是操作系统给这个函数做最后的清理,一个绝好的例子是C++类的析构函数。当一个函数的异常处理程序拒绝处理某个异常时,通常执行流程并不会正常地从那个函数退出。现在,想像一个定义了 一个C++类的实例作为局部变量的函数。C++规范规定析构函数必须被调用。这带EH_UNWINDING标志的第二次回调就给这个函数一个机会去做一些类似于调用析构函数和__finally块之类的清理工作。
粗暴地来说,展开就是二次执行异常处理函数,并做一些清理。
编译器层面的SEH 虽然使用几句简单的汇编也可以安装SEH结构,但是在使用上多少还是有不便,于是乎Visual为此进行了设置语法结构,即简单的__try{} __except{}
结构,它的模式是这样的
1 2 3 4 5 6 __try { } __except (过滤器表达式) { }
在汇编层面实际上与之前的是一样的,也就是在进入一个__try
块之前一定会看到类似的语句
1 MOV DWORD PTR FS:[00000000 ],ESP
也就是安装SEH到fs:[0]
处,与之前的知识是一致的
但实际上,Visual的__try
结构是扩展了的,它往里面_EXCEPTION_REGISTRATION_RECORD
结构添了点新东西。
1 2 3 4 5 6 7 8 9 struct _EXCEPTION_REGISTRATION_RECORD { struct _EXCEPTION_REGISTRATION *prev ; void (*handler)( PEXCEPTION_RECORD, PEXCEPTION_REGISTRATION, PCONTEXT, PEXCEPTION_RECORD); struct scopetable_entry *scopetable ; int trylevel; };
此结构的第一个和第二成员与之前的是一样的,后面三个域:scopetable(作用域表)、trylevel和_ebp是新增加的。scopetable域指向一个scopetable_entry结构数组,而trylevel域实际上是这个数组的索引。最后一个域_ebp,是EXCEPTION_REGISTRATION结构创建之前栈帧指针(EBP)的值。
还有一个结构与此相关,也是Visual真正使用的结构CPPEH_RECORD
1 2 3 4 5 struct CPPEH_RECORD { DWORD old_esp; EXCEPTION_POINTERS *exc_ptr; struct _EXCEPTION_REGISTRATION_RECORD registration ; }
此处的old_esp
是真正开始执行正常函数操作时的esp
同时保留一个DWORD来存储EXCEPTION_POINTERS
结构,这个结构其实就是调用GetExceptionInformation 所返回的指针,如下是此结构的定义
1 2 3 4 typedef struct _EXCEPTION_POINTERS { PEXCEPTION_RECORD ExceptionRecord; PCONTEXT ContextRecord; } EXCEPTION_POINTERS, *PEXCEPTION_POINTERS;
说了这么多不如直接来看看Visual是怎么做的吧!
下面给出源码和进入__try
之前的反汇编
1 2 3 4 5 6 7 8 9 10 11 12 13 int main () { __try { } __except (EXCEPTION_EXECUTE_HANDLER) { } return 0 ;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 push ebp mov ebp,esp push 0F FFFFFFEh push 539228 h push offset _except_handler4 (0533 DB0h) mov eax,dword ptr fs:[00000000 h] push eax add esp,0F FFFFF38h push ebx push esi push edi lea edi,[ebp-18 h] xor ecx,ecx mov eax,0 CCCCCCCCh rep stos dword ptr es:[edi] mov eax,dword ptr [__security_cookie (053 A004h)] xor dword ptr [ebp-8 ],eax xor eax,ebp push eax lea eax,[ebp-10 h] mov dword ptr fs:[00000000 h],eax mov dword ptr [ebp-18 h],esp mov ecx,offset _EF1F479B_SEH@cpp (053 C0A2h) call @__CheckForDebuggerJustMyCode@4 (053132F h)
这里给出栈的分布图,就会更加明朗了
现在安装SEH后的栈分布图知道了,就可以具体研究那些新增成员的含义了。
如下是_SCOPETABLE_ENTRY
结构定义
1 2 3 4 5 typedef struct _SCOPETABLE_ENTRY { DWORD EnclosingLevel; PVOID FilterFunc; PVOID HandlerFunc; }
这里的FilterFunc
就是except
里面的过滤表达式,它在这里充当异常筛选的作用,可以完成任何想要完成的工作,只要最后反回的结果符号要求即可,其返回值
1 2 3 #define EXCEPTION_EXECUTE_HANDLER 1 #define EXCEPTION_CONTINUE_SEARCH 0 #define EXCEPTION_CONTINUE_EXECUTION -1
HandlerFunc
就是之前的异常处理函数了。
那么编译器为什么要选择对结构进行扩展呢?其原因是编译器并不会对每个__try
块都生成一个EXCEPTION_REGISTRATION_RECORD
结构,而是不管有多层嵌套调用的__try
块都只生成一个EXCEPTION_REGISTRATION_RECORD
结构并挂入线程的异常链表。对于那些不同__except
块代码则用一个代理函数处理,这个代理函数就是扩展结构_EXCEPTION_REGISTRATION_RECORD
的_except_handler4
函数,而我们自己写的__except
代码则被存储在scopetable
数组中。
换句话说,编译器将多层的__try
块进行抽象,使用_except_handler4
来处理所有的__except
代码,在scopetable
数组中存储了相应层级的FilterFunc
、HandlerFunc
,而扩展结构中的TryLevel
就是这个数组的索引,_except_handler4
就可以通过这些结构来调用相应的函数。
再谈展开 前面提及展开就是给函数第二次机会,让他清理一下自己的东西,并且把SEH的链表头设置为当前的SEH。这一部分是操作系统自动完成的,当然我们也可以手动进行展开操作,微软提供一个APIRtlUnwind
可以完成这个操作,这个函数声明如下
1 RtlUnwind(VirtualTargetFrame,TargetPC,ExceptionRecord,ReturnValue)
参数说明:
VirtualTargetFrame:展开时最后SEH停止与回调函数对应的EXCEPTION_REGISTRATION_RECORD指针,即希望在哪个回调函数前展开调用停止
TargetPC:调用RtlUnwind返回后应执行指令的地址,如果0,则自然返回RtlUnwind调用后下一条指令
ExceptionRecord:当前异常EXCEPTION_RECORD结构
ReturnValue:返回值,通常不使用
实际上大多时候并不需要自己手动调用,微软也并没有十分公开此API,由操作系统自动调用即可。
例子 以今年moectf为例来解说一下吧
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 int __cdecl main_0 (int argc, const char **argv, const char **envp) { char v4; __CheckForDebuggerJustMyCode(&unk_5EC063); puts ("Welcome to moectf2023!!! Now you find YunZh1Jun's revenge!!!" ); puts ("Do you know TEA(an encryption algorithm)? Do you know unwind in SEH? " ); puts ("I believe you can understand them! So let me check your flag~" ); sub_5E10CD("Input:" , v4); sub_5E13CA("%64s" , (char )&flag); MEMORY[0 ] = 0 ; sub_5E10FF(); puts ("Right flag! Have fun in moectf2023~" ); return 0 ; }
这是主函数的反汇编,如果使用IDA打开会发现大红大红的MEMORY[0] = 0
,实际上就是故意引发错误来使用SEH,IDA并不会把__except
内容给反汇编出来,所以必须看汇编代码,如下是相关的汇编代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 .text:005E5908 ; __try { .text:005E5908 mov [ebp+ms_exc.registration.TryLevel], 0 .text:005E590 F mov large dword ptr ds:0 , 0 .text:005E590 F ; } .text:005E5919 mov [ebp+ms_exc.registration.TryLevel], 0F FFFFFFEh .text:005E5920 jmp short loc_5E597A .text:005E5928 loc_5E5928: .text:005E5928 ; __except(loc_5E5922) .text:005E5928 mov esp, [ebp+ms_exc.old_esp] .text:005E592 B push offset aDx3906 ; "DX3906" .text:005E5930 push offset flag .text:005E5935 call enc .text:005E593 A add esp, 8 .text:005E593 D push offset aDoctor3 ; "doctor3" .text:005E5942 push offset dword_5EA580 .text:005E5947 call enc .text:005E594 C add esp, 8 .text:005E594 F push offset aFux1aoyun ; "FUX1AOYUN" .text:005E5954 push offset unk_5EA588 .text:005E5959 call enc .text:005E595 E add esp, 8 .text:005E5961 push offset aR3verier ; "R3verier" .text:005E5966 push offset dword_5EA590 .text:005E596 B call enc .text:005E5970 add esp, 8 .text:005E5973 mov [ebp+ms_exc.registration.TryLevel], 0F FFFFFFEh
其中//
所注释的语句都是IDA自动识别添加的,可以通过这几个注释语句很明显的看到__try{} __except{}
结构,而__except
括号中的代码就应该是异常筛选函数了,这题没有使用异常筛选函数,均是直接处理异常。
所以loc_5E5928
处代码就是相关的异常处理函数了,此异常的主要代码就在这里面,所使用的加密算法就是普通的TEA,这不是本文主要分析内容且相关加解密也很简单就不再赘述。
继续往下看
1 2 3 4 5 6 7 8 9 10 11 12 13 loc_5E597A: .text:005E597 A ; __try { .text:005E597 A mov [ebp+ms_exc.registration.TryLevel], 1 .text:005E5981 call sub_5E10FF .text:005E5981 ; } .text:005E5986 mov [ebp+ms_exc.registration.TryLevel], 0F FFFFFFEh .text:005E598 D jmp short loc_5E5A01 loc_5E5995: .text:005E5995 ; __except(loc_5E598F) .text:005E5995 mov esp, [ebp+ms_exc.old_esp] .text:005E5998 mov [ebp+var_20], 0 .text:005E599 F jmp short loc_5E59AA
显然此时的关键函数是__try
块里的sub_5E10FF
,跟进去查看
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 .text:005E1820 var_E4= dword ptr -0E4 h .text:005E1820 var_10= byte ptr -10 h .text:005E1820 var_C= dword ptr -0 Ch .text:005E1820 var_4= dword ptr -4 .text:005E1820 .text:005E1820 push ebp .text:005E1821 mov ebp, esp .text:005E1823 sub esp, 0 D0h .text:005E1829 push ebx .text:005E182 A push esi .text:005E182 B push edi .text:005E182 C lea edi, [ebp+var_10] .text:005E182 F mov ecx, 4 .text:005E1834 mov eax, 0 CCCCCCCCh .text:005E1839 rep stosd .text:005E183 B mov eax, ___security_cookie .text:005E1840 xor eax, ebp .text:005E1842 mov [ebp+var_4], eax .text:005E1845 mov [ebp+var_C], offset sub_5E12FD .text:005E184 C push [ebp+var_C] .text:005E184 F push large dword ptr fs:0 .text:005E1856 mov large fs:0 , esp .text:005E185 D int 3 ; Trap to Debugger .text:005E185 E mov eax, [esp+0E4 h+var_E4] .text:005E1861 mov large fs:0 , eax .text:005E1867 add esp, 8 .text:005E186 A pop edi .text:005E186 B pop esi .text:005E186 C pop ebx .text:005E186 D mov ecx, [ebp+var_4] .text:005E1870 xor ecx, ebp ; StackCookie .text:005E1872 call j_@__security_check_cookie@4 ; __security_check_cookie(x) .text:005E1877 add esp, 0 D0h .text:005E187 D cmp ebp, esp .text:005E187 F call j___RTC_CheckEsp .text:005E1884 mov esp, ebp .text:005E1886 pop ebp .text:005E1887 retn
让我们把目光集中到地址5E1845处
1 2 3 4 5 .text:005E1845 mov [ebp+var_C], offset sub_5E12FD .text:005E184 C push [ebp+var_C] .text:005E184 F push large dword ptr fs:0 .text:005E1856 mov large fs:0 , esp .text:005E185 D int 3 ; Trap to Debugger
这几句汇编是否觉得很熟悉,这不就是SEH安装流程嘛!。先回调函数压栈,再压入当前的fs:[0]
,然后再将上一个SEH地址存储到fs:[0]
,最后引发一个断点异常,所以此时关键就是那个回调函数sub_5E12FD
现在就来分析一下这个回调函数吧
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 .text:005E1 B50 push ebp .text:005E1 B51 mov ebp, esp .text:005E1 B53 sub esp, 0 C0h .text:005E1 B59 push ebx .text:005E1 B5A push esi .text:005E1 B5B push edi .text:005E1 B5C mov edi, ebp .text:005E1 B5E xor ecx, ecx .text:005E1 B60 mov eax, 0 CCCCCCCCh .text:005E1 B65 rep stosd .text:005E1 B67 mov ecx, offset unk_5EC063 .text:005E1 B6C call j_@__CheckForDebuggerJustMyCode@4 ; __CheckForDebuggerJustMyCode(x) .text:005E1 B71 push offset aDx3906 ; "DX3906" .text:005E1 B76 push offset dword_5EA598 .text:005E1 B7B call enc .text:005E1 B80 add esp, 8 .text:005E1 B83 push offset aDoctor3 ; "doctor3" .text:005E1 B88 push offset unk_5EA5A0 .text:005E1 B8D call enc .text:005E1 B92 add esp, 8 .text:005E1 B95 push offset aFux1aoyun ; "FUX1AOYUN" .text:005E1 B9A push offset unk_5EA5A8 .text:005E1 B9F call enc .text:005E1 BA4 add esp, 8 .text:005E1 BA7 push offset aR3verier ; "R3verier" .text:005E1 BAC push offset dword_5EA5B0 .text:005E1 BB1 call enc .text:005E1 BB6 add esp, 8 .text:005E1 BB9 mov eax, 1 .text:005E1 BBE pop edi .text:005E1 BBF pop esi .text:005E1 BC0 pop ebx .text:005E1 BC1 add esp, 0 C0h .text:005E1 BC7 cmp ebp, esp .text:005E1 BC9 call j___RTC_CheckEsp .text:005E1 BCE mov esp, ebp .text:005E1 BD0 pop ebp .text:005E1 BD1 retn
这个处理函数所执行的操作也是TEA加密,但是我们关注最后的返回值是1,也即ExceptionContinueSearch
,也就是说操作系统认为这个处理函数没有真正处理异常,这就需要向外去找__except
块代码来处理,但是在调用__except
之前,操作系统还会再调用以一次这个函数,也就是我们之前提及的展开,可以把一段代码与初探展开的代码比较,会发现有异曲同工之妙。所以这一块涉及的密文需要两次TEA解密,这也是此题真正的考点。
后续的内容都比较简单了,就是比较密文,相同即可得到flag,只要按部就班根据TEA解密就行了,不过需要注意的是后半部分的flag需要两次TEA解密,而且TEA使用的K数组是DWORD型的。
完结!
参考资料:
1.《加密与解密》—— Windows下异常处理
2.深入解析结构化异常
3.《逆向工程核心原理》—— SEH部分