Win32病毒入门--ring3篇

2008-01-02

16:34

341次

0人

2个


一篇讲述病毒原理的理论性文章,任何人如果通过本文中讲述的技术或利用本文中的代码写出恶性病毒,造成的任何影响均与作者无关。



 


 
病毒是什么?病毒就是一个具有一定生物病毒特性,可以进行传播、感染的程序。病毒同样是一个程序,只不过它经常做着一些正常程序不常做的事情而已,仅此而已。在这篇文章中我们将揭开病毒的神秘面纱,动手写一个病毒(当然这个病毒是不具有破坏力的,仅仅是一个良性病毒)。

如果你有一定的病毒编写基础,那么就此打住,这是一篇为对病毒编程完全没有概念的读者编写的,是一篇超级入门的文章 :P

这是一篇完整、详细的入门文章,但是如果读者对编程还没有什么认识我想也不可能顺利地读下去。本文要求读者:
 
1)  有基本的C/C++语言知识。因为文章中的很多结构的定义我使用的是C/C++的语法。
  
2)  有一定的汇编基础。在这篇文章中我们将使用FASM编译器,这个编译器对很多读者来说
 
    可能很陌生,不过没关系,让我们一起来熟悉它 :P
  
3)  有文件格式的概念,知道一个可执行文件可以有ELF、MZ、LE、PE之分。





 
-------------
 


 
DOS下,可执行文件分为两种,一种是从CP/M继承来的COM小程序,另一种是EXE可执行文件,
 
我们称之为MZ文件。而Win32下,一种新的可执行文件可是取代了MZ文件,就是我们这一节
 
的主角 -- PE文件。
 


 
PE(Portable Executable File Format)称为可移植执行文件格式,我们可以用如下的表
 
来描述一个PE文件:
 


 
+-----------------------------+     --------------------------------------------
 
|         DOS MZ文件头        |                                         ^
 
+-----------------------------+                                      DOS部分
 
|            DOS块            |                                         v
 
+-----------------------------+     --------------------------------------------
 
|           PE\0\0            |                                         ^
 
+-----------------------------+                                         |
 
|    IMAGE_FILE_HEADER结构    |                                      PE文件头
 
+-----------------------------+                                         |
 
| IMAGE_OPTIONAL_HEADER32结构 |                                         v
 
+-----------------------------+     --------------------------------------------
 
|                             |-----+                                   ^
 
|                             |-----+-----+                             |
 
|  n*IMAGE_SECTION_HEADER结构 |-----+-----+-----+                     节表
 
|                             |-----+-----+-----+-----+                 |
 
|                             |-----+-----+-----+-----+-----+           v
 
+-----------------------------+     |     |     |     |     |     --------------
 
|           .text节           |<----+     |     |     |     |           ^
 
+-----------------------------+           |     |     |     |           |
 
|           .data节           |<----------+     |     |     |           |
 
+-----------------------------+                 |     |     |           |
 
|           .idata节          |<----------------+     |     |        节数据
 
+-----------------------------+                       |     |           |
 
|           .reloc节          |<----------------------+     |           |
 
+-----------------------------+                             |           |
 
|             ...             |<----------------------------+           v
 
+-----------------------------+     --------------------------------------------
 


 
我们要对PE格式进行一次超高速洗礼。
 


 
PE文件的头部是一个DOS MZ文件头,这是为了可执行文件的向下兼容性设计的。PE文件的DOS
 
部分分为两部分,一个是MZ文件头,另一部分是DOS块,这里面存放的是可执行代码部分。还
 
记得在DOS下运行一个PE文件时的情景么:“This program cannot be run in DOS mode.”。
 
没错,这就是DOS块(DOS Stub)完成的工作。下面我们先来看看MZ文件头的定义:
 


 
typedef struct _IMAGE_DOS_HEADER {      // DOS .EXE header
 
    WORD   e_magic;                     // Magic number
 
    WORD   e_cblp;                      // Bytes on last page of file
 
    WORD   e_cp;                        // Pages in file
 
    WORD   e_crlc;                      // Relocations
 
    WORD   e_cparhdr;                   // Size of header in paragraphs
 
    WORD   e_minalloc;                  // Minimum extra paragraphs needed
 
    WORD   e_maxalloc;                  // Maximum extra paragraphs needed
 
    WORD   e_ss;                        // Initial (relative) SS value
 
    WORD   e_sp;                        // Initial SP value
 
    WORD   e_csum;                      // Checksum
 
    WORD   e_ip;                        // Initial IP value
 
    WORD   e_cs;                        // Initial (relative) CS value
 
    WORD   e_lfarlc;                    // File address of relocation table
 
    WORD   e_ovno;                      // Overlay number
 
    WORD   e_res[4];                    // Reserved words
 
    WORD   e_oemid;                     // OEM identifier (for e_oeminfo)
 
    WORD   e_oeminfo;                   // OEM information; e_oemid specific
 
    WORD   e_res2[10];                  // Reserved words
 
    LONG   e_lfanew;                    // File address of new exe header
 
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
 


 
其中e_magic就是鼎鼎大名的‘MZ’,这个我们并不陌生。后面的字段指明了入口地址、堆
 
栈位置和重定位表位置等。我们还要关心的一个字段是e_lfanew字段,它指定了真正的PE文
 
件头,这个地址总是经过8字节对齐的。
 


 
下面让我们来真正地走进PE文件,下面是PE文件头的定义:
 


 
typedef struct _IMAGE_NT_HEADERS {
 
    DWORD Signature;
 
    IMAGE_FILE_HEADER FileHeader;
 
    IMAGE_OPTIONAL_HEADER32 OptionalHeader;
 
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
 


 
PE文件头的第一个双字是00004550h,即字符P、E和两个0。后面还有两个结构:
 


 
typedef struct _IMAGE_FILE_HEADER {
 
    WORD    Machine;
 
    WORD    NumberOfSections;
 
    DWORD   TimeDateStamp;
 
    DWORD   PointerToSymbolTable;
 
    DWORD   NumberOfSymbols;
 
    WORD    SizeOfOptionalHeader;
 
    WORD    Characteristics;
 
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
 


 
typedef struct _IMAGE_OPTIONAL_HEADER {
 
    //
 
    // Standard fields.
 
    //
 


 
    WORD    Magic;
 
    BYTE    MajorLinkerVersion;
 
    BYTE    MinorLinkerVersion;
 
    DWORD   SizeOfCode;
 
    DWORD   SizeOfInitializedData;
 
    DWORD   SizeOfUninitializedData;
 
    DWORD   AddressOfEntryPoint;
 
    DWORD   BaseOfCode;
 
    DWORD   BaseOfData;
 


 
    //
 
    // NT additional fields.
 
    //
 


 
    DWORD   ImageBase;
 
    DWORD   SectionAlignment;
 
    DWORD   FileAlignment;
 
    WORD    MajorOperatingSystemVersion;
 
    WORD    MinorOperatingSystemVersion;
 
    WORD    MajorImageVersion;
 
    WORD    MinorImageVersion;
 
    WORD    MajorSubsystemVersion;
 
    WORD    MinorSubsystemVersion;
 
    DWORD   Win32VersionValue;
 
    DWORD   SizeOfImage;
 
    DWORD   SizeOfHeaders;
 
    DWORD   CheckSum;
 
    WORD    Subsystem;
 
    WORD    DllCharacteristics;
 
    DWORD   SizeOfStackReserve;
 
    DWORD   SizeOfStackCommit;
 
    DWORD   SizeOfHeapReserve;
 
    DWORD   SizeOfHeapCommit;
 
    DWORD   LoaderFlags;
 
    DWORD   NumberOfRvaAndSizes;
 
    IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
 
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;
 


 
我们先来看看IMAGE_FILE_HEADER。Machine字段指定了程序的运行平台。
 


 
NumberOfSections指定了文件中节(有关节的概念后面会有介绍)的数量。
 


 
TimeDataStamp是编译次文件的时间,它是从1969年12月31日下午4:00开始到创建为止的总秒数。
 


 
PointerToSymbolTable指向调试符号表。NumberOfSymbols是调试符号的个数。这两个字段我们不需要关心。
 


 
SizeOfOptionalHeader指定了紧跟在后面的IMAGE_OPTIONAL_HEADER结构的大小,它总等于0e0h。
 


 
Characteristics是一个很重要的字段,它描述了文件的属性,它决定了系统对这个文件的装载方式。下面是这个字段每个位的含义(略去了一些我们不需要关心的字段):
 


 
#define IMAGE_FILE_RELOCS_STRIPPED           0x0001  // 文件中不存在重定位信息
 
#define IMAGE_FILE_EXECUTABLE_IMAGE          0x0002  // 文件是可执行的
 
#define IMAGE_FILE_LARGE_ADDRESS_AWARE       0x0020  // 程序可以触及大于2G的地址
 
#define IMAGE_FILE_BYTES_REVERSED_LO         0x0080  // 小尾方式
 
#define IMAGE_FILE_32BIT_MACHINE             0x0100  // 32位机器
 
#define IMAGE_FILE_REMOVABLE_RUN_FROM_SWAP   0x0400  // 不可在可移动介质上运行
 
#define IMAGE_FILE_NET_RUN_FROM_SWAP         0x0800  // 不可在网络上运行
 
#define IMAGE_FILE_SYSTEM                    0x1000  // 系统文件
 
#define IMAGE_FILE_DLL                       0x2000  // 文件是一个DLL
 
#define IMAGE_FILE_UP_SYSTEM_ONLY            0x4000  // 只能在单处理器计算机上运行
 
#define IMAGE_FILE_BYTES_REVERSED_HI         0x8000  // 大尾方式
 


 
下面我们再来看一下IMAGE_OPTIONAL_HEADER32结构,从字面上看好象这个结构是可选的,其实则不然,它是每个PE文件不可缺少的部分。我们分别对每个字段进行讲解,同样我们仍省略了一些我们不太关心的字段。
 


 
Magic字段可能是两个值:107h表示是一个ROM映像,10bh表示是一个EXE映像。
 


 
SizeOfCode表示代码节的总大小。
 


 
SizeOfInitializedData指定了已初始化数据节的大小,SizeOfUninitializedData包含未初始化数据节的大小。
 


 
AddressOfEntryPoint是程序入口的RVA(关于RVA的概念将在后面介绍,这是PE文件中的一个非常重要又非常容易混淆的概念)。如果我们要改变程序的执行入口则可以改变这个值 :P
 


 
BaseOfCode和BaseOfData分别是代码节和数据节的起始RVA。
 


 
ImageBase是程序建议的装载地址。如果可能的话系统将文件加载到ImageBase指定的地址,如果这个地址被占用文件才被加载到其他地址上。由于每个程序的虚拟地址空间是独立的,所以对于优先装入的EXE文件而言,其地址空间不可能被占用;而对于DLL,其装入的地址空间要依具体程序的地址空间的使用状态而定,所以可能每次装载的地址是不同的。这还引出了另一个问题就是,一般的EXE文件不需要定位表,而DLL文件必须要有一个重定位表。
 


 
SectionAligment和FileAligment分别是内存中和文件中的对齐粒度,正是由于程序在内存中和文件中的对齐粒度不同才产生了RVA概念,后面提到。
 


 
SizeOfImage是内存中整个PE的大小。
 


 
SizeOfHeaders是所有头加节表的大小。
 


 
CheckSum是文件的校验和,对于一般的PE文件系统并不检查这个值。而对于系统文件,如驱动等,系统会严格检查这个值,如果这个值不正确系统则不予以加载。
 


 
Subsystem指定文件的子系统。关于各个取值的定义如下:
 


 
#define IMAGE_SUBSYSTEM_UNKNOWN              0   // 未知子系统
 
#define IMAGE_SUBSYSTEM_NATIVE               1   // 不需要子系统
 
#define IMAGE_SUBSYSTEM_WINDOWS_GUI          2   // Windows图形界面
 
#define IMAGE_SUBSYSTEM_WINDOWS_CUI          3   // Windows控制台界面
 
#define IMAGE_SUBSYSTEM_OS2_CUI              5   // OS/2控制台界面
 
#define IMAGE_SUBSYSTEM_POSIX_CUI            7   // Posiz控制台界面
 
#define IMAGE_SUBSYSTEM_NATIVE_WINDOWS       8   // Win9x驱动程序,不需要子系统
 
#define IMAGE_SUBSYSTEM_WINDOWS_CE_GUI       9   // Windows CE子系统
 


 
NumberOfRvaAndSizes指定了数据目录结构的数量,这个数量一般总为16。
 


 
DataDirectory为数据目录。
 


 
下面是数据目录的定义:
 


 
typedef struct _IMAGE_DATA_DIRECTORY {
 
    DWORD   VirtualAddress;
 
    DWORD   Size;
 
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
 


 
VirtualAddress为数据的起始RVA,Size为数据块的长度。下面是数据目录列表的含义:
 


 
#define IMAGE_DIRECTORY_ENTRY_EXPORT          0   // 导出表
 
#define IMAGE_DIRECTORY_ENTRY_IMPORT          1   // 引入表
 
#define IMAGE_DIRECTORY_ENTRY_RESOURCE        2   // 资源
 
#define IMAGE_DIRECTORY_ENTRY_EXCEPTION       3   // 异常
 
#define IMAGE_DIRECTORY_ENTRY_SECURITY        4   // 安全
 
#define IMAGE_DIRECTORY_ENTRY_BASERELOC       5   // 重定位表
 
#define IMAGE_DIRECTORY_ENTRY_DEBUG           6   // 调试信息
 
#define IMAGE_DIRECTORY_ENTRY_ARCHITECTURE    7   // 版权信息
 
......
 


 
看到这里大家是不是很混乱呢?没办法,只能硬着头皮“啃”下去,把上面的内容再重新读一遍... 下面我们继续,做好准备了么?我们开始啦!!
 


 
紧接着IMAGE_NT_HEADERS结构的是节表。什么是节表呢?别着急,我们先要清楚一下什么是节。PE文件是按照节的方式组织的,比如:数据节、代码节、重定位节等。每个节有着自己的属性,如:只读、只写、可读可写、可执行、可丢弃等。其实在执行一个PE文件的时候,Windows并不是把整个PE文件一下读入内存,而是采用内存映射的机制。当程序执行到某个内存页中的指令或者访问到某个内存页中的数据时,如果这个页在内存中那么就执行或访问,如果这个页不在内存中而是在磁盘中,这时会引发一个缺页故障,系统会自动把这个页从交换文件中提交的物理内存并重新执行故障指令。由于这时这个内存页已经提交到了物理内存则程序可以继续执行。这样的机制使得文件装入的速度和文件的大小不成比例关系。
 


 
节表就是描述每个节属性的表,文件中有多少个节就有多少个节表。下面我们来看一下节表的结构:
 


 
#define IMAGE_SIZEOF_SHORT_NAME              8
 


 
typedef struct _IMAGE_SECTION_HEADER {
 
    BYTE    Name[IMAGE_SIZEOF_SHORT_NAME];
 
    union {
 
            DWORD   PhysicalAddress;
 
            DWORD   VirtualSize;
 
    } Misc;
 
    DWORD   VirtualAddress;
 
    DWORD   SizeOfRawData;
 
    DWORD   PointerToRawData;
 
    DWORD   PointerToRelocations;
 
    DWORD   PointerToLinenumbers;
 
    WORD    NumberOfRelocations;
 
    WORD    NumberOfLinenumbers;
 
    DWORD   Characteristics;
 
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
 


 
Name为一个8个字节的数组。定义了节的名字,如:.text等。习惯上我们把代码节称为.text,把数据节称为.data,把重定位节称为.reloc,把资源节称为.rsrc等。但注意:这些名字不是一定的,可一任意命名,千万不要通过节的名字来定位一个节。
 


 
Misc是一个联合。通常是VirtualSize有效。它指定了节的大小。这是节在没有进行对齐前的
 
大小。
 


 
VirtualAddress指定了这个节在被映射到内存中后的偏移地址,是一个RVA地址。这个地址是经过对齐的,以SectionAlignment为对齐粒度。
 


 
PointerToRawData指定了节在磁盘文件中的偏移,注意不要与RVA混淆。
 


 
SizeOfRawData指定了节在文件中对齐后的大小,即VirtualSize的值根据FileAlignment粒度对齐后的大小。
 


 
Characteristics同样又是一个很重要的字段。它指定了节的属性。下面是部分属性的定义:
 


 
#define IMAGE_SCN_CNT_CODE                   0x00000020  // 节中包含代码
 
#define IMAGE_SCN_CNT_INITIALIZED_DATA       0x00000040  // 节中包含已初始化数据
 
#define IMAGE_SCN_CNT_UNINITIALIZED_DATA     0x00000080  // 节中包含未初始化数据
 
#define IMAGE_SCN_MEM_DISCARDABLE            0x02000000  // 是一个可丢弃的节,即
 
                                                         // 节中的数据在进程开始
 
                                                         // 后将被丢弃
 
#define IMAGE_SCN_MEM_NOT_CACHED             0x04000000  // 节中数据不经过缓存
 
#define IMAGE_SCN_MEM_NOT_PAGED              0x08000000  // 节中数据不被交换出内存
 
#define IMAGE_SCN_MEM_SHARED                 0x10000000  // 节中数据可共享
 
#define IMAGE_SCN_MEM_EXECUTE                0x20000000  // 可执行节
 
#define IMAGE_SCN_MEM_READ                   0x40000000  // 可读节
 
#define IMAGE_SCN_MEM_WRITE                  0x80000000  // 可写节
 


 
好了,是时候跟大家介绍RVA的概念了。这是一个大多数初学者经常搞不清楚的容易混淆的概念。RVA是Relative Virtual Address的缩写,即相对虚拟地址。那么RVA到底代表什么呢?简单的说就是,RVA是内存中相对装载基址的偏移。假设一个进程的装载地址为00400000h,一个数据的地址为00401234h,那么这个数据的RVA为00401234h-00400000h=1234h。
 


 :P因为Win32下的可执行文件、DLL和驱动等都是PE格式的,我们的病毒要感染它们,所以必须要把整个PE格式烂熟于心。
 


 
其实关于PE文件我们还有导入表、导出表、重定位表、资源等很多内容没有讲。
 



2. 关于FASM

 
-----------
 


 
下面我们用FASM来编写我们的第一个程序。我们可以编写如下代码:
 


 
format  PE GUI 4.0
 
entry   __start
 


 
section '.text' code    readable executable
 


 
    __start:
 
            ret
 


 
我们把这个文件存为test.asm并编译它:
 


 
fasm test.asm test.exe
 


 
没有任何烦人的参数,很方便,不是么? :P
 


 
我们先来看一下这个程序的结构。第一句是format指示字,它指定了程序的类型,PE表示我们编写的是一个PE文件,后面的GUI指示编译器我们将使用Windows图形界面。如果要编写一个控制台应用程序则可以指定为CONSOLE。如果要写一个内核驱动,可以指定为NATIVE,表示不需要子系统支持。最后的4.0指定了子系统的版本号(还记得前面的MajorSubsystemVersion和MinorSubsystemVersion么?)。
 


 
下面一行指定了程序的入口为__start。
 


 
section指示字表示我们要开始一个新节。我们的程序只有一个节,即代码节,我们将其命名为.text,并指定节属性为只读(readable)和可执行(executable)。
 


 
之后就是我们的代码了,我们仅仅用一条ret指令返回系统,这时堆栈里的返回地址为Exit-Thread,所以程序直接退出。
 


 
下面运行它,程序只是简单地退出了,我们成功地用FASM编写了一个程序!我们已经迈出了第一步,下面要让我们的程序可以做点什么。我们想要调用一个API,我们要怎么做呢?让我们再来充充电吧 :D
 


 


 

 
----------
 


 
我们编写如下代码并用TASM编译:
 


 
;
 
; tasm32 /ml /m5 test.asm
 
; tlink32 -Tpe -aa test.obj ,,, import32.lib
 
;
 


 
        ideal
 
        p586
 
        model   use32 flat
 


 
extrn   MessageBoxA:near
 


 
        dataseg
 
str_hello       db      'Hello',0
 


 
        codeseg
 
__start:
 
        push    0
 
        push    offset str_hello
 
        push    offset str_hello
 
        push    0
 
        call    MessageBoxA
 
        ret
 


 
        end     __start
 


 
下面我们用w32dasm反汇编,得到:
 


 
:00401000   6A00                    push    00000000
 
:00401002   6800204000              push    00402000
 
:00401007   6800204000              push    00402000
 
:0040100C   6A00                    push    00000000
 
:0040100E   E801000000              call    00401014
 
:00401013   C3                      ret
 
:00401014   FF2530304000            jmp     dword ptr [00403030]
 


 
可以看到代码中的call MessageBoxA被翻译成了call 00401014,在这个地址处是一个跳转指令jmp dword ptr [00403030],我们可以确定在地址00403030处存放的是MessageBoxA的真正地址。
 


 
其实这个地址是位于PE文件的导入表中的。下面我们继续我们的PE文件的学习。我们先来看一下导入表的结构。导入表是由一系列的IMAGE_IMPORT_DESCRIPTOR结构组成的。结构的个数由文件引用的DLL个数决定,文件引用了多少个DLL就有多少个IMAGE_IMPORT_DESCRIPTOR结构,最后还有一个全为零的IMAGE_IMPORT_DESCRIPTOR作为结束。
 


 
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
 
    union {
 
        DWORD   Characteristics;
 
        DWORD   OriginalFirstThunk;
 
    };
 
    DWORD   TimeDateStamp;
 
    DWORD   ForwarderChain;
 
    DWORD   Name;
 
    DWORD   FirstThunk;
 
} IMAGE_IMPORT_DESCRIPTOR;
 


 
typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED *PIMAGE_IMPORT_DESCRIPTOR;
 


 
Name字段是一个RVA,指定了引入的DLL的名字。
 


 
OriginalFirstThunk和FirstThunk在一个PE没有加载到内存中的时候是一样的,都是指向一个IMAGE_THUNK_DATA结构数组。最后以一个内容为0的结构结束。其实这个结构就是一个双字。这个结构很有意思,因为在不同的时候这个结构代表着不同的含义。当这个双字的最高位为1时,表示函数是以序号的方式导入的;当最高位为0时,表示函数是以名称方式导入的,这是这个双字是一个RVA,指向一个IMAGE_IMPORT_BY_NAME结构,这个结构用来指定导入函数名称。
 


 
typedef struct _IMAGE_IMPORT_BY_NAME {
 
    WORD    Hint;
 
    BYTE    Name[1];
 
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;
 


 
Hint字段表示一个序号,不过因为是按名称导入,所以这个序号一般为零。
 


 
Name字段是函数的名称。
 


 
下面我们用一张图来说明这个复杂的过程。假设一个PE引用了kernel32.dll中的LoadLibraryA和GetProcAddress,还有一个按序号导入的函数80010002h。
 


 
IMAGE_IMPORT_DESCRIPTOR                                  IMAGE_IMPORT_BY_NAME
 


 
+--------------------+   +--> +------------------+     +-----------------------+
 
| OriginalFirstThunk | --+    | IMAGE_THUNK_DATA | --> | 023B |  ExitProcess   | <--+
 
+--------------------+        +------------------+     +-----------------------+    |
 
|   TimeDataStamp    |        | IMAGE_THUNK_DATA | --> | 0191 | GetProcAddress | <--+--+
 
+--------------------+        +------------------+     +-----------------------+    |  |
 
|   ForwarderChain   |        |     80010002h    |                                  |  |
 
+--------------------+        +------------------+    +---> +------------------+    |  |
 
|        Name        | --+    |         0        |    |     | IMAGE_THUNK_DATA | ---+  |
 
+--------------------+   |    +------------------+    |     +------------------+       |
 
|     FirstThunk     |-+ |                            |     | IMAGE_THUNK_DATA | ------+
 
+--------------------+ | |    +------------------+    |     +------------------+
 
                       | +--> |   kernel32.dll   |    |     |     80010002h    |
 
                       |      +------------------+    |     +------------------+
 
                       |                              |     |         0        |
 
                       +------------------------------+     +------------------+
 


 
还记得前面我们说过在一个PE没有被加载到内存中的时候IMAGE_IMPORT_DESCRIPTOR中的OriginalFirstThunk和FirstThunk是相同的,那么为什么Windows要占用两个字段呢?其实是这样的,在PE文件被PE加载器加载到内存中的时候这个加载器会自动把FirstThunk的值替换为API函数的真正入口,也就是那个前面jmp的真正地址,而OriginalFirstThunk只不过是用来反向查找函数名而已。
 


 
好了,又讲了这么多是要做什么呢?你马上就会看到。下面我们就来构造我们的导入表。
 


 
我们用以下代码来开始我们的引入节:
 


 
section '.idata' import data    readable
 


 
section指示字表示我们要开始一个新节。.idata是这个新节的名称。import data表示这是一个引入节。readable表示这个节的节属性是只读的。
 


 
假设我们的程序只需要引入user32.dll中的MessageBoxA函数,那么我们的引入节只有一个描述这个dll的IMAGE_IMPORT_DESCRIPTOR和一个全0的结构。考虑如下代码:
 


 
    dd      0                   ; 我们并不需要OriginalFirstThunk
 
    dd      0                   ; 我们也不需要管这个时间戳
 
    dd      0                   ; 我们也不关心这个链
 
    dd      RVA usr_dll         ; 指向我们的DLL名称的RVA
 
    dd      RVA usr_thunk       ; 指向我们的IMAGE_IMPORT_BY_NAME数组的RVA
 
                                ; 注意这个数组也是以0结尾的
 
    dd      0,0,0,0,0           ; 结束标志
 


 
上面用到了一个RVA伪指令,它指定的地址在编译时被自动写为对应的RVA值。下面定义我们要引入的动态链接库的名字,这是一个以0结尾的字符串:
 


 
    usr_dll     db      'user32.dll',0
 


 
还有我们的IMAGE_THUNK_DATA:
 


 
    usr_thunk:
 
        MessageBox      dd      RVA __imp_MessageBox
 
                        dd      0                   ; 结束标志
 


 
上面的__imp_MessageBox在编译时由于前面有RVA指示,所以表示是IMAGE_IMPORT_BY_NAME的RVA。下面我们定义这个结构:
 


 
    __imp_MessageBox    dw      0                   ; 我们不按序号导入,所以可以
 
                                                    ; 简单地置0
 
                        db      'MessageBoxA',0     ; 导入的函数名
 


 
好了,我们完成了导入表的建立。下面我们来看一个完整的程序,看看一个完整的FASM程序是多么的漂亮 :P
 


 
format  PE GUI 4.0
 
entry   __start
 


 


 
;
 
; data section...
 
;
 


 
section '.data' data    readable
 


 
    pszText         db      'Hello, FASM world!',0
 
    pszCaption      db      'Flat Assembler',0
 


 


 
;
 
; code section...
 
;
 


 
section '.text' code    readable executable
 


 
    __start:
 
            push    0
 
            push    pszCaption
 
            push    pszText
 
            push    0
 
            call    [MessageBox]
 
            push    0
 
            call    [ExitProcess]
 


 


 
;
 
; import section...
 
;
 


 
section '.idata' import data    readable
 


 
    ; image import descriptor
 
    dd      0,0,0,RVA usr_dll,RVA usr_thunk
 
    dd      0,0,0,RVA krnl_dll,RVA krnl_thunk
 
    dd      0,0,0,0,0
 


 
    ; dll name
 
    usr_dll     db      'user32.dll',0
 
    krnl_dll    db      'kernel32.dll',0
 


 
    ; image thunk data
 
    usr_thunk:
 
        MessageBox      dd      RVA __imp_MessageBox
 
                        dd      0
 


 
    krnl_thunk:
 
        ExitProcess     dd      RVA __imp_ExitProcess
 
                        dd      0
 


 
    ; image import by name
 
    __imp_MessageBox    dw      0
 
                        db      'MessageBoxA',0
 


 
    __imp_ExitProcess   dw      0
 
                        db      'ExitProcess',0
 


 
看到这里我相信大家都对FASM这个编译器有了一个初步的认识,也一定有很多读者会说:“这么麻烦啊,干吗要用这个编译器呢?”。是的,也许上面的代码看起来很复杂,编写起来也很麻烦,但FASM的一个好处在于我们可以更主动地控制我们生成的PE文件结构,同时能对PE文件有更理性的认识。不过每个人的口味不同,嘿嘿,也许上面的理由还不够说服各位读者,没关系,选择一款适合你的编译器吧,它们都同样出色 :P
 


 


 

 
----------
 


 
通过导入表的学习,我想各位读者已经对PE文件的学习过程有了自己认识和方法,所以下面关于导出表的一节我将加快一些速度。“朋友们注意啦!!! @#$%$%&#^”  :D
 


 
在导出表的起始位置是一个IMAGE_EXPORT_DIRECTORY结构,但与引入表不同的是在导出表中只有一个这个结构。下面我们来看一下这个结构的定义:
 


 
typedef struct _IMAGE_EXPORT_DIRECTORY {
 
    DWORD   Characteristics;
 
    DWORD   TimeDateStamp;
 
    WORD    MajorVersion;
 
    WORD    MinorVersion;
 
    DWORD   Name;
 
    DWORD   Base;
 
    DWORD   NumberOfFunctions;
 
    DWORD   NumberOfNames;
 
    DWORD   AddressOfFunctions;     // RVA from base of image
 
    DWORD   AddressOfNames;         // RVA from base of image
 
    DWORD   AddressOfNameOrdinals;  // RVA from base of image
 
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;
 


 
Characteristics、MajorVersion和MinorVersion不使用,一般为0。
 


 
TimeDataStamp是时间戳。
 


 
Name字段是一个RVA值,它指向了这个模块的原始名称。这个名称与编译后的文件名无关。
 


 
Base字段指定了导出函数序号的起始序号。假如Base的值为n,那么导出函数入口地址表中的第一个函数的序号就是n,第二个就是n+1...
 


 
NumberOfFunctions指定了导出函数的总数。
 


 
NumberOfNames指定了按名称导出的函数的总数。按序号导出的函数总数就是这个值与到处总数NumberOfFunctions的差。
 


 
AddressOfFunctions字段是一个RVA值,指向一个RVA数组,数组中的每个RVA均指向一个导出函数的入口地址。数组的项数等于NumberOfFuntions。
 


 
AddressOfNames字段是一个RVA值,同样指向一个RVA数组,数组中的每个双字是一个指向函数名字符串的RVA。数组的项数等于NumberOfNames。
 


 
AddressOfNameOrdinals字段是一个RVA值,它指向一个字数组,注意这里不再是双字了!!
 
这个数组起着很重要的作用,它的项数等于NumberOfNames,并与AddressOfNames指向的数组一一对应。其每个项目的值代表了这个函数在入口地址表中索引。现在我们来看一个例子,假如一个导出函数Foo在导出入口地址表中处于第m个位置,我们查找Ordinal数组的第m项,假设这个值为x,我们把这个值与导出序号的起始值Base的值n相加得到的值就是函数在入口地址表中索引。
 


 
下图表示了导出表的结构和上述过程:
 


 
+-----------------------+         +-----------------+
 
|    Characteristics    |  +----> | 'dlltest.dll',0 |
 
+-----------------------+  |      +-----------------+
 
|     TimeDataStamp     |  |
 
+-----------------------+  |  +-> +-----------------+
 
|      MajorVersion     |  |  | 0 | 函数入口地址RVA | ==> 函数Foo,序号n+0    <--+
 
+-----------------------+  |  |   +-----------------+                            |
 
|      MinorVersion     |  |  |   |       ...       |                            |
 
+-----------------------+  |  |   +-----------------+                            |
 
|         Name          | -+  | x | 函数入口地址RVA | ==> 按序号导出,序号为n+x  |
 
+-----------------------+     |   +-----------------+                            |
 
|    Base(假设值为n)  |     |   |       ...       |                            |
 
+-----------------------+     |   +-----------------+                            |
 
|   NumberOfFunctions   |     |                                                  |
 
+-----------------------+     |  +-> +-----+     +----------+      +-----+ <-+   |
 
|     NumberOfNames     |     |  |   | RVA | --> | '_foo',0 | <==> |  0  | --+---+
 
+-----------------------+     |  |   +-----+     +----------+      +-----+   |
 
|   AddressOfFunctions  | ----+  |   | ... |                       | ... |   |
 
+-----------------------+        |   +-----+                       +-----+   |
 
|     AddressOfNames    | -------+                                           |
 
+-----------------------+                                                    |
 
| AddressOfNameOrdinals | ---------------------------------------------------+
 
+-----------------------+
 


 
好了,下面我们来看构键我们的导出表。假设我们按名称导出一个函数_foo。我们以如下代码开始:
 


 
section '.edata' export data    readable
 


 
接着是IMAGE_EXPORT_DIRECTORY结构:
 


 
    dd      0                   ; Characteristics
 
    dd      0                   ; TimeDataStamp
 
    dw      0                   ; MajorVersion
 
    dw      0                   ; MinorVersion
 
    dd      RVA dll_name        ; RVA,指向DLL名称
 
    dd      0                   ; 起始序号为0
 
    dd      1                   ; 只导出一个函数
 
    dd      1                   ; 这个函数是按名称方式导出的
 
    dd      RVA addr_tab        ; RVA,指向导出函数入口地址表
 
    dd      RVA name_tab        ; RVA,指向函数名称地址表
 
    dd      RVA ordinal_tab     ; RVA,指向函数索引表
 


 
下面我们定义DLL名称:
 


 
    dll_name    db      'foo.dll',0     ; DLL名称,编译的文件名可以与它不同
 


 
接下来是导出函数入口地址表和函数名称地址表,我们要导出一个叫_foo的函数:
 


 
    addr_tab    dd      RVA _foo        ; 函数入口地址
 
    name_tab    dd      RVA func_name
 


 
    func_name   db      '_foo',0        ; 函数名称
 


 
最后是函数索引表:
 


 
    ordinal_tab     dw      0           ; 只有一个按名称导出函数,序号为0
 


 
下面我们看一个完整的程序:
 


 
format  PE GUI 4.0 DLL at 76000000h
 
entry   _dll_entry
 


 


 
;
 
; data section...
 
;
 


 
section '.data' data    readable
 


 
    pszText         db      'Hello, FASM world!',0
 
    pszCaption      db      'Flat Assembler',0
 


 


 
;
 
; code section...
 
;
 


 
section '.text' code    readable executable
 


 
    _foo:
 
            push    0
 
            push    pszCaption
 
            push    pszText
 
            push    0
 
            call    [MessageBox]
 
            ret
 


 
    _dll_entry:
 
            xor     eax,eax
 
            inc     eax
 
            ret     0ch
 


 


 
;
 
; import section...
 
;
 


 
section '.idata' import data    readable
 


 
    ; image import descriptor
 
    dd      0,0,0,RVA usr_dll,RVA usr_thunk
 
    dd      0,0,0,RVA krnl_dll,RVA krnl_thunk
 
    dd      0,0,0,0,0
 


 
    ; dll name
 
    usr_dll     db      'user32.dll',0
 
    krnl_dll    db      'kernel32.dll',0
 


 
    ; image thunk data
 
    usr_thunk:
 
        MessageBox      dd      RVA __imp_MessageBox
 
                        dd      0
 


 
    krnl_thunk:
 
        ExitProcess     dd      RVA __imp_ExitProcess
 
                        dd      0
 


 
    ; image import by name
 
    __imp_MessageBox    dw      0
 
                        db      'MessageBoxA',0
 


 
    __imp_ExitProcess   dw      0
 
                        db      'ExitProcess',0
 


 


 
;
 
; export section...
 
;
 


 
section '.edata' export data    readable
 


 
    ; image export directory
 
    dd      0,0,0,RVA dll_name,0,1,1
 
    dd      RVA addr_tab
 
    dd      RVA name_tab
 
    dd      RVA ordinal_tab
 


 
    ; dll name
 
    dll_name        db      'foo.dll',0
 


 
    ; function address table
 
    addr_tab        dd      RVA _foo
 


 
    ; function name table
 
    name_tab        dd      RVA ex_foo
 


 
    ; export name table
 
    ex_foo          db      '_foo',0
 


 
    ; ordinal table
 
    ordinal_tab     dw      0
 


 


 
;
 
; relocation section...
 
;
 


 
section '.reloc' fixups data     discardable
 


 
程序的一开始用format指定了PE和GUI,在子系统版本号的后面我们使用了DLL指示字,表示
 
这是一个DLL文件。最后还有一个at关键字,指示了文件的image base。
 


 
程序的最后一个节是重定位节,对于重定位表我不做过多解释,有兴趣的读者可以参考其他
 
书籍或文章。我们可以把刚才的程序编译成一个DLL:
 


 
fasm foo.asm foo.dll
 


 
下面我们编写一个测试程序检验程序的正确性:
 


 
#include <windows.h>
 


 
int __stdcall WinMain (HINSTANCE,HINSTANCE,LPTSTR,int)
 
{
 
    HMODULE hFoo=LoadLibrary ("foo.dll");
 
    FARPROC _foo=GetProcAddress (hFoo,"_foo");
 
    _foo ();
 
    FreeLibrary (hFoo);
 


 
    return 0;
 
}
 


 
我们把编译后的exe和刚才的dll放在同一个目录下并运行,看看程序运行是否正确 :P



 
-------------


 
关于FASM,还有一个强大的功能就是宏。大家对宏一定都不陌生,下面我们来看看在FASM中如何定义宏。假设我们要编写一个复制字符串的宏,其中源、目的串由ESI和EDI指定,我们可以:


 
macro @copysz
 
{
 
        local   next_char
 


 
    next_char:
 
        lodsb
 
        stosb
 
        or      al,al
 
        jnz     next_char
 
}
 


 
下面我们再来看一个带参数的宏定义:
 


 
macro @stosd _dword
 
{
 
    mov     eax,_dword
 
    stosd
 
}
 


 
如果我们要多次存入几个不同的双字我们可以简单地在定义宏时把参数用中括号括起来,比如:
 


 
macro @stosd [_dword]
 
{
 
    mov     eax,_dword
 
    stosd
 
}
 


 
这样当我们调用@stosd 1,2,3的时候,我们的代码被编译成:
 


 
mov     eax,1
 
stosd
 
mov     eax,2
 
stosd
 
mov     eax,3
 
stosd
 


 
对于这种多参数的宏,FASM提供了三个伪指令common、forward和reverse。他们把宏代码分成块并分别处理。下面我分别来介绍:
 


 
forward限定的块表示指令块对参数进行顺序处理,比如上面的宏,如果把上面的代码定义在forward块中,我们可以得到相同的结果。对于forward块我们可以这样定义
 


 
macro @stosd [_dword]
 
{
 
    forward
 
        mov     eax,_dword
 
        stosd
 
}
 


 
reverse和forward正好相反,表示指令块对参数进行反向处理。对于上面的指令块如果用reverse限定,那么我们的参数将被按照相反的顺序存入内存。
 


 
macro @stosd [_dword]
 
{
 
    reverse
 
        mov     eax,_dword
 
        stosd
 
}
 


 
这时当我们调用@stosd 1,2,3的时候,我们的代码被编译成:
 


 
mov     eax,3
 
stosd
 
mov     eax,2
 
stosd
 
mov     eax,1
 
stosd
 


 
common限定的块将仅被处理处理一次。我们现在编写一个调用API的宏@invoke:
 


 
macro @invoke _api,[_argv]
 
{
 
    reverse
 
        push    _argv
 
    common
 
        call    [_api]
 
}
 


 
现在我们可以使用这个宏来调用API了,比如:
 


 
@invoke     MessageBox,0,pszText,pszCaption,0
 


 
对于宏的使用我们就介绍这些,更多的代码可以参看我的useful.inc(其中有很多29A的宏,tnx 29a :P)
 


 


 


 
3. 重定位的奥秘
 
----------------
 


 
重定位源于代码中的地址操作,如果没有地址操作那么就不存在所谓的重定位了。让我们先来分析一段代码。考虑如下代码:
 


 
format  PE GUI 4.0
 


 
mov     esi,pszText
 
ret
 


 
pszText     db      '#$%*(*)@#$%',0
 


 
打开softice,看看我们代码被编译为:
 


 
001B:00401000   BE06104000          MOV     ESI,00401006
 
001B:00401005   C3                  RET
 
001B:00401006   ...
 


 
可见,pszText的地址是在编译时计算好的。我们的病毒代码如果要插入到宿主体内,那么这个地址就不正确了。我们必须使我们的这个地址是在运行时计算出来的。这就是病毒中经典的重定位问题。考虑如下代码:
 


 
format  PE GUI 4.0
 


 
        call    delta
 
    delta:
 
        pop     ebp
 
        sub     ebp,delta
 
        lea     esi,dword [ebp+pszText]
 
        ret
 


 
pszText     db      '#$%*(*)@#$%',0
 


 
我们再来看看这次我们的代码被翻译成了什么样 :P
 


 
001B:00401000   E800000000          CALL    00401005
 
001B:00401005   5D                  POP     EBP
 
001B:00401006   81ED05104000        SUB     00401005
 
001B:0040100C   8DB513104000        LEA     ESI,[EBP+00401013]
 
001B:00401012   C3                  RET
 
001B:00401013   ...
 


 
我们首先用call/pop指令得到了delta在内存中的实际地址(为什么要用这样一个call/pop
 
结构呢?我们看到这个call被翻译成E8 00 00 00 00,后面的00000000为相对地址,所以这
 
个指令被翻译成mov 00401005。因为后面是一个相对地址,所以当这段代码被插入到宿主中
 
后这个call依然可以得到正确的地址),在这个程序中是00401005。然后得到delta的偏移
 
地址(offset),这个地址也是00401005,但我们从指令的机器码中看到这个地址是个绝对
 
地址。我们用这个实际的地址减去这个绝对的偏移地址就得到了这个程序段对于插入前原程
 
序段的偏移量。这是什么意思呢,上面的程序其实根本不需要重定位,让我们来考虑这样一
 
个情况:
 


 
假设上面的代码被插入到了宿主中。假设插入的地址为00501000(取这个地址是为了计算方
 
便 :P),这时通过call/pop得到delta的地址为00501005。但delta的offset是在编译时计算
 
的绝对地址,所以仍为00401005。这两个值相减就得到了这个程序段相对于原程序段的偏移
 
量00100000。这就意味着我们所有地址操作都要加上这个偏移才能调整到正确的地址。这就
 
是代码的自身重定位。
 


 
当然这种重定位还可以写成别的形式,比如:
 
        call
 
    shit:
 
        ...
 
    delta:
 
        pop     ebp
 
        sub     ebp,shit
 
        ...
 


 
等等... 这些就留给读者自己去分析吧。
 


 


 


 

 
-------
 


 
我们都知道,在x86系列中,保护模式下的异常处理是CPU通过在IDT中查询相应的异常处理
 
例程来完成的。Win32中,系统利用SEH(Structured Exception Handling,结构化异常处
 
理)来实现对IDT内异常的处理。同时,SEH还被用来处理用户自定义异常。
 


 
可能读者对SEH这个词不很熟悉,但对于下边的程序大家也许都不会感到陌生:
 


 
#pragma warning (disable: 4723)
 


 
#include <windows.h>
 
#include <iostream>
 
using namespace std;
 


 
int main (int argc, char *argv[])
 
{
 
    __try
 
    {
 
        int a=0,b=456;
 
        a=b/a;
 
    }
 


 
    __except (GetExceptionCode () == EXCEPTION_INT_DIVIDE_BY_ZERO ?
 
              EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH)
 
    {
 
        cout<<"产生除0异常\n"<<endl;
 
    }
 


 
    return 0;
 
}
 


 
这里的__try / __except用到的就是SEH。下面我们来看一下SEH的工作原理。在Win32的线
 
程中,FS总是指向一个叫做TIB(Thread Information Block,线程信息块)的结构,在NT
 
系统中这个结构为TEB(Thread Environment Block,线程环境块)。我们不需要清楚整个
 
结构,我们只需要知道这个结构的第一个双字是指向EXCEPTION_REGISTRATION结构的指针。
 


 
; 这是FASM对结构的定义,熟悉一下 :P
 
struc EXCEPTION_REGISTRATION
 
{
 
    .prev       dd      ?
 
    .handler    dd      ?
 
}
 


 
prev字段指向下一个ER结构。handler指向异常处理例程。这是一个典型的链表结构。每当
 
有异常发生时,SEH机制被激活。然后SEH通过TIB/TEB找到ER链,并搜寻合适的异常处理例
 
程。
 


 
下面我们看一个简单的程序,这个程序演示了怎样利用SEH来除错。
 


 
format  PE GUI 4.0
 
entry   __start
 


 


 
section '.text' code    readable executable
 


 
    __start:
 
        xor     eax,eax
 
        xchg    [eax],eax
 
        ret
 


 
运行程序,发现产生了异常,下面我们把上面的代码前面加上这两句:
 


 
        push    dword [fs:0]
 
        mov     [fs:0],esp
 


 
再次运行程序,怎么样?程序正常退出了。打开SOFTICE并加载该程序进行调试。查看ESP指
 
向的地址:
 


 
: d esp2
 
0023:0006FFC4 C7 14 E6 77 ..
 


 
可知程序RET后的返回地址为77e614c7h,所以查看这个地址处的代码:
 


 
: u 77e614c7
 
001B:77E614C7 PUSH EAX
 
001B:77E614C8 CALL Kernel32! ExitThread
 


 
可见,程序被加载到内存后栈顶的双字指向ExitThread,我们的程序就是简单地把这个函数
 
当做了异常处理例程。这样当有异常发生是程序便退出了,没有了那个讨厌的异常对话框。
 


 
当然,我们利用SEH的目的并不是简单地让程序在发生错误时直接退出。多数教程在将SEH时
 
都会举除0错误并用SEH除错的例子。这样的例子太多了,google上可以搜到很多,所以这里
 
我就不做无用功了 :P 下面的例子演示了一个利用SEH解密的例子:
 


 
format  PE GUI 4.0
 
entry   __start
 


 


 
;
 
; code section...
 
;
 


 
section '.text' code    readable writeable executable
 


 
    _decript:
 
            mov     ecx,encripted_size              ; decript
 
            mov     esi,encripted
 
            mov     edi,esi
 
        decript:
 
            lodsb
 
            xor     al,15h
 
            stosb
 
            loop    decript
 


 
            mov     eax,[esp+0ch]                   ; context
 
            mov     dword [eax+0b8h],encripted
 


 
            xor     eax,eax         ; ExceptionContinueExecution
 
            ret
 


 


 
    __start:
 
            lea     eax,[esp-8]                     ; setup seh frame
 
            xchg    eax,[fs:0]
 
            push    _decript
 
            push    eax
 


 
            mov     ecx,encripted_size              ; encript
 
            mov     esi,encripted
 
            mov     edi,esi
 
        encript:
 
            lodsb
 
            xor     al,15h
 
            stosb
 
            loop    encript
 


 
            int     3                               ; start decription
 


 
        encripted:
 
            xor     eax,eax                         ; simply show a message box
 
            push    eax
 
            call    push_caption
 
            db      'SEH',0
 
        push_caption:
 
            call    push_text
 
            db      'A simple SEH test :P',0
 
        push_text:
 
            push    eax
 
            call    [MessageBox]
 
        encripted_size    =   $-encripted
 


 
            ret
 


 


 
;
 
; import section...
 
;
 


 
section '.idata' import data    readable
 


 
    ; image import descriptor
 
    dd      0,0,0,RVA usr_dll,RVA usr_thunk
 
    dd      0,0,0,0,0
 


 
    ; dll name
 
    usr_dll     db      'user32.dll',0
 


 
    ; image thunk data
 
    usr_thunk:
 
        MessageBox      dd      RVA __imp_MessageBox
 
                        dd      0
 


 
    ; image import by name
 
    __imp_MessageBox    dw      0
 
                        db      'MessageBoxA',0
 


 
程序分为三个部分:建立自定义异常处理例程、加密代码、利用SEH解密。下面我们对这三个
 
部分分别进行分析。
 


 
程序首先在堆栈上腾出一个ER空间(lea),然后然后使FS:0指向它。之后填充这个ER结构,
 
prev字段填为之前的FS:[0],handler字段为自定义的异常处理例程_decript。这样我们就
 
完成了SEH的修改。
 


 
下面是代码的加密,这段代码在后面的章节会讲到。这里是简单地把被加密代码的每个字节
 
与一个特定的值(程序中是15h)相异或(再次异或即解密),这就是最简单的加密手段。
 


 
之后我们用int 3引发一个异常,这时我们的_decript被激活,我们使用与加密完全相同的
 
代码解密。到这里,我们还是在复习前面的知识 :P 后面的代码有点费解了,没关系,让我
 
们来慢慢理解 :P
 


 
我们先来看看SEH要求的异常处理例程回调函数原形:
 


 
VOID WINAPI (*_STRUCTURED_EXCEPTION_HANDLER) (
 
    PEXCEPTION_RECORD pExceptionRecord,
 
    PEXCEPTION_REGISTRATION pSEH,
 
    PCONTEXT pContext,
 
    PEXCEPTION_RECORD pDispatcherContext
 
);
 


 
我们先来看一下EXCEPTION_RECORD结构:
 


 
typedef struct _EXCEPTION_RECORD {
 
    DWORD ExceptionCode;
 
    DWORD ExceptionFlags;
 
    struct _EXCEPTION_RECORD *ExceptionRecord;
 
    PVOID ExceptionAddress;
 
    DWORD NumberParameters;
 
    ULONG_PTR ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS];
 
} EXCEPTION_RECORD, *PEXCEPTION_RECORD;
 


 
ExceptionCode字段定义了产生异常的原因,下面是WinNT.h中对异常的部分定义:
 


 
...
 
#define STATUS_GUARD_PAGE_VIOLATION      ((DWORD   )0x80000001L)
 
#define STATUS_DATATYPE_MISALIGNMENT     ((DWORD   )0x80000002L)
 
#define STATUS_BREAKPOINT                ((DWORD   )0x80000003L)
 
#define STATUS_SINGLE_STEP               ((DWORD   )0x80000004L)
 
#define STATUS_ACCESS_VIOLATION          ((DWORD   )0xC0000005L)
 
#define STATUS_IN_PAGE_ERROR             ((DWORD   )0xC0000006L)
 
#define STATUS_INVALID_HANDLE            ((DWORD   )0xC0000008L)
 
#define STATUS_NO_MEMORY                 ((DWORD   )0xC0000017L)
 
#define STATUS_ILLEGAL_INSTRUCTION       ((DWORD   )0xC000001DL)
 
...
 


 
我们并不太关心这个结构的其他字段。下面我们需要理解的是CONTEXT结构。我们知道Win-
 
dows为线程循环地分配时间片,当一个线程被挂起后,为了以后它还可以恢复运行,系统必
 
须保存其线程环境。对一个线程来说,其环境就是各个寄存器的值,只要寄存器的值不变其
 
线程环境就没有变。所以只需要把这个线程的寄存器状态保存下来就可以了。Windows用一个
 
CONTEXT结构来保存这些寄存器的状态。下面是WinNT.h中对CONTEXT的定义:
 


 
typedef struct _CONTEXT {
 


 
    DWORD ContextFlags;
 


 
    DWORD   Dr0;
 
    DWORD   Dr1;
 
    DWORD   Dr2;
 
    DWORD   Dr3;
 
    DWORD   Dr6;
 
    DWORD   Dr7;
 


 
    FLOATING_SAVE_AREA FloatSave;
 


 
    DWORD   SegGs;
 
    DWORD   SegFs;
 
    DWORD   SegEs;
 
    DWORD   SegDs;
 


 
    DWORD   Edi;
 
    DWORD   Esi;
 
    DWORD   Ebx;
 
    DWORD   Edx;
 
    DWORD   Ecx;
 
    DWORD   Eax;
 


 
    DWORD   Ebp;
 
    DWORD   Eip;
 
    DWORD   SegCs;
 
    DWORD   EFlags;
 
    DWORD   Esp;
 
    DWORD   SegSs;
 


 
    BYTE    ExtendedRegisters[MAXIMUM_SUPPORTED_EXTENSION];
 


 
} CONTEXT, *PCONTEXT;
 


 
最后我们再来说一下这个异常处理过程的返回值,这个返回值决定了程序下一步的执行情
 
况,很多人在刚刚接触SEH的时候总是忽略这个返回值,导致程序不能得到正确的结果,我
 
就犯过这样的错误 :P
 


 
SEH异常处理例程的返回值有4种定义:
 


 
ExceptionContinueExecution(=0):返回后系统把线程环境设置为CONTEXT的状态后继续
 
执行。
 


 
ExceptionContinueSearch(=1):表示这个异常处理例程拒绝处理这个异常,系统会根据
 
ER的prev字段搜索下一个异常处理例程并调用它。
 


 
ExceptionNestedException(=2):表示发生了嵌套异常,即异常处理例程中发生了新的异
 
常。
 


 
ExceptionCollidedUnwind(=3):发生了嵌套的展开。
 


 
现在我们再回过头来看我们刚才的代码,程序中首先通过mov eax,[esp+0ch]得到CONTEXT结
 
构,然后通过mov dword [eax+0b8h],encripted把encripted的地址写到CONTEXT的Eip字段
 
中。这样,当这个异常处理以ExceptionContinueExecution返回时程序就会执行Eip处开始
 
的代码了。而异常处理中的代码是很难动态跟踪的,我们可以利用SEH的这个特点骗过一些
 
杀毒软件 :P

-----------

 
--------------------
 


 
回忆一下刚才我们是如何调用API的:首先,引入表是由一系列的IMAGE_IMPORT_DESCRIPTOR
 
结构组成的,这个结构中有一个FirstThunk字段,它指向一个数组,这个数组中的值在文件
 
被pe ldr加载到内存后被改写成函数的真正入口。一些编译器在调用API时把后面的地址指向
 
一个跳转表,这个跳转表中的jmp后面的地址就是FirstThunk中函数的真正入口。对于FASM
 
编译器,由于PE文件的引入表是由我们自己建立的,所以我们可以直接使用FirstThunk数组
 
中的值。
 


 
无论是哪种情况,总之,call的地址在编译时就被确定了。而我们的病毒代码是要插入到宿
 
主的代码中去的,所以我们的call指令后面的地址必须是在运行时计算的。那么怎么找到API
 
函数的地址呢?我们可以到宿主的引入表中去搜索那个对应函数的FirstThunk,但是这样做
 
有一个问题,我们需要函数并不一定是宿主程序需要的。换句话说,就是可能我们需要的函
 
数在宿主的引入表中不存在。这使我们不得不考虑别的实现。我们可以直接从模块的导出表
 
中搜索API的地址。
 


 


 

 
------------------------
 


 
在kernel32.dll中有两个API -- LoadLibraryA和GetProcAddress。前者用来加载一个动态
 
链接库,后者用来从一个已加载的动态链接库中找到API的地址。我们只要得到这两个函数
 
就可以调用任何库中的任意函数了。
 


 
在上一节中我们说过,程序被加载后[esp]的值是kernel32.dll中的ExitThread的地址,所以
 
我们可以肯定kernel32.dll是一定被加载的模块。所以我们第一步就是要找到kernel32.dll
 
在内存中的基地址。
 


 
那么我们从哪里入手呢?我们可以使用硬编码,比如Win2k下一般是77e60000h,WinXP SP1
 
是77e40000h,SP2是7c800000h等。但是这么做不具有通用性,所以这里我们介绍一个通用
 
也是现在最流行的方法:暴力搜索kernel32.dll。
 


 
大概的思想是这样的:我们只要找到得到任意一个位于kernel32.dll地址空间的地址,从这
 
个地址向下搜索就一定能得到kernel32.dll的基址。还记得刚才说的那个[esp]么,那个
 
ExitThread的地址就是位于kernel32.dll中的,我们可以从这里入手。考虑如下代码:
 


 
            mov     edi,[esp]               ; get address of kernel32!ExitThread
 
            and     edi,0ffff0000h          ; base address must be aligned by 1000h
 


 
        krnl_search:
 
            cmp     word [edi],'MZ'         ; 'MZ' signature?
 
            jnz     not_pe                  ; it's not a PE, continue searching
 
            lea     esi,[edi+3ch]           ; point to e_lfanew
 
            lodsd                           ; get e_lfanew
 
            test    eax,0fffff000h          ; DOS header+DOS stub mustn't > 4k
 
            jnz     not_pe                  ; it's not a PE, continue searching
 
            add     eax,edi                 ; point to IMAGE_NT_HEADER
 
            cmp     word [eax],'PE'         ; 'PE' signature?
 
            jnz     not_pe                  ; it's not a PE, continue searching
 
            jmp     krnl_found
 


 
        not_pe:
 
            dec     edi
 
            xor     di,di                   ; decrease 4k bytes
 
            cmp     edi,70000000h           ; the base cannot below 70000000h
 
            jnb     krnl_search
 
            xor     edi,edi                 ; base not found
 


 
        krnl_found:
 
            ...                             ; now EDI contains the kernel base
 
                                            ; zero if not found
 


 
程序首先把ExitThread的地址和0ffff0000h相与,因为kernel32.dll在内存中一定是1000h字
 
节对齐的(什么?为什么?还记得IMAGE_OPTIONAL_HEADER中的SectionAlignment么 :P)。
 
然后我们比较EDI指向的字单元是不是MZ标识,如果不是那么一定不是一个PE文件的起始位
 
置;如果是,那么我们就得到e_lfanew。我们先检查这个偏移是不是小于4k,因为这个值一
 
般是不会大于4k的。如果仍然符合条件,