SEH异常处理机制


简介

基本说明

SEH 是 Windows 操作系统提供的异常处理机制,在程序源代码中使用__try__except__finally关键字来具体实现。在逆向分析中,SEH 除了基本的异常处理功能外,还大量应用于反调试程序。

基本使用

在 C 语言中使用__try__except__finally关键字就可以很容易地向代码添加 SEH。在汇编语言中添加 SEH 的方法更加简单。

  • 在 C 语言中使用 SEH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>
#include <windows.h>

// 自定义异常函数,触发除零异常
void div_zero() {
int a = 10, b = 0;
int result = a / b; // 除零异常
printf("Result: ");
}

int main() {
__try {
// 执行除零异常处理
printf("Trying to divide by zero...");
div_zero();
}
__except (EXCEPTION_EXECUTE_HANDLER) {
// 捕获除零异常
printf("Exception caught: Division by zero!")
}
return 0;
}
  • 在汇编语言中使用 SEH
1
2
3
push @MyHandler           ;异常处理器
push dword ptr fs:[0] ;Head of SEH Linked List
mov dword ptr fs:[0],esp ;添加链表

在程序代码中使用 SEH 就是指,将自身的异常处理器添加到已有的 SEH 链。从技术层面讲就是自身的EXCEPTION_REGISTRATION_RECORD结构体链接到EXCEPTION_REGISTRATION_RECORD结构体链表。

OS的异常处理方法

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

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

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

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

遇到异常时经常采用的几种处理方法如下所示:

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

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

  1. 将异常抛给被调试者处理

如果被调试者内部存在 SEH (异常处理函数)能够处理异常,那么异常通知会发送给被调试者,由被调试者自行处理。这与程序正常运行时的异常处理方式是一样的。

  1. OS 默认的异常处理机制

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

异常

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

以上异常列表中,我们调试时会经常接触 5 种最具代表性的异常,接下来分别介绍。

EXCEPTION_ACCESS_VIOLATION(C0000005)

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

EXCEPTION_BREAKPOINT(80000003)

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

INT3

设置断点命令对应的汇编指令为 INT3,对应的机器指令为 0xCC。CPU 运行代码的过程中若遇到汇编指令 INT3,则会触发EXCEPTION_BREAKPOINT异常。

EXCEPTION_ILLEGAL_INSTRUCTION(C000001D)

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

EXCEPTION_INT_DIVIDE_BY_ZERO(C0000094)

INTEGER(整数)除法运算中,若分母为 0(即被 0 除),则引发EXCEPTION_INT_DIVIDE_BY_ZERO异常。

编写应用程序时偶尔会发生该异常,分母为变量时,该变量在某个瞬间变为 0,执行除法运算就会引发EXCEPTION_INT_DIVIDE_BY_ZERO异常。

EXCEPTION_SINGLE_STEP(80000004)

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

SEH 详细说明

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,则表示它是链表的最后一个结点。

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

异常处理函数的定义

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)。该异常处理函数由系统调用,是一个回调函数,系统调用它时会给出代码中的 4 个参数,这 4 个参数中保存着与异常相关的信息。首先,第一个参数是指向EXCEPTION_RECORD结构体的指针,EXCEPTION_RECORD结构体的定义如下:

1
2
3
4
5
6
7
8
9
10
typedef struct _EXCEPTION_RECORD
{
DWORD ExceptionCode; //异常代码
DWORD ExceptionFlags;
struct _EXCEPTION_RECORD *ExceptionRecord;
PVOID ExceptionAddress; //异常发生地址
DWORD NumberParameters;
ULONG_PTR ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS];
//15
}EXCEPTION_RECORD, *PEXCEPTION_RECORD;

请注意该结构体中ExceptionCodeExceptionAddress这 2 个成员,ExceptionCode成员用来指出异常类型,ExceptionAddress成员表示发生异常的代码地址。异常处理函数的第三个参数是指向CONTEXT结构体的指针。

CONTEXT结构体的定义如下:

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
// CONTEXT_IA32
struct CONTEXT{
DWORD ContextFlags;
DWORD Dr0; // 04h
DWORD Dr1; // 08h
DWORD Dr2; // 0Ch
DWORD Dr3; // 10h
DWORD Dr6; // 14h
DWORD Dr7; // 18h

FLOATING_SAVE_AREA FloatSave;

DWORD SegGS; // 88h
DWORD SegFs; // 90h
DWORD SegEs; // 94h
DWORD SegDs; // 98h

DWORD Edi; // 9Ch
DWORD Esi; // A0h
DWORD Ebi; // A4h
DWORD Edi; // A8h
DWORD Eci; // ACh
DWORD Eai; // B0h

DWORD Ebp; // B4h
DWORD Eip; // B8h
DWORD SegCs; // BCh (must be sanitized)
DWORD EFlags; // C0h
DWORD Esp; // C4h
DWORD SegSs; // C8h

BYTE ExtendedRegisters[MAXIMUM_SUPPORTED_EXTENSION]; // 512 bytes
}

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

异常发生时,执行异常代码的线程就会中断,转而运行 SEH(异常处理器/异常处理函数),此时 OS 会把线程的CONTEXT结构体的指针传递给异常处理函数(异常处理器)的相应参数。CONTENT结构体成员中有 1 个Eip成员(偏移量:B8)。在异常处理函数中将参数传递过来的CONTENT.Eip设置为其他地址,然后返回异常处理函数。这样,之前暂停的线程会执行新设置的EIP地址处的代码。在异常函数定义中可以看到异常处理函数的返回值为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 链的下一个异常处理器。

TEB.NtTib.ExceptionList

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

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

1
TEB.NtTib.ExceptionList=FS:[0]