前言
本文不建议初学者阅读。
本文将涉及少量汇编,请确保你已经了解x86汇编的基础知识。
确保你已经了解异常处理的基础知识。
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更详细的内容可以查阅参考文献。
使用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 { PVC_EXCEPTION_REGISTRATION prev; PEXCEPTION_HANDLER handler; PSCOPETABLE scopetable; int trylevel; void *_ebp; } EH3_EXCEPTION_REGISTRATION, *PEH3_EXCEPTION_REGISTRATION;
prev
和handler
就是之前的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;
关于scoptable
和trylevel
,我们将在稍后进行讲解,现在我们仅给出SCOPETABLE
的结构:
1 2 3 4 5 typedef struct _SCOPETABLE { int prev; FARPROC filter; FARPROC handler; } SCOPETABLE, *PSCOPETABLE;
要想弄明白scoptable
和trylevel
,我想可能需要理解一下_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 ) return ExceptionContinueExecution; _global_unwind2(frame); EBP = &frame->_ebp; _local_unwind2(frame, trylevel); _NLG_Notify(1 ); frame->trylevel = scopetable[trylevel].prev; scopetable[trylevel].handler(); } } trylevel = scopetable[trylevel].prev; } } else { PUSH EBP EBP = &frame->_ebp; _local_unwind2(frame, TRYLEVEL_NONE); POP EBP } return ExceptionContinueSearch; }
相信大家看完这段伪代码就明白scopetable
和trylevel
的作用是什么了。
prev
用来实现嵌套的try-except,每层scope
的prev
均为前一层的索引,最外层的scope
的prev
为TRYLEVEL_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 , 0xC0000040 , 0xC0000080 , 0x40000040 , 0x40000040 , 0xE0000060 , 0x40000040 , 0x42000802 , 0x42000040 , };
这些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 )) 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
,当filter
为NULL
时,异常处理例程会将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块。在此之前,我们移除栈顶的EBP
和ESP
,因为没有异常发生,我们不再需要异常处理帧了。为了压缩代码量,我们采用分步的方式来移除它。finally块执行完毕后,我们检查变量_code
。_code
用来记录错误代码,如果没有错误或者错误已经被处理,那么_code
为零,我们直接来到_l_end
来销毁异常处理帧。
否则,当执行try块出现异常时,我们来到_l_except
。这里可能有一些令人困惑的地方。
我们的异常处理例程事实上是原来函数的一部分,因此没有构造栈帧,这意味着我们不能简单地使用return
语句返回,因为我们知道return
在tcc的实现事实上是leave; ret
,而leave
指令不是我们所期待的。
虽然它确实是原来函数的一部分,但是需要注意,此时它是被内核函数调用的,这意味着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链。
这里我们使用吧主的帖子中的例子来测试:
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段的属性改为只读。
注意,由于context
的EFlags
成员的TF位为0,所以我们要手动将其置1。如果我们遇到了nop
,那么就结束动态加解密。
但是,我们在这里面调用了库函数,而库函数是没有被加密的,所以,在调用库函数时,TF
必须复位。我们在call
的后面插一个int3
,并用tmp
保存我们破环的指令的首字节。当库函数执行完毕,产生断点异常,我们再把tmp
写回下一条指令。
main
的最后用__except1
来移除我们构造的SEH结点。
参考文献