PE 的意思是 Portable Executable(可移植的执行体),它是 Win32环境自身所带的执行文件格式。在Win32系统中,PE文件可以认为.exe、.dll、.sys 、.scr类型的文件,这些文件在磁盘上存贮的格式都是有一定规律的。
PE 的意思是 Portable Executable(可移植的执行体)。它是 Win32环境自身所带的执行文件格式。它的一些特性继承自Unix的Coff(common object file format)文件格式。“Portable Executable”(可移植的执行体)意味着此文件格式是跨Win32平台的;即使Windows运行在非Intel的CPU上,任何win32平台的PE装载器都能识别和使用该文件格式。
PE文件在文件系统中,与存贮在磁盘上的其它文件一样,都是二进制数据,对于操作系统来讲,可以认为是特定信息的一个载体,如果要让计算机系统执行某程序,则程序文件的载体必须符合某种特定的格式。要分析特定信息载体的格式,要求分析人员有数据分析、编码分析的能力。在Win32系统中,PE文件可以认为.exe、.dll、.sys 、.scr类型的文件,这些文件在磁盘上存贮的格式都是有一定规律的。
一、PE格式基础
下表列出了PE的总体结构
DOS MZ header DOS stub PE header Section table Section 1 Section 2 … Section n
|
一个完整的PE文件,前五项是必定要有的,如果缺少或者数据出错,系统会拒绝执行该文件如下图
图1 文件头格式错误
图2 格式数据错误
图3 代码错误
DOS MZ header部分是DOS时代遗留的产物,是PE文件的一个遗传基因,一个Win32程序如果在DOS下也是可以执行,只是提示:“This program cannot be run in DOS mode.”然后就结束执行,提示执行者,这个程序要在Win32系统下执行。
DOS stub 部分是DOS插桩代码,是DOS下的16位程序代码,只是为了显示上面的提示数据。这段代码是编译器在程序编译过程中自动添加的。
PE header 是真正的Win32程序的格式头部,其中包括了PE格式的各种信息,指导系统如何装载和执行此程序代码。
Section table部分是PE代码和数据的结构数据,指示装载系统代码段在哪里,数据段在哪里等。对于不同的PE文件,设计者可能要求该文件包括不同的数据的Section。所以有一个Section Table 作为索引。Section多少可以根据实际情况而不同。但至少要有一个Section。如果一个程序连代码都没有,那么他也不能称为可执行代码。在Section Table后,Section数目的多少是不定的。
二、程序的装入
当我们在explorer.exe(资源管理器)中双击某文件,执行一个可执行程序,系统会根据文件扩展名启动一个程序装载器,称之为Loader。Loader会首先检查DOS MZ Header,如果存在,就继续寻找PE header,如果这两项都不存在,就认为是DOS 16位代码,如果只存在DOS MZ Header,而其中又指示了而其中又指示了PE Header 的位置,那么Loader 就判定此文件不一个有效的PE文件,拒绝执行。
如果DOS Header 和PE Header都正常有效,那么Loader就会根据PE Header 及Section Table的指示,将相应的代码和数据映射到内存中,然后根据不同的Section进行数据的初始化,最后开始执行程序段代码。
三、PE格式高级分析
下面我们以一个真实的程序为例详细分析PE格式,分析PE格式最好有PE分析器,常用的软件是Lord PE,也有其它的分析工具和软件如PE Editor 、Stud PE等。
先分析一下磁盘文件的内容,这里我们使用UltraEdit32(UE)工具,这是一个实用的文件编辑器,可以编辑文本和二进制文件。
图4 PE文件开始的磁盘数据
在文件的一开始有两位16进制数据4D 5A,其对应的ASCII字符是MZ,这个标志就是DOS MZ Header 的标志。下面是通过Load PE列出 的DOS MZ Header
1. DOS Header
数据结构名称
| 值
|
e_magic:
| 0x
5A
4D->‘MZ’
|
e_cblp:
| 0x0090
|
e_cp:
| 0x0003
|
e_crlc:
| 0x0000
|
e_cparhdr:
| 0x0004
|
e_minalloc:
| 0x0000
|
e_maxalloc:
| 0xFFFF
|
e_ss:
| 0x0000
|
e_sp:
| 0x00B8
|
e_csum:
| 0x0000
|
e_ip:
| 0x0000
|
e_cs:
| 0x0000
|
e_lfarlc:
| 0x0040
|
e_ovno:
| 0x0000
|
e_res:
| 0x0000000000000000
|
e_oemid:
| 0x0000
|
e_oeminfo:
| 0x0000
|
e_res2:
| 0x0000000000000000000000000000000000000000
|
e_lfanew:
| 0x
000000F
8
|
这是一个PE文件的DOS Header,其中我们最关心的就是e_lfanew这个字段的值,它指向了PE Header 在磁盘文件中相对于文件开始的偏移地址,这里是F8。在本文件00F8h处果然找到了“PE”两个字符,那么在00F8h处就是PE Header 的有效头载荷。
2. PE Header
我们可以在winnt.h这个文件中找到关于PE文件头的定义:
typedef struct _IMAGE_NT_HEADERS { DWORD Signature;//PE文件头标志:PE/0/0。在开始DOS header的偏移3CH(e_lfanew)处所指向的地址开始 IMAGE_FILE_HEADER FileHeader;//PE文件物理分布的信息 IMAGE_OPTIONAL_HEADER32 OptionalHeader;//PE文件逻辑分布的信息 } IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
|
2.1 IMAGE_FILE_HEADER和IMAGE_OPTIONAL_HEADER
typedef struct _IMAGE_FILE_HEADER { WORD Machine;//该文件运行所需要的CPU,对于Intel平台是14Ch WORD NumberOfSections;//文件的节数目 DWORD TimeDateStamp;//文件创建日期和时间 DWORD PointerToSymbolTable;//用于调试 DWORD NumberOfSymbols;//符号表中符号个数 WORD SizeOfOptionalHeader;//OptionalHeader 结构大小 WORD Characteristics;//文件信息标记,区分文件是exe还是dll } IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER; 重要的 Characteristics值 #define IMAGE_FILE_RELOCS_STRIPPED 0001h // 文件中是否存在重定位信息 #define IMAGE_FILE_EXECUTABLE_IMAGE 0002h // 文件是可执行的 #define IMAGE_FILE_LARGE_ADDRESS_AWARE 0020h // 程序可以触及大于2G的地址 #define IMAGE_FILE_BYTES_REVERSED_LO 0080h // 保留的机器类型低位 #define IMAGE_FILE_32BIT_MACHINE 0100h // 32位机器 #define IMAGE_FILE_REMOVABLE_RUN_FROM_SWAP 0400h // 不可在可移动介质上运行 #define IMAGE_FILE_NET_RUN_FROM_SWAP 0800h // 不可在网络上运行 #define IMAGE_FILE_SYSTEM 1000h // 系统文件 #define IMAGE_FILE_DLL 2000h // 文件是一个DLL #define IMAGE_FILE_UP_SYSTEM_ONLY 4000h // 只能在单处理器计算机上运行 #define IMAGE_FILE_BYTES_REVERSED_HI 8000h //保留的机器类型高位
typedef struct _IMAGE_OPTIONAL_HEADER { WORD Magic;//标志字(总是010bh) BYTE MajorLinkerVersion;//连接器高版本号 BYTE MinorLinkerVersion;//连接器低版本号 DWORD SizeOfCode;//代码段大小 DWORD SizeOfInitializedData;//已初始化数据块大小 DWORD SizeOfUninitializedData;//未初始化数据块大小 DWORD AddressOfEntryPoint;//PE装载器准备运行的PE文件的第一个指令的RVA,若要改变整个执行的流程, 可以将该值指定到新的RVA,这样新RVA处的指令首先被执行。DWORD BaseOfCode;//代码段起始RVA DWORD BaseOfData;//数据段起始RVA DWORD ImageBase;//PE文件的装载地址 DWORD SectionAlignment;//块对齐因子 DWORD FileAlignment;//文件块对齐因子 WORD MajorOperatingSystemVersion;//所需操作系统高位版本号 WORD MinorOperatingSystemVersion;// 所需操作系统低位版本号 WORD MajorImageVersion;//用户自定义高位版本号 WORD MinorImageVersion;//用户自定义低位版本号 WORD MajorSubsystemVersion;//win32子系统版本。若PE文件是专门为Win32设计的 WORD MinorSubsystemVersion;//该子系统版本必定是4.0否则对话框不会有3维立体感 DWORD Win32VersionValue;//保留值,系统没用到的,一般被作为是否感染的标志 DWORD SizeOfImage;//内存中整个PE映像体的尺寸 DWORD SizeOfHeaders;//所有头+节表的大小 DWORD CheckSum;//校验和 WORD Subsystem;//NT用来识别PE文件属于哪个子系统 WORD DllCharacteristics;// 用来表示一个DLL映像是否为进程和线程的初始化及终止包含入口点的标记 DWORD SizeOfStackReserve;// DWORD SizeOfStackCommit;// DWORD SizeOfHeapReserve;// DWORD SizeOfHeapCommit;// //堆栈大小 这些域控制要保留的地址空间数量,并且负责栈和默认堆的申请。在默认情况下, 栈和堆都拥有1个页面的申请值以及16个页面的保留值 DWORD LoaderFlags;// 告知装载器是否在装载时中止和调试,或者默认地正常运行 DWORD NumberOfRvaAndSizes;// 该字段标识了接下来的DataDirectory数组个数。 IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES]; //IMAGE_DATA_DIRECTORY 结构数组。每个结构给出一个重要数据结构的RVA,比如引入地址表等 }IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;
|
2.2 IMAGE_DATA_DIRECTORY
typedef struct _IMAGE_DATA_DIRECTORY { DWORD VirtualAddress;//表的RVA地址 DWORD Size;//大小 } IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
|
图5 Load PE 读取的PE Header 的重要部分数据
图6 Subsystem 类型
图7 Load Pe 读取的IMAGE_DATA_DIRECTORY 信息
在IMAGE_OPTIONAL_HEADER32后部一般是16项IMAGE_DATA_DIRECTORY数据,其中最后一项是保留数据。每一项数据都有其固定的含义,并且位置不可改变。
Export Table 导出函数表,主要用于DLL中的导出函数 Import Table 导入函数表,使用外部函数的数据表 Resource 资源数据表 Exception 异常处理表 Security 安全处理数据表 Relocation 重定位信息表,一般和DLL相关 Debug 调试信息表 Copyright 版权信息表 Globalptr 机器值(MIPS GP) Tls Table 线程信息表 LoadConfig 装配信息表 BoundImport 输入函数绑定信息表 IAT 也ImportTable对应,由Loader填写的输入函数地址 DelayImport 延迟装入的函数信息 COM 公用组件信息表 Reserved 保留信息,系统没有使用,为以后扩展使用 这16项数据,其所在位置由RVA指定,大小由Size指定。对于一般的一个可执行程序(.exe), 最重要的是导入表(Import Table和IAT)、资源数据表(Resoruce)。
|
2.3 IMAGE_SECTION_HEADER
PE文件头后是节表,在winnt.h下如下定义 typedef struct _IMAGE_SECTION_HEADER { BYTE Name[IMAGE_SIZEOF_SHORT_NAME];//节表名称,如“.text” //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;
|
图8 Load PE 读取的 IMAGE_SECTION_HEADER 信息
Name:节名称 VOffset:相对于ImageBase的虚拟偏移 VSize:实际大小 ROffset:相对于文件起始处的偏移 RSize所占的文件空间大小 Flags:节属性 |
图9 Load PE 读取的.text节的属性
重要的节属性定义: #define IMAGE_SCN_CNT_CODE 00000020h // 节中包含代码 #define IMAGE_SCN_CNT_INITIALIZED_DATA 00000040h // 节中包含已初始化数据 #define IMAGE_SCN_CNT_UNINITIALIZED_DATA 00000080h // 节中包含未初始化数据 #define IMAGE_SCN_MEM_DISCARDABLE 02000000h // 是一个可丢弃的节,即节中的数据在进程开始后将被丢弃 #define IMAGE_SCN_MEM_NOT_CACHED 04000000h // 节中数据不经过缓存 #define IMAGE_SCN_MEM_NOT_PAGED 08000000h // 节中数据不被交换出内存 #define IMAGE_SCN_MEM_SHARED 10000000h // 节中数据可共享 #define IMAGE_SCN_MEM_EXECUTE 20000000h // 可执行节 #define IMAGE_SCN_MEM_READ 40000000h // 可读节 #define IMAGE_SCN_MEM_WRITE 80000000h // 可写节 |
[注]
RVA:虚拟偏移地址。RAV是指的某一处由Loader装入内存后,这一处应该在虚拟内存的什么地方,RAV也称为虚拟偏移地址。
Alignment:对齐因子。与对齐因子相关的值有2个地方,一处是文件对齐因子,另一处是内存对齐因子。对齐因子指示出某一类型的对齐方式,以文件对齐为例,如果Alignment 为200h,说明文件中的内容是以200h为单位的,如果数据大小正好是200h的整数倍,则不存在对齐问题,如果数据大小是非200h的整数倍,则要使用Alignment 对数据所占的空间进行修正,取其上限数值(如310h->400h),使其所占的空间是200h的整数倍。
2.3 Improt Table 和IAT
IMAGE_DATA_DIRECTORY的第2项和第13项,指示导入表和导入函数地址表的位置。这部分对于一个PE文件相当重要,很多系统函数都是由此导入。
Import Table 的VirtualAddress指向了一个RVA,他是一个导入表结构数组,数组以全0作为结束标记,该结构定义如下:
typedef struct _IMAGE_IMPORT_DESCRIPTOR { union { DWORD Characteristics; DWORD OriginalFirstThunk;// 指向一个 IMAGE_THUNK_DATA 结构数组的RVA } DWORD TimeDateStamp;// 文件生成的时间 DWORD ForwarderChain;// 这个数据一般为0,可以不关心 DWORD Name1; // RVA,指向DLL名字的指针,ASCII字符串 DWORD FirstThunk; //指向一个 IMAGE_THUNK_DATA 结构数组的RVA,这个数据与IAT所指向的地址一致 }IMAGE_IMPORT_DESCRIPTOR,*PIMAGE_IMPORT_DESCRIPTOR IMAGE_THUNK_DATA 这是一个DWORD类型的集合。通常我们将其解释为指向一个 IMAGE_IMPORT_BY_NAME 结构的指针,其定义如下: IMAGE_THUNK_DATA{ union { PBYTE ForwarderString; PDWORD Function; DWORD Ordinal;//判定当前结构数据是不是以序号为输出的,如果是的话该值为0x800000000,此时PIMAGE_IMPORT_BY_NAME不可做为名称使用 PIMAGE_IMPORT_BY_NAME AddressOfData; }u1; } IMAGE_THUNK_DATA,*PIMAGE_THUNK_DATA;
typedef struct _IMAGE_IMPORT_BY_NAME{ WORD Hint;// 函数输出序号 BYTE Name1[1];//输出函数名称 } IMAGE_IMPORT_BY_NAME,*PIMAGE_IMPORT_BY_NAME
|
3.IMP和IAT的关系
静态分析时,OriginalFirstThunk与FirstThunk指向的数据是同一组IMAGE_IMPROT_BY_NAME。
OriginalFirstThunk
|
| IMAGE_IMPORT_BY_NAME
|
| FirstThunk
|
IMAGE_THUNK_DATA
IMAGE_THUNK_DATA
IMAGE_THUNK_DATA
IMAGE_THUNK_DATA
...
IMAGE_THUNK_DATA
| --->
--->
--->
--->
--->
--->
| Function 1
Function 2
Function 3
Function 4
...
Function n
| <---
<---
<---
<---
<---
<---
| IMAGE_THUNK_DATA
IMAGE_THUNK_DATA
IMAGE_THUNK_DATA
IMAGE_THUNK_DATA
...
IMAGE_THUNK_DATA
|
Loader 在装入一个可执行的代码时,会分析该文件的导入表(IMP),然后通过导入表的指引,修改IAT指向的数据,这们在装载完成后,数据会变成如下形式
OriginalFirstThunk
|
| IMAGE_IMPORT_BY_NAME
|
| FirstThunk
|
IMAGE_THUNK_DATA
IMAGE_THUNK_DATA
IMAGE_THUNK_DATA
IMAGE_THUNK_DATA
...
IMAGE_THUNK_DATA
| --->
--->
--->
--->
--->
--->
| Function 1
Function 2
Function 3
Function 4
...
Function n
|
| Address of Function 1
Address of Function 2
Address of Function 3
Address of Function 4
...
Address of Function n
|
可以看出,OriginalFirstThunk与FirstThunk所指向的数据分离。FirstThunk指向的不再是IMAGE_IMPORT_BY_NAME,而是指向了函数的真实地址。代码段对于一个外部函数的引用始终使用的是FirstThunk处的RVA。当程序真正执行时,就可以跳到真正的函数入口了。
四、修改PE文件
PE文件是由源代码经由编译器编译、链接后形成的可执行文件,由系统加载执行。通过对PE文件的分析,给理论上修改PE文件提供了可能。下面我们分几个步骤修改PE文件。这些步骤不是修改PE的必须步骤,但从中我们可以讨论如何修改PE文件。
1.给PE文件增加一个新节
PE文件节的信息保存在文件头的最后部分,如果预留磁盘空间足够大(大于或等于了个节的结构数据大小),我们就可以为其增加一个新节。
由IMAGE_SECTION_HEADER的结构定义可知,IMAGE_SECTION_HEADER的大小为10个DWORD类型数据的大小,也就是40(28h)个字节,我们观察要修改的目标
图10 PE文件的节部分数据
再由图8的数据我们得知,该PE文件的第一个节的数据的文件偏移地址为400h,而最后一节.rsrc,的末尾偏移是中28fh,400h-28fh=171h>28h,可以增加新的节标志。增加新的节标志数据,首先要修改IMAGE_FILE_HEADER结构的NumberOfSections数值(4->5),然后在290h处开始按IMAGE_SECTION_HEADER结构填入相应的数值。这里我们使用Load PE添加新的数据
图11 添加了新节的信息
由于新的节没有实际数据,所以其VSize和RSize大小为0,新节的属性与.text代码段相同,添加新节后,这里只是添加了节信息,还要补充节数据,补充数据我们使用UE进行复制粘贴就可,这一步涉及了两处Alignment,要注意使用,我们先补充100h字节,但由于文件对齐因子,所在至少在文件末尾处添加200h个空白数据,最后修改节的信息和IMAGE_OPTIONAL_HEADER的SizeOfImage(003f000h ->0040000h),使得我们添加的数据也可以由Loader加载到内存中。添加数据完成后,先执行程序,确定PE头信息的正确。
图12修正后的文件头部信息
图13 修改后的程序正常执行的界面
2. 在新节中添加代码
在新节中添加代码是本文修改PE文件的关键,我们的目的不仅仅是添加数据,而是添加可执行的代码,通过添加代码研究PE文件的可感染性。由于高级编译器都会将数据段和代码段分开来编译,所以我们添加的代码将会因为找不到数据而使程序崩溃,因此我们要将我们需要的数据和代码放在同一个Section 内,方便编程。
示例汇编代码:
pushad ;以下两行为保存当前程序上下文 pushfd mov esi, ImageBase+SectionRVA+messbody ;提示信息体 mov edi, ImageBase+ SectionRVA +messtitle ;提示信息标题 push 0 ;MB_OK的类型的消息框 push esi ;参数入栈 push edi ;参数入栈 push 0 ;hWND call MessageBoxA popfd ;恢复程序上下文 popad mov eax,ImageBase+oldoep;原始的入口地址送到eax jmp eax ;跳到原始入口处 messbody db ‘New Section Add This File’,0 messtitle db ‘I Sucess’,0
|
在这一段代码中我们的是程序首先执行植入代码,完成特定功能,在执行完毕后,跳到原代码入口处继续执行(要注意保存初始环境),此段代码的主要目的就是给用户一个提示,表示我们成功的感染了这个程序。这段代码中涉及的数据有消息框的标题和内容,我们都要在此段中进行定义。我们可以通过NASM编译这段代码为纯二进制代码。(关于NASM编译器,可以通过网络查找其编译程序和文档)。
为使编译通过,我们首先确定MessageBoxA的地址和oldoep的地址
图14 Load PE 文件的人Import Table
通过查阅MSDN,我们知道MessageBoxA的函数由USER32.dll导出,而应用程序使用的这个信息就在导入表中。通过Load PE 查看文件的Import Table,我们找到USER32.dll 和MessageBox的地址在0002743Ch,要执行0002743Ch处的索引函数,就在在其前面加上ImageBase的值。也就在此代码在0042743Ch处,实际调用参考为 Call dword [0042743ch]。Oldoep由IMAGE_OPTIONAL_HEADER的AddressOfEntryPoint得到
(0000D1B5h+00400000h=0040d1b5h)
图15 通过NASM编译后的代码
将这段代码复制到修改后的.exe 的38c00h处,然后保存。如下图:
图16 修改后的新节的数据
运行程序:
图17 节的程序可以照常执行
程序首先弹出对话框,单击确定后看到了程序的初始界面
3. 其它植入方法
启动OllDbg(Ring3)调试器,调试上面刚处理过的程序,发现调试器会给出一个警告,如下图
图18 OllDbg的警告信息
通过实验得出,之所以OllDbg 会发出如此的警告,是因为该文件的PE信息中AddressOfEntryPoint超出了Code Section(.text)段所记录的地址(0000000h~00270000h)。将.text判定为代码段,由PE头的BaseOfCode得出。如果将此段代码植入.text 段,那么将不会出现此提示。
PE文件能否正常加载执行,与磁盘文件结构密切相关,但一旦将磁盘文件映射为内存镜像后,就与磁盘文件脱离了关系。所以磁盘仅仅是一个规范的数据结构。
通过实验,我们可以得出这样的结论:对于一个小的代码段(其二进制代码长度小于代码段下一节的RVA-(BaseOfCode+代码段的VirtualSize)),植入是成功的。
那么我们通过分析磁盘文件和内存映射的关系,就可以修改代码段,将代码植入到代码段。在植入代码段后,要对PE文件的磁盘数据和进行一次定位修复,就可以完成代码的整体植入。
代码段一般是PE文件的第一个Section,如果此段变长,就要将其后续段的RVA和磁盘偏移地址都要进行修正。修正完成后,仅仅是保证了PE文件的磁盘格式正确,接下来主要就是修改导入表数据,资源表数据。最后要参照原PE文件将新PE文件对数据段的数据的引用进行修正。
至此,我们完成了一次对PE文件新代码的引入问题的研究。但对于一个复杂的PE文件,修改还远不如此。还要处理输出表、TLS表及其它数据的表的内容。