PE文件与壳
一、PE文件的载入机制:
PE文件并不是作为单一映射文件被载入,它先被Windows加载器(PE装载器)
遍历,决定哪个部分要被映射,(映射是高偏移地址对应高内存地址)。
然后PE文件被载入内存,数据结构布局与原始的一致之外,数据间的相对位置不一定一致(如下图)。所以某一部分的载入偏移地址不一定等于原始偏移地址。被载入到内存的部分统称模块
,那么模块句柄
其实就是映射文件(等同于 PE文件 的虚拟空间 )的起始地址(但 Windows CE 除外),它还有另一个名字叫基地址
,我们在Windows编程时用到的API函数GetModuleHandle
就是用来获取模块句柄,即基地址 用的。
PE文件的基地址由文件自身决定,按照默认设置,VC++编译链接生成的EXE文件的基地址是400000h,DLL文件则是10000000h。
(一)相对虚拟地址RVA:
相对虚拟地址
是PE文件在 内存中的相对于PE文件载入地址(基地址)的偏移地址,它被采用的意义就在于,载入的指针可以在内存中的任意位置,可以很好地确定某一部分的具体位置。
相 对 虚 拟 地 址 = 目 标 地 址 ( 虚 拟 地 址 ) − 载 入 地 址 ( 基 地 址 ) 相对虚拟地址 = 目标地址(虚拟地址) - 载入地址(基地址) 相对虚拟地址=目标地址(虚拟地址)−载入地址(基地址)
(二)文件偏移地址(物理地址):
文件偏移地址
是PE文件在磁盘中相对于文件头的偏移地址,
其实我们用winhex等16进制编辑器
打开一个PE文件后,在左边栏呈现的就是文件偏移地址,即物理地址
。
二、PE文件结构简述
(一)MS-DOS头部
由DOS MZ
(MZ头)和紧随其后的DOS stub
(DOS块)组成
- MZ头告诉DOS这是一个有效执行体【就把他当作文件头就好】
- DOS块由编译器自动生成
这个结构中有用的字段主要是e_magic
和e_lfanew
e_magic
:MZ
的ASCII编码,可执行文件都以其开头。e_lfanew
:记录真正PE头的RVA。
(二)PE文件头
Windows加载器会读取e_lfanew字段,进而算出PE文件头的指针,PE文件头又可以被划分成3个部分,分别是Signature
、IMAGE_FILE_HEADER(映像文件头)
、IMAGE_OPTIONAL_HEADER(可选文件头)
Signature
:"PE\0\0"的ASCII编码映像文件头
:记录一些PE文件的基本信息,里面的一个字段指出可选映像头
的大小。具体参考 《加密与解密(第四版)》P.409可选映像头
:更详尽地描述了PE文件的基本信息,一些有用的信息可以通过LordPE等PE编辑器直观地看出来。在这里详细说说位于可选映像头的一个重要字段DataDirectory[16]
,其他的具体参考 《加密与解密(第四版)》P.409
——DataDirectory[16](数据目录表):
这16个元素的结构都是
IMAGE_DATA_DIRECTORY
(记录偏移地址和占用空间大小),指向输入表、输出表、资源块等数据,用于定位输入输出表等资源。
打开LordPE,进入目录界面可以直观的看到数据目录表的信息。
(三)区块表与区块
区块表
:紧接着可选映像头就是区块表
,是一个IMAGE_SECTION_HEADER
结构数组,一个IMAGE_SECTION_HEADER结构对应一个区块,每个这样的结构记录的是对应区块的基本信息。而这个数组有多少个取决于PE文件头
的映像文件头
的NumberOfSections字段
的数值。
我们用LordPE编辑一个PE文件的时候,进入区段窗口可以清晰的看到各个区块即它们的一些重要信息
关于块属性字段Characteristic,它的值是由下表中的值相或得到:
地址 用途 00000020h 包含代码,常与10000000h一起设置 00000040h 该块包含已经初始化的数据 00000080h 该块包含未初始化的数据 02000000h 该块可丢弃,一旦被载入,进程便不再需要它,例如重定位块.reloc 10000000h 该块为共享块 20000000h 该块可执行,通常00000020h被设置时它也被设置 40000000h 该块可读,可执行文件中的块总是设置该标志 80000000h 该块可写,如果PE文件中没有设置该标志,装载程序就将内存映像页标记为可读或者可执行
区块
:一个PE文件至少有两个区块(代码块
和数据块
)组成,在映像中的排列顺序按照起始地址排列而非字母表,不额外自定义区块名的情况下,链接器给这些区块的命名是由链接器本身决定的(微软的链接器和Borland的链接器设置的名称不同)。常见区块命名详见《加密与解密(第四版)》P.417
——区块合并
从源代码到可执行文件的过程中,一些区块在OBJ文件时就已经被放置了,可能还有特殊的用于给链接器传递消息的区块,而
链接器
做的就是按照一定规则合并OBJ和区块,这样做可以节省磁盘与内存空间。
(要是合并的过程中要合并的区块有一个是只读属性,那么系统临时将其设置为可读可写,再进行合并操作,初始化之后恢复)——区块对齐与地址转换运算
PE文件头
的可选映像头
中的FIleAlignment字段
定义了磁盘区块的对齐值,SectionAligment字段
定义了内存区块的对齐值,
- 磁盘中,每一个区块以
磁盘区块对齐值
的整数倍作为偏移地址,不足的地方(区块间隙)用00h
填充,- 内存中,区块至少从一个页边界处开始
当区块在内存中的偏移跟文件中的偏移一致时可以提高载入速度,但会使可执行文件变大,这么做取决于文件是否足够小。
结合下面的磁盘到内存的映射图我们发现,MS-DOS到块表的部分无论是在磁盘中还是在内存中,它们的偏移都是一致的,不一致的是其后的块的偏移,由于区块对齐,对于不同块来说,各个块在内存中的偏移与磁盘中的偏移的差值是不一定相同,但在同一个区块中磁盘与内存的对应地址,这个差值又是相同的,不难得出下面的公式:
F i l e O f f s e t = RVA − Δ k \textcolor{red}{{\mathnormal{FileOffset}} = \text{RVA} -\varDelta{k}} FileOffset=RVA−Δk
F i l e O f f s e t = RVA − I m a g e B a s e − Δ k \textcolor{red}{\mathnormal{FileOffset} = \text{RVA} - {ImageBase} - \varDelta{k}} FileOffset=RVA−ImageBase−Δk
三、输入表(.idata区块)
(一)输入函数的调用
PE文件载入内存前,要用到的输入函数的基本信息已经存在于PE文件中,但Windows加载器在PE文件载入内存之后才将相关DLL载入内存,并将调用输入函数的指令与输入函数的实际地址关联,同时输入地址表
(IAT
)中也被写入了输入函数的地址。
由于使用来自其他DLL的代码和数据的过程叫
输入
,所以输入函数
即外部函数
有的时候程序本可以直接用下面汇编语句高效调用API
call DWORD PTR [某API的地址]
但因为编译器分辨不出输入函数的调用和普通函数的调用而一视同仁使用下面的低效调用方式。
call 地址1 ;子程序
…… ……
地址1:
jmp dword ptr [某API的地址]
我们只需要在输入函数的申明前面加上_declspec(dllimport)
即可解决这个问题
(二)输入表的结构 与 IAT/INT
IAT
:输入地址表INT
:输入名称表
输入表
以一个IMAGE_IMPORT_DIRECTORY(IID)
结构数组开始,一个IID对应一个DLL等等,数组的最后一个元素是一个内容全0的IID作为该数组结束的标志。
IID中有两个很重要的字段OriginalFirstThunk
和FirstThunk
,分别指向输入名称表INT
和输入地址表IAT
的虚拟偏移地址,
IAT和INT本质上是一个IMAGE_THUNK_DATA数组,数组中的一个元素即一个IMAGE_THUNK_DATA(双字)对应一个输入函数,而IMAGE_THUNK_DATA本质上是指针,在不同的时刻有不同含义
IMAGE_THUNK_DATA的最高位为1
:函数以序号方式输入,此时低31位
代表被输入API的序数值。IMAGE_THUNK_DATA的最高位为0
:函数以字符串类型的函数名方式输入,此时双字指向一个IMAGE_IMPORT_BY_NAME结构(单字)
IMAGE_IMPORT_BY_NAME结构
存储一个输入函数的相关信息如下:
hint
:占一个字,输入函数在外部DLL输出表中的序号name
:所占空间可变,输入函数的函数名称
在PE文件加载到内存前,所有IMAGE_THUNK_DATA结构
都指向IMAGE_IMPORT_BY_NAME结构
。
IAT和INT都是以一个内容全为0的IMAGE_THUNK_DATA结构
作为结束标志。
也就是说,在载入内存前,PE文件的IAT和INT都是指向IMAGE_IMPORT_BY_NAME结构
的,但在载入内存之后,Windows加载器通过INT找到所有输入函数的地址,然后用这些地址去替代IAT中指向IMAGE_IMPORT_BY_NAME结构
的地址,此时IAT存入了输入函数的地址,输入表中别的部分已经不再重要。
(三)实例分析
下面我们以一个PE文件为例用
010 Editor
和LordPE
分别从载入内存前
和载入内存后
分析它的输入表
。
载入前的输入表实例分析
我们知道,可选映像头中数据目录字段的第二个元素记录的就是输入表的RVA,数据目录表相对于PE头的偏移是80h,在010 Editor中可以很容易定位得到输入表的RVA的值为13A1EC
LordPE中也可以一目了然得到。
目录表
界面下输入表
旁点击H
按钮即可立即查看输入表的内容(黑色底纹处)
要想在010 Editor中定位输入表的位置需要知道的是物理地址,但物理地址并不等于相对虚拟地址(RVA),所以就要用到物理地址跟虚拟地址的转换。
可以直接用LordPE内置的文件位置计算器
就可以得到文件偏移地址
提取出来如下,5个双字为一个IID。
OriginalFirstThunk | TimeDateStamp | Forward | Name(指向了DLL的名称) | First Thunk |
---|---|---|---|---|
E4A3 1300 | 0000 0000 | 0000 0000 | 22A4 1300 | BCA1 1300 |
28A2 1300 | 0000 0000 | 0000 0000 | 4EAA 1300 | 00A0 1300 |
0000 0000 | 0000 0000 | 0000 0000 | 0000 0000 | 0000 0000 |
以第一个IID为例,Name
是指向DLL名称的指针,因为高位对应高地址,所以22A4 1300
倒置过来就是
RVA
=
0013
A
422
\textcolor{red}{\text{RVA} = 0013A422}
RVA=0013A422,位置计算器计算出物理地址E0A22
010 Editor
中定位,可以发现第一个DLL对应USER32.dll
之前探讨过,加载到内存之前,IAT和INT结构相同,数值一致。OriginalFirstThunk
指向INT
,第一个IID
的OriginalFirstThunk
为0013A3E4
,物理地址对应0E09E4
,
定位到0E09E4
,
再看看第一个IID中指向IAT的First Thunk字段,
RVA
=
0013
A
1
B
C
\textcolor{red}{\text{RVA} = 0013A1BC}
RVA=0013A1BC,对应物理地址0E07BC
定位到0E07BC
,发现IAT与INT此时的值一致,都是0013A414
最高位为0,说明以函数名的方式输入,包含了函数名名称的IMAGE_IMPORT_BY_NAME结构
的RVA为0013A414
,物理地址是000E0A14
得到物理地址后010 Editor
中定位,可以发现这个IMAGE_IMPORT_BY_NAME结构
对应的输入函数是MessageBoxA函数
载入后的输入表实例分析
我们把程序运行过程中内存中的数据dump下来,然后再分析输入表,这里可以写个程序dump,也可以直接用OD插件,我选择的是用OD插件的方式
先用LordPE找到输入表的RVA为0013A1EC
,由于是dump出来的程序,此时的RVA已经是等于物理地址了,不需要再转换。
010 Editor中定位,与载入内存前的输入表一致,
现在看看第一个IID的OriginalFirstThunk
字段指向的INT,物理地址即RVA 为0013A3E4
,数据相比载入内存前没有改变
再分析一下FirstThunk
指向的IAT,物理地址即RVA 为0013A1BC,数据产生了变化,变成了75A1 ED60
,这应该就是USER32.dll链接库
中MessageBox函数
的地址