掀起你的盖头来——Windows SEH

将浏览器宽度调窄些有利于阅读本文

掀起你的盖头来——Windows SEH

SEH(Structured Exception Handling)亦即结构化异常处理,是Windows操作系统提供处理程序错误或异常的机制。相应的在C/C++中也提供了__try{}、__except{}、__try{}、__finally{}结构,但这些并不完全是由编译器本身提供的,编译器只不过是将Windows SHE结构化异常处理进行包装,提供给程序设计者使用。

程序在运行的过程中因环境的变化、程序设计上的失误而引起的错误或异常,轻则弹出错误提示,重则导致程序崩溃,甚至系统崩溃。所以对于严谨的程序来说,对异常错误的扑捉处理就很有必要了。以我个人的观点,异常错误处理大概分三个层次:系统级、调试器级、用户程序级。Windows操作系统为我们的程序安装了一个默认的异常处理句柄,下面的截图就是这个默认的异常处理程序弹出的提示框:

这个提示框很不友好,而且这个提示能给我们提供的错误信息也很少,大概就只能够看出是哪个程序出了问题和这个程序出现错误的内存位置。

    自然,我们会想由自己来处理自己程序中的错误,而不是程序一出现问题就弹出这个讨厌的对话框。很幸运,做为一名高级语言程序员,实现这个愿望非常简单,VB中有“On Error Resume Next”语句,JAVA中有”Try{…}Catch(e){…}”结构,而我们熟悉的VC中也有“__try{} __except(e){}”和“__try{} __finally{}”。下面的小程序段就是扑捉异常的例子:

#include <windows.h>

void main()

{

     char *p=NULL;

     __try

     {

         (*p)++;  //此语句的执行会报错

     }

     __except(1)   //这里没有进行任何筛选

     {

         MessageBoxA(NULL,"Exception Occur","Error",0);

     }

}

现大致介绍“__try{} __except(e){}”和“__try{} __finally{}”结构的作用:Windows 异常处理分两类,每线程异常处理和最终异常处理,笔者认为vc中给我们包装的__except 、__finally 都应算为每线程异常处理。“__try{}、__except(){}”的作用是在__try包装的语句发生异常时,调用__except来处理(还要根据过滤器来判断调用与否),“__try{} 、__finally{}”的作用是当被__try包装的语句执行完时(这里的执行完毕是指程序的执行流程从此块中跳出,不管何种方式,异常也算),即刻执行__finally语句块,这可以用来确保资源的释放。想了解详细请查看MSDN文档中的 “Structured Exception Handling”主题,那里面不但对语法做了很详细的解释,对异常处理机制也有一定的介绍。

    下面先简单介绍Windows操作系统在发现异常时调用处理例程大概过程,在此过程中只要有一个例程表示处理了异常,则操作系统就不会异常继续向下传递:

1、 判断此异常是否发送给用户程序,如若不发送则由操作系统负责处理异常;如若发送,则还要判断此程序是否处于被调试之中,被调试则由调试程序处理。最后才会调用用户程序的异常处理例程。

2、 如果决定由用户程序处理异常,系统则调用用户程序的线程内处理例程(可能是一系列,下面会有图解释)。

3、 当以上各例程都不处理,且程序处于被调试状态,则系统会挂起程序并再次将异常传递给调试器。

4、 如果程序未处于调试状态或者调试器没有处理异常,但这程序调用了SetUnhandledExceptionFilter函数安装了最终异常处理例程,则系统会调用此例程。

5、 如果没有安装最终异常处理例程或者该例程不处理此异常,则系统会调用默认的异常处理例程,会弹出类似上面截图的对话框。

以上过程是笔者个人的见闻,可能与权威有不相合的地方。总括起来,操作系统会按照调试器、SHE链上的各异常处理例程、系统默认处理例程调用。

每线程异常处理

我们知道,线程是Windows操作系统调用的最小单元,Windows 用TEB(Thread Information Block)来表示一个线程,用户态下,fs段选择子指向的段就是TEB,而TEB中前4个字节(FS:[0])就指向异常处理链表首地址。此链表的数据结构为:

struct _ERR

{

     struct _ERR        *pNextErr;             // 指向下一个异常处理例程

     DWORD              CurrentHandler;        // 此处理例程首地址

DWORD              Reserve1;              // 可不用,但在VC编译器生成的程序中此处为一DWORD类型变量地址,而且指向的内容必须为xFFFFFFFE,否则会报错

     DWORD              Reserve2;          // 可不用,但在VC中此值必须为xFFFFFFFE,否则会报错

     DWORD              SaveEBP;           // 保存EBP寄存器内容

};

以下是一简单的示意图:

更确切的说这是一个栈结构,因为我们只能够方便的从FS:[0]指向的这头添加和删除异常处理例程。下面的汇编片段是笔者反汇编VC++2008为我们生成的程序得到:

00401000 55               push        ebp 

00401001 8B EC            mov         ebp,esp

00401003 6A FE            push        0FFFFFFFEh

00401005 68 48 22 40 00   push        offset ___rtc_tzz+68h (402248h)

0040100A 68 55 17 40 00   push        offset _except_handler4 (401755h)

0040100F 64 A1 00 00 00 00 mov         eax,dword ptr fs:[00000000h]

00401015 50               push        eax 

这些语句的作用就是在堆栈中构造一个ERR结构,笔者在此还强调一个地方,fs:[00000000h]

是先前ERR链表的头指针,在此例程中作为Next,而此例程将作为链表中第一个异常处理例程。下面的这两条语句将新构造的ERR结构加入链表头

00401027 8D 45 F0         lea         eax,[ebp-10h]

0040102A 64 A3 00 00 00 00 mov         dword ptr fs:[00000000h],eax

_except_handler的函数原型写法如下:

_except_handler4(_EXCEPTION_RECORD *, _EXCEPTION_REGISTRATION_RECORD *, _CONTEXT *, void *)

下面的语句将会拆除这个异常处理句柄(此线程结束前执行这个动作):

0040109A 8B 4D F0         mov         ecx,dword ptr [ebp-10h]

0040109D 64 89 0D 00 00 00 00 mov         dword ptr fs:[0],ecx

    综合起来,每线程异常处理大致有三个基本过程:1、在当前堆栈中建立新的ERR结构;2、将新的ERR结构地址安装到由FS:[0]指针指向的ERR链表之中;3、不需要此异常处理例程时,线程结束之前,将此ERR从链表中拆除。

最终异常处理

无论那个线程发生了异常,如果线程自己没有相应的处理,则异常会被操作系统传递到最终异常处理例程,也就是说最终异常处理例程可以处理那些被操作系统传递给进程而进程中的各线程又没有处理的异常。Kernel32.dll中导出了一个可以安装最终异常处理例程的函数——SetUnhandledExceptionFilter,此函数要求传递LPTOP_LEVEL_EXCEPTION_FILTER

类型的函数指针作为参数。下面的例子将演示安装和触发最终异常处理例程的过程:

#include <windows.h>

char *p=NULL;

char szTitle[]="Finally Exception";           

char szMsg[]="Finally Exception Occur!";

LONG WINAPI FinallyHandler( struct _EXCEPTION_POINTERS* ExceptionInfo)

{

     // 通过ExceptionInfo指针传进了关于异常错误的详细描述,实际应用中应该加以判断应用,在此不考虑

     MessageBoxA(NULL,"Finally Catch This Exception","Exception Occur",0);

     return 1; // 返回表示异常在此已经处理了,则操作系统将不会继续向下传递异常,如果返回则异常会向下传递

}

void NewThread()

{

     // 新线程没有异常处理例程,当发生异常后,操作系统会将异常继续下传,由进程的最终异常处理例程负责处理掉了。

     (*p)++;       // 引发异常

}

void main()

{

     HANDLE hThread;

     HMODULE  hModule;

     FARPROC lpSetUnhandledExceptionFilter;

     if(NULL==(hModule=LoadLibrary(L"kernel32.dll")))

         ExitProcess(0);

     if(NULL==(lpSetUnhandledExceptionFilter=GetProcAddress(hModule,"SetUnhandledExceptionFilter")))

         ExitProcess(0);

     // 直接得到SetUnhandledExceptionFilter函数地址,然后传参调用,则不会被VC编译器替换掉

     // 对这得到函数地址的调用,我们可以查MSDN中此函数原型然后定义相应的指针类型,并加以调用

     // 但在此,笔者用汇编语言的方式调用,方便很多

     _asm

     {

         push FinallyHandler

         call lpSetUnhandledExceptionFilter

     }

     __try

     {

         (*p)++;       // 引发异常

     }

     __except(1)

     {

         MessageBoxA(NULL,"Thread Catch This Exception","Exception Occur",0);

         _asm jmp Next // 主线程异常处理之后即刻跳转Next

     }

     _asm

     {

Next:

         nop

     }

     hThread=CreateThread(NULL,0,(LPTHREAD_START_ROUTINE)NewThread,NULL,0,NULL);

     WaitForSingleObject(hThread,INFINITE);    // 等待线程的结束

}

    细心的朋友也许会发现,笔者在安装最终异常处理例程时对SetUnhandledExceptionFilter函数的调用不同于通常的函数调用,而是利用直接得到函数地址的方法进行调用。刚开始的时候,笔者也是直接调用,但调试时老是出错误(运行是不会出现错误的),想了好久都想不明白究竟那里出了错误,后来跟踪反汇编发现VC++2008编译器重写了这个函数。而在此函数中就有检查的机制,我想这大概是为了保证最终异常处理只被安装一次吧,但又不对啊,我还是第一次调用这SetUnhandledExceptionFilter函数啊,莫非是编译器在后台为我们调用了呢?带这这个问题,又新建了一个TestFinallyHandler.cpp文件,其中只有一个空的main函数。编译连接之后用以下命令反汇编之后观察到确实调用了。

反汇编命令:dumpbin /disasm TestFinallyHandler.exe > TestFinallyHandler.txt

以下汇编语句:

___CxxSetUnhandledExceptionFilter:

  00401406: push        offset ?__CxxUnhandledExceptionFilter@@YGJPAU_EXCEPTION_POINTERS@@@Z

  0040140B: call        dword ptr [__imp__SetUnhandledExceptionFilter@4]

__CxxUnhandledExceptionFilter@@YGJPAU_EXCEPTION_POINTERS@@@Z函数很简单,就是判断是否需要终止进程call        ?terminate@@YAXXZ,此函数不会弹出任何提示框。

__imp__SetUnhandledExceptionFilter@4在运行时由装载程序找到地址。

查阅资料才知道,原来cl.exe编译器有这个选项,而VC++2008在调用的时候默认设置好为开启最终异常处理。请看如下截图:

    明白了这些原理之后,我们就不再局限于编译器给我们包装的异常处理功能了。假设有这样一种情况,我们在写一程序的时候,想让这程序在出现异常的时候不弹出那些讨厌的错误提示对话框,而是直接终止运行。实现这个要求,至少有两条途径,安装最终异常处理例程和销毁给线程的异常处理链表(因为系统默认的异常处理例程也在此链表)下面的程序就是销毁链表的例子:

void main()

{

     _asm mov  fs:[0],0h     // 直接将异常处理链表头指针置空

     char *p=NULL;     

     (*p)++;                //此语句的执行会出错

}

CIH病毒中的异常处理

另外,笔者所知道的异常处理在计算机病毒界中也有很广泛的应用,古老年代的CIH病毒中就有以下语句用来安装自己的异常处理:

push ebp

         lea      eax,[esp-04h*2]    ;eax中为前一个结构地址

         xor      ebx,ebx            ;此中用ebx=0只是为了病毒的优化

         xchg eax,fs:[ebx] 

         call a0

         a0:

         pop      ebx

         lea      ecx,StopToRunVirtulsCode-a0[0]

         push ecx

         push eax

         StopToRunVirusCode:         ; 发生异常则会转到此处理

         a1=StopToRunVirusCode

         xor  ebx,ebx

         mov  eax,fs:[ebx]

         mov  esp,[eax]

         RestoreSE:

         pop  dword ptr fs:[ebx]

         pop  eax                   ;此语句只起平衡堆栈的作用

这CIH就是高明,不仔细读还真搞不明白啊。笔者能力有限,还得继续学习学习。。。

小试“进程注入”技术

    说到病毒这事儿,不知道大家是否注意到我最上面截的那个图,我用了这么久的“谷歌金山词霸”,还真没有发现它出现异常或者错误,不是笔者做手脚是截不到那个图的。在此暂且把我用的技术叫做“进程注入”吧。很多的病毒就利用这个技术向其他的进程地址空间中写入二进制代码,然后让这些代码在别的进程中执行,这样就很难被用户发现,因为用户看到的都是他熟悉的应用程序进程。我所知道的病毒最喜欢注入的进程就是explorer.exe。下面说说原理:

1、 调用OpenProcess打开一个进程。

2、 利用VirtualAllocEx在打开的进程中申请一段内存空间。

3、 运用WriteProcessMemory向申请到的内存空间中写入一段二进制机器指令。

4、 调用CreateRemoteThread执行你写入的那些指令就可以了。

但你要保证你写入的指令是可以正确的执行,否则会出现如上那样的错误提示框,以下跟大家分享笔者在做试验时构造好的一段指令数据:

Char ThreadData[]="\xbf\xcc\xcc\xcc\xcc\xbb\xcc\xcc\xcc\xcc\xbe\xcc\xcc\xcc\xcc\x6a\x00\xba\xcc\xcc\xcc\xcc\x52\xba\xcc\xcc\xcc\xcc\x52\x6a\x00\xff\xd3\x68\xe8\x03\x00\x00\xff\xd7\xff\xe6";

//****************************************************************************

//                          ThreadData数据说明如下:

/*

              \xbf\xcc\xcc\xcc\xcc        //edi-sleep地址

              \xbb\xcc\xcc\xcc\xcc        //ebx-MessageBoxW地址

              \xbe\xcc\xcc\xcc\xcc        //esi-此线程首地址

              // 调用MessageBoxW

              \x6a\x00                    //push 0

              \xba\xcc\xcc\xcc\xcc        //edx中放入地址,要求是字符串首地址,且可以寻址

              \x52                        //push edx

              \xba\xcc\xcc\xcc\xcc        //edx中放入地址,要求是字符串首地址,且可以寻址

              \x52                        //push edx

              \x6a\x00                    //push 0

              \xff\xd3                    //call ebx

              // 调用Sleep

              \x68\xe8\x03\x00\x00        //push 1000

              \xff\xd7                    //call edi

              \xff\xe6                    //jmp esi 跳转进入循环

*/

//****************************************************************************

等价的C语言如下:

while(1)

     {

         MessageBoxW(NULL,szMsg,szTitle,0);

         Sleep(1000);

     }

啊哈,学习、探究这Windows SEH 可用了我好几天的时间啊。不过也很有意思,又多了解了一些Windows的内部机制。另外,在做这个试验时,发现VC++2008对异常处理的封装非常巧妙,我跟踪进去了好久都没有搞明白(汇编技术不是很高,程序流程都没有搞清楚,被那些跳转、调用弄得晕头转向)有汇编基础的朋友可以进去看看。写这篇文章花了将近一天的时间,也算是总结这几天的学习吧。作为高级语言程序员,我想在写程序时应始终不忘3个概念:时间、人物、地点,这样才会不做名糊涂的Programmer:

    时间­——Running Time 和Compile Time.

    人物——You 和 Programmer of Compiler.

    地点——Stack 和Heap

“Know Why”rather than“Know How”是我一直向往的Programmer’s LifeStyle。

============================================================================================

By SageMiner

QQ:496565825

Email:nlq_417@163.com

Date:2009-12-26

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值