让tcc用上SEH

前言

  1. 本文不建议初学者阅读。

  2. 本文将涉及少量汇编,请确保你已经了解x86汇编的基础知识。

  3. 确保你已经了解异常处理的基础知识。

tcc(Tiny C Compiler)是Fabrice Bellard大佬用C和汇编实现的一个的C语言编译器,可自举并支持部分GCC扩展。并不建议在学习或生产中使用tcc,因为它有着较多缺陷,譬如switch的作用域处理有问题,asm的"=q"未正常弹栈,vm类型大小分析错误,等等。同时,它对C99的支持也不完善,它并不支持C99的复数类型。比起一个工具,它更像是一个玩具。

本文使用的tcc版本:tcc version 0.9.27 (i386 Windows)。

SEH简介

在开始之前,我们先简要地介绍一下SEH。SEH,顾名思义,就是结构化异常处理(Structured Exception Handling)。

当线程出现错误时,操作系统调用用户定义的回调函数来处理这个错误。回调函数长这样:

1
EXCEPTION_DISPOSITION __cdecl except_handler(PEXCEPTION_RECORD record, PEXCEPTION_REGISTRATION frame, PCONTEXT context, void *dispatcher);

record包含了异常的一些重要信息,包括异常代码、异常标志、发生异常时的地址,等等。

frame非常重要,我们稍后提及,并围绕它逐步深入。

context包含了异常发生时寄存器的值。

dispatcher会涉及比较深入的内容,由于本文仅介绍SEH最基础的内容,所以我们忽略它,把它当做保留参数即可。

当线程发生错误时,操作系统如何知道去哪调用异常处理例程呢?答案是线程信息块(TIB, Thread Information Block)。TIB的第一个成员指向了一个EXCEPTION_REGISTRATION结构,它的定义如下:

1
2
3
4
typedef struct _EXCEPTION_REGISTRATION {
PEXCEPTION_REGISTRATION prev;
PEXCEPTION_HANDLER handler;
} EXCEPTION_REGISTRATION, *PEXCEPTION_REGISTRATION;

注意,这里是有语法错误的。但为了简洁起见,我们忽略这个错误。后同不述。

prev事实上还有写成next的,但是我更倾向于写成prev。它指向了前一个EXCEPTION_REGISTRATION。事实上,这是一个链表,每一个结点都给出了一个异常处理例程,而TIB的第一个成员便是这个链表的头结点。

handler是异常处理例程,每个函数可以根据异常代码选择是否处理该异常,并通过返回值来指示接下来应当进行何种操作。

winnt.h中,这个结构被称为EXCEPTION_REGISTRATION_RECORD

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

那么我们又应当在哪里去找TIB呢?答案是FS段寄存器,FS段的首地址即存放着该线程的TIB,因此fs:[0]便是EXCEPTION_REGISTARTION的地址。

虽说我们不怎么涉及展开(Unwinding),但是若不介绍似乎又有些缺憾,这里非常简要地介绍一下:

我们知道,异常发生时,操作系统遍历EXCEPTION_REGISTARTION结构链表,直到有一个函数处理了这个异常。此时,操作系统再次遍历链表,并将ExceptionRecord中的异常标志设置为EH_UNWINDING。它为异常处理例程提供清理的机会。

好了,说了这么多,相信读者已经差不多头晕了。但事实上,这只是SEH中最基础的内容,本文还有相当多的细节未提及,但我们先放下它,因为这些内容已经足够阅读本文了。关于SEH更详细的内容可以查阅参考文献[1]

使用tcc自带的SEH

众所周知,tcc确实已经为我们提供了简单的SEH支持。tcc提供了一个__TRY__宏,它被定义在_mingw.h。它为我们构造的try-except块长这样:

1
2
3
4
5
6
__try {
// ...
}
__except (_XcptFilter(GetExceptionCode(), GetExceptionInformation())) {
exit(GetExceptionCode());
}

这个宏被用于tcc的start函数(即在调用main之前的thunk函数),包括_tstart_twinstart以及对应的run版本。

细心的读者可能就会发现了,是的,它与VC6的start函数的try-except块神似。

但是有些人就会说了,要是每个try-except都长一样的话那也没啥用啊。是的,tcc事实上还提供了两个宏,见excpt.h。其中,__try1宏用于构造异常处理帧,而__except1宏用于移除异常处理帧。我们来看一下这两个宏:

1
2
3
4
5
6
#define __try1(pHandler) \
__asm__ ("pushl %0;pushl %%fs:0;movl %%esp,%%fs:0;" : : "g" (pHandler));

#define __except1 \
__asm__ ("movl (%%esp),%%eax;movl %%eax,%%fs:0;addl $8,%%esp;" \
: : : "%eax");

我们解释一下:

1
2
3
4
5
6
7
8
; __try1
push handler ;异常处理例程
push dword ptr fs:[0] ;从fs:[0]获取前一个结构
mov fs:[0], esp ;安装SEH
;__except1
mov eax, [esp] ;获取前一个结构
mov fs:[0], eax ;安装前一个结构
add esp, 8 ;恢复堆栈

比较搞笑的是,当你使用__except1时会报错:error: invalid clobber register '%eax'。这是因为tcc不支持这种写法,所以得把%eax%给去掉。

好了,现在我们可以自己写一个函数,用__try1注册异常处理例程,并用__except1卸载,我们写一个简单的demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>
#include <excpt.h>
#include <windows.h>

EXCEPTION_DISPOSITION
handler(PEXCEPTION_RECORD record, PEXCEPTION_REGISTRATION frame,
PCONTEXT context,void *dispatcher)
{
if (record->ExceptionCode == EXCEPTION_ACCESS_VIOLATION)
printf("access violation at %p\n", record->ExceptionAddress);
return ExceptionContinueSearch; // 不处理
}

int main(void)
{
__try1(handler)
int n = 0;
scanf("%d", n);
__except1
}

在这个demo里,由于scanf中故意没写取址符,因此在尝试向0地址写入内容时会触发一个EXCEPTION_ACCESS_VIOLATION异常,被我们的异常处理例程捕获,打印提示信息后继续让其他处理程序去处理异常。

我们还可以用这两个宏来做一些更有趣的事情,比如实现一个简单的反调试手段:通过设置TF标志位来让程序产生单步断点,在产生单步异常后,我们用异常处理例程处理异常发生地址的内容来实现代码的动态加解密。

如果诸位吃饱了撑着可以尝试一下我写的一个简单的注册机demo,源代码和解析会放在文章的最后。

链接:reg.exe (lanzoum.com)

密码:1234

扩展SEH

这里是之前的内容,是try-except-finally的实现,并不好用,且更为复杂。不感兴趣的话可以直接转到更简单的SEH宏

也许有人会说,SEH的作用实在有限啊,这样怎么实现try-except-finally?事实上这是因为基本的SEH帧包含的信息太少了,我们只需扩展一下EXCEPTION_REGISTRATION结构,便能做很多事情了。接下来让我们扩展这个结构并先实现类VC的try-except扩展,然后再实现try-except-finally。

在上文,我们用__try1__except1来安装任意的异常处理例程。然而在VC6里,try-except事实上只使用了一个异常处理例程,那就是_except_handler3

我们先来看一下VC6使用的扩展EXCEPTION_REGISTRATION结构。

1
2
3
4
5
6
7
8
9
typedef struct _EH3_EXCEPTION_REGISTRATION {
//-8 void *_esp;
//-4 PEXCEPTION_POINTERS xpointers;
PVC_EXCEPTION_REGISTRATION prev;
PEXCEPTION_HANDLER handler;
PSCOPETABLE scopetable;
int trylevel;
void *_ebp;
} EH3_EXCEPTION_REGISTRATION, *PEH3_EXCEPTION_REGISTRATION;

prevhandler就是之前的EXCEPTION_REGISTRATION成员。

_esp_ebp也很好理解,就是这两个寄存器的值。

xpointers用于保存在异常发生时_except_handler3传回的异常信息,EXCEPTION_POINTERS的结构如下:

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

关于scoptabletrylevel,我们将在稍后进行讲解,现在我们仅给出SCOPETABLE的结构:

1
2
3
4
5
typedef struct _SCOPETABLE {
int prev;
FARPROC filter;
FARPROC handler;
} SCOPETABLE, *PSCOPETABLE;

要想弄明白scoptabletrylevel,我想可能需要理解一下_except_handler3。伪代码如下:

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
EXCEPTION_DISPOSITION
_except_handler3(PEXCEPTION_RECORD record, PEH3EXCEPTION_REGISTRATION frame,
PCONTEXT context,void *dispatcher)
{
CLD
if (!(record->ExceptionFlags & (EXCEPTION_UNWINDING | EXCEPTION_EXIT_UNWIND))) {
EXCEPTION_POINTERS xpointers = {record, context};
frame->xpointers = xpointers; // 传回异常信息
int trylevel = frame->trylevel;
PSCOPETABLE scopetable = frame->scopetable;
while (trylevel != TRYLEVEL_NONE) {
if (scopetable[trylevel].filter) {
PUSH ESI
PUSH EBP
EBP = &frame->_ebp; // 恢复异常前的栈帧
int ret = scopetable[trylevel].filter();
POP EBP
POP ESI
if (ret != EXCEPTION_CONTINUE_SEARCH) {
if (ret < 0) // EXCEPTION_CONTINUE_EXECUTION
return ExceptionContinueExecution;
// EXCEPTION_EXECUTE_HANDLER
// 这里必须手动执行展开,因为在调用handler后不会返回
_global_unwind2(frame);
EBP = &frame->_ebp;
_local_unwind2(frame, trylevel);
_NLG_Notify(1);
frame->trylevel = scopetable[trylevel].prev;
scopetable[trylevel].handler(); // noreturn
}
}
trylevel = scopetable[trylevel].prev;
}
} else { // unwind
PUSH EBP
EBP = &frame->_ebp;
_local_unwind2(frame, TRYLEVEL_NONE);
POP EBP
}
return ExceptionContinueSearch;
}

相信大家看完这段伪代码就明白scopetabletrylevel的作用是什么了。

prev用来实现嵌套的try-except,每层scopeprev均为前一层的索引,最外层的scopeprevTRYLEVEL_NONE

filter是一个函数指针,_except_handler3调用它来决定是不处理异常、重新执行原来的指令还是执行handler。是的,虽然它写在__except后面的括号里,但它并不是个表达式。

handler也是一个函数指针。但不同于filter的是,_except_handler3在调用handler后并不会返回,这有些类似于longjmp。当filter返回EXCEPTION_EXECUTE_HANDLER时,_except_handler3会调用handler,也就是__except花括号里的内容。

好了,说了这么多,我们总算把VC的扩展EXCEPTION_REGISTRATION给介绍完了,现在我们终于可以开始为tcc编写try-except扩展了。

为tcc编写try-except扩展

我们都知道,tcc会这样构造栈帧:

1
2
3
4
5
6
7
8
push ebp
mov ebp,esp
sub esp,XXX
nop
;...
pop ebp
leave
ret

我们发现tcc似乎没有给我们留下操作空间,我们既不能直接通过push指令来构造异常处理帧,也不能通过EBP的偏移来构造异常处理帧,因为我们会占用自动变量的空间。但事实真是如此吗?我们还知道tcc这样一个特性,自动变量在堆栈上的空间严格按照其在函数中定义的顺序。如此一来,我们只要在函数的开头定义一个充足大小的自动变量即可。故而我们可以很容易写出如下宏来构造和移除异常处理帧:

1
2
3
4
5
6
7
8
9
10
11
12
13
#define __seh_begin void *_seh[6]; \
asm( \
"movl $-1,-4(%%ebp);" \
"movl %%eax,-8(%%ebp);" \
"movl $_except_handler3,-12(%%ebp);" \
"movl %%fs:0,%%eax;" \
"movl %%eax,-16(%%ebp);" \
"movl %%esp,-24(%%ebp);" \
"leal -16(%%ebp),%%eax;" \
"movl %%eax,%%fs:0;": :"a"(_scopetable));

#define __seh_end asm( \
"movl -16(%%ebp),%%eax;movl %%eax,%%fs:0;": : :"eax");

现在还有一个问题:_scopetable还没处理。有一点是毫无疑问的,_scopetable需要我们手动构造,确实比较麻烦。所以我们来简化一下:

1
2
3
4
5
struct _scope {int trylevel; void *filter, *handler;};

#define __seh_scope static struct _scope _scopetable[] =

#define __scope(l,i) {l, &&_filter ## i, &&_handler ## i}

如果要写出类似try-except的结构,那就要知道filter和handler的地址,所以我们需要GNU C扩展&&来获取label的地址;要实现嵌套scope,我们又不得不为每个label编号。

一通折腾,我们总算能做出scopetable了。我们写出:

1
2
3
4
5
6
7
8
9
void foo(void)
{
__seh_scope {
__scope(-1, 0), __scope(0, 1)
};
__seh_begin
// ...
__seh_end
}

看上去非常不错。虽然做了很多妥协,但似乎也不是不能接受。

接下来我们来实现__try__except__leave。至于__finally,我们先放在一边。

我们写出以下宏:

1
2
3
4
5
6
7
8
9
10
#define __try asm("incl -4(%ebp)"); do

#define __except(i,f) while (0); __leave(i); \
_filter ## i: \
asm("ret": :"a"(({f;}))); \
_handler ## i: do

#define __end(i) while (0); _end ## i: ;

#define __leave(i) asm("decl -4(%ebp)"); goto _end ## i

每次进入try块时,frame.trylevel加1。在try块正常结束后,将其减1来表示回到上一层scope。

__except定义了两个标签,i为标签索引,f为filter语句。因为scope.filter只需是一个函数指针,我们不妨用GNU C扩展复合语句表达式来将语句转为表达式。

__end只是定义了一个标签。

__leave也很好理解,恢复trylevel并跳到对应的__end处。

如果你还没有头晕,那么就让我们继续吧!我们写出这样一个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
void foo(void)
{
__seh_scope {
__scope(-1, 0), __scope(0, 1)
};
__seh_begin

__try {
printf("success\n");
__try {
*(int*)0 = 1;
printf("success2\n");
} __except(1,EF_HANDLE) {
printf("catch2\n");
} __end(1)
} __except(0,EF_HANDLE) {
printf("catch\n");
} __end(0)

__try {
__leave(2);
printf("hello\n");
} __except(2,EF_SEARCH) {
;
} __end(2)

__seh_end
}

int main(void)
{
foo();
}

因为我懒,嫌EXCEPTION_的宏常量实在太长,所以自己又定义了一遍短版本:

1
2
3
#define EF_EXECUTE (-1)
#define EF_SEARCH 0
#define EF_HANDLE 1

看起来一切准备就绪,让我们开始运行吧!

然后,我们发现,handler并没有被执行。惊不惊喜,意不意外?这是因为_except_handler3会调用_ValidateEH3RN进行安全性检查,它要求scopetable必须对齐且放在只读节

那把它放在.rdata节不就行了吗?不行,因为tcc的.rdata节是可写的!那放在.text节又怎么样呢?也不行,因为tcc的神奇特性(bug),它虽然会将scopetable放在.text节,但是由于我们在函数内定义了它(虽然是static),代码会直接覆盖scopetable!

难道我们就没有办法了吗?把scopetable放在函数外面?虽然可行(没错,tcc又一个诡异特性,label在它所在的函数定义前是可见的),但是宏将被改写得十分麻烦。

那么,打开tccpe.c,我们看到:

1
2
3
4
5
6
7
8
9
10
11
static const DWORD pe_sec_flags[] = {
0x60000020, /* ".text" , */
0xC0000040, /* ".data" , */
0xC0000080, /* ".bss" , */
0x40000040, /* ".idata" , */
0x40000040, /* ".pdata" , */
0xE0000060, /* < other > , */
0x40000040, /* ".rsrc" , */
0x42000802, /* ".stab" , */
0x42000040, /* ".reloc" , */
};

这些flags便是节的flags了。我们再来看看pe_section_class函数:

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
static int pe_section_class(Section *s)
{
int type, flags;
const char *name;

type = s->sh_type;
flags = s->sh_flags;
name = s->name;
if (flags & SHF_ALLOC) {
if (type == SHT_PROGBITS) {
if (flags & SHF_EXECINSTR)
return sec_text;
if (flags & SHF_WRITE)
return sec_data;
if (0 == strcmp(name, ".rsrc"))
return sec_rsrc;
if (0 == strcmp(name, ".iedat"))
return sec_idata;
if (0 == strcmp(name, ".pdata"))
return sec_pdata;
return sec_other;
} else if (type == SHT_NOBITS) {
if (flags & SHF_WRITE)
return sec_bss;
}
} else {
if (0 == strcmp(name, ".reloc"))
return sec_reloc;
if (0 == strncmp(name, ".stab", 5)) /* .stab and .stabstr */
return sec_stab;
}
return -1;
}

在此我们便可以解释.rdata为何可写了。由于没有.rdata,所以它被归为other,也就是可RWE,且包含代码和初始化数据,自然也就无法通过检查了。

基于上述分析,我们略微修改__seh_scope宏:

1
2
#define __seh_scope static __attribute__((section(".pdata"))) \
struct _scope _scopetable[] =

让我们再次运行,终于成功了!

那么,我们还差最后一样东西:throw。虽说它不属于try-except,但是管它呢,我们直接简单地封装一下RaiseException即可:

1
#define throw(code) RaiseException(code, 0, 0, NULL)

也可以修改RaiseException的第3、4个参数来传递额外的信息,来实现功能更强的throw

让我们重新整理一下:

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
#ifndef _TCCSEH_H
#define _TCCSEH_H

#ifndef NULL
#define NULL ((void *)0)
#endif

#ifndef _WINBASE_
void __stdcall RaiseException(unsigned long dwExceptionCode, unsigned long dwExceptionFlags, int nNumberOfArguments, const unsigned long *lpArguments);
#endif

#ifndef _INC_EXCPT
unsigned long __cdecl _exception_code(void);
#endif

#define __seh_begin void *_seh[6]; \
asm( \
"movl $-1,-4(%%ebp);" \
"movl %%eax,-8(%%ebp);" \
"movl $_except_handler3,-12(%%ebp);" \
"movl %%fs:0,%%eax;" \
"movl %%eax,-16(%%ebp);" \
"movl %%esp,-24(%%ebp);" \
"leal -16(%%ebp),%%eax;" \
"movl %%eax,%%fs:0;": :"a"(_scopetable));
#define __seh_end asm( \
"movl -16(%%ebp),%%eax;movl %%eax,%%fs:0;": : :"eax");

struct _scope {int trylevel; void *filter, *handler;};
#define __seh_scope static __attribute__((section(".pdata"))) struct _scope _scopetable[] =
#define __scope(l,i) {l, &&_filter ## i, &&_handler ## i}

#define __try asm("incl -4(%ebp)"); do
#define __except(i,f...) while (0); __leave(i); \
_filter ## i: \
asm("ret": :"a"(({f;}))); \
_handler ## i: do
#define __end(i) while (0); _end ## i: ;
#define __leave(i) asm("decl -4(%ebp)"); goto _end ## i

#define throw(code) RaiseException(code, 0, 0, NULL)

#define EF_EXECUTE (-1)
#define EF_SEARCH 0
#define EF_HANDLE 1

#endif

这样,我们就成功实现了类VC的try-except扩展了。

加上finally

好了,现在我们重新捡起我们扔在一边的__finally。事实上,由于__except__finally的实现机制不同,加上__finally将使得我们的宏无法共用。所以,我们不得不为它们制定两套宏。但是,如此一来,意义便不大了。因此,我们仅在此处介绍_except_handler3__finally实现机制,而不为其编写__finally宏。

我们只需知道,__finally的实现是通过展开来实现的。在_except_handler3找到处理异常的handler时,调用_local_unwind2执行展开。这个函数会遍历scopetable,当filterNULL时,异常处理例程会将handler成员当作__finally块执行。

如何尽可能简单地加上__finally呢?一个简单的思路便是抛弃_except_handler3,我们重新构造scopetable

我们定义:

1
2
3
4
5
6
typedef struct _SCOPETABLE {
int prev;
void *filter;
void *handler;
void *unwind;
} SCOPETABLE, *PSCOPETABLE;

很容易写出以下汇编代码:

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
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
.text

.global _tcc_except_handler
// cdecl: record, frame, context, dispatcher_context
_tcc_except_handler:
push %ebp
mov %esp,%ebp
sub $8,%esp
push %ebx
push %esi
push %edi

cld
// frame
mov 12(%ebp),%ebx
// record
mov 8(%ebp),%eax
// ExceptionFlags & (EXCEPTION_UNWINDING | EXCEPTION_EXIT_UNWIND)
testl $6,4(%eax)
jnz _unwinding
// ptrs = {record, context}
mov %eax,-8(%ebp)
mov 16(%ebp),%eax
mov %eax,-4(%ebp)
// xpointers
lea -8(%ebp),%eax
mov %eax,-4(%ebx)
// trylevel
mov 12(%ebx),%esi
// scopetable
mov 8(%ebx),%edi
_traverse:
cmp $-1,%esi
je _continue_search
shl $4,%esi
// filter
cmpl $0,4(%edi,%esi)
je _go_prev
push %esi
push %ebp
lea 16(%ebx),%ebp
call *4(%edi,%esi)
pop %ebp
pop %esi

or %eax, %eax
je _go_prev
js _continue_excution
mov 8(%ebx),%edi
push %ebx
call _global_unwind2
pop %ecx
lea 16(%ebx),%ebp
mov %esi,%eax
shr $4,%eax
push %eax
push %ebx
call _tcc_local_unwind
// we discard __NLG_Notify
mov (%edi,%esi),%eax
mov %eax,12(%ebx)
call *8(%edi,%esi)
_go_prev:
mov 8(%ebx),%edi
mov (%edi,%esi),%esi
jmp _traverse
_continue_excution:
mov $0,%eax
jmp _eh_ret
_unwinding:
push %ebp
lea 16(%ebx),%ebp
push $-1
push %ebx
call _tcc_local_unwind
pop %ebp
_continue_search:
mov $1,%eax
_eh_ret:
pop %edi
pop %esi
pop %ebx
leave
ret


// stacall: frame, trylvel
_tcc_local_unwind:
push %ebx
push %esi
push %edi
push $_local_unwind_eh
pushl %fs:0
mov %esp,%fs:0
_lu_t:
mov 24(%esp),%eax
// scopetable
mov 8(%eax),%ebx
// trylevel
mov 12(%eax),%esi
cmp $-1,%esi
je _lu_ret
cmpl 28(%esp),%esi
je _lu_ret
shl $4,%esi
mov (%ebx,%esi),%ecx
mov %ecx,12(%eax)
call *12(%ebx,%esi)
jmp _lu_t
_lu_ret:
popl %fs:0
pop %ecx
pop %edi
pop %esi
pop %ebx
ret $8


_local_unwind_eh:
mov 4(%esp),%ecx
testl $6,4(%ecx)
mov $1,%eax
jz _lueh_ret
// dispatcher = frame
mov 8(%esp),%eax
mov 16(%esp),%edx
mov %eax,(%edx)
mov $3,%eax
_lueh_ret:
ret

并重写我们的宏:

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
#ifndef _TCCSEH_H
#define _TCCSEH_H

#ifndef NULL
#define NULL ((void *)0)
#endif

#ifndef _WINBASE_
void __stdcall RaiseException(unsigned long dwExceptionCode, unsigned long dwExceptionFlags, int nNumberOfArguments, const unsigned long *lpArguments);
#endif

#ifndef _INC_EXCPT
unsigned long __cdecl _exception_code(void);
#endif

struct _scope {int trylevel; void *filter, *handler, *unwind;};
#define __seh_scope static struct _scope _scopetable[] =
#define __scope(l,i) {l, &&_filter ## i, &&_handler ## i, &&_unwind ## i}

#define __seh_begin void *_seh[6]; \
asm( \
"movl $-1,-4(%%ebp);" \
"movl %%eax,-8(%%ebp);" \
"movl $_tcc_except_handler,-12(%%ebp);" \
"movl %%fs:0,%%eax;" \
"movl %%eax,-16(%%ebp);" \
"movl %%esp,-24(%%ebp);" \
"leal -16(%%ebp),%%eax;" \
"movl %%eax,%%fs:0;": :"a"(_scopetable));

#define __seh_end asm( \
"movl -16(%%ebp),%%eax;movl %%eax,%%fs:0;": : :"eax");

#define __try asm("incl -4(%ebp)"); do

#define __except(i,f...) while(0); __leave(i); \
_filter ## i: \
asm("ret": :"a"(({f;}))); \
_handler ## i: asm("push %0": :"r"(&&_end ## i)); do

#define __finally(i) while (0); \
_unwind ## i: do

#define __end(i) while(0); asm("ret"); _end ## i: ;

#define __leave(i) asm("decl -4(%%ebp);push %0": :"r"(&&_end ## i)); goto _unwind ## i

#define EF_EXECUTE (-1)
#define EF_SEARCH 0
#define EF_HANDLE 1

#define throw(code) RaiseException(code, 0, 0, NULL)

#define __catch(i,code) __except(i,_exception_code() == (code) ? EF_HANDLE : EF_SEARCH)

#endif

而这便是全部了。

更简单的SEH宏

事实上,用VC的实现思路来写SEH宏确实太麻烦了,我想没人会去用如此麻烦的宏。因此,我们重新调整思路,只是简单地使用__try1__except1来实现SEH扩展。

注意到,让我们前文写的宏变得相当麻烦的就是scopetable的实现。然而,实现SEH完全不需要scopetable,我们完全可以用顺序或嵌套的__try1__except1来实现对应的功能。此外,我们实现try-catch-finally,而非try-except-finally,因为except需要一个filter函数,不如用若干个catch来的方便。

我们先给出实现,然后再进行讲解。

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
#ifdef NDEBUG
#define _TCC_SEH_PROLOG
#define _TCC_SEH_EPILOG
#else
#define _TCC_SEH_PROLOG asm("pushl %ebx");
#define _TCC_SEH_EPILOG asm("popl %ebx");
#endif

#define __seh(try, xcpt, fin) do { \
__label__ _l_finally, _l_except, _l_end; \
const void *_ll = &&_l_except; int _code = 0, _mode = 0; \
_TCC_SEH_PROLOG __try1(_ll) asm("pushl %esp; pushl %ebp"); \
try \
asm("add $8, %esp"); \
_l_finally: fin \
if (!_code) goto _l_end; \
asm("movl $1,%eax; popl %ecx; popl %ebp; ret"); \
_l_except: asm("pushl %%ebp; movl 16(%%ebp),%%eax; movl 12(%%ebp),%%ebp; pushl -4(%%ebp); movl -8(%%ebp),%%ebp; movl 176(%%eax),%%eax": "=a"(_code)); \
switch (_code) { \
default: if (!_mode) goto _l_finally; xcpt break; \
} \
_code = 0; \
asm("popl %%esp; jmp *%%edx": :"d"(&&_l_finally)); \
_l_end: __except1 _TCC_SEH_EPILOG \
} while(0);
#define __try
#define __catch(code) break; case code:
#define __catchall (_mode = 1)
#define __finally
#define __throw(code) asm("movl %%eax,(0)": : "a"(code))

先解释__seh。同样的,我们仍然需要使用一些goto来控制流程,但是,由于我们不需要构建scopetable,这意味着我们可以使用局部标签,从而省去为标签命名的麻烦。

然后,我们构造异常处理帧。注意到这里我用了两个宏_TCC_SEH_PROLOG_TCC_SEH_PROLOG来控制是否保护EBX。这是因为tcc使用-run参数时会用到EBX,因此我们需要防止它被破坏。如果直接编译成EXE后再运行,那么这就不是必要的了。这里我使用NDEBUG宏来控制。我们扩展的异常处理帧的布局如下:

1
2
3
4
5
EBP     ;-8
ESP ;-4
prev ;0
handler ;4
EBX ;8, optional

构造完毕后,我们执行try块。如果没有异常,那么会正常地来到_l_finally执行finally块。在此之前,我们移除栈顶的EBPESP,因为没有异常发生,我们不再需要异常处理帧了。为了压缩代码量,我们采用分步的方式来移除它。finally块执行完毕后,我们检查变量_code_code用来记录错误代码,如果没有错误或者错误已经被处理,那么_code为零,我们直接来到_l_end来销毁异常处理帧。

否则,当执行try块出现异常时,我们来到_l_except。这里可能有一些令人困惑的地方。

  1. 我们的异常处理例程事实上是原来函数的一部分,因此没有构造栈帧,这意味着我们不能简单地使用return语句返回,因为我们知道return在tcc的实现事实上是leave; ret,而leave指令不是我们所期待的。
  2. 虽然它确实是原来函数的一部分,但是需要注意,此时它是被内核函数调用的,这意味着EBP的值已经发生了改变,所有xcpt中使用的局部变量的定位都会错乱。这时就用上了我们扩展的异常处理帧,我们从中取出EBP来恢复异常发生前的栈帧,并将取出的ESP压栈(我们在销毁异常处理帧的时候需要保证ESP指向异常处理帧)。

在这里,我们还读取了context->Eax的值,它被我们约定用来表示异常处理代码。然后,我们通过一个switch来检查是否有匹配的catch。如果有,我们执行xcpt,并将_code清零。然后我们弹栈取回ESP使得其指向异常处理帧,并跳转至_l_finally执行finally块。如果没有匹配的catch,那么我们来到switch的default标签,这里的_mode用来控制出现未捕获异常时的行为。0表示继续搜索上一层异常处理例程;1表示捕获所有异常,交由xcpt最前面无catch的语句处理。如果我们向上传播异常,那么先执行finally块,然后恢复EBP,返回ExceptionContinueSearch来继续搜索。

在end部分,我们销毁异常处理例程,从而完全销毁异常处理帧。

__try__finally没有任何行为。__catch只是简单的case标签,我们把break写在前面,因为__catch后需要跟随一个语句,而我们的宏并不打算将这个语句包含在内。__catchall只是设置_mode来控制出现未捕获异常时的行为。至于__throw,我们将异常代码放入EAX中,并有意地触发一个EXCEPTION_ACCESS_VIOLATION异常,让内核函数去遍历SEH链。

这里我们使用吧主的帖子[2]中的例子来测试:

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
void func_throws_9(void)
{
__seh(
__try {
printf("%s: Hello, world!\n", __func__);
__throw(9);
printf("%s: This line won't be printed\n", __func__);
}, __catch(1) {
printf("%s: exception caught: 1\n", __func__);
}, __finally {
printf("%s: finally\n", __func__);
})
printf("%s: This line after __try won't be printed\n", __func__);
}

int main(void)
{
__seh(
__try {
__catchall;
printf("%s: Hello world!\n", __func__);
func_throws_9();
__throw(2);
printf("%s: This line won't be printed\n", __func__);
},
{printf("!!!Uncaught exception: %d, in <%s>\n", _code, __func__);}
__catch(2) {
printf("%s: exception caught: 2\n", __func__);
}, __finally {
printf("%s: finally\n", __func__);
})
}

__catchall的实现并不十分好。另外,由于__seh是个宏,因此这里会出现一些讨厌的逗号。但总体上效果是差不多的。运行结果如下:

1
2
3
4
5
main: Hello world!
func_throws_9: Hello, world!
func_throws_9: finally
!!!Uncaught exception: 9, in <main>
main: finally

结果略有不同,这是因为在我的实现中,“兜底”逻辑由用户实现,未捕获的异常通过__catchall来强制捕获;而在吧主的实现中,“兜底”逻辑在内部实现,未捕获的异常在所有事务处理完毕后再进行处理。

注册机源码及解析

源码

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
86
87
#include <stdio.h>
#include <conio.h>
#include <string.h>
#include <windows.h>

static const char xlat[] = "8n9oALN6uK+i#Z5G>JFr1\"xvC:c_PBld)qm;@U*~!&4-?^<]E0Iz%f7XOt$.p[hV";

#define NOP __asm__ __volatile__ ("nop");

static unsigned char *prev, tmp;

EXCEPTION_DISPOSITION
handler(PEXCEPTION_RECORD record, PEXCEPTION_REGISTRATION frame,
PCONTEXT context,void *dispatcher)
{
if (record->ExceptionCode != EXCEPTION_SINGLE_STEP &&
record->ExceptionCode != EXCEPTION_BREAKPOINT)
return ExceptionContinueSearch;
DWORD old;
VirtualProtect(handler, 0x1000, PAGE_EXECUTE_READWRITE, &old);
++*prev;
prev = record->ExceptionAddress;
if (*prev == 0xCC)
*prev = tmp;
--*prev;
if (*prev == 0xE8) {
tmp = prev[5];
prev[5] = 0xCC;
} else if (*prev != 0x90) {
context->EFlags |= 0x100;
}
VirtualProtect(handler, 0x1000, old, &old);
return ExceptionContinueExecution;
}

void gen_mc(char *p)
{
int info[4];
__cpuid(info, 1);
sprintf(p, "DUCK-%08X%08X", info[3], info[0]);
}

int verify(char *mc, char *sn)
{
__asm__ (
"movl %%eax,prev;"
"pushf;popl %%eax;"
"orl $0x100,%%eax;"
"pushl %%eax;popf;"
: :"a"(&&_start));
_start:
NOP
char s[33];
for (int i = 0, j = 0; j < 32; ++j) {
s[j] = sn[i];
i = i * 5 + 1 & 31;
}
s[32] = 0;
int a[5];
sscanf(mc, "DUCK-%08X%08X", a + 1, a + 3);
a[4] = 0xDEADCAFE;
a[0] = 0xdead1eaf;
a[2] = 0xC0FF1DE;
unsigned char *p = (void*)a;
char r[33];
for (int i = 0; i < 32; ++i) {
int a = i * 5 >> 3, b = i * 5 & 7;
r[i] = xlat[(p[a] >> b | p[a + 1] << (8 - b)) & 63];
}
r[32] = 0;
int i = !strcmp(r, s);
NOP
return i;
}

int main(void)
{
__try1(handler)
char mc[73];
gen_mc(mc);
printf("machine code: %s", mc);
char sn[103];
puts("\ninput sn code:");
scanf("%97s", sn);
printf("%scorrect", (verify(mc, sn) << 1) + "in");
__except1
}

解析

我们这样实现加密:将每条指令的首字节加1。
首先我们用tcc自带的__try1来把handler作为我们的异常处理例程。
随后调用gen_mc来生成机器码,gen_mc只是简单地用cpuid指令获取cpu的信息,并将其作为机器码。
之后输入sn,并调用verify来检验sn是否正确。
verify在一开始把全局变量prev赋值为_start标签的地址。这不是必要的,但是不初始化prev的话需要在handler加上一个prev==NULL的特判。
然后修改标志寄存器,将TF位置1。tcc不认识pushfd/popfd,要写成pushf/popf
因为popfd后不会立刻产生单步中断。我们学过8086,都知道如果当执行某条指令前,TF为1,那么会在执行这条指令后自动插入一条int1,产生一个单步中断。cpu根据IVT跳到相应的ISR,ISR用iret返回。
因此,我们需要在加密指令前插入一条空指令,以保证在执行加密指令前产生单步中断。
在执行nop后,由于TF=1,cpu插入int1,产生单步中断,cpu将控制权转到Ring 0的一个内核函数处理,它遍历SEH链分发单步异常,我们的异常处理例程handler接受并处理异常。
接下来我们看handler。倘若不是单步异常或者断点异常,那么我们就不处理。
否则,用VirtualProtect将.text段的属性改为可写,将prev(前一条指令)的首字节加1实现加密。
然后我们获取异常发生的地址,即下一条指令的地址,并更新prev。同理我们减1来实现解密。
最后,再用VirtualProtect将.text段的属性改为只读。
注意,由于contextEFlags成员的TF位为0,所以我们要手动将其置1。如果我们遇到了nop,那么就结束动态加解密。
但是,我们在这里面调用了库函数,而库函数是没有被加密的,所以,在调用库函数时,TF必须复位。我们在call的后面插一个int3,并用tmp保存我们破环的指令的首字节。当库函数执行完毕,产生断点异常,我们再把tmp写回下一条指令。
main的最后用__except1来移除我们构造的SEH结点。

参考文献


  1. Matt Pietrek. (1997, January). A Crash Course on the Depths of Win32™ Structured Exception Handling. Microsoft Systems Journal. http://www.microsoft.com/msj/0197/Exception/Exception.aspx. Archive: A Crash Course on theDepths of Win32 Structured Exception Handling, MSJ January 1997 (bytepointer.com) ↩︎

  2. GTA小鸡. (2023, August 19). setjmp/longjmp实现简单的异常处理. C语言吧, Baidu Tieba. https://tieba.baidu.com/p/8559540058 ↩︎