恶意样本分析手册–理论篇(下)

恶意样本分析手册–理论篇(下)

4.3 PE文件格式

PE是Portable Executable Format(可移植的执行体)简写,它是目前Windows平台的主流可执行文件格式。

MS-DOS头部

每个PE文件是以一个DOS程序开始的,有了它,一旦程序运行在DOS下执行,DOS就能识别出这是有效的执行体,然后运行紧随MZ header之后的DOS stub(DOS块)。DOS stub实际上是一个有效的EXE,平常把DOS MZ头与DOS stub合称为DOS文件头。

PE文件的第一个字节起始于一个传统的MS-DOS头部,被称作IMAGE_DOS_HEADER。其结构如下:

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合e_lfanew。e_magic字段需要被设置为值5A4D,再ASCII表示法里,它的ASCII值为“MZ”。e_lfanew字段是真正的PE文件头的相对偏移,其指出真正PE头的文件偏移位置,它占用4字节,位于文件开始偏移3Ch字节中。

PE文件头

紧跟着Dos stub的是PE文件头(PE Header),PE Header是PE相关结构NT映像头(IMAGE_NT_HEADERS)的简称,其中包含很多PE装载器用到的重要字段。知性体再支持PE文件结构的操作系统中执行时,PE装载器从IMAGE_DOS_HEADER结构中的e_lfanew字段里找到PE Header的起始偏移量,加上基址得到PE文件头的指针。

PNTHeader=ImageBase+dosHeader->e_lfanew

实际上又两个版本的IMAGE_NT_HEADER结构,一个是为32位的可执行文件准备的,另一个是64位版本,在后面的讨论中不做考虑,他们几乎没有区别。

IMAGE_NT_HEADER由三个字段组成:

IMAGE_NT_HEADERS STRUCT

{

DWORD Signature;                   //PE文件头标志,为ASCII的“PE”,+0h

IMAGE_FILE_HEADER FileHeader;      //+4h

IMAGE_OPTIONAL_HEADER32 OptionalHeader;    //+18h

}

Signature字段被设置为00004550h,ASCII码字符是“PE00”.“PE\0\0”是PE文件头的开始,DOS头部的e_lfanew字段正是执行“PE\0\0”。

typedef struct _IMAGE_FILE_HEADER {

WORD    Machine;                    //+04h

WORD    NumberOfSections;           //+06h 文件的区块数目

DWORD   TimeDateStamp;          //+08h

DWORD   PointerToSymbolTable;       //+0Ch

DWORD   NumberOfSymbols;            //+10h

WORD    SizeOfOptionalHeader;   //+14h  IMAGE_OPTIONAL_HEADER32结构大小

WORD    Characteristics;                //+16h 文件属性

} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;

IMAGE_FILE_HEADER(映像文件头)结构包含了PE文件的一些基本信息,最重要的一个域指出了IMAGE_OPTIONAL_HEADER的大小。

Machine:可执行文件的目标CPU类型。PE文件可以在多种机器上使用,不同平台指令机器码是不同的,下表所示是几种典型的机器类型标志。

机器标志
Intel i38614Ch
MIPS R3000162h
MIPS R4000166h
Alpha AXP184h
Power PC1F0H

NumberOfSections:区块数目,块表紧跟在IMAGE_NT_HEADERS后面

TimeDateStamp:表明文件是何时被创建的。

PointerToSymbolTable:COFF符号表的问啊金偏移位置

NumberOfSymbols:如果有COFF符号表,它代表其中的符号数目。

SizeOfOptionalHeader:紧跟着IMAGE_FILE_HEADER后面的数据的大小。在PE文件中,这个数据结构叫IMAGE_OPTIONAL_HEADER,其大小依赖于32位还是64位文件,对于32位文件,这个域通常是00E0h;对于64位文件,这个域是00F0h。这些值要求的最小值,较大的值也可能出现。

IMAGE_OPTIONAL_HEADER结构

可选映像头(IMAGE_OPTIONAL_HEADER)是一个可选的结构,但实际上IMAGE_FILE_HEADER结构不足以定义PE文件属性,因此可选映像头中定义了更多的数据,完全不必考虑两个结构区别在哪里,两者连起来就是一个完整的PE文件头结构。IMAGE_OPTIONAL_HEADER32结构如下:

typedef struct _IMAGE_OPTIONAL_HEADER

{

//

// Standard fields.

//

+18h WORD Magic; // 标志字, ROM 映像(0107h),普通可执行文件(010Bh)

+1Ah BYTE MajorLinkerVersion; // 链接程序的主版本号

+1Bh BYTE MinorLinkerVersion; // 链接程序的次版本号

+1Ch DWORD SizeOfCode; // 所有含代码的节的总大小

+20h DWORD SizeOfInitializedData; // 所有含已初始化数据的节的总大小

+24h DWORD SizeOfUninitializedData; // 所有含未初始化数据的节的大小

+28h DWORD AddressOfEntryPoint; // 程序执行入口RVA

+2Ch DWORD BaseOfCode; // 代码的区块的起始RVA

+30h DWORD BaseOfData; // 数据的区块的起始RVA

//

// NT additional fields. 以下是属于NT结构增加的领域。

//

+34h DWORD ImageBase; // 程序的首选装载地址

+38h DWORD SectionAlignment; // 内存中的区块的对齐大小

+3Ch DWORD FileAlignment; // 文件中的区块的对齐大小

+40h WORD MajorOperatingSystemVersion; // 要求操作系统最低版本号的主版本号

+42h WORD MinorOperatingSystemVersion; // 要求操作系统最低版本号的副版本号

+44h WORD MajorImageVersion; // 可运行于操作系统的主版本号

+46h WORD MinorImageVersion; // 可运行于操作系统的次版本号

+48h WORD MajorSubsystemVersion; // 要求最低子系统版本的主版本号

+4Ah WORD MinorSubsystemVersion; // 要求最低子系统版本的次版本号

+4Ch DWORD Win32VersionValue; // 莫须有字段,不被病毒利用的话一般为0

+50h DWORD SizeOfImage; // 映像装入内存后的总尺寸

+54h DWORD SizeOfHeaders; // 所有头 + 区块表的尺寸大小

+58h DWORD CheckSum; // 映像的校检和

+5Ch WORD Subsystem; // 可执行文件期望的子系统

+5Eh WORD DllCharacteristics; // DllMain()函数何时被调用,默认为 0

+60h DWORD SizeOfStackReserve; // 初始化时的栈大小

+64h DWORD SizeOfStackCommit; // 初始化时实际提交的栈大小

+68h DWORD SizeOfHeapReserve; // 初始化时保留的堆大小

+6Ch DWORD SizeOfHeapCommit; // 初始化时实际提交的堆大小

+70h DWORD LoaderFlags; // 与调试有关,默认为 0

+74h DWORD NumberOfRvaAndSizes; // 下边数据目录的项数,这个字段自Windows NT 发布以来 // 一直是16

+78h IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];

// 数据目录表

} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;

下面讲解几个比较重要的字段:

AddressOfEntryPoint:程序执行入口RVA,对于DLL,这个入口点是在进程初始化和关闭时以及线程创建/毁灭时被调用。在大多数可执行文件中,这个地址并不直接指向Main,WinMain或DllMain,而是指向运行时库代码并由他来调用上述函数。在DLL中这个域能被设为0,前面提到的通知消息都不能收到。链接器/NOENTRY开关可以设置这个域为0.

ImageBase:文件在内存中首选装入地址。如果有可能(也就是说,目前如果没有其他占据这个块地址,它是正确对齐的并且是一个合法的地址等),加载器试图在这个地址装入PE文件,如果可执行文件是在这个地址转入的,那么加载器将跳过应用基址重定位的步骤。

SectionAlignment:当被装入内存时的区块对齐大小。每个块被装入的地址必定是本字段指定数值的整数倍。默认的对齐尺寸是目标CPU的页尺寸。

FileAlignment:磁盘上PE文件内的区块对齐大小。组成块的原始数据必须保证从本字段的倍数地址开始。对于x86可执行文件,这个值通常是200h或1000h,这是为了保证块总是从磁盘的扇区开始。这个值必须是2的幂,其最小值为200h,并且,如果SectionAlignment小于CPU的页尺寸,这个域必须与SectionAlignment匹配。

Subsystem:一个标明可执行文件所期望的子系统的枚举值,取值如下:

取 值Windows.inc中的预定义值含 义
0IMAGE_SUBSYSTEM_UNKNOWN未知的子系统
1IMAGE_SUBSYSTEM_NATIVE不需要子系统(如驱动程序)
2IMAGE_SUBSYSTEM_WINDOWS_GUIWindows图形界面
3IMAGE_SUBSYSTEM_WINDOWS_CUIWindows控制台界面
5IMAGE_SUBSYSTEM_OS2_CUIOS2控制台界面
7IMAGE_SUBSYSTEM_POSIX_CUIPOSIX控制台界面
8IMAGE_SUBSYSTEM_NATIVE_WINDOWS不需要子系统
9IMAGE_SUBSYSTEM_WINDOWS_CE_GUIWindows CE图形界面

Data Directory[16]:数据目录表,由数个相同IMAGE_DATA_DIRECTORY结构组成,指向输出表,输入表,资源块等数据。IMAGE_DATA_DIRECTORY结构的定义如下:

typedef struct _IMAGE_DATA_DIRECTORY {

DWORD   VirtualAddress;             //数据块的起始RVA

DWORD   Size;                       //数据块长度

} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;

数据目录列表如下:

索 引索引值在Windows.inc中的预定义值对应的数据块
0IMAGE_DIRECTORY_ENTRY_EXPORT导出表
1IMAGE_DIRECTORY_ENTRY_IMPORT导入表
2IMAGE_DIRECTORY_ENTRY_RESOURCE资源
3IMAGE_DIRECTORY_ENTRY_EXCEPTION异常(具体资料不详)
4IMAGE_DIRECTORY_ENTRY_SECURITY安全(具体资料不详)
5IMAGE_DIRECTORY_ENTRY_BASERELOC重定位表
6IMAGE_DIRECTORY_ENTRY_DEBUG调试信息
7IMAGE_DIRECTORY_ENTRY_ARCHITECTURE版权信息
8IMAGE_DIRECTORY_ENTRY_GLOBALPTR具体资料不详
9IMAGE_DIRECTORY_ENTRY_TLSThread Local Storage
10IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG具体资料不详
11IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT具体资料不详
12IMAGE_DIRECTORY_ENTRY_IAT导入函数地址表
13IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT具体资料不详
14IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR具体资料不详
15未使用

区块表:

紧跟着IMAGE_NT_HEADER后的是区块表,他是一个IMAGE_SECTION_HEADER结构数组。每个IMAGE_SECTION_HEADER结构包含了它所关联区块的信息,如位置,长度,属性,该数组的数目由IMAGE_NT_HEADERS.FileHeader.NumberOfSections指出。

IMAGE_SECTION_HEADER结构定义如下:

typedef struct _IMAGE_SECTION_HEADER {

BYTE    Name[IMAGE_SIZEOF_SHORT_NAME];      //8个字节的区块名

union {                                     //区块尺寸

DWORD   PhysicalAddress;

DWORD   VirtualSize;

} Misc;

DWORD   VirtualAddress;                     //区块的RVA地址

DWORD   SizeOfRawData;                      //文件对齐后的尺寸

DWORD   PointerToRawData;                   //文件偏移

DWORD   PointerToRelocations;

DWORD   PointerToLinenumbers;

WORD    NumberOfRelocations;

WORD    NumberOfLinenumbers;

DWORD   Characteristics;                    //区块的属性

} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

VirtualSize:指出实际的,被使用的区块大小,是区块在没对齐处理前的实际大小。如果VirtualSize大于SizeOfRawData,那么SizeOfRawData是来自可执行文件初始化数据的大小,与VirtualSize相差的字节用零填充。

VirtualAddress:该块装载到内存中的RVA。这个地址是按照内存页对齐的,它的数值总是SectionAligment的整数倍。

SizeOfRawData:该块在磁盘文件中所占的大小。在可执行文件中,该字段包含经过File Alignment调整后的块的长度。例如:指定FileAlignment的大小为200h,如果VirtualSize中的块的长度为19Ah个字节,这一块应保存的长度为200h个字节。

PointerToRawData:该块在磁盘文件中所占的偏移。程序进编译或汇编后生成原始数据,这个字段用于给出原始数据在文件中的偏移。

输入表

PE文件头的可选映像头中数据目录表的第二成员指向输入表。输入表以一个IMAGE_IMPORT_DESCRIPTOR(IID)数组开始。每个被PE文件隐式的连接进来的DLL都有一个IID。在这个数组中,没有字段指出该结构数组的项数,但他的最后一个单元是NULL,可以由此计算出该数组的项数。例如:某个PE文件从两个DLL文件中引入函数,就存在两个IID结构来描述这些DLL文件,并在两个IID结构的最后由一个内容全为0的IID结构作为结束。

typedef struct _IMAGE_IMPORT_DESCRIPTOR {

union {                         // +00h

DWORD   Characteristics;

DWORD   OriginalFirstThunk; //指向输入名称表INT

};

DWORD   TimeDateStamp;          //+04h

DWORD   ForwarderChain;         //+08h

DWORD   Name;                   //+0Ch DLL名称的指针

DWORD   FirstThunk;             //10h 指向输入地址表IAT。

} IMAGE_IMPORT_DESCRIPTOR;

OriginalFirstThunk(Characteristics):包含指向输入名称表(INT)的RVA,INT是一个IMAGE_THUNK_DATA结构的数据,数组中的每个IMAGE_THUNK_DATA结构指向IMAGE_IMPORT_BY_NAME结构,数组最后以一个内容为0的IMAGE_THUNK_DATA的结构结束。

Name:DLL名字的指针。一个以00结尾的ASCII字符的RVA地址,该字符串包含输入的DLL名。

FirstThunk:包含指向输入地址表(IAT)的RVA,IAT是一个IMAGE_THUNK_DATA结构的数组。

OriginalFirstThunk和FirstThunk很相似。他们指向两个本质上相同的数组IMAGE_THUNK_DATA,名字叫做输入名称表(Import Name Table,INT)和输入地址表(Import Address Table,IAT)。

两个数组都有IMAGE_THUNK_DATA结构类型的元素,他是一个指针大小的联合。每个IMAGE_THUNK_DATA元素对应一个从可执行文件输入的函数。两个数组的结束是通过一个值为0的IMAGE_THUNK_DATA元素来表示的。IMAGE_THUNK_DATA实际上是一个双字,该结构在不同时刻由不同的含义,定义如下:

typedef struct _IMAGE_THUNK_DATA {

union {

PBYTE  ForwarderString;             //指向一个转向者字符串的RVA

PDWORD Function;                    //被输入的函数的内存地址

DWORD Ordinal;                      //被输入的API的序数值

PIMAGE_IMPORT_BY_NAME  AddressOfData;//指向IMAGE_IMPORT_BY_NAME

} u1;

} IMAGE_THUNK_DATA;

当IMAGE_THUNK_DATA值的最高位为1时,表示函数以序号的方式输入,这是低31位(或者一个64位可执行文件的低63位)被看作是一个函数序号。当双字的最高位为0时,表示函数以字符串类型的函数名方式输入,这时双字的值是一个RVA,指向一个IMAGE_IMPORT_BY_NAME结构。

IMAGE_IMPORT_BY_NAME结构仅一个字大小,村有一个 输入函数的相关信息结构,其结构如下:

typedef struct _IMAGE_IMPORT_BY_NAME {

WORD    Hint;

BYTE    Name[1];

} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;

Hint:指示本函数在其所驻留DLL的输出表中的序号。该域被PE装载器用来在DLL的输出表里快速查询函数。

Name:函数输入函数的函数名,函数名是一个ASCII码字符串,以NULL结尾。虽然这里将Name的大小定义为字节,起始它是可变尺寸域。

输入地址表(IAT)

由OriginalFirstThunk所指向的那个数组是单独的一项,而且不可改写,称为INT。FirstThunk所指向的数组是由PE装载器重写的。PE装载器搜索OriginalFirstThunk,如果找到,加载程序迭代搜索数组中的每个指针,找到每个IMAGE_IMPORT_BY_NAME结构所指向的输入函数的地址,然后加载器用函数真正入口地址来替代由FirstThunk指向的IMAGE_THUNK_DATA数组里的元素值。Jmp dword ptr[xxxxxxxx]中的[xxxxxxxx]是指FirstThunk数组中的一个入口,因此它称为输入地址表(IAT)。因此,当PE文件装在内存后准备执行时,所有函数入口地址被排列在一起,此时,输入表中其他部分就不重要了,程序依靠IAT提供的函数地址就可以正常运行。

有些情况下,一些函数仅有序号引出,也就是说,不能用函数名来调用他们,只能用他们的位置来调用。

另一种情况是程序OriginalFirstThunk的值为0。在初始化时,系统根据FirstThunk的值找到指向函数名的地址串,由地址串找到函数名,再根据函数名得到入口地址,然后用入口地址取代FirstThunk指向的地址串中的原值。

输出表:

输出表中的主要成分是一个表格,内含函数名称,输出序数等。序数是指定DLL中某个函数的16位数字,在所指向的DLL里是独一无二的。

输出表是数据目录表的第一个成员,指向IMAGE_EXPORT_DIRECTORY(IED)结构,定义如下:

typedef struct _IMAGE_EXPORT_DIRECTORY {

DWORD   Characteristics;

DWORD   TimeDateStamp;

WORD    MajorVersion;

WORD    MinorVersion;

DWORD   Name;                   //模块的真实名称

DWORD   Base;                   //基数,加上序数就是函数地址数组的索引数

DWORD   NumberOfFunctions;      // AddressOfFunctions中元素个数

DWORD   NumberOfNames;          // AddressOfNames中元素个数

DWORD   AddressOfFunctions;     // 指向函数地址数组

DWORD   AddressOfNames;         // 函数名字的指针地址

DWORD   AddressOfNameOrdinals;  // 指向输出序列号数组

} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;

输出表的设计是为了方便PE装载器工作。首先,模块必须保存所有输出函数的地址,供PE装载器查询。模块将这些信息保存在AddressOfFunctions域所指向的数组中,而数组元素数目存放在NumberOfFunctions域中。如果有些函数是通过名字引出的,那么模块必定也在文件中保留了这些信息。这些名字的RVA存放在一个数组中,供PE装载器查询。该数组由AddressOfName指向,NumberOfNames包含名字数组。Pe装载器知道函数名,并想以此获取这些函数的地址。至今为止已有两个模块:名字数组和地址数组,PE参考指出使用到地址数组的索引为连接两者的纽带,因此PE装载器在名字数组中找到匹配名字的同时,它也获取指向地址表中对应元素的索引。这些索引保存在由AddressOfNameOrdinals域所指向的另一个数组中。由于该数组起到联系名字和地址的作用,所以起元素数目必定和名字数组相同。

第五章:Windows内核加载器

在Windows NT/XP/2003系统中,Windows内核加载器指的是NTLDR文件,而在Vista/Windows 7中,指的是bootmgr文件。这里主要说ntldr文件。它位于系统分区的根目录下,如C:/ntldr,是一个隐藏的,只读的文件,去除“隐藏受保护的操作系统文件”就可以看见。Ntldr的主要作用是引导和加载操作系统。

Ntldr是可执行的16进制文件,有两部分组成:前半部分是startup.com,称为su模块(一部分是16位程序,在实模式下运行,另一部分是32位程序,在保护模式下运行)。后半部分是osloader.exe,称为loader模块(32位程序,主要在保护模式下运行)。

引导驱动器读取第一个扇区到0x7c00后,控制权交给MBR,MBR代码再搜索系统活动分区表,加载活动分区第一扇区到特定的内存地址(如物理内存地址0xd000).这个扇区称为操作系统分区引导记录(Partition Boot Record ,PBR)。MBR接着将控制权交给PBR。PBR代码解析FAT和NTFS格式找到引导内核的文件NTLDR,并将NTLDR文件加载到指定物理内存地址(0x20000),最后将控制权转移交给NTLDR。NTLDR的SU模块首先获得控制权,前半部分主要是在实模式下工作,检测物理内存,开始A20地址线,重定位GDT和IDT。开启保护模式后,SU解析osloader.exe文件,将其加载到物理地址0x00400000,最后控制权交给Loader。

5.1 主引导记录MBR讲解:

MBR在windows启动之前已经被填充好,它并不属于任何一个操作系统。MBR包含代码和数据两部分,前半部分是启动引导代码,后半部分是一张磁盘分区表,记录每个分区在磁盘上的位置,大小及分区类型。MBR只有512B,具有一下特征:

  • 446B的代码区
  • 64B的磁盘分区表,分为四个分区表项,每项用16B描述
  • 2B的MBR签名,固定为55AA

MBR找到NTLDR文件在磁盘上的地址后,需要将其加载到物理地址0x20000,加载的方式是调用一个BIOS中断13h/AH=42h进行读取。BIOS中断13h/AH=42h一次最多只能读取127扇区。

5.2 SU模块

SU模块是NTLDR文件的前半部分,负责实模式下的初始化工作,为NTLDR文件的另一部分Loader模块提供支持。SU模块的主要工作流程如下:

  • 检测物理地址
  • 开启A20地址线
  • 重定位GDT和ID T
  • 开启保护模式
  • 加载Loader模块
  • 转移控制权

检测物理内存

检测物理内存,实际上借助BIOS中断15H/E820完成。获得的内存块存储在物理地址0x70000,是以数组的形式存储。SU模块并不对物理内存进行管理,它只管收集物理内存,内存管理要到Loader之后。

计算机中的物理内存分成若干区域,有些区域是可用的,还有少量区域则被保留。SU将调用ConstructMemoryDescriptors函数来检测可用的内存区域,生成一个内存描述符链表结构,由MemoryDescriptorList链表指针所指。这条内存描述链将被Loader使用。内存描述链的每个表项都是MEMORY_DESCRIPTOR结构,用来描述一块内存区域的基址和长度。内存描述符结构如下:

typedef struct _MEMORY_DESCRIPTOR{

ULONG BlockBase;//物理块基址

ULONG BlockSize;//物理块大小

} MEMORY_DESCRIPTOR,*P MEMORY_DESCRIPTOR;

这个结构提供两个字段,Block Base指明物理内存的起始地址,BlockSize指明物理内存块的大小。还有一个结构是提供给BIOS中断15H/E820使用的结构,用来检测和返回物理内存块的信息,它描述一块物理内存的具体信息:

E820Frame struc

ErrorFlag dd ?  //错误标志

Key dd ?       //是否还有物理内存块存在

DescSize dd ?   //物理内存块描述信息的大小,即Descriptor结构大小

BaseAddrLow dd ? //物理内存块起始地址低32位

BaseAddrHigh dd ? //物理内存块起始地址高32位

SizeLow dd ? //物理内存块大小的低32位

SizeHigh dd ? //物理内存块大小的高32位

MemoryType dd ? //物理内存块的类型,设为1时表示可用,为0表示不可用

E820Frame ends

ConstructMemoryDescriptors

;ConstructMemoryDescriptors的c语言版本

BOOLEAN ConstructMemoryDescriptors(VOID)

{

ULONG StartAddr,EndAddr;

E820FRAME E820Frame;

MemoryDescriptorList->BlockSize = 0; //初始化首个链表为0

MemoryDescriptorList->BlockBase = 0; //初始化首个链表为0

//循环检测物理地址

do{

E820Frame.Size = sizeof(E820Frame.Descriptor);

Int15E820 (&E820Frame);

if (E820Frame.ErrorFlag || E820Frame.Size(sizeof(E820Frame.Descriptor)) //这里作者在书上的括号写错了

break;

//获取开始地址和结束地址

StartAddr = E820Frame.Descriptor.BaseAddrLow;

EndAddr = E820Frame.Descriptor.BaseAddrLow + E820Frame.Descriptor.SizeLow – 1;

//高于4G的内存并不使用

if(E820Frame.Descriptor.BaseAddrHigh == 0)

{

if(EndAddr < StartAddr)

{

//EndAddr 字长是4B 及32位 最多表示4G

//如果EndAddr  <  StartAddr 表示EndAddr溢出了直接设置为0xFFFFFFFF及4G

//终于晓得为啥子32位只支持4G寻址了

EndAddr = 0xFFFFFFF;

}

//仅需要内存类型为BiosMemoryUsable (1) 的内存

if(E820Frame.Descriptor.MemoryType==1)

{

//插入内存描述符链表

InsertDescriptor(StartAddr,EndAddr – StartAddr +1)

}

}

}while (E820Frame.Key);  //如果key不等于0 说明还有内存块继续循环

return TRUE;

}

内存描述链表指针Memory DescriptorList指向的第一个表项(MEMORY_DESCRIPTOR结构)的BlockBase和BlockSize均初始化为0,指示第一个内存块为空,也就表示当前没有检测到内存块。

下面进入循环检测物理内存阶段。调用Int15E820函数进行物理内存块的枚举(其内部使用BIOS中断INT 15H/E820)。循环条件是E820Frame.key,当该值不为0时,表示还有物理内存块存在,需要继续进行检测,这个Key值是调用INT 15H后由ebx寄存器返回的结果值。

Key值为0时,表示已无物理内存块存在,可以停止检测。调用Int15E820函数返回一块物理内存的具体信息,若该物理内存信息无错误,计算该块物理内存的起始地址和结束地址,因为这里最多只支持4GB内存,所以内存地址的高字节只能为0.若参数值MemroyType为BiosMemoryusable(1),表示物理内存可用,那么调用InsertDescriptor函数就可以将物理内存块插入到物理内存描述链表中。

开启A20地址线

开启A20地址线,可以寻址大于1MB的物理内存,如果不开启A20,后面无法加载osloader.exe到物理内存空间0x400000,并且地址线的第20位只能为0,那么能被访问的内存只能是奇数M段,即1M,3M,5M……。这样可以访问的内存不是连续的。只有开启A20地址线,才能访问到连续的内存。

重新定位GDT和IDT

GDT是一个由段描述符及其他描述符构成的表,而IDT是一个中断和异常描述符的表,这两个表的存放位置是紧连着的,在SU数据段中已经设置好。GDT属于保护模式范畴,IDT属于中断和异常。

Relocatex86Structures函数用来把在SU数据段的GDT和IDT移到物理地址0x1e000.

Relocatex86Structures:

push bp

mov bp,sp

push si;保存寄存器

push di

push es

;把指针移动到GDT开始,并计算需要移动的大小

mov si,GDTBase

mov ax,SYSTEM_PAGE_BASE>>4

mov es,ax

xor di,di

mov cx,IDTEnd-GDTBase

;用一个循环移动数据到0x1e000,循环次数为IDTEnd-GDTBase

.loop

mov al,[si]

es  mov [di],al

inc si

inc di

dec cx

jnz .loop;循环复制

;返回寄存器

pop es

pop di

pop si

mov sp,bp

pop bp

retn

保护模式

保护模式是从80286系列开始出现的一种新的运行模式。在实模式中,采用的是16位地址模式,最多只能寻址10FFEFh的地址空间,同时,它的分段是针对所有的物理空间进行的,系统程序和用户程序都能访问素有的地址,如果某个存放了系统程序的内存空间呗用户程序修改了,将会造成无法预料的错误。引入保护模式就是为了解决上述两个缺陷。一方面,在保护模式中,采用的是32位地址模式,全部32条地址线都能使用,因此最多能寻址232B=4GB内存空间。另一方面,保护模式中还引入了段保护机制,使得物理内存不能再被直接访问,程序使用的都是虚拟地址,需要通过操作系统页表将这些虚拟地址转换为物理地址,才能被访问。

开启保护模式

GDT表已经在SU数据段中建立,但是需要让处理器知道在哪里。GDTR寄存器就是用来专门存放GDT表的入口,使用lgdt指令加载进GDTR寄存器。CPU以后就根据GDTR寄存器来访问GDT表。GDTR是48位寄存器,前16位指明GDT表的大小,后32位指明GDT的地址。

执行下面代码就能进入保护模式:

EnableProtectPaging:

;BIOS留下的标志寄存器flags,在进入保护模式前,我们把它设为0,同时ES和GS也设为0

push dword 0

popfd

mov bx,sp

mov dx,[bx+2]

xor ax,ax

mov gs,ax

mov es,ax

;FS指向PCR

push KePcrSelector

pop fs

;加载GDT和ID T,关中断,因为在进入保护模式前我们暂时不能处理中断

cli

;利用指令lgdt,将GDT表加载进GDTR寄存器

lgdt [GDTregister]

;利用指令lidt,将IDT表加载进IDT寄存器

lidt [IDTregister]

mov eax,cr0

;若dx=0,表示只开启保护模式,不开启分页模式

or dx,dx

jz .set_protect

;设置保护模式和分页机制,将CR0寄存器的bit 0位置1开启保护模式,bit 31位置1开启分页机制

or eax,PROTECT_MODE|ENABLE_PAGING

mov cr0,eax

jmp .set_end

.set_protect:

or eax,PROTECT_MODE;开启保护模式

mov cr0,eax

.set_end:

;将SU代码选择子58h加载进CS段寄存器

push SuCodeSelector

push .restart

retf

.restart:

;将SU数据选择子60h加载进DS,SS段寄存器

push ax,SuCodeSelector

mov ds,ax

mov ss,ax

xor bx,bx

;加载局部描述符,只能在保护模式下使用这条指令

lldt bx

or dx,dx

jnz .return

mov bx,KeTssSelector

;ltr是一条特权指令,一般在操作系统初始化过程中执行,用来初始化任务寄存器,之后任务寄存器的内容通过每次任务切换来改变

ltr bx

.return:

retn

进入保护模式后,我们访问的地址一般叫线性地址,段寄存器变成了段描述符的选择子,不再参与地址转译。

加载Loader模块

oloader.exe是标准的PE文件,在运行前,必须按照标准格式加载进内存。Osloader.exe文件在编译链接时嵌套在startup.com末尾,所以解析时只需定位到startup.com的结尾,即osloader.exe文件的开始。首先需要解析osloader.exe文件头,然后按内存对齐值复制个个段,复制完成后返回osloader.exe映像代码入口。

将Loader加载进来之后,SU把系统控制权移交给Loader。所谓移交控制权,实际上就是跳转到osloader.exe的入口点,即交由Loader的NtProcessstartup函数去执行。

第六章:Hook、RootKit

6.1 使用注册表来注入DLL

在注册表路径HKEY_LOCAL_MACHINE\Software\Microsoft\Windows NT\CurrentVersioin\Windows\下,AppInit_Dlls键的值可能会包含一个DLL的文件名活一组DLL的文件名(通过空格或逗号分隔)。将自己写的DLL文件的路径值写入AppInit_Dlls中,再创建一个名为LoadAppInit_Dlls,类型为DWORD的注册表项,并将其值设为1.当User32.dll被映射到一个新的进程时,会受到DLL_PROCESS_ATTACH通知。当User32.dll对它进行处理的时候,会获取上述注册表键的值,并调用LoadLibrary来载入这个字符串中指定的每个DLL。

6.2 使用Widows挂钩来注入DLL

调用函数SetWindowsHookEx来安装钩子,此函数的声明如下:

HHOOK WINAPI SetWindowsHookEx(

In  int idHook,

In  HOOKPROC lpfn,

In  HINSTANCE hMod,

In  DWORD dwThreadId

);

idHook表示要安装的挂钩的类型,lpfn是一个函数的地址,在窗口即将处理一条消息的时候,系统应该调用这个函数,hMod标识一个DLL,这个DLL包含了lpfn函数,dwThreadId表示要给哪个线程安装挂钩。如果这个参数传0,表示要给系统中所有GUI线程安装挂钩。

例如进程A使用SetWindowsHookEx(WH_GETMESSAGE,GetMsgProc,hInstDll,0)函数安装挂钩后:

  • 进程B中的一个线程准备向一个窗口派送一条消息
  • 系统检查该线程是否安装了WH_GETMESSAGE挂钩
  • 系统检查GetMsgProc所在的DLL是否已经被映射到进程B的地址空间中,如果DLL尚未被映射,那么系统会强制将该DLL映射到进程B的地址空间中,并将进程B中该DLL的锁计数器递增
  • 由于DLL的hInstDll是在进程B中映射的,因此系统会对他进行检查,看他在进程A中的位置是否相同,如果相同,那么在两个进程空间中,GetMsgProc函数位于相同的位置,系统就可以直接在进程A的地址空间中调用GetMsgProc。如果不相同,那么系统必须确定GetMsgProc函数在进程B的地址空间中的虚拟内存地址。使用公式GetMsgProc B=hInstDll B+(GetMsgProc A-hInstDll A)获得
  • 系统在进程B中递增该DLL的锁计数器
  • 系统在进程B的地址空间中调用GetMsgProc函数
  • 当GetMsgProc返回的时候,系统递减该DLL在进程B中的锁计数器

6.3 使用远程线程来注入DLL

  • 使用函数VirtualAllocEx在远程进程的地址空间中分配一块内存
  • 使用函数WriteProcessMemory函数把DLL的路径名复制到第一步分配的内存中
  • 使用函数GetProcAddress得到LoadLibrary函数的实际地址
  • 使用函数CreateRemoteThread函数在远程进程中创建一个线程,让新线程调用正确的LoadLibrary函数并在参数中传入第一步分配的内存地址。现在远程进程中有一块内存,它是在第一步分配的,DLL也还在远程进程的地址空间中。为了对它进行清理,需要在远程线程退出之后执行后续步骤
  • 使用函数VirtualFreeEx释放第一步分配的内存
  • 使用函数GetProcAddress来得到FreeLibrary函数的实际地址
  • 使用函数CreateRemoteThread在远程进程中创建一个线程,让该线程调用FreeLibrary函数并在参数中传入远程DLL的

6.4 动态库劫持

简单来说就是DLL文件替换。通俗说法如下:

A.exe想要调用B.dll,并且使用里面的FunC函数,这样的话我们把B.Dll改名BB.Dll(有的不用,直接根据路径劫持),然后我们自己写一个B.Dll(假的)里面有一个FunC这个函数,然后我们在这个函数里加载BB.Dll(原B.Dll),并且调用里面的FunC函数,之后我们在干一些自己的事,对于A.exe来说通常没什么异常感觉,这样我们的目的就达到了,记住此时的你,也就是B.dll(假的)的权限和内存归属都是A的,也即是你和A是一家的了,类似于代码注入之后直接修改内存一样。

WIndows上的Dll加载有一个默认的规则,就是先在主程序目录下查找B.dll,如果没有就在系统路径下找,如果还没有,就去环境变量路径里找,就因为这个我们可以轻松的在相应的位置给做劫持,然后问题就是如果实现劫持,就要知道B.Dll里面的所有函数名字以及函数参数,这个地方比较不好搞,此地不考虑。

6.5 APC注入

APC注入的原理是利用当线程被唤醒时APC中的注册函数会被执行的机制,并以此去执行我们的DLL加载代码,进而完成DLL注入的目的,其具体流程如下:

1)当EXE里某个线程执行到SleepEx()或者WaitForSingleObjectEx()时,系统就会产生一个软中断。

2)当线程再次被唤醒时,此线程会首先执行APC队列中的被注册的函数。

3)利用QueueUserAPC()这个API可以在软中断时向线程的APC队列插入一个函数指针,如果我们插入的是Loadlibrary()执行函数的话,就能达到注入DLL的目的。

6.6 使用CreateProcess注入代码

  • 用CreateProcess以CREATE_SUSPENDED的方式启动目标进程
  • 找到目标进程的入口
  • 将目标进程入口的代码保存起来
  • 在目标进程的入口写LoadLibrary(MyDll)实现Dll注入
  • 用ResumeThread运行目标进程
  • 目标进程就运行了LoadLibrary(MyDll),实现DLL的注入
  • 目标进程运行完LoadLibrary(MyDll)后,将原来的代码写回目标进程的入口
  • 目标进程jmp到原来的入口,继续运行程序

InlineHook

InlineHook的工作方式如下所示:

  • 在内存中对要拦截的函数进行定位,从而得到它的内存地址
  • 把这个函数起始的几个字节保存到我们自己的内存中
  • 使用jmp指令来覆盖这个函数起始的几个字节,这条jmp指令用来跳转到我们的替代函数的内存地址。当然,我们的替代函数的函数签名必须与要拦截的函数的函数签名完全相同:所有的参数必须相同,返回值必须相同,调用约定也必须相同
  • 现在,当线程调用被拦截函数的时候,跳转指令实际上会跳转到我们的替代函数。这时,我们就可以执行自己想要执行的任何代码
  • 为了撤销对函数的拦截,需要把第二步保存下来的字节放回被拦截函数起始的几个字节中
  • 我们调用被拦截函数(现在已经不再对它进行拦截了),让函数执行它正常处理
  • 当原来的函数返回时,我们再次执行第二步和第三步,这样替代函数将来还会被调用到。

6.7 IDT Hook

IDT=Interrupt Descriptor Table中断描述表。IDT是一个有256个入口的线形表,每个IDT的入口是8字节的描述符,所以整个IDT表的大小为256*8=2048 bytes,每个中断向量关联了一个中断处理过程。所谓的中断向量就是把每个中断或者异常用一个0-255的数字识别。Intel称这个数字为向量(vector)。

对于中断描述表,操作系统使用IDTR寄存器来记录idt位置和大小。IDTR寄存器是48位寄存器,用于保存idt信息。其中低16位代表IDT的大小,大小为7FFH,高32位代表IDT的基地址。我们可以利用指令sidt读出IDTR寄存器中的信息,从而找到IDT在内存中的位置。

IDT有三种不同的描述符或者说是入口,分别是:

1。任务门描述符

2。中断门描述符

3。陷阱门描述符

也就是说,在保护模式下,80386只有通过中断门、陷阱门或任务门才能转移到对应的中断或异常处理程序。

中断分为两种类型:可屏蔽中断–它在短时间片段里可被忽略;不可屏蔽中断–它必须被立即处理。例如:硬件失败为不可屏蔽中断,IRQS(中断请求)失败为可屏蔽中断。

异常被分为不同的两类:处理器产生的异常(Faults, Traps, Aborts)和编程安排的异常(用汇编指令int or int3 触发)。后一种就是我们经常说到的软中断。

下图是三种描述符的图示:

其中:后两种描述符,非常的相似,只有1个bit位的差别。在处理上,采用相同的处理方式。如图所示,在这后两类的描述符里面记录了一个中断服务程序(ISR )的地址offset. 在IDT的256个向量中,除3个任务门入口外,其他都是这两种门的入口。并且所有的trap/interrupt gate的入口,他们的segment selector都是一样的,即:08h. 我们察看GDT中Selector = 8的描述符,描述的是00000000h ~ 0ffffffffh的4G地址空间。 因此,在描述符中的中断服务程序(ISR )的地址offset就代表了函数的入口地址。windows在处理的时候,按照下图方式,来处理这两类的描述符入口。即:根据segment selector在GDT中找出段基地址等信息,然后跟描述符中的中断服务程序(ISR )的地址offset相加得到代码段中的函数入口地址。然后调用该函数。

这个过程,我写得比较直接,在操作系统执行这过程时,还有很多的出错判断和异常保护,这里我们略过。

下图是任务门描述符的情况

首先,根据IDT中任务门描述符的TSS Segment  Selector ,我们在GDT中找出这个选择子。在这个选择子中,对应一个tss描述符,即:任务状态段描述符。这个描述符大小为068h, 即104字节。
下面是这个任务状态段描述符的格式。

在这个描述符中记录了任务状态段的位置和大小。

根据任务状态段描述符中的base Address, 找到TSS的内存位置。然后就可以进行任务切换。所谓任务切换是指,挂起当前正在执行的任务,恢复或启动另一任务的执行。在任务切换过程中,首先,处理器中各寄存器的当前值被自动保存到TR所指定的TSS中;然后,下一任务的TSS的选择子被装入TR;最后,从TR所指定的TSS中取出各寄存器的值送到处理器的各寄存器中。由此可见,通过在TSS中保存任务现场各寄存器状态的完整映象,实现任务的切换。 TR寄存器可见部分保存了tss selector, 不可见部分,保存了任务状态段的位置和大小.

任务状态段TSS的基本格式如下图所示。

从图中可见,TSS的基本格式由104字节组成。这104字节的基本格式是不可改变的,但在此之外系统软件还可定义若干附加信息。

知道了IDT的基本知识后,再来理解IDT Hook的原理就比较简单了。就是将将系统原来的中断处理函数地址替换为我们自己的函数的地址。这样系统在处理相应的中断时,就会调用我们的处理函数。

比如:出现页错误,调用IDT中的0x0E。或用户进程请求系统服务(SSDT)时,调用IDT中的0x2E。而系统服务的调用是经常的,这个中断就能触发。所以方法就是先在系统中找到IDT,然后确定0x2E在IDT中的地址,最后用我们的函数地址去取代它,这样以来,用户的进程(可以特定设置)调用系统服务,我们的hook函数即被激发。

使用sidt指令可以在内存中找到IDT的地址,返回一个IDTINFO结构的地址。这个结构中国含有IDT的高半地址和低半地址。IDT有最多256个入口。将IDT看作 是一排有256间房组成的线性结构,那么只要知道了整个入口结构,就相当于知道了每间房的长度,先获取所有的入口idt_entrys,那么第0x2E个房间的地址就可以确定了。即idt_entrys[0x2E]。找到目标入口后,将我们的函数与其原来的函数进行替换即可。

6.8 SSDT Hook、SSSDT Hook

SSDT 既 System Service Dispath Table。在Windows NT 下, NT 的 executive( NTOSKRNL.EXE 的一部分)提供了核心系统服务。由于子系统不同, API 函数的函数名也不同。 例如,要用Win32API 打开一个文件,应用程序会调用 CreateFile(),而要用 POSIXAPI,则应用程序调用 open() 函数。这两种应用程序最终都会调用 NT executive 中的NtCreateFile() 系统服务。

用户模式( User mode)的所有调用,如 Kernel32,User32.dll,Advapi32.dll等提供的API, 最终都封装在Ntdll.dll中,然后通过Int 2E或SYSENTER进入到内核模式, 通过服务ID,在System Service DispatcherTable中分派系统函数。例如下图:

SSDT就是一个表,这个表中有内核调用的函数地址。从上图可见,当用户层调用FindNextFile函数时, 最终会调用内核层的 NtQueryDirectoryFile函数, 而这个函数的地址就在SSDT表中, 如果我们事先把这个地址改成我们特定函数的地址,那么就实现了SSDT Hook。

下面来介绍以下SSDT的结构:

KeServiceDescriptorTable是由内核(ntoskrnl.exe)导出的一个表,这个表是访问SSDT的关键,结构形式如下:

typedef struct ServiceDescriptorTable {

PVOID ServiceTableBase;

PVOID ServiceCounterTable(0);

unsigned int NumberOfServices;

PVOID ParamTableBase;

ServiceTableBase: System Service Dispatch Table 的基地址。

NumberOfServices :由 ServiceTableBase 描述的服务的数目。

ServiceCounterTable: 此域用于操作系统的 checked builds,包含着 SSDT 中每个服务被调用次数的计数器。这个计数器由 INT 2Eh 处理程序 (KiSystemService)更新。

ParamTableBase: 包含每个系统服务参数字节数表的基地址。

System Service Dispath Table( SSDT):系统服务分发表,给出了服务函数的地址,每个地址4子节长。

System Service Parameter Table(SSPT):系统服务参数表,定义了对应函数的参数字节,每个函数对应一个字节。如在0x804AB3BF处的函数需0x18字节的参数。

要对SSDT进行Hook,首先需要改变SSDT的内存保护,因为系统对SSDT都是只读的,不能写。如果视图去写,就会造成蓝屏。一般可以修改内存的方法有通过cr0寄存器和Memory Descriptor List(MDL)。

通过cr0寄存器:

Windows对内存的分配,是采用的分页管理,其中有个cr0寄存器,其中第一位叫做保护属性位,控制着页的读或写属性。如果为1,则可以读/写执行;如果为0,则只可以读执行。所以我们要将这一位设为1.

通过MDL

将原来的SSDT的区域映射到我们自己的MDL区域中,并把这个区域设置成可写就行了。

接下来获得SSDT中函数的地址。使用四个有用的宏。

SYSTEMSERVICE macro:可以获得由ntoskrnl.exe导出函数,以Zw开头函数的地址, 这个函数的返回值就是Nt函数, Nt*函数的地址就在SSDT中。

SYSCALL_INDEXmacro: 获得Zw函数的地址并返回与之通信的函数在SSDT中的索引。这两个宏之所以能工作,是因为所有的 Zw函数都开始于opcode: MOV eax, ULONG, 这

里的ULONG就是系统调用函数在SSDT中的索引。

HOOK_SYSCALL和UNHOOK_SYSCALLmacros: 获得Zw*函数的地址, 取得他的索引,

自动的交换SSDT中索引所对应的函数地址和我们hook函数的地址。

还有一个这样的表,叫做KeServiceDescriptorTableShadow,它主要包含GDI服务,也就是我们常用的窗口,桌面相关,具体存在于Win32k.sys。如下图:

右侧的服务分布就通过KeServiceDescriptorTableShadow。

SSSDT Hook和SSDT Hook的方式差不多,在此不再进行介绍。

6.9 IAT Hook

IAT即Import Address Table 是PE(可以理解为EXE)的输入地址表,我们知道一个程序运行时可以要调用多个模块,或者说要调用许多API函数,但这些函数不一定都在EXE本身中,例如你调用Messagebox来显示一个对话框时,你只需要调用它,你并没有编写Messagebox的函数的实现过程,Messagebox的函数的实现过程实际上是在user32.dll这个库文件中,当这个程序运行时会在user32.dll中找到Messagebox并调用它。

下图是导入表中的部分结构图:

IMAGE_THUNK_DATA指向 IMAGE_IMPORT_BY_NAME 结构的RVA,OriginalFirstThunk 和 FirstThunk 所指向的这两个数组大小取决于PE文件从DLL中引入函数的数目。当PE文件被装载到内存时,PE装载器将查找IMAGE_THUNK_DATA 和 IMAGE_IMPORT_BY_NAME 这些结构数组,以此决定引入函数的地址。然后用引入函数真实地址来替代由FirstThunk指向的 IMAGE_THUNK_DATA 数组里的元素值。因此当PE文件准备执行时,上图已转换下图所示:

所以IAT Hook的原理就是把后面的目标函数的地址改成我们自己写的函数的地址。这样,当在此调用目标函数的时候,就会调用我们的函数的地址。

6.10 EAT Hook

函数导入的函数的地址是再运行时候才确定的,比如我们的一个驱动程序导入了PsGetCurrentProcessId这个ntkrnlpa.exe导出的函数,那在我们驱动程序加载运行的时候,装载程序会确定ntkrnlpa.exe在内存的基地址,接着遍历它的导出表,在AddressOfNames指向的”函数名字表”中找到PsGetCurrentProcessId的位置,也就是如果在AddressOfNames[i]中找到PsGetCurrentProcessId,那就用i在AddressOfNameOrdinals中索引,假使得到是X,那么AddressOfFunctions[index]的值就是PsGetCurrentProcessId的RVA了,最后就可以知道PsGetCurrentProcessId在内存的值是MM=ntkrnlpa.exe在内存的基地址+PsGetCurrentProcessId的RVA,然后转载程序就把这个值写到我们驱动程序的IAT中,好了知道这些后,EAT HOOK就是修改PsGetCurrentProcessId的RVA,使得PsGetCurrentProcessId的RVA(修改后的)+ntkrnlpa.exe在内存的基地址=我们自己函数的值,这样装载程序会把我们的函数的地址写入那些调用PsGetCurrentProcessId的驱动程序的IAT,那么当那些驱动程序调用PsGetCurrentProcessId时,实际上是执行了我们自己的函数

第七章:断点

7.1 软件断点

x86系列处理器从其第一代英特尔8086开始就提供了一条专门用来支持调试的指令,即INT3。简单的说,这条指令的目的就是使CPU中断(break)到调试器,以供调试者对执行现场进行各种分析。当我们调试程序时,可以在可能有问题的地方插入一条INT3指令,使CPU执行到这一点时停下来。这便是软件调试中经常用到的断点功能,因此INT3指令又称为断点指令。

当我们在调试器中对代码的某一行设置断点时,调试器会把这里的本来指令的第一个字节保存起来,然后写入一条INT3指令。因为INT3指令的机器码是0xCC,仅有一个字节,所以设置和取消断点时也只需要保存和恢复一个字节。

当CPU执行到INT3指令时,由于INT3指令的设计目的就是中断到调试器,因此,CPU执行执行这条指令的过程也就是产生断点异常并转去执行异常处理的过程。在跳转到处理历程之前,CPU会保存当前的执行上下文,包括段寄存器,程序指针寄存器等内容。

在保护模式下,在保存当前执行上下文之后,cpu会从IDTR寄存器中获得IDT的地址,在IDT表中查询异常处理函数。

在Windows系统中,INT 3异常处理函数是操作系统内核函数KiTrap03。因此遇到INT 3会导致执行nt!KiTrap03函数。因为我们现在讨论的是应用程序调试,断点指令位于用户模式下的应用程序代码中,因此CPU会从用户模式转入内核模式。接下来,经过几个内核函数的分发和处理,因为这个异常来自用户模式,而且该异常的拥有进程正在被调试,所以内核例程会把这个异常通过调试子系统以调试事件的形式分发给用户模式的调试器,通知完用户模式调试器后,内核的调试子系统函数会等待调试器的回复。受到调试器的回复后,调试子系统的函数会层层返回,最后返回到异常处理例程,异常处理例程执行中断返回指令,使被调试的程序继续执行。

在调试器收到调试事件后,会根据调试事件数据结构中的程序指针,得到断点发生的位置,然后在自己的断点列表中寻找与其匹配的项。如果能找到说明是自己设置的断点。如果找不到,则说明导致这个异常的INT 3指令不是自己放进去的。会告诉用户:一个用户插入的断点被触发了。

在调试器下,我们看不到动态替换到程序的INT 3指令。大多数调试器的做法是在调试目标被中断到调试器之前,会先将所有断点位置被替换为INT 3的指令恢复成原来的指令,然后再把控制权交给用户。

当用户结束分析希望恢复被调试程序时,调试器通过调试API通知调试子系统,这会导致系统内核的异常分发函数返回到异常处理例程,然后异常处理例程通过IRET/IRETD指令触发一个异常返回动作,使CPU恢复执行上下文,从发生异常的位置继续执行。注意,这时的程序指针是指向断点所在的那条指令的,此时刚才的断点指令已经被替换成本来的指令,于是程序会从断点位置的原来指令继续执行。

软件断点虽然使用方便,但是也有局限性:

  • 属于代码类断点,即可以让CPU执行到代码段内的某个地址是停下来,不使用于数据段和I/O控件
  • 对于ROM中执行的程序,无法动态增加软件断点。因为目标内存是只读的,无法动态写入断点指令。这时就需要使用硬件断点。
  • 在中断向量表或中断描述表(IDT)没有准备好或遭破坏的情况下,这类断点是无法或不能正常工作的,比如系统刚刚启动时或IDT被病毒篡改后,这时只能用硬件级的调试工具

7.2 硬件断点

IA-32处理器定义了8个调试寄存器,分别称为DR0-DR7。在32位模式下,他们都是32位的;在64位模式下,都是64位的。下面以32位的情况来介绍。

DR4和DR5是保留的,当调试扩展功能被启用(CR4寄存器的DE位设为1)时,任何对DR4和DR5的引用都会导致一个非法指令异常,当此功能被禁止时,DR4和DR5分别是DR6和DR7的别名寄存器,即等价于访问后者。其他的6个寄存器:

  • 4个32位的调试地址寄存器(DR0-DR3),64位下是64位的
  • 1个32位调试控制寄存器(DR7),64位时,高32位保留未用
  • 1个32位调试状态寄存器(DR6),64位时,高32位保留未用

通过以上寄存器可以最多设置4个断点,基本分工是DR0-DR3用来指定断点的内存(线性地址)或I/O地址。DR7用来进一步定义断点的中断条件。DR6的作用是当调试事件发生时,向调试器报告事件的详细信息,以供调试器判断发生的是何种事件。

调试地址寄存器(DR0-DR3)用来指定断点的地址。对于设置在内存中的断点,这个地址应该是断点的线性地址而不是物理地址,因为CPU是在线性地址被翻译为物理地址之前来做断点匹配工作的。这意味着,在保护模式下,我们不能使用调试寄存器来针对一个物理内存地址设置断点。

调试控制寄存器(DR7)中,有24位被划分成四组分别与四个调试地址寄存器相对应。

R/W0-R/W3:读写域,分别与DR0-DR3四个调试地址寄存器相对应,用来指定被监控的访问类型,含义如下:

  • 00:仅当执行对应地址的指令时中断
  • 01:仅当向对应地址写数据时中断
  • 10:当向相应地址进行输入输出(即I/O读写)时中断
  • 11:当向相应地址读写数据时都中断,但是从该地址读取指令除外

LEN0-LEN3:长度域。分别与DR0-DR3四个调试地址寄存器相对应,用来指定要监控的区域长度,含义如下:

  • 00:1字节长
  • 01:2字节长
  • 10:8字节长或未定义(其他处理器)
  • 11:4字节长

L0-L3:局部断点启用:分别与DR0-DR3四个调试地址寄存器相对应,用来启用或禁止对应断点的局部匹配,如果该值设为1,当CPU在当前任务中检测到满足所定义的断点条件时便中断,并且自动清除此位,如果该位设为0,便禁止此断点。

G0-G3:全部断点启用。分别与DR0-DR3四个调试地址寄存器相对应,用来全局启用和禁止对应的断点。如果该位设为1,当CPU在任何任务中检测到满足所定义的断电条件时都会中断;如果该位设为0,便禁止此断点。与L0-L3不同,断点条件发生时,CPU不会自动清除此位。

LE和GE:这个在P6以下系列CPU上不被支持,在升级版的系列里面:如果被置位,那么cpu将会追踪精确的数据断点。LE是局部的,GE是全局的。

GD:启用或禁止调试寄存器的保护。当设为1时,如果CPU检测到将修改调试寄存器(DR0-DR7)的指令时,CPU会在执行这条指令前产生一个调试异常。

我们可以通过设置读写域来指定断点的访问类型。读写域占两个二进制位,可以指定4中访问方式。这里介绍三种典型的方式:

读/写内存中的数据时中断:这种断点又被称为数据访问断点。利用数据访问断点,可以监控全局变量或局部变量的读写操作。

执行内存中的代码时中断:这种断点又被称为代码访问断点或指令断点。代码访问断点在实现的功能上看与软件断点类似,都是当CPU执行指定地址开始的指令时中断。但是通过寄存器实现的代码访问断点与软件断点相比有个优点,就是不需要像软件断点那样像目标代码中插入断点指令。例如:当我们调试位于ROM上的代码(比如BIOS中的POST程序)时,根本没有办法向那里插入软件断点(INT3)指令,因为目标内存是只读的。另外,软件断点的另一个局限性是,只有当目标代码被加载进内存后才可以向该区域设置软件断点。而调试寄存器断点没有这些限制,因为只要把需要终端的内存地址放入调试地址寄存器(DR0-DR3),并设置好调试控制寄存器(DR7)的相应位就可以了。

读写I/O(输入输出)端口时中断:这种断点又被称为I/O访问断点。I/O访问断点对于调试使用输入输出端口的设备驱动程序非常有用。也可以利用I/O访问断点来监视I/O空间的非法读写操作,提高系统的安全性。因为某些恶意程序在实现破坏动作时,需要对特定的I/O端口进行读写操作。

7.3 条件断点

条件断点是一个带有条件表达式的普通INT3断点,是软件断点的一种,只有某些条件得到满足时这个断点才能中断执行程序。对于频繁调用的API函数,仅当特定参数传给他时才中断程序执行,这种情况下,条件断点特别有用,它可以节省调试的时间。

7.4 内存断点

改变内存分页的属性,如内存访问断点设为不可访问属性。由于分页粒度的限制,无法保证精度,最小改变一页的属性,不过内存断点不改变指令,不会被自校验检测到,并且没有个数限制,同时可以对一整段内存下断。归属于硬件断点。

第八章:调试器的原理

本文介绍的原理以大家所熟知的OllyDbg为例进行讲解。

Ollydbg的断点功能是基于异常处理来实现的,通过捕获程序执行过程中的异常信息来中断程序的执行流程。Ollydbg常用的断点类型有三种:INT3断点,内存断点,硬件断点。每种断点都是一种制造异常的方法,首先使程序在运行过程中产生错误,然后由Ollydbg的异常处理来接管,从而实现断点的功能。

8.1 加载调试程序

调试程序的第一步就是使用OllyDbg来加载程序,加载的过程是通过创建新进程来完成的。OllyDbg通过CreateProcess以调试的方式开启新进程。在创建调成程序前,OllyDbg需要进行一些必要的检查工作。

首先是针对快捷方式的检查。OllyDbg根据可执行程序的后缀名来判断分析程序是否为一个快捷方式,如果是快捷方式,则会找到这个快捷方式所对应的可执行程序的全路径。通过检查DOS头与NT头来判定分析文件是否为合法的PE文件。当调试文件为DLL动态库时,Olly/Dbg会使用自带的LoadDll.exe将Dll文件进行加载。当调试文件为exe可执行程序时,会跳过Dll文件的处理部分,直接获取相关的配置文件信息并进行加载和调试。

8.2 异常处理机制

异常就是程序运行过程中产生的错误。OllyDbg利用异常机制捕获调试程序在运行过程中产生的异常,对异常进行排查,从而实现断点功能,使程序暂停运行。OllyDbg将异常处理过程放置在一个大消息循环中,捕获异常的流程如下:

  • 进入消息循环
  • 利用WaitForDebugEvent函数捕获异常信息,如果捕获失败,则回到循环起始处。
  • 捕获到异常,率先由OllyDbg插件进行异常处理
  • 检查是否为调试异常,如果不是,则继续执行程序,回到循环起始处
  • 如果是调试异常,则进行相关检查,进入断点异常处理函数中。

当进入最后一步时,程序已经被成功断下,调试程序出于挂起状态,等待调试者的处理。异常处理首先检查调试事件类型,如果调试信息为异常,则进入异常处理部分,判断异常类型。先判断异常是否为INT3断点所产生的,如果是,则通过跳转指令执行对应的代码。下面介绍INT3断点的捕获过程:OllyDbg将调试程序停留在正确的INT3断点处,在显示反汇编代码的过程中,没有直接显示断点处机器码0xCC或0xCD,而是通过查找断点信息表中所对应的原机器码的信息来进行显示,以防止因修改指令造成的指令混乱。

在调试人员发出在此运行的指令后,OllyDbg将会先修复INT3断点处的内存数据,然后再次运行修复后的指令代码。INT3断点处的指令被执行后,此处将会被再次设置为INT3断点。

如果检测INT3断点失败,则会开始内存断点的异常检查。内存断点的设置过程是通过修改内存属性来达到触发异常的目的的。因此,内存断点的触发便是内存访问类错误。其流程如下:

  • 得到线程信息
  • 跳转到相应的异常处理分支
  • 若得到线程信息,则根据线程信息的eip进行赋值,否则根据异常地址进行赋值
  • 得到异常所处的模块的信息,并解析反汇编信息,以进行相关检查
  • 若模块为自解压(SFX)模式,则进行相应的检查以及错误处理
  • 检查内存断点是否在dll中,弹出提示窗口,并将断点去除
  • 最后调整优先级并退出

硬件断点的捕获过程是由调试寄存器来完成的,因此OllyDbg没有捕获处理过程。

8.3 INT3断点

INT3断点是最常用的断点,其工作流程时通过修改机器码为0xCC来制造异常。当程序执行0xCC代码时会触发INT3异常,OllyDbg将捕获此异常并等待用户的处理。跳过INT3断点则是将0xCC处的代码恢复,在此运行,以保证程序的正常运行。

OllyDbg实现INT3断点的主要流程如下:

  • 检查INT3断点是否在记录的断点信息表中
  • 将INT3断点信息记录到表中
  • 记录INT3断点处的机器码信息
  • 将INT3断点处的机器码修改为0xCC
  • 设置断点信息表

8.4 内存断点

内存断点用来监控内存,它可以对内存数据的访问和写入进行监控。内存断点的设置主要依靠两个API来完成:VirtualQuery和VirtualProtectEx。通过VirtualQuery来获取原内存页的属性,以便于还原;通过VirtualProtectEx修改内存页属性,以制造内存访问异常。被调试的目标进程发生异常后,首先处理这个异常的是调试器。因此调试器可以成功捕获这个异常。内存断点的处理过程是由异常处理部分来完成。

8.5 硬件断点

在寄存器中,有一些寄存器专门用于调试,称为调试寄存器,调试寄存器一共有8个:Dr0-Dr7;对于Dr0-Dr3四个寄存器,作用是存放中断的地址,Dr4和Dr5一般不使用,保留,Dr6和Dr7这两个寄存器的作用是用来记录Dr0-Dr3中下断的地址的属性,比如:对这个401000是硬件读还是写,或者是执行;是对字节还是对字,或者是双字。

关于硬件断点的详细信息,请参阅断点部分的硬件断点知识。

8.6 单步执行

SEH即结构化异常处理(Structured Exception Handling),当程序出现错误时,系统把当前的一些信息压入堆栈,然后转入我们设置好的异常处理程序中执行,在异常处理程序中我们可以终止程序或者修复异常后继续执行。

异常处理处理分两种,顶层异常处理和线程异常处理,下面介绍的是线程异常处理。每个线程的FS: [0]处都是一个指向包含异常处理程序的结构的指针,这个结构又可以指向下一个结构,从而形成一个异常处理程序链。当发生异常时,系统就沿着这条链执行下去,直到异常被处理为止。

下面以最常见的OllyDbg调试器为例讲解调试器单步执行时的工作方式。

当在调试器中选择“步过”某条指令时,程序自动在下一 条语句停下来,这其实也属于一种中断,而且可以说是最常用的一种形式了,当我们需要对某段语句详细分析,想找出程序的执行流程和注册算法时必须要进行这一 步。是80386以上的INTEL CPU中EFLAGS寄存器,其中的TF标志位表示单步中断。当TF为1时,CPU执行完一条指令后会产生单步异常,进入异常处理程序后TF自动置0。调试器通过处理这个单步异常实现对程序的中断控制。持续地把TF置1,程序就可以每执行一句中断一次,从而实现调试器的单步跟踪功能。

单步执行中包含StepIn和StepOver两种:

StepIn:

StepIn即逐条语句执行,遇到函数调用时进入函数内部,其实现方式如下:

  • 通过调试符号获取当前指令对应的行信息,并保存该行的信息。
  • 设置TF位,开始CPU的单步执行。
  • 在处理单步执行异常时,获取当前指令对应的行信息,与1)中保存的行信息进行比较。如果相同,表示仍然在同一行上,转到2);如果不相同,表示已到了不同的行,结束StepIn。

StepOver:

StepOver即逐条语句执行,遇到函数调用时不进入函数内部,其实现方式如下:

  • 通过调试符号获取当前指令对应的行信息,并保存该行的信息。
  • 检查当前指令是否CALL指令。如果是,则在下一条指令设置一个断点,然后让被调试进程继续运行;如果不是,则设置TF位,开始CPU的单步执行,跳到4)。
  • 处理断点异常时,恢复断点所在指令第一个字节的内容。然后获取当前指令对应的行信息,与1)中保存的行信息进行比较,如果相同,跳到2);否则停止StepOver。
  • 处理单步执行异常时,获取当前指令对应的行信息,与①中保存的行信息进行比较。如果相同,跳到2);否则停止StepOver。