第48章 SEH

本文最后更新于:2022年5月27日 下午

第48章 SEH

SEH是Windows操作系统默认的异常处理机制。逆向分析中,SEH除了基本的异常处理功能外,还大量应用于反调试程序。本章将学习SEH相关知识。

48.1 SEH

基本说明

SEH是Windows操作系统提供的异常处理机制,在程序源代码中使用__try、__except、__finally关键字来具体实现。本章我们将从代码逆向分析角度来介绍SEH,并通过练习示例详细了解SEH的基本工作原理及其在反调试中的具体使用方法。

SEH与C中的try、catch异常处理具有不同结构,请各位不要混淆。从时间上看, 与C的try、catch异常处理相比,微软先创建出了 SEH机制,然后才将它搭载到VC中。所以SEH是一种从属于VC开发工具和Windows操作系统的异常处理机制。

48.2 SEH练习示例#1

先简单介绍练习示例程序seh.exe,该程序故意触发了内存非法访问(Memory Access Violation)异常,然后通过SEH机制来处理该异常。并且使用PEB信息向程序添加简单的反调试代码,使程序在正常运行与调试运行时表现出不同的行为动作。

示例程序seh.exe并没有相应的源代码,其编写过程如下:先使用VC++编写一个空的main()函数,然后选择合适的选项编译,生成一个不执行任何动作的PE文件。使用OllyDbg打开该文件,借助调试器的汇编功能添加汇编代码,最终保存为seh.exe文件。

本示例程序在Windows XP&7(32位)中正常运行。

48.2.1正常运行

seh.exe程序非常简单,双击运行,弹出一个消息框,显示“Hello:)” 字符串,如图48-1所示。

图48-1 运行seh.exe弹出消息框

表面上程序正常运行,其实进程内部已经发生了异常,但由于使用SEH机制进行了处理,所以程序运行正常

48.2.2调试运行

使用OllyDbg调试器打开seh.exe示例程序,如图48-2所示。

图48-2 使用OllyDbg调试器打开seh.exe

发生异常导致调试暂停

在OllyDbg中打开seh.exe程序后按F9键运行,发生非法访问异常后暂停调试,如图48-3所示。

图48-3 调试中发生非法访问异常

401019地址处添加的MOV DWORD PTR DS:[EAX],1指令用来触发异常,当前EAX寄存器的值为0,所以该指令的实际含义是向内存地址0处写入值1。但试图向未分配的内存地址0处写入某个值时,就会触发内存非法访问异常。

内存地址0虽然属于seh.exe进程的用户内存区域,但由于是未分配的空间,所以无法随意访问。查看OllyDbg的内存映射(View-Memory菜单),可以看到进程中内存地址0被标识为未分配区域(参考图48-4)。

图48-4 内存映射

如上所述,访问未分配的内存区域时,就会触发非法访问异常。

那么,为什么被调试进程发生异常时会暂停呢?这只是意味着运行的时候很正常,下面仔细分析原因。

发生异常时调试器运行

在图48-3中查看OllyDbg的状态窗口,可以看到如下警告语句:

“Access violation when writing to [00000000] - use Shift+F7/F8/F9 to pass exception to program”

即在内存0处发生写入异常,若想将异常抛给程序,请使用Shift+F7/F8/F9组合键。

其中,“将异常抛给程序”是什么意思呢?暂且放下诸多疑问,根据调试器给出的提示按Shift+F9键继续运行程序。调试运行开始后弹岀消息对话框,如图48-5所示。

图48-5 调试器运行时弹出的消息框

从图48-5中弹出的消息框可以看到,它与程序正常运行时弹岀的对话框是不同的,消息内容为“检测到调试器”(我向程序中插入了一段简单的调试器检测代码)。以上练习示例的目的就在于,观察程序在正常运行与调试运行时表现出的不同行为。其实,程序在这2种形式运行下使用的异常处理方式是不同的(下面会讲解)。以上就是逆向分析中常用的“利用SEH机制的反调试技术”。

接下来详细讲解OS的异常与异常处理机制,还要仔细了解SEH具体的实现方法,以及在调试器中处理异常的方法。

调试运行练习示例时,有时调试器不会像图48-3那样暂停,而会一直正常运行。这是因为设置了OllyDbg的选项,或者安装了某个特定插件。遇到这种情况请参考后面“设置OllyDbg选项”的内容。

48.3 OS的异常处理方法

通过前面的学习我们了解到,同一程序(seh.exe)在正常运行与调试运行时表现岀的行为动
作是不同的。这是由Windows OS异常处理方法的不同造成的。

48.3.1 正常运行时的异常处理方法

进程运行过程中若发生异常,OS会委托进程处理。若进程代码中存在具体的异常处理(如SEH异常处理器)代码,则能顺利处理相关异常,程序继续运行。但如果进程内部没有具体实现SEH,那么相关异常就无法处理,OS就会启动默认的异常处理机制,终止进程运行(参考图48-6)。

图48-6 Windows 7的默认异常处理机制

48.3.2 调试运行时的异常处理方法

调试运行中发生异常时,处理方法与上面有些不同。若被调试进程内部发生异常,OS会首先把异常拋给调试进程处理。调试器几乎拥有被调试者的所有权限,它不仅可以运行、终止被调试者,还拥有被调试进程的虚拟内存、寄存器的读写权限。需要特别指岀的是,被调试者内部发生的所有异常(错误)都由调试器处理。所以调试过程中发生的所有异常(错误)都要先交由调试器管理(被调试者的SEH依据优先顺序推给调试器)。像这样,被调试者发生异常时,调试器就会暂停运行,必须采取某种措施来处理异常,完成后继续调试。遇到异常时经常采用的几种处理方法如下所示。

(1)直接修改异常:代码、寄存器、内存

被调试者发生异常时,调试器会在发生异常的代码处暂停,此时可以通过调试器直接修改有问题的代码、内存、寄存器等,排除异常后,调试器继续运行程序。

遇到图48-3中的异常时,采用直接修改异常的方法进行如下处理。

  • 由于EAX寄存器所指的地址值错误,所以只要把EAX寄存器的值修改为有效的内存地址即可。
  • 由于401019地址处的代码触发了异常,使用OllyDbg的汇编(Space)或编辑(Ctrl+E)功能将相关代码修改为NOP指令,运行后也可排除异常。
  • 也可以使用OllyDbg的New Origin here(Ctrl+Gray *)功能改变程序的运行路径(因为无法直接修改EIP寄存器,所以需要借助该功能修改)。

请不要随意使用这些修改方法,必须在明确知道程序错误的情形下才能使用。

(2) 将异常抛给被调试者处理
如果被调试者内部存在SEH(异常处理函数)能够处理异常,那么异常通知会发送给被调试者,由被调试者自行处理。这与程序正常运行时的异常处理方式是一样的。前面的seh.exe练习示例中,使用OllyDbg中的Shift+F7/F8/F9命令(StepInto/StepOver/Run)可以直接将当前异常抛还给被调试者。

(3) OS默认的异常处理机制
若调试器与被调试者都无法处理(或故意不处理)当前发生的异常,则OS的默认异常处理机制会处理它,终止被调试进程,同时结束调试。

48.4异常

学习异常处理前,有必要了解操作系统中定义的异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
STILL_ACTIVE						(0x00000103L)
EXCEPTION_ACCESS_VIOLATION (0xC0000005L)
EXCEPTION_DATATYPE_MISALIGNMENT (0x80000002L)
EXCEPTION_BREAKPOINT (0x80000003L)
EXCEPTION_SINGLE_STEP (0x80000004L)
EXCEPTION_ARRAY_BOUNDS_EXCEEDED (0xC000008CL)
EXCEPTION_FLT_DENORMAL_OPERAND (0xC000008DL)
EXCEPTION_FLT_DIVIDE_BY_ZERO (0xC000008EL)
EXCEPTION_FLT_INEXACT_RESULT (0xC000008FL)
EXCEPTION_FLT_INVALID_OPERATION (0xC0000090L)
EXCEPTION_FLT_OVERFLOW (0xC0000091L)
EXCEPTION_FLT_STACK_CHECK (0xC0000092L)
EXCEPTION_FLT_UNDERFLOW (0xC0000093L)
EXCEPTION_INT_DIVIDE_BY_ZERO (0xC0000094L)
EXCEPTION_INT_OVERFLOW (0xC0000095L)
EXCEPTION_PRIV_INSTRUCTION (0xC0000096L)
EXCEPTION_IN_PAGE_ERROR (0xC0000006L)
EXCEPTION_ILLEGAL_INSTRUCTION (0xC000001DL)
EXCEPTION_NONCONTINUABLE_EXCEPTION (0xC0000025L)
EXCEPTION_STACK_OVERFLOW (0xC00000FDL)
EXCEPTION_INVALID_DISPOSITION (0xC0000026L)
EXCEPTION_GUARD_PAGE (0x80000001L)
EXCEPTION_INVALID_HANDLE (0xC0000008L)
CONTROL_C_EXIT (0xC000013AL)

以上异常列表中,我们调试时会经常接触5种最具代表性的异常,接下来分别介绍(其他异常请参考MSDN)。

48.4.1 EXCEPTION_ACCESS_VIOLATION(C0000005)

试图访问不存在或不具访问权限的内存区域时,就会发生EXCEPTION_ACCESS_VIOLATION(非法访问异常,该异常最常见)。

MOV DWORD PTR DS:[0], 1

→ 内存地址0处是尚未分配的区域。

ADD DWORD PTR DS:[401000],1

→ .text节区的起始地址401000仅具有“读”权限(无“写”权限)。

XOR DWORD PTR DS:[80000000],1234

→ 内存地址80000000属于内核区域,用户模式下无法访问。

48.4.2 EXCEPTION_BREAKPOINT(80000003)

在运行代码中设置断点后,CPU尝试执行该地址处的指令时,将发生EXCEPTION_BREAKPOINT异常。调试器就是利用该异常实现断点功能的。下面仔细了解实现方法。

INT3

设置断点命令对应的汇编指令为INT3,对应的机器指令(IA-32指令)为0xCC。CPU运行代码的过程中若遇到汇编指令INT3,则会触发EXCEPTION_BREAKPOINT异常。在OllyDbg调试器某个地址处设置好断点后,确认该地址处的指令是否真会变为INT3(0xCC)。在OllyDbg中再次打开seh.exe文件,转到401000地址处(011+0),按F2键设置好断点,如图48-7所示。

图48-7 在401000地址处设置断点

从图48-7中可以看到,虽然401000地址处设置了断点,但是该地址处的指令并未变为INT3(汇编指令),也未由 “68” 变为“CC”(机器指令)。为什么跟前面讲的不一样呢?其实,这是OllyDbg耍的一个小花招。由于在OllyDbg中按F2键设置的断点是用户用来调试的临时断点(User Temporary Break Point),所以不需要在调试画面中显示。在代码与内存中将用户设置的临时断点全部显示出来,反而会大大降低代码的可读性,给代码调试带来不便。换言之,实际进程内存中401000地址处的指令 “68” 已经被更改为“CC”,但是为了调试方便,OllyDbg并未将其显示出来。将进程内存转储之后可以看到更改后的CC指令,先使用PE Tools工具转储进程内存,如图48-8所示。以seh_dump.exe文件名保存转储文件后,使用Hex Editor工具打开,查看图48-7中位于401000地址处(文件偏移地址为1000)的指令,如图48-9所示。

图48-8 使用PE Tools工具转储seh.exe进程内存

seh_dump.exe 的 ImageBase 为400000,所以 VA 401000对应 RVA 1000(RVA=VAImageBase)。由于seh_dump.exe是直接由seh.exe进程内存转储而来的,所以RVA就 是RAW(文件偏移量)。

查看文件偏移1000处,可以看到机器指令CC(INT3指令-Breakpoint)。也就是说,图48-7中进程内存的实际值为0xCC,但是OllyDbg调试器在显示时先将其更改为原来的操作码 “68”,然后再显示出来。

以上就是断点内部工作原理,灵活运用这一原理能为程序调试带来很大便利。比如,使用Hex Editor工具打开PE文件,修改EP地址对应的文件偏移处的第一个字节为CC,然后运行该PE文件就会发生EXCEPTION_BREAKPOINT异常,经过OS的默认异常处理后终止运行。若在系统注册表中将默认调试器设置为OllyDbg,那么发生以上异常时OS会自动运行OllyDbg调试器,附加发生异常的进程(第八部分中将详细讲解利用这一原理调试的方法)。

48.4.3 EXCEPTION_ILLEGAL_INSTRUCTION(C000001D)

CPU遇到无法解析的指令时引发该异常。比如 “0FFF”指令在x86 CPU中未定义,CPU遇到该指令将引发EXCEPTION_ILLEGAL_INSTRUCTION异常。

下面使用OllyDbg调试器进行简单测试。首先使用OllyDbg调试器打开seh.exe,在EP代码地址处直接修改指令为0FFF,然后运行程序将引发EXCEPTION_ILLEGAL_INSTRUCTION异常,调试器暂停运行,如图48-10所示。

图48-10 EXCEPTION_ILLEGAL_INSTRUCTION异常

48.4.4 EXCEPTION_INT_DIVIDE_BY_ZERO(C0000094)

INTEGER(整数)除法运算中,若分母为0(即被0除),则引发EXCEPTION_INT_DIVIDE_BY_ZERO异常。编写应用程序时偶尔会发生该异常,分母为变量时,该变量在某个瞬间变为0,执行除法运算就会引发EXCEPTION_INT_DIVIDE_BY_ZERO异常。下面进行简单测试,首先在OllyDbg调试器中打开seh.exe,使用汇编指令(Space)在EP代码处修改代码,即图48-11粗线框中的代码,然后运行程序。

「图48-11 EXCEPTION_INT_DIVIDE_BY_ZERO异常

401220地址处的DIV ECX指令执行EAX/ECX运算,然后将商保存到EAX寄存器。但由于此时ECX寄存器的值为0,即除法的分母为0,所以引发EXCEPTION_INT_DIVIDE_BY_ZERO异常,调试器暂停运行。

48.4.5 EXCEPTION_SINGLE_STEP(80000004)

Single Step(单步)的含义是执行1条指令,然后暂停。CPU进入单步模式后,每执行一条指令就会引发EXCEPTION_SINGLE_STEP异常,暂停运行。将EFLAGS寄存器的TF(Trap Flag, 陷阱标志)位设置为1后,CPU就会进入单步工作模式。

关于陷阱标志与单步的详细说明请参考第52章。

48.5 SEH详细说明

48.5.1 SEH 链

SEH以链的形式存在。第一个异常处理器中若未处理相关异常,它就会被传递到下一个异常处理器,直到得到处理。从技术层面看,SEH是由_EXCEPTION_REGISTRATION_RECORD结构体组成的链表。

1
2
3
4
5
typedef struct EXCEPTION REGISTRATION RECORD
{
PEXCEPTION_REGISTRATION_RECORD Next;
PEXCEPTION_DISPOSITION Handler;
} EXCEPTION_REGISTRATION_RECORD, *PEXCEPTION_REGISTRATION_RECORD;

Next成员是指向下一个_EXCEPTION_REGISTRATION_RECORD结构体的指针,Handler成员是异常处理函数(异常处理器)。若Next成员的值为FFFFFFFF,则表示它是链表的最后一个结点。图48-12直观形象地描述了进程SEH链的结构。

图48-12 SEH链

图48-12中共存在3个SEH(异常处理器),发生异常时,该异常会按照(A)→(B)→(C)的顺序依次传递,直到有异常处理器处理。

48.5.2异常处理函数的定义

SEH异常处理函数(SEH函数)定义如下:

1
2
3
4
5
6
7
EXCEPTION_DISPOSITION _except_handler
(
EXCEPTION_RECORD *pRecord,
EXCEPTION_REGISTRATION_RECORD *pFrame,
CONTEXT *pContext,
PVOID pValue
);

异常处理函数(异常处理器)接收4个参数输入,返回名为EXCEPTION_DISPOSITION的枚举类型(enum)。该异常处理函数由系统调用,是一个回调函数,系统调用它时会给出代码48-4中的4个参数,这4个参数中保存着与异常相关的信息。首先,第一个参数是指向EXCEPTION_RECORD结构体的指针,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;
ULONG_PTR ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS];
} EXCEPTION_RECORD, *PEXCEPTION_RECORD;

请注意该结构体中ExceptionCode与ExceptionAddress这2个成员,ExceptionCode成员用来指出
异常类型,ExceptionAddress成员表示发生异常的代码地址。代码48-4中异常处理函数的第三个参
数是指向CONTEXT结构体的指针,CONTEXT结构体的定义如下(供IA-32使用):

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
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
typedef struct _CONTEXT {

//
// The flags values within this flag control the contents of
// a CONTEXT record.
//
// If the context record is used as an input parameter, then
// for each portion of the context record controlled by a flag
// whose value is set, it is assumed that that portion of the
// context record contains valid context. If the context record
// is being used to modify a threads context, then only that
// portion of the threads context will be modified.
//
// If the context record is used as an IN OUT parameter to capture
// the context of a thread, then only those portions of the thread's
// context corresponding to set flags will be returned.
//
// The context record is never used as an OUT only parameter.
//

DWORD ContextFlags;

//
// This section is specified/returned if CONTEXT_DEBUG_REGISTERS is
// set in ContextFlags. Note that CONTEXT_DEBUG_REGISTERS is NOT
// included in CONTEXT_FULL.
//

DWORD Dr0;
DWORD Dr1;
DWORD Dr2;
DWORD Dr3;
DWORD Dr6;
DWORD Dr7;

//
// This section is specified/returned if the
// ContextFlags word contians the flag CONTEXT_FLOATING_POINT.
//

FLOATING_SAVE_AREA FloatSave;

//
// This section is specified/returned if the
// ContextFlags word contians the flag CONTEXT_SEGMENTS.
//

DWORD SegGs;
DWORD SegFs;
DWORD SegEs;
DWORD SegDs;

//
// This section is specified/returned if the
// ContextFlags word contians the flag CONTEXT_INTEGER.
//

DWORD Edi;
DWORD Esi;
DWORD Ebx;
DWORD Edx;
DWORD Ecx;
DWORD Eax;

//
// This section is specified/returned if the
// ContextFlags word contians the flag CONTEXT_CONTROL.
//

DWORD Ebp;
DWORD Eip;
DWORD SegCs; // MUST BE SANITIZED
DWORD EFlags; // MUST BE SANITIZED
DWORD Esp;
DWORD SegSs;

//
// This section is specified/returned if the ContextFlags word
// contains the flag CONTEXT_EXTENDED_REGISTERS.
// The format and contexts are processor specific
//

BYTE ExtendedRegisters[MAXIMUM_SUPPORTED_EXTENSION];

} CONTEXT;

CONTEXT结构体用来备份CPU寄存器的值,因为多线程环境下需要这样做。每个线程内部都拥有1个CONTEXT结构体。CPU暂时离开当前线程去运行其他线程时,CPU寄存器的值就会保存到当前线程的CONTEXT结构体;CPU再次运行该线程时,会使用保存在CONTEXT结构体的值来覆盖CPU寄存器的值,然后从之前暂停的代码处继续执行。通过这种方式,OS可以在多线程环境下安全运行各线程。

众所周知,多线程的实现基于CPU的时间片切分机制(Time-Slicing)。这种机制下,CPU会用一定时间(时间片)依次运行各线程,时间片极短,使多个线程看上去就像在同时运行一样(根据线程的优先级,各线程在获取CPU控制权的次数上有差异)。

异常发生时,执行异常代码的线程就会中断运行,转而运行SEH(异常处理器/异常处理函数),此时OS会把线程的CONTEXT结构体的指针传递给异常处理函数(异常处理器)的相应参数。代码48-6的结构体成员中有1个Eip成员(偏移量:B8)。在异常处理函数中将参数传递过来的CONTEXT.Eip设置为其他地址,然后返回异常处理函数。这样,之前暂停的线程会执行新设置的EIP地址处的代码(反调试中经常采用这一技术,练习示例seh.exe中也采用了该技术,后面会详细分析)。在代码48-4中可以看到异常处理函数的返回值为EXCEPTION_DISPOSITION枚举类型,下面了解一下该类型。

1
2
3
4
5
6
7
typedef enum _EXCEPTION_DISPOSITION
{
ExceptionContinueExecution = 0, // 继续执行代码
ExceptionContinueSearch = 1, // 运行下一个异常处理器
ExceptionNestedException = 2, // 在OS内部使用
ExceptionCollidedUnwind = 3 // 在OS内部使用
} EXCEPTION_DISPOSITION;

异常处理器处理异常后会返回ExceptionContinueExecution(0),从发生异常的代码处继续运行。若当前异常处理器无法处理异常,则返回ExceptionContinueSearch(1),将异常派送到SEH链的下一个异常处理器。

我们在后面会调试seh.exe的异常处理函数(异常处理器)来进一步了解其工作原理。

48.5.3 TEB.NtTib.ExceptionList

通过TTB结构体的NtTib成员可以很容易地访问进程的SEH链,方法非常简单。

如图48-13所示,TEB.NtTib.ExceptionList成员是TEB结构体的第一个成员。FS段寄存器指向段内存的起始地址,TEB结构体即位于此,所以通过下列公式可以轻松获取TEB.NtTib.ExceptionListd的地址。

关于TEB结构体的详细说明请参考第46章。

48.5.4 SEH安装方法

在C语言中使用__try、__except、 __finally关键字就可以很容易地向代码添加SEH。在汇编
语言中添加SEH的方法更加简单,如代码48-8所示。

1
2
3
PUSH @MyHandler				; 异常处理器
PUSH DWORD PTR FS:[0] ; Head of SEH Linked List
MOV DWORD PTR FS:[0],ESP ; 添加链表

看代码48-8就容易理解了。“在程序代码中安装SEH”就是指,将自身的异常处理器添加到已有的SEH链。从技术层面讲,就是将自身的EXCEPTION_REGISTRATION_RECORD结构体链接到EXCEPTION_REGISTRATION_RECORD结构体链表。前面出现的seh.exe程序就是采用上述汇编代码添加的SEH,下面再次调试seh.exe程序以进一步了解添加SEH的方法及其工作原理。

48.6 SEH练习示例 #2(seh.exe)

首先使用OllyDbg调试器打开seh.exe程序,运行到401000地址处(此处为seh.exe程序的main()函数)。我编写的全部代码如图48-14所示。

图48-14 seh.exe代码

位于401000、401005、40100C地址处的3条指令与“SEH安装方法”中讲的汇编指令是一样的。从图48-14中可以看到,新添加的异常处理器就是位于40105A地址处的异常处理函数。

48.6.1 查看SEH链

继续运行代码到401005地址处,查看FS:[0]的值,其值就是SEH链的起始地址,如图48-15所示。

图48-15 FS:[0]=SEH链起始地址

从代码信息窗口中可以看到,FS:[0]=[7FFDF000]=12FF78,其中12FF78就是SEH链的起始地址(即EXCEPTION_REGISTRATION_RECORD结构体链表的起始地址)。在上图的栈窗口中查看地址12FF78,可以发现第一个EXCEPTION_REGISTRATION_RECORD结构体(Next=12FFC4,Handler=402730)。异常处理器地址402730存在于seh.exe进程的代码节区(该异常处理器是VC++生成PE文件时默认添加到其启动函数的,请各位自行查看位于402730地址处的异常处理器代码)。然后转到12FFC4地址处,查看链表中的第二个EXCEPTION_REGISTRATION_RECORD结构体(参考图48-16)。

图48-16 最后一个异常处理器

从图48-16可以看到,第二个结构体的Next成员值为FFFFFFFF,所以第二个EXCEPTION_REGISTRATION_RECORD结构体也是SEH链表的最后一个结构体。异常处理器地址为7717D74D,它位于ntdll.dll模块的代码区域,是OS的默认异常处理器(创建进程时,OS会自动产生默认的SEH)。

48.6.2 添加SEH

运行401005地址处的PUSH DWORD PTRFS:[0]指令(参考图48-15),查看栈窗口,如图48-17所示。

图48-17 栈窗口

栈中新创建了_exception_registration_record结构体。继续执行40100C地址处的MOV DWORD PTR FS:[0],ESP指令(参考图48-15),查看栈窗口,如图48-18所示。

图48-18 添加新的SEH

栈窗口中岀现了新生成的SEH的注释(Next=12FF78, Handler=40105A)。新的异常处理器(40105A)就这样添加到SEH链。

只看代码48-6会感觉很难,实际调试并查看栈就比较容易理解。

OllyDbg调试器中提供了查看SEH链的功能。在OllyDbg主菜单中依次选择View-SEH Chain项目,即可打开SEH链查看窗口。

如图48-19所示,在SEH链窗口中可以看到添加在顶端的异常处理器(40105A)。

图48-19 SEH链查看窗口

48.6.3发生异常

如果执行401019地址处的MOV DWORD PTR DS:[EAX],1指令(参考图48-14),就会引发EXCEPTION_ACCESS_VIOLATION异常(该异常已做说明,此处不再赘述)。此时程序处在调试之中,根据异常处理的顺序,OS会把控制权交给调试器(异常处理器(40105A)未运行)。在40105A地址处设置断点,然后按Shift+F9组合键,再将异常派送给被调试进程(seh.exe),调试器暂停在设置的断点处(40105A)。

如图48-20所示,被调试者会调用注册在自身SEH链中的异常处理器来处理异常。设置好断点后,接下来即可调试异常处理器。

图48-20 异常处理器(40105A)

48.6.4 查看异常处理器参数

调试SEH时,栈中存储的参数(关于参数的说明请参考代码48-4)如图48-21所示。

图48-21 调用异常处理器时的栈

第一个参数(ESP+4)是指向EXCEPTION_RECORD结构体的指针pReord(12FAC0),查看结构体中的数据,如图48-22所示。

图48-22 EXCEPTION_RECORD结构体

参考图48-22以及代码48-5中关于EXCEPTION_RECORD结构体的定义可知,ExceptionCode(pRecord+0)为 C0000005(EXCEPTION_ACCESS_VIOLATION),发生异常的代码地址ExceptionAddress为401019(对照图48-14可知发生异常的代码地址是准确的)。

第二个参数(ESP+8)是指向EXCEPTION_REGISTRATION_RECORD结构体的指针(pFrame),其值为12FF3C,它是SEH链的起始地址。

第三个参数(ESP+C)是指向CONTEXT结构体的指针pContext(12FADC),查看指针pContext所指的地址空间。

如图48-23所示,CONTEXT是一个非常大的结构体(大部分成员的值为NULL)。其中需要特别注意的是Eip成员,它位于从结构体偏移B8的位置,存储着发生异常的代码地址。

图48-23 CONTEXT结构体

最后一个参数pValue(ESP+10)供系统内部使用,可以忽略。

48.6.5调试异常处理器

40105A地址处的异常处理器(参考图48-20)中存在着调试器检测代码。虽然简单,却是非常具有代表性的反调试代码。下面仔细分析一下。

1
0040105A      8B7424 0C     MOV ESI,DWORD PTR SS:[ARG.3]	; ESI = pContext

[ESP+C]是异常处理器第三个参数pContext的值。以上命令用来将pContext地址(12FC58)传送到ESI寄存器。

1
0040105E      64:A1 3000000 MOV EAX,DWORD PTR FS:[30]	;EAX = address of PEB

上述指令用于将FS:[30]的值传送给EAX寄存器,FS:[30]就是PEB结构体的起始地址(7FFDD000,参考图48-24)。

图48-24 PEB的起始地址

1
00401064      8078 02 01    CMP BYTE PTR DS:[EAX+2],1

上述指令用于读取[EAX+2]地址中的1个字节值,然后与1比较。由于EAX当前保存着PEB的起始地址,所以[EAX+2]指的是PEB.BeingDebugged成员。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
+0x000 InheritedAddressSpace : UChar
+0x001 ReadImageFileExecOptions : UChar
+0x002 BeingDebugged : UChar
+0x003 BitField : UChar
+0x003 ImageUsesLargePages : Pos 0, 1 Bit
+0x003 IsProtectedProcess : Pos 1, 1 Bit
+0x003 IsLegacyProcess : Pos 2, 1 Bit
+0x003 IsImageDynamicallyRelocated : Pos 3, 1 Bit
+0x003 SkipPatchingUser32Forwarders : Pos 4, 1 Bit
+0x003 SpareBits : Pos 5, 3 Bits
+0x004 Mutant : Ptr32 Void
+0x008 ImageBaseAddress : Ptr32 Void
+0x00c Ldr : Ptr32 _PEB_LDR_DATA
+0x010 ProcessParameters : Ptr32 _RTL_USER_PROCESS_PARAMETERS

从图48-25可以看到,[EAX+2]=[7FFD7002]=PEB.BeingDebugged的值被设置为1,表示进程处于调试状态。

图48-25 PEB.BeingDebugged

1
00401068      75 0C         JNE SHORT 00401076

若上一条CMP命令中的2个比较对象不同,则执行JNZ(Jump if Not Zero)命令跳转。由于PEB.BeingDebugged的值为1,所以不跳转,即不执行该JNZ指令,如图48-26所示

图48-26 JNZ指令

程序非调试运行时,执行此处会跳转到401076地址处。若程序处在调试运行状态,则跳过该JNZ指令,直接执行40106A地址处的指令。

1
0040106A      C786 B8000000 MOV DWORD PTR DS:[ESI+0B8],00401023

由于当前进程处于调试之中,所以会执行上述指令。当前ESI寄存器中保存着CONTEXT结构体的起始地址(pContext=12FC58)。从图48-26可知,[ESI+B8]=[12FD10]=pContext→Eip(当前值为401019)。

也就是说,上述指令用来将pContext→Eip值更改为401023。异常处理器终止时,发生异常的线程会运行401023地址处的代码。如图48-27所示,401023地址处的代码用来弹岀一个消息框,显示 “Debugger Detected:(” 消息文本。

为了方便调试,在401023地址处设置断点。

1
00401074      EB 0A         JMP SHORT 00401080

由于pContext→Eip值已经发生改变,所以执行流跳转到异常处理器的终止代码处(401080)。

1
00401076      C786 B8000000 MOV DWORD PTR DS:[ESI+0B8],00401039	; Entry point

若程序运行在非调试状态下,则执行401068地址处的JNZ指令(参考图48-26),跳转到401076地址处。如上所示,401076地址处的指令用来将pContext→Eip值更改为401039, 401039地址处的代码用来弹出消息对话框,显示“Hello:)” 消息文本(参 考图48-28)。

图48-27 401023地址处的代码

1
2
00401080      33C0          XOR EAX,EAX
00401082 C3 RETN

最后两条指令中先将返回值(EAX)设置为0,然后异常处理器返回。返回值0代表EXCEPTION_CONTINUE_EXECUTION,表示异常得到处理,相关线程可以继续运行(参考代码48-7)。

本练习示例(seh.exe)的目的在于向各位展示使用SEH进行反调试的技术。所以在代码中故意引发了异常,然后在SEH中根据调试与否修改了运行分支。若熟悉了该技术,调试压缩器/保护器类的文件时会非常有帮助。

运行到401082地址处的RETN指令时,控制权被返回至ntdll.dll模块中的代码区域,它属于系统区域,所以在OllyDbg中按F9运行键后,调试会在401023地址处(设置有断点)暂停(参考图48-27)。

使用StepOver(F8)指令使调试运行到401031地址处的CALL指令,弹出一个消息框。按“确定”按钮关闭消息框后,执行401037地址处的JMP SHORT 40104D指令,跳转到删除SEH的代码处(40104D)。

48.6.6 删除 SEH

在程序终止前删除已注册的SEH,如图48-29所示。

图48-29 删除SEH的代码

调试运行到40104D地址处查看栈,EXCEPTION_REGISTRATION_RECORD结构体存储在其中(12FF3C),该结构体是SEH链中最初运行的异常处理器。40104D处的POP DWORD PTR FS:[0]指令用来读取栈值(12FF78),并将其放入FS:[0]。FS:[0]是TEB.NtTib.ExceptionList,12FF78就是下一个SEH的起始地址。执行该命令后,前面注册的SEH(12FF3C)被从SEH链中删除。然后
执行401054地址处的ADD ESP,4指令,将栈中的异常处理器地址(40105A)也删除。请各位反复调试,查清栈中数据变化的情况。

48.7 设置OllyDbg选项

我们已经学习了SEH的工作原理,并通过练习示例了解了利用SEH进行反调试的技术。本章最重要、最关键的内容概括如下:

通过处理使被调试者将自身异常首先发送给调试器。

上述原理作用下,程序在正常运行与调试运行时有不同的分支代码,借助SEH实现的反调试技术非常多,这为代码调试带来诸多不便,使调试更加困难。那么,有没有更方便的调试方法呢?OllyDbg调试器提供了调试选项,调试中的程序发生异常时,调试器不会暂停,会自动将异常派送给被调试者(看上去与正常运行一样)。在OllyDbg的菜单栏中选择Options - Debugging options菜单(快捷键Alt+O),打开Debugging options对话框,如图48-30所示。

图48-30 Debugging options菜单

然后在Debugging options对话框中选择Exceptions选项卡。

如图48-31所示,Exceptions选项卡包含多个选项,下面逐一介绍。

图48-31 Debugging options对话框的Exceptions选项卡

48.7.1 忽略KERNEL32中发生的内存非法访问异常

复选Ignore memory access violations in KERNEL32选项后,kernel32.dll模块中发生的内存非法访问异常都会被忽略(该选项默认处于选中状态,保持不变即可)。

48.7.2 向被调试者派送异常

Ignore(pass to program)following exceptions选项下存在多个异常复选框,如图48-32所示。

图48-32 Ignore(pass to program)following exceptions选项

Ignore(pass to program)following exceptions选项共有6个异常选项,前5个已经介绍过了。单击左侧复选框选中后,发生相应异常时OllyDbg调试器就会忽略该异常,并且将其派送给被调试者。

接下来简单介绍All FPU exceptions选项。FPU(Floating Point Unit,浮点运算单元)是专门 用于浮点数运算的处理器,它有一套专用指令,与普通x86指令的形态结构不同。复选All FPU exceptions选项后,处理FPU指令过程发生异常时,调试器会无条件将异常派送给被调试者处理。

48.7.3其他异常处理

Exceptions选项卡中还有一个Ignore also following custom exceptions for ranges选项,如图48-31所示。复选该选项后,用户可以直接添加(或删除)其他各种异常,发生这些异常时,调试器会将它们直接派送给被调试者处理。调试时灵活运用OllyDbg的Exceptions选项,可以在不暂停调试器的前提下自动规避使用SEH实现的反调试“花招”,从而继续调试。

48.7.4简单练习

首先在OllyDbg调试器中打开seh.exe程序,然后在Exceptions选项卡中进行相应设置,如图48-33所示。

图48-33 忽略6异常

如上设置后,程序在调试运行时发生以上6种异常时,调试器会忽略,将它们直接派送给被调试者。所以seh.exe程序中发生的EXECEPTION_ACCESS_VIOLATION异常会由自身的SEH处 理(调试过程不会暂停)。关闭Debugging options对话框后,按F9运行程序,直接弹岀“Debugger detected:消息框。

图48-34 消息框“Debugger detected:(”

程序运行过程发生异常时,调试器会将它们派送给被调试者的SEH处理,调试器不会暂停而直接弹出图48-34所示的消息框。通过SEH内部的调试器检测代码(PEB.BeingDebugged)弹出与正常运行时完全不同的消息框(相关解决方法请参考第50章)。

我并未在一开始的时候就介绍OllyDbg调试器的Exceptions选项,而是将它放在本章最后,目的在于先向各位讲解SEH的内部工作原理。不理解内部工作原理,只学习相关工具的使用技巧,就如同在沙滩上建房子一样。SEH用途广泛,若想学好逆向分析技术,必须先掌握SEH的内部工作原理。

48.8小结

SEH大量应用于压缩器、保护器、恶意程序(Malware),用来反调试。大家研究与调试SEH的过程中,会进一步加深对Wiondows OS内部结构的认识,提高自身逆向分析技术水平。关于SEH的讲解先到这里,后面的调试练习中还会遇到。


第48章 SEH
https://m0ck1ng-b1rd.github.io/1999/04/08/逆向工程核心原理/第48章 SEH/
作者
何语灵
发布于
1999年4月8日
许可协议