May 27, 2022

Windows-SEH学习笔记

初探Windows SEH(只是入门级

一些闲话

其实SEH的概念之前已经接触过几次了,第一次是在瞎翻track和含树师傅博客的时候,看到21年的miniL有涉及到这个东西,觉得还是含树师傅的方法(一个一个翻函数)最管用,所以就没太在意这个东西。直到寒假做hgame的题被教育了,后来刷buu逆向的时候做到SCTF2019的creakme又被教育了一通,觉得还是得了解这个东西吧。
怎么说呢,做题毕竟只是做题,可能这道题可以嗯翻函数,另一个题可以嗯怼汇编,即使一点也不了解这个东西也能拿到flag。但是我感觉,不知道原理的话,对一些比较屑的题还是难免无从下手吧,而且明白一个东西之后用起来感觉会更顺手一些。
最初知道寻找SEH相关的一些调用地址是在《逆向工程核心原理》这本书上,然后自己拿题目瞎调了一通。但是调出来的东西,跟书上说的东西,不能说是毫不相干,只能说是八竿子打不着(x,只好去网上找各种博客康。看了很多篇之后慢慢感觉理解了一些,感觉里面还是有挺多很有意思的东西,然后又综合《逆向工程核心原理》和《加密与解密》, 再顺便完成复现题目的任务, 自己写了一篇博客。

不过这篇文章只是一些比较简单的应用,而且只涉及了SEH,也只有静态分析的部分, (因为我太菜了,OD还用不熟练,之后也许会写一篇动调的) 对于VEH、UEH、VCH等异常处理还没有研究过,SEH栈展开之类的高级内容也许以后学了会再写一篇(逃

本文面向的读者

初次接触很多新的概念是会感觉陌生和难以理解,翻来覆去多看几遍,对照着题目练习,慢慢就能接受了。

用到的示例题目

参考资料

winnt.h文档
https://bbs.pediy.com/thread-32222.htm
https://www.cnblogs.com/lanrenxinxin/p/4631836.html
https://bbs.pediy.com/thread-206603.htm
《逆向工程核心原理》第46~48章
《加密与解密》第8章

SEH简要说明

使用方法

SEH是Windows提供的一种异常处理机制,在源代码中使用__try__except__finally关键字来实现。简而言之,就是当程序(__try块中的语句)发生异常,我们可以选择使用自己定义的函数来处理异常(在__except块中调用),而不是使用操作系统提供的异常(弹一个报错窗口然后退出程序)来处理。

SEH与逆向

静态分析

异常处理对于逆向来说最大的问题就是 __except块中的内容不会被IDA反编译出来 ,可以据此隐藏核心代码。所以当解密过程很明确,但就是死活解出来一堆乱码,或者反编译出来的代码逻辑很迷的时候,就要考虑是不是用一些特殊的方法隐藏了核心代码……

具体而言,我寒假做hgame的时候被week2的fakeshell和creakme2折磨N久,一个是RC4,一个是xtea,都有很明确的解密流程,但解出来就是一堆乱码……我甚至尝试调试后把乱码数据写到栈上,然后用题目程序加密。结果当然是跟密文一样,所以某人直接自闭了……
后来看题解才知道,fakeshell是使用__attribute((constructor)) 隐藏了一段代码逻辑,而creakme2是使用SEH隐藏了一段代码逻辑……
__attribute((constructor)) 是用于初始化的一段函数,在main函数之前执行,反编译结果在是在_libc_start_main中的init参数里。
所以现在养成习惯,看main函数之前先翻一下init段……

动态调试

进程在运行过程中发生异常时,操作系统会委托进程自身处理。如果进程自身有相关的异常处理(比如SEH),那么就由程序自身处理,否则OS启动默认的异常处理机制,终止程序,也就是上面说到的过程。
而当程序处于调试状态时,调试者拥有被调试者的所有权限(读写内存、寄存器等),所以 调试过程中的任何异常都要先交由调试者处理,而不会流转到正常的异常处理过程。 这样就增加了调试的难度。

调试遇到异常时,如果需要进入异常,一般的处理方法:

如果无需进入异常,直接nop掉产生异常的代码即可,或者根据异常代码处理相应的内存和寄存器值也可以。

SEH简要分析

SEH具体实现

SEH异常处理程序的基本结构:

1
2
3
4
5
6
__try {
// 受保护的代码
}
__except ( /*异常过滤器exception filter*/ ) {
// 异常处理程序exception handler
}

__try

__try块中包含可能触发异常的代码。如果代码抛出异常,则交由__except块处理。

__except

__except块中是用户定义的处理异常的代码。

exception filter

exception filter称为异常过滤器。顾名思义,它的作用是对异常进行过滤。

异常过滤器只有三个值(定义在Windows的Excpt.h中):

异常过滤器决定了是否处理当前异常,即是否执行__except块中的代码(异常处理程序exception handler)。

异常过滤器的使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
__try {
……
}
__except ( MyFilter( GetExceptionCode() ) )
{
……
}

LONG MyFilter(DWORD dwExceptionCode )
{
if ( dwExceptionCode == EXCEPTION_ACCESS_VIOLATION )
return EXCEPTION_EXECUTE_HANDLER ;
else
return EXCEPTION_CONTINUE_SEARCH ;
}

对于这段代码而言,在异常过滤器中自定义了一个函数MyFilter,以GetExceptionCode()的返回值作为参数,返回值是一个异常过滤器的值,所以也可以直接在__except块的参数中写入异常过滤器的值,如__except (EXCEPTION_EXECUTE_HANDLER)
具体而言,GetExceptionCode()函数返回__try块中产生的异常值(也就是产生异常的原因),据此我们可以实现对异常的过滤。

下表列举操作系统中的异常值:

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
EXCEPTION_ACCESS_VIOLATION         0xC0000005     
程序企图读写一个不可访问的地址时引发的异常。例如企图读取0地址处的内存。
EXCEPTION_ARRAY_BOUNDS_EXCEEDED 0xC000008C
数组访问越界时引发的异常。
EXCEPTION_BREAKPOINT 0x80000003
触发断点时引发的异常。
EXCEPTION_DATATYPE_MISALIGNMENT 0x80000002
程序读取一个未经对齐的数据时引发的异常。
EXCEPTION_FLT_DENORMAL_OPERAND 0xC000008D
如果浮点数操作的操作数是非正常的,则引发该异常。所谓非正常,即它的值太小以至于不能用标准格式表示出来。
EXCEPTION_FLT_DIVIDE_BY_ZERO 0xC000008E
浮点数除法的除数是0时引发该异常。
EXCEPTION_FLT_INEXACT_RESULT 0xC000008F
浮点数操作的结果不能精确表示成小数时引发该异常。
EXCEPTION_FLT_INVALID_OPERATION 0xC0000090
该异常表示不包括在这个表内的其它浮点数异常。
EXCEPTION_FLT_OVERFLOW 0xC0000091
浮点数的指数超过所能表示的最大值时引发该异常。
EXCEPTION_FLT_STACK_CHECK 0xC0000092
进行浮点数运算时栈发生溢出或下溢时引发该异常。
EXCEPTION_FLT_UNDERFLOW 0xC0000093
浮点数的指数小于所能表示的最小值时引发该异常。
EXCEPTION_ILLEGAL_INSTRUCTION 0xC000001D
程序企图执行一个无效的指令时引发该异常。
EXCEPTION_IN_PAGE_ERROR 0xC0000006
程序要访问的内存页不在物理内存中时引发的异常。
EXCEPTION_INT_DIVIDE_BY_ZERO 0xC0000094
整数除法的除数是0时引发该异常。
EXCEPTION_INT_OVERFLOW 0xC0000095
整数操作的结果溢出时引发该异常。
EXCEPTION_INVALID_DISPOSITION 0xC0000026
异常处理器返回一个无效的处理的时引发该异常。
EXCEPTION_NONCONTINUABLE_EXCEPTION 0xC0000025
发生一个不可继续执行的异常时,如果程序继续执行,则会引发该异常。
EXCEPTION_PRIV_INSTRUCTION 0xC0000096
程序企图执行一条当前CPU模式不允许的指令时引发该异常。
EXCEPTION_SINGLE_STEP 0x80000004
标志寄存器的TF位为1时,每执行一条指令就会引发该异常。主要用于单步调试。
EXCEPTION_STACK_OVERFLOW 0xC00000FD
栈溢出时引发该异常。

举例来讲,如果__try块中产生整数除零异常,那么GetExceptionCode()函数返回0xC0000094。自定义过滤器中将这个值与预先设定好的异常值比较。在此例中,这个异常值是EXCEPTION_ACCESS_VIOLATION,即0xC0000005。如果异常值相等,那么就返回EXCEPTION_EXECUTE_HANDLER,进而执行exception handler,也就是使用当前__except块处理异常,否则就返回EXCEPTION_CONTINUE_SEARCH,即继续搜索下一个异常处理程序。

据此,我们就使用自定义的MyFilter完成了对异常的过滤:只有__try块中产生读写不可访问地址异常时,__except块才会处理该异常。也就是说,除了EXCEPTION_ACCESS_VIOLATION这个异常,__except都不会处理。

为了便于理解以上概念,以hgame的creakme2为例说明。

hgame creakme2

这道题是一个xtea的加密,关于这种加密方式不是本文重点,我不细说,主要看SEH的部分。
IDA反编译出来的核心加密函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
__int64 __fastcall sub_140001070(unsigned int a1, unsigned int *a2, __int64 a3)
{
__int64 result; // rax
int v4; // [rsp+20h] [rbp-38h]
unsigned int v5; // [rsp+24h] [rbp-34h]
unsigned int v6; // [rsp+28h] [rbp-30h]
unsigned int i; // [rsp+2Ch] [rbp-2Ch]

v5 = *a2;
v6 = a2[1];
v4 = 0;
for ( i = 0; i < a1; ++i )
{
v5 += (*(_DWORD *)(a3 + 4i64 * (v4 & 3)) + v4) ^ (v6 + ((v6 >> 5) ^ (16 * v6)));
v4 += dword_140003034;
v6 += (*(_DWORD *)(a3 + 4i64 * ((v4 >> 11) & 3)) + v4) ^ (v5 + ((v5 >> 5) ^ (16 * v5)));
}
*a2 = v5;
result = 4i64;
a2[1] = v6;
return result;
}

正常按部就班用xtea的解密方式解密出来是一堆乱码,我们有理由怀疑此函数隐藏了一些代码逻辑。去翻一下这个函数的汇编,会发现以下代码:

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
.text:0000000140001112 loc_140001112:                          ; DATA XREF: .rdata:00000001400027C4↓o
.text:0000000140001112 ; .rdata:00000001400027D4↓o
.text:0000000140001112 ; __try { // __except at loc_140001150
.text:0000000140001112 ; __try { // __except at loc_140001141
.text:0000000140001112 mov eax, cs:dword_140003034
.text:0000000140001118 mov ecx, [rsp+58h+var_38]
.text:000000014000111C add ecx, eax
.text:000000014000111E mov eax, ecx
.text:0000000140001120 mov [rsp+58h+var_38], eax
.text:0000000140001124 mov eax, [rsp+58h+var_38]
.text:0000000140001128 sar eax, 1Fh
.text:000000014000112B mov [rsp+58h+var_28], eax
.text:000000014000112F mov eax, 1
.text:0000000140001134 cdq
.text:0000000140001135 mov ecx, [rsp+58h+var_28]
.text:0000000140001139 idiv ecx
.text:000000014000113B mov [rsp+58h+var_1C], eax
.text:000000014000113F jmp short loc_14000114E
.text:000000014000113F ; } // starts at 140001112
.text:0000000140001141 ; ---------------------------------------------------------------------------
.text:0000000140001141
.text:0000000140001141 loc_140001141: ; DATA XREF: .rdata:00000001400027C4↓o
.text:0000000140001141 ; __except(loc_140001DF6) // owned by 140001112
.text:0000000140001141 mov eax, [rsp+58h+var_38]
.text:0000000140001145 xor eax, 1234567h
.text:000000014000114A mov [rsp+58h+var_38], eax
.text:000000014000114E
.text:000000014000114E loc_14000114E: ; CODE XREF: sub_140001070+CF↑j
.text:000000014000114E jmp short loc_140001158
.text:000000014000114E ; } // starts at 140001112
.text:0000000140001150 ; ---------------------------------------------------------------------------
.text:0000000140001150
.text:0000000140001150 loc_140001150: ; DATA XREF: .rdata:00000001400027D4↓o
.text:0000000140001150 ; __except(loc_140001E21) // owned by 140001112
.text:0000000140001150 mov [rsp+58h+var_38], 9E3779B1h

140001112处有两个__try块,分别属于loc_140001150和loc_140001141。
我们先去看最内层,双击loc_140001141,IDA跳转到相应地址,代码如下:

1
2
3
4
5
.text:0000000140001141 loc_140001141:                          ; DATA XREF: .rdata:00000001400027C4↓o
.text:0000000140001141 ; __except(loc_140001DF6) // owned by 140001112
.text:0000000140001141 mov eax, [rsp+58h+var_38]
.text:0000000140001145 xor eax, 1234567h
.text:000000014000114A mov [rsp+58h+var_38], eax

这里的代码即__except块中处理异常的代码,也就是上面__except大括号中省略号的内容。
具体而言,这段代码的作用是每次对xtea加密中的sum值(v4)异或0x1234567。

那么这个异常是如何触发的?双击__except(loc_140001DF6)中的地址loc_140001DF6,按照上文所提及的,这里就是__except块的异常过滤器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.text:0000000140001DF6 ;   __except filter // owned by 140001112
.text:0000000140001DF6 push rbp
.text:0000000140001DF8 sub rsp, 20h
.text:0000000140001DFC mov rbp, rdx
.text:0000000140001DFF mov [rbp+40h], rcx
.text:0000000140001E03 mov rax, [rbp+40h]
.text:0000000140001E07 mov rax, [rax]
.text:0000000140001E0A mov eax, [rax]
.text:0000000140001E0C mov [rbp+34h], eax
.text:0000000140001E0F mov eax, [rbp+34h]
.text:0000000140001E12 mov ecx, eax
.text:0000000140001E14 call sub_140001058
.text:0000000140001E19 nop
.text:0000000140001E1A add rsp, 20h
.text:0000000140001E1E pop rbp
.text:0000000140001E1F retn

可以看到,IDA标注这里是__except filter的内容,不过实际上,这些是为了传递一些异常信息,真正的filter函数在sub_140001058中,也就是这段代码里所调用的一个函数。
跳转到这个函数:

1
2
3
4
5
6
7
.text:0000000140001058 sub_140001058   proc near               ; CODE XREF: sub_140001070+DA4↓p
.text:0000000140001058 ; DATA XREF: .pdata:ExceptionDir↓o
.text:0000000140001058 xor eax, eax
.text:000000014000105A cmp ecx, 0C0000094h
.text:0000000140001060 setz al
.text:0000000140001063 retn
.text:0000000140001063 sub_140001058 endp

如果反编译一下,发现是retn了奇怪的值,即这里的0xC0000094。对照上文给出的操作系统异常值的表,可以发现这是一个整数除零异常,即EXCEPTION_INT_DIVIDE_BY_ZERO。

不过这样手动去对照异常找比较麻烦,我们可以使用IDA来识别相应的异常,操作如下:
选中这个值然后按M,可以选择枚举常量(IDA可能会弹窗,选yes即可),然后可以发现IDA会搜索到异常值。

选择正确的异常值(在我这里是第一个)即可,IDA会将数字转为相应的宏,如下:

1
2
3
4
5
6
7
.text:0000000140001058 sub_140001058   proc near               ; CODE XREF: sub_140001070+DA4↓p
.text:0000000140001058 ; DATA XREF: .pdata:ExceptionDir↓o
.text:0000000140001058 xor eax, eax
.text:000000014000105A cmp ecx, EXCEPTION_INT_DIVIDE_BY_ZERO
.text:0000000140001060 setz al
.text:0000000140001063 retn
.text:0000000140001063 sub_140001058 endp

从名字可以看出来,这是一个整数除零异常。外层的loc_140001150处的异常处理分析过程一致,不再赘述,不过外层处理的异常值是0xC0000095,即EXCEPTION_INT_OVERFLOW。这个异常处理中__except块中的内容是每次将0x9E3779B1赋给xtea加密中的sum值(v4)。相应的汇编代码如下:

1
2
3
.text:0000000140001150 loc_140001150:                          ; DATA XREF: .rdata:00000001400027D4↓o
.text:0000000140001150 ; __except(loc_140001E21) // owned by 140001112
.text:0000000140001150 mov [rsp+58h+var_38], 9E3779B1h

分析清楚了两个__except块中的内容,现在我们返回去看__try中的内容。相应代码上面已经贴了,大概意思就是在v4每次加上delta(即dword_140003034)后,计算1/(v4>>31)。这里使用一个变量v4>>31作为分母,当v4首位为0时,就会触发除零异常,执行__except块中的代码,对v4异或0x1234567,以此来魔改加密过程。
外层的EXCEPTION_INT_OVERFLOW并没有触发,只是出题人用来迷惑选手的。

出题人在wp中提供的题目源代码如下:

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
int FilterFuncofDBZ(int dwExceptionCode)
{
if (dwExceptionCode == EXCEPTION_INT_DIVIDE_BY_ZERO)
{
//printf("DIVIDE_BY_ZERO catch\n");
return EXCEPTION_EXECUTE_HANDLER;
}
return EXCEPTION_CONTINUE_SEARCH;
}
int FilterFuncofOF(int dwExceptionCode)
{
if (dwExceptionCode == EXCEPTION_INT_OVERFLOW)
{
//printf("OVERFLOW catch\n");
return EXCEPTION_EXECUTE_HANDLER;
}
return EXCEPTION_CONTINUE_SEARCH;
}
void encipher(unsigned int num_rounds, uint32_t v[2], uint32_t key[4]) {
unsigned int i;
uint32_t v0 = v[0], v1 = v[1];
int sum = 0;
for (i = 0; i < num_rounds; i++) {
v0 += (((v1 << 4) ^ (v1 >> 5)) + v1) ^ (sum + key[sum & 3]);
int a;
__try {
__try {
sum += delta;
a=1 / (sum >> 31);
}
__except (FilterFuncofDBZ(GetExceptionCode()))
{
sum ^= 0x1234567;
}
}__except (FilterFuncofOF(GetExceptionCode()))
{
sum = 0x9E3779B1;
}
v1 += (((v0 << 4) ^ (v0 >> 5)) + v0) ^ (sum + key[(sum >> 11) & 3]);
}
//printf("%x\n", sum);
v[0] = v0; v[1] = v1;
}

如果有难以理解的地方,可以对照源码分析。

SEH深入分析

SEH链

SEH以链的形式存在。第一个异常处理中未处理相关异常,它就会被传递到下一个异常处理器,直到得到处理。
SEH链是由_EXCEPTION_REGISTRATION_RECORD结构体组成的链表,此结构体定义如下:

1
2
3
4
5
6
7
typedef struct _EXCEPTION_REGISTRATION_RECORD {
struct _EXCEPTION_REGISTRATION_RECORD *Next;
//指向下一个 EXCEPTION_REGISTRATION_RECORD

PEXCEPTION_DISPOSITION Handler;
//指向异常处理函数
} EXCEPTION_REGISTRATION_RECORD,*PEXCEPTION_REGISTRATION_RECORD;

如果你有一丢丢数据结构知识,你会发现这是一个最简单的链表结构:一个是元素值,一个是下一个元素的地址。而这里的元素值Handler就存储了当前节点的异常处理函数的地址,Next则指向下一个节点。若Next成员的值为FFFFFFFF,则表示它是链表最后一个结点。

异常处理函数

异常处理函数定义如下:

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

异常处理函数接受4个参数输入,这4个参数保存着一些与异常相关的信息。
第2个参数EXCEPTION_REGISTRATION_RECORD即是上文提到过的SEH链结构体,第4个参数是OS保留,可以忽略,下面会分析第1和第3个参数。

异常处理函数返回名为EXCEPTION_DISPOSITION的枚举类型,它由系统调用,是一个回调函数。

EXCEPTION_RECORD

异常处理函数接受的第一个参数是一个指向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;

ExceptionCode指出异常类型,即上文提到过的一些对应的异常值。
ExceptionAddress表示发生异常的代码地址。

CONTEXT

异常处理函数接受的第三个参数是指向CONTEXT结构体的指针,它用来备份CPU的值。
多线程环境下,每个线程内部都有一个CONTEXT结构体。CPU离开当前线程转而运行其他线程时,CPU寄存器的值被保存到当前线程的CONTEXT结构体中,当CPU返回该线程时,使用保存在CONTEXT结构体中的值来覆盖CPU各寄存器的值,以此来保证多线程安全。
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
typedef struct _CONTEXT {
DWORD ContextFlags;
DWORD Dr0; //0x04
DWORD Dr1; //0x08
DWORD Dr2; //0x0c
DWORD Dr3; //0x10
DWORD Dr6; //0x14
DWORD Dr7; //0x18

FLOATING_SAVE_AREA FloatSave;

DWORD SegGs; //0x88
DWORD SegFs; //0x90
DWORD SegEs; //0x94
DWORD SegDs; //0x98

DWORD Edi; //0x9c
DWORD Esi; //0xa0
DWORD Ebx; //0xa4
DWORD Edx; //0xa8
DWORD Ecx; //0xac
DWORD Eax; //0xb0
DWORD Ebp; //0xb4
DWORD Eip; //0xb8

DWORD SegCs; //0xbc MUST BE SANITIZED
DWORD EFlags; //0xc0 MUST BE SANITIZED
DWORD Esp; //0xc4
DWORD SegSs; //0xc8

BYTE ExtendedRegisters[MAXIMUM_SUPPORTED_EXTENSION];
} CONTEXT;

这里只写了一部分重要的,微软的文档有更新,我还是按照《逆向工程核心原理》这本书上的定义来写,想看最新的定义可以戳这里

注意里面的Eip成员(偏移为0xb8),一般来讲Eip成员应该存储触发异常后的代码地址,即触发异常时的Eip值。
具体而言,当某一句代码触发异常,那么Eip的值应该指向这句代码的结束地址。这样当SEH处理完毕异常后,程序可以回到原来的地方,继续执行正常的代码。
但是, 在异常处理函数中可能将参数传递过来的CONTEXT.Eip设置为其他地址,然后返回处理函数。 这样之前暂停的线程会执行新的EIP地址处的代码(反调试中经常使用这个技术)。

EXCEPTION_DISPOSITION

异常处理函数的返回值是一个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;

结构化异常处理内部函数

了解上述参数的意义和结构之后,如何在源代码(C++)中访问?

结构化异常处理提供了两个可用于 try-except 语句的内部函数:

EXCEPTION_POINTERS结构体定义如下:

1
2
3
4
typedef struct _EXCEPTION_POINTERS {
PEXCEPTION_RECORD ExceptionRecord;
PCONTEXT ContextRecord;
} EXCEPTION_POINTERS, *PEXCEPTION_POINTERS;

其中ExceptionRecord是一个指向EXCEPTION_RECORD的指针。
ContextRecord是一个指向CONTEXT的指针。

同样,用miniL2021的题目0oooops来帮助理解以上概念。

miniL2021 0oooops

反编译主函数的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int __cdecl main_0(int argc, const char **argv, const char **envp)
{
char v4[100]; // [esp+E0h] [ebp-F0h] BYREF
char Str[104]; // [esp+14Ch] [ebp-84h] BYREF
CPPEH_RECORD ms_exc; // [esp+1B8h] [ebp-18h]

j_memset(Str, 0, 0x64u);
strcpy(v4, "Please input your flag: ");
j_memset(&v4[25], 0, 0x4Bu);
sub_4110DC("%s", (char)v4);
sub_411037("%s", (char)Str);
if ( (unsigned __int8)sub_4112DA(Str) )
{
MEMORY[0] = 0;
ms_exc.registration.TryLevel = -2;
}
sub_41126C();
return 0;
}

如果是在IDA里看,可以看到MEMORY[0] = 0;被标着显眼的大红色,这其实是一个很强的提示:即程序中触发了非法内存访问的异常(MEMORY[0]一定是一个非法的地址)。出题人好温柔我真的哭死

那么开同步去看这句代码的汇编部分,可以看到显眼的__try块:

1
2
3
4
5
6
7
8
9
10
.text:00412330 loc_412330:                             ; CODE XREF: _main_0+15C↑j
.text:00412330 ; __try { // __except at loc_412377
.text:00412330 mov [ebp+ms_exc.registration.TryLevel], 0
.text:00412337 lea ebx, [ebp+Str]
.text:0041233D xor eax, eax
.text:0041233F db 3Eh
.text:0041233F mov dword ptr [eax], 0
.text:00412346 mov edx, 0
.text:0041234B div edx
.text:0041234B ; } // starts at 412330

分析步骤在上一题中已经详细说过,此处不再赘述。
跟进loc_412377后再跟进loc_412356的__except filter,然后跟进sub_411131,此函数经过一次跳转到sub_411DD0。反编译结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int __cdecl sub_411DD0(int a1, int a2)
{
unsigned int i; // [esp+D0h] [ebp-40h]
char v4[40]; // [esp+DCh] [ebp-34h] BYREF
int v5; // [esp+104h] [ebp-Ch]

__CheckForDebuggerJustMyCode(&unk_41D015);
if ( **(_DWORD **)a2 != -1073741676 )
return 0;
v5 = *(_DWORD *)(*(_DWORD *)(a2 + 4) + 164) + 9;
qmemcpy(v4, "!V -}VG-bp}m-nG!b|ra GyGE|Drp D", 31);
for ( i = 0; i < 0x1F; ++i )
{
if ( v4[i] != ((unsigned __int8)*(_DWORD *)(*(_DWORD *)(a2 + 4) + 184) ^ ((*(char *)(v5 + 2 * i + 1) ^ 0x4D) - 4) ^ 0x13) )
{
*(_DWORD *)(*(_DWORD *)(a2 + 4) + 184) += 54;
return -1;
}
}
*(_DWORD *)(*(_DWORD *)(a2 + 4) + 184) += 63;
return -1;
}

可以看到a2也是一个奇怪的值,按照上文说过的步骤去转成枚举类型,发现这个异常类型是EXCEPTION_INT_DIVIDE_BY_ZERO,即整数除零异常。那么再去看刚开始的__try块中的内容,发现最后两句mov edx, 0div edx触发了除零异常,那么我们有理由相信这个异常中的函数确实会被调用。

观察这段加密函数,会发现对明文的运算除了一些常规的异或和减法之外,还异或了(unsigned __int8)*(_DWORD *)(*(_DWORD *)(a2 + 4) + 184)这个值。
这个操作就是把a2转成DWORD类型后,取偏移为184的地址的值。184的十六进制是0xb8,如果对照上面说过的CONTEXT结构体后面的注释,会发现这个值实际上是Eip的值,即触发异常代码的下一句代码的地址值,取它的最后一字节来异或。
那么现在再回去看try块,try块结束的地址是0x41234B,所以这个用来异或的值就是0x4b。

或者也可以根据程序逻辑推知这一点:
在验证失败后,执行的代码是*(_DWORD *)(*(_DWORD *)(a2 + 4) + 184) += 54;然后退出程序;验证成功后,执行的代码是*(_DWORD *)(*(_DWORD *)(a2 + 4) + 184) += 63;然后退出程序。
上文在CONTEXT里已经强调过,可以通过修改CONTEXT的Eip来控制程序返回的地址。我们有理由相信这两个操作是为了转向不同的结果:即为我们打印失败或成功的信息。所以这里应当修改的是Eip的值,也就是控制程序返回不同的地址。有兴趣可以自行验证这一点。

当然,这样手动找或者推理很麻烦,跟上一题一样,我们还是可以借助IDA来完成这些工作。

**(_DWORD **)a2我们已经知道是EXCEPTION_INT_DIVIDE_BY_ZERO,即_EXCEPTION_RECORD中的成员ExceptionCode。
上文已经提及过,结构化异常处理内部函数只有GetExceptionCodeGetExceptionInformation。代码中对a2解引用两次才得到ExceptionCode,并且此后有对a2的偏移值有一些操作,所以我们可以推知代码访问使用的函数应该是GetExceptionInformation,而a2则是此函数的返回值类型。
具体而言,站在异常的角度来看,a2的数据类型应该是_EXCEPTION_POINTERS *类型的。

在IDA中选中a2,摁Y修改数据类型,输入_EXCEPTION_POINTERS *a2,会发现反编译的代码变成下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int __cdecl sub_411DD0(int a1, _EXCEPTION_POINTERS *a2)
{
unsigned int i; // [esp+D0h] [ebp-40h]
char v4[40]; // [esp+DCh] [ebp-34h] BYREF
DWORD v5; // [esp+104h] [ebp-Ch]

__CheckForDebuggerJustMyCode(&unk_41D015);
if ( a2->ExceptionRecord->ExceptionCode != EXCEPTION_INT_DIVIDE_BY_ZERO )
return 0;
v5 = a2->ContextRecord->Ebx + 9;
qmemcpy(v4, "!V -}VG-bp}m-nG!b|ra GyGE|Drp D", 31);
for ( i = 0; i < 0x1F; ++i )
{
if ( v4[i] != ((unsigned __int8)a2->ContextRecord->Eip ^ ((*(char *)(v5 + 2 * i + 1) ^ 0x4D) - 4) ^ 0x13) )
{
a2->ContextRecord->Eip += 54;
return -1;
}
}
a2->ContextRecord->Eip += 63;
return -1;
}

显然,IDA已经帮我们识别出来了这个值是ContextRecord结构体中的Eip值,我们只需要去看try块的结束地址就能获取到这个值。

也许你会突然想起来,我们最开始找到try块的那个提示非法内存访问的异常呢?上面说过的只是除零异常触发的函数,那么非法内存访问异常有没有触发函数?
那当然是有的。上面那个函数里只是奇数处字符的加密函数,偶数处字符的加密函数在TlsCallback_0_0里,可以在IDA的导出表(exports)窗口中找到这个函数,非法内存访问异常触发的函数就在这里。

TLS,Thread Local Storage 线程局部存储,TLS回调函数的调用运行要先于PE代码执行,该特性使它可以作为一种反调试技术使用。TLS是各线程的独立的数据存储空间,使用TLS技术可在线程内部独立使用或修改进程的全局数据或静态数据。

这里涉及到Windows异常处理的另一种机制VEH,由于这个不是本文的重点 (其实是我太菜了不会) ,所以先不细说。对于这个题而言,点进这个函数直接分析即可。
track神的博客有讲,感兴趣可以去康康。

两道题目的比较

最后强调一下这两个题目的一点细微差异:

如果读者还记得我最开始在静态分析中说过的,那么应该知道我强调过一点:__except块中的代码(也就是异常处理程序except handler)中的内容是不能被IDA反编译出来的。
在hgame那道题中,我们是通过阅读汇编代码获得的程序逻辑,但在miniL这道题中,我们是看反编译出来的结果,为什么会这样?(能够理解这一点的可以跳过这里)

再来回顾一下__except块中的内容:

1
2
3
__except ( /*异常过滤器exception filter*/ ) {
// 异常处理程序exception handler
}

注意__except块中有两个重要的成员:

hgame的creakme2将核心代码写入exception handler,也就是__except块大括号包裹的内容,这里的东西是不能够被IDA反编译出来的,所以我们只能通过阅读汇编获得程序逻辑。
而miniL的题目则是将核心代码写入exception filter。由于异常过滤器实际上也是一个函数,所以能够被IDA识别并且反编译。

简而言之,如果出题人想考验选手阅读汇编代码的能力,那么就将代码直接写在exception handler中。如果出题人不想为难选手嗯怼汇编,就把代码写入exception filter函数中,或者在exception handler中调用一个写入核心加密过程的函数(这种方法我们将在下一道题中看到)。

TEB

下面要介绍VC++编译器对SEH所做的增强版本,在这之前,先说明一些关于TEB(Thread Environment Block,线程环境块)的知识。在这里只讲解与SEH相关的内容。

TEB成员众多,此处我们只需要了解_NT_TIB。

_NT_TIB

TIB(Thread Information Block,线程信息块)
_NT_TIB结构体定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct	_NT_TIB{
struct _EXCEPTION_REGISTRATION_RECORD *ExceptionList;
PVOID StackBase;
PVOID StackLimit;
PVOID SubSystemTib;
union {
PVOID FiberData;
DWORD Version;
};
PVOID ArbitraryUserPointer;
struct _NT_TIB *Self;
} NT_TIB;
typedef NT_TIB *PNT_TIB;

其中ExceptionList成员指向_EXCEPTION_REGISTRATION_RECORD结构体组成的链表,也就是SEH链。

TEB访问方法

Ntdll.NtCurrentTeb()

用户模式下使用此函数访问TEB,Ntdll.NtCurrentTeb()返回当前线程的TEB结构体的地址。

FS段寄存器

FS:[0]指向SEH起始地址。

VC++编译器级的SEH实现

前述的SEH只是一个简单的模型,只有一个异常链表,这种机制存在很多问题。
所以现实程序设计中,大部分采用在VC++6.0中对这个体系进行扩展增强的SEH版本。

编译器提供的增强版本的结构体:

1
2
3
4
5
6
7
8
9
10
11
struct _EXCEPTION_REGISTRATION
{
  struct _EXCEPTION_REGISTRATION *prev; //ebp-0x14
  void *handler; //ebp-0x0c
  struct scopetable_entry *scopetable;//ebp-8
//类型为 scopetable_entry 的数组
  int trylevel; //ebp-4
//数组下标,用来索引 scopetable 中的数组成员
  int _ebp; //ebp
//包含该 _EXCEPTION_REGISTRATION 结构体的函数的栈帧指针
};

原先的结构体:

1
2
3
4
5
6
7
typedef struct _EXCEPTION_REGISTRATION_RECORD {
struct _EXCEPTION_REGISTRATION_RECORD *Next;
//指向下一个 EXCEPTION_REGISTRATION_RECORD

PEXCEPTION_DISPOSITION Handler;
//指向异常处理函数
} EXCEPTION_REGISTRATION_RECORD,*PEXCEPTION_REGISTRATION_RECORD;

既然是扩展,那么必然保留原来的结构,在此基础上增加一部分内容。进行对比可以发现,前两个结构保留,而最后三个结构是追加的。
注释中已经说明,ebp保存函数栈帧;trylevel是一个数组索引,指向scopetable数组

_SCOPETABLE

1
2
3
4
5
6
typedef struct _SCOPETABLE
{
   DWORD previousTryLevel; //定位前一个try块的索引值
   DWORD lpfnFilter; //当前try块的过滤函数
   DWORD lpfnHandler; //当前try块的终止函数
}SCOPETABLE, *PSCOPETABLE;

扩展异常处理机制

按照原始的设计,每一个__try/__except(__finally) 都应该对应一个 EXCEPTION_REGISTRATION。
但是VS实际实现中,每个使用 __try/__except(__finally) 的函数,不管其内部嵌套或反复使用多少 __try/__except(__finally),都只注册一遍,即只将一个 EXCEPTION_REGISTRATION 挂入当前线程的异常链表中。
MSC 提供一个处理函数,即 EXCEPTION_REGISTRATION::handler 被设置为 MSC 的某个函数,而不是我们自己提供的 __except 代码块,我们自己提供的多个 __except 块被存储在 EXCEPTION_REGISTRATION::scopetable 数组中。

乍看这一段话可能会觉得难以理解,我们以SCTF2019的creakme为例进行说明。

SCTF2019 creakme

主函数太长就不贴出来了,程序的大致流程是在第一个函数中触发一个断点异常,然后在except handler中调用一个用于SMC的函数,在第二个函数中调用这个经过SMC的函数对密文进行一些变换。后来就是对输入进行AES加密,与经过变换的密文比较。我们只说SEH的部分。

查看第一个函数sub_402320的汇编代码(只贴了需要分析的核心内容):

1
2
3
4
5
6
7
8
9
.text:00402320 sub_402320      proc near               ; CODE XREF: _main+35↓p
.text:00402320 ; __unwind { // __except_handler4
.text:00402320 push ebp
.text:00402321 mov ebp, esp
.text:00402323 push 0FFFFFFFEh
.text:00402325 push offset stru_407B58
.text:0040232A push offset __except_handler4
.text:0040232F mov eax, large fs:0
.text:00402335 push eax

对照上文提到过的增强版的SEH结构体,我们可以发现入栈的参数是一一对应的:
入栈的参数是ebp、0xFFFFFFFE、stru_407B58、__except_handler4、large fs:0,分别对应_EXCEPTION_REGISTRATION中的_ebp、trylevel、scopetable、handler和prev(前面介绍TEB时已经说过,FS:[0]指向SEH起始地址)。

那么按照scopetable的定义,这个结构体中存储了lpfnFilter(当前try块的过滤函数)和lpfnHandler(当前try块的Handler)。双击stru_407B58,查看这个结构体:

1
2
3
4
5
6
7
8
9
.rdata:00407B58 stru_407B58     dd 0FFFFFFE4h           ; GSCookieOffset
.rdata:00407B58 ; DATA XREF: sub_402320+5↑o
.rdata:00407B58 dd 0 ; GSCookieXOROffset
.rdata:00407B58 dd 0FFFFFFC4h ; EHCookieOffset
.rdata:00407B58 dd 0 ; EHCookieXOROffset
.rdata:00407B58 dd 0FFFFFFFEh ; ScopeRecord.EnclosingLevel
.rdata:00407B58 dd offset loc_4023DC ; ScopeRecord.FilterFunc
.rdata:00407B58 dd offset loc_4023EF ; ScopeRecord.HandlerFunc
.rdata:00407B74 align 8

IDA已经为我们注释出来了,loc_4023DC是FilterFunc,loc_4023EF是HandlerFunc。

先查看FilterFunc:

1
2
3
4
5
6
7
8
.text:004023DC loc_4023DC:                             ; DATA XREF: .rdata:stru_407B58↓o
.text:004023DC mov eax, [ebp+ms_exc.exc_ptr]
.text:004023DF mov eax, [eax]
.text:004023E1 xor ecx, ecx
.text:004023E3 cmp dword ptr [eax], 80000003h
.text:004023E9 setz cl
.text:004023EC mov eax, ecx
.text:004023EE retn

触发异常的值是0x80000003,使用之前的方法转为枚举类型时,IDA没有搜索出来这个异常类型,但是没有关系,我们可以手动对照一下异常代码表,发现这是一个EXCEPTION_BREAKPOINT,即断点异常。

现在回过头去看sub_402320反编译出来的伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void __thiscall sub_402320(_DWORD *this)
{
int v1; // eax
__int16 v2; // bx
const char *v3; // esi
int i; // edi
int v5; // eax

v1 = this[15];
v2 = *(_WORD *)((char *)this + v1 + 6);
v3 = (char *)this + v1 + 248;
for ( i = 0; i < v2; ++i )
{
v5 = strcmp(v3, ".SCTF");
if ( v5 )
v5 = v5 < 0 ? -1 : 1;
if ( !v5 )
{
DebugBreak();
return;
}
v3 += 40;
}
}

可以发现,DebugBreak();可以触发断点异常。看其他师傅的博客说这个是通过调试器附加的手段来触发断点异常的 这个我太菜了不会,所以暂且不谈。
总之结果就是通过这个函数触发了此处的异常。

那么我们返回去看loc_4023EF处的HandlerFunc:

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
.text:004023EF loc_4023EF:                             ; DATA XREF: .rdata:stru_407B58↓o
.text:004023EF mov esp, [ebp+ms_exc.old_esp]
.text:004023F2 lea eax, [ebp+pbDebuggerPresent]
.text:004023F5 push eax ; pbDebuggerPresent
.text:004023F6 call ds:GetCurrentProcess
.text:004023FC push eax ; hProcess
.text:004023FD call ds:CheckRemoteDebuggerPresent
.text:00402403 call ds:IsDebuggerPresent
.text:00402409 test eax, eax
.text:0040240B jnz short loc_4023B9
.text:0040240D cmp [ebp+pbDebuggerPresent], eax
.text:00402410 jnz short loc_4023B9
.text:00402412 mov eax, [ebp+var_24]
.text:00402415 mov edx, [eax+10h]
.text:00402418 mov ecx, [eax+0Ch]
.text:0040241B add ecx, [ebp+var_28]
.text:0040241E mov esi, [ebp+var_2C]
.text:00402421 lea edi, [esi+1]
.text:00402424
.text:00402424 loc_402424: ; CODE XREF: sub_402320+109↓j
.text:00402424 mov al, [esi]
.text:00402426 inc esi
.text:00402427 test al, al
.text:00402429 jnz short loc_402424
.text:0040242B sub esi, edi
.text:0040242D push esi
.text:0040242E push ecx
.text:0040242F call sub_402450
.text:00402434 add esp, 8
.text:00402437 jmp short loc_4023B9

前面写了一堆反调(CheckRemoteDebuggerPresentIsDebuggerPresent),后面调用了一个函数sub_402450,跟进这个函数看看,F5反编译出来伪代码,可以发现就是用于SMC的代码。

这道题目的SEH分析过程到此结束,但是上面还有一个问题:MSC提供唯一一个handler处理函数到底是怎么回事?

现在再回去看sub_402320的汇编代码,入栈的第四个参数__except_handler4,之前说过,对应_EXCEPTION_REGISTRATION中的handler,这个函数就是MSC提供的唯一一个EXCEPTION_REGISTRATION::handler处理函数。

我们返回sub_402320,双击__except_handler4,查看汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.text:004033DE SEH_402320:
.text:004033DE push ebp
.text:004033DF mov ebp, esp
.text:004033E1 push [ebp+arg_C]
.text:004033E4 push [ebp+arg_8]
.text:004033E7 push [ebp+arg_4]
.text:004033EA push [ebp+arg_0]
.text:004033ED push offset @__security_check_cookie@4 ; __security_check_cookie(x)
.text:004033F2 push offset ___security_cookie
.text:004033F7 call _except_handler4_common
.text:004033FC add esp, 18h
.text:004033FF pop ebp
.text:00403400 retn
.text:00403400 __except_handler4 endp

既然这是每一个异常的EXCEPTION_REGISTRATION::handler都会被设置的函数,那么通过这个函数,我们应该可以找到程序中的所有__except块。

选中SEH_402320,摁X查看交叉引用,可以发现在所有向上引用中,除了我们上文提到过的sub_402320,还有一个sub_4024A0也引用了这个函数。
sub_4024A0中分析except块,过程与上面一致,此处不再说明。阅读汇编会发现,sub_4024A0的except实际上什么也没干。对照源码(上面已经给出链接),我们可以证实这一点。

完结撒花!(bushi
第一次写这种文章,如果有错误欢迎指出~

关于本文

本文作者 云之君, 许可由 CC BY-NC 4.0.