PE文件在文件系统中,与存贮在磁盘上的其它文件一样,都是二进制数据,对于操作系统来讲,可以认为是特定信息的一个载体,如果要让计算机系统执行某程序,则程序文件的载体必须符合某种特定的格式。要分析特定信息载体的格式,要求分析人员有数据分析、编码分析的能力。在Win32系统中,PE文件可以认为.exe、.dll、.sys 、.scr类型的文件,这些文件在磁盘上存贮的格式都是有一定规律的。
一、PE格式基础
下表列出了PE的总体结构
DOS MZ header |
一个完整的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 |
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 |
这是一个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 typedef struct _IMAGE_OPTIONAL_HEADER 可以将该值指定到新的RVA,这样新RVA处的指令首先被执行。DWORD BaseOfCode;//代码段起始RVA 栈和堆都拥有1个页面的申请值以及16个页面的保留值 |
|
图5 Load PE 读取的PE Header 的重要部分数据
图6 Subsystem 类型
图7 Load Pe 读取的IMAGE_DATA_DIRECTORY 信息
在IMAGE_OPTIONAL_HEADER32后部一般是16项IMAGE_DATA_DIRECTORY数据,其中最后一项是保留数据。每一项数据都有其固定的含义,并且位置不可改变。
|
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; |
Name:节名称 |
重要的节属性定义: |
[注]
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 { typedef struct _IMAGE_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 ;以下两行为保存当前程序上下文 |
在这一段代码中我们的是程序首先执行植入代码,完成特定功能,在执行完毕后,跳到原代码入口处继续执行(要注意保存初始环境),此段代码的主要目的就是给用户一个提示,表示我们成功的感染了这个程序。这段代码中涉及的数据有消息框的标题和内容,我们都要在此段中进行定义。我们可以通过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表及其它数据的表的内容。