PE文件格式学习笔记
感觉只学手操不学基础知识还是不行,趁着暑假赶紧补一补, 跟着这篇学的 。
从一次PE编辑实现弹窗开始
堆理论知识之前先搞一波实操:在一个exe程序启动之前实现一个弹窗功能。
添加区段
这里就用线程的LordPE来实现吧,我们先拷贝一份_LordPE_fix - 副本
,打开后显示出这样的界面
这便是该程序的启动界面,我们要做的就是在这个界面之前添加一个弹窗。
我们用LordPE打开之前拷贝的副本(没错,我改我自己),显示的便是拷贝文件的一些基本信息
重点关注以下信息:
区段数目 | NumberOfSections | 0004 |
---|---|---|
入口点 | EntryPoint | 0x00004340 |
镜像基址 | ImageBase | 0x00040000 |
镜像大小 | SizeOfImages | 0x00036000 |
点击区段,显示出区段表,正好有4项
名称后面分别是:
VOffset | VSize | ROffset | Rsize | Flags |
---|---|---|---|---|
映射入内存后的虚拟地址 | 映射入内存后的虚拟地址长度 | 文件中的位置 | 文件中的长度 | 区段属性 |
右击最下面的区段后点击添加区段,发现多了一个NewSec区段。
之后右击修改新区段在虚拟和物理磁盘中的大小以及标志,把不需要的取消勾选。
修改后的区段表如下:
我们发现区段数已经增加了,我们单机保存即可,虽然添加了一个区段,但是文件的实际大小却没有改变
我们尝试运行副本
发现无法运行,这是因为我们添加了一个区段后,这是因为我们刚才只给了该文件一个区段,但是却没有对应的文件,因此会出现报错,无法被windows系统加载,于是我们选择打开16进制编辑器。
用16进制编辑器修改文件
这里选择HXD_Hex_32来完成。
用编辑器打开后发现末尾地址是0x32FFF
通过简单计算, 正好是我们添加的区段在文件中的地址起始点,我们在末尾添加512个字节(用16进制00填充),发现文件变大了
此时再次打开副本发现可以打开。
TIPS:新版的windows可能改过PE文件加载过程,如果win版本较低的话这里可能双击仍然无法正常运行,这时可以用LordPE继续修改,把镜像基址0x00036000加0x1000变成0x00037000,win10下他自动改好了
修改汇编添加弹出对话框功能
拖入OD发现停在了EntryPoint处
此时对应的是虚拟地址,该地址的计算方法为:虚拟地址 = 镜像基址 +入口点
由于我们要实现的功能是在程序启动前实现弹窗,于是我们考虑把入口点设置为之前添加的NewSec区段映射入内存后的起始地址。
再次拖入OD,发现入口点发生改变,变成了00436000。
按Ctrl+G定位到地址00436020,选中部分字节后按空格编辑,选择ASCII码直接输入字符串HelloWorld!!
,发现上方的汇编语言也跟着改变了。
这是因为OD把输入的HelloWorld!! 和 Hello 两串字符当成了汇编代码进行显示,这当然没办法执行。
之后我们从入口点00436000开始插入以下汇编代码
1 | push 0 |
发现录入汇编后,在注释的位置便可以自动解析出对应地址我们之前输入的ASCII码。
之后我们右击->复制到可执行文件->选择->保存文件并覆盖原文件,我们就把改好的汇编代码添加到了原来的LordPE副本文件。此时我们点击运行该文件,发现出现了弹窗
但是PE文件本体却没有执行,这是因为我们修改了入口点,并且添加的汇编代码没有跳转到原来的代码起始点的指令,因此在弹窗之后原来的文件是无法打开的。
于是我们在此修改汇编,在弹窗指令后加入一个跳转指令
我们对其进行调试,发现在执行call语句弹窗后,我们点击确定执行jmp跳转指令,程序便运行到之前的入口点处了。
此时我们再次覆盖文件后,发现程序便可以再弹窗后正常打开了。
小结
本次操作成功实现了在一个可执行文件之前添加一个弹窗并且成功跳转到原入口点处的功能。
这种应用有很多实际意义,比如
- 病毒技术,反病毒技术,反反病毒(免杀)技术
- 软件保护技术,如加壳、脱壳技术
如果我们刚刚在本体前加的不是弹对话框,而是其他的一些文件操作函数,比如遍历磁盘并删除文件,这就是一款具有破坏性的病毒了,对于反病毒技术,如何分离病毒代码与宿主程序,或者修复被破坏的程序,这也需要用到PE文件结构的知识。
PE文件结构概述
PE文件格式是Portable Executable的缩写,即可移植的执行体。windows操作系统下,所有的可执行文件都是PE格式,如exe文件,dll文件,sys文件,ocx文件等。
PE结构不是一个单纯的结构,一个PE文件由若干个结构集合所构成,不同的结构有不同的用处。
PE文件格式是一种对文件组织管理的方式
这里的数据指的是广义上的数据,因为无论是代码,数据还是资源,其实初始状态都是数据。
一个栗子(用RadASM编写一个简单程序)
打开RadASM创建工程,直接编写汇编代码即可。
1 | .386 //用到的汇编指令的指令集是.386 |
编译后连接,工程文件夹中出现demo.exe文件,执行后就是我们刚才编写的弹窗程序
在WinHex中对比可执行文件在文件和内存中的差距
用winhex打开demo.exe,之后执行demo.exe,在工具栏中打开RAM,找到demo#6164下的demo.exe打开
分别为文件中和内存中的demo.exe:
对比发现
文件中的demo.exe是从00000000开始的,但是内存中的demo.exe却是从00400000开始的。
此外
190到220这一段数据是可以对应上的,但同时也发现
文件中的600开始和内存中的2000开始发现调用的dll是一样的,可以对应上,但是前面的部分数据却对应不上。
用PEView查看可执行文件的结构
用PEView打开demo.exe
右边显示的和之前在winhex中的一样,分别是偏移Offset,数据Raw Data和对应的值Value(以字符的方式显示,如果无法解析则用.
代替)
然后我们点击第一个IMAGE_DOS_HEADER
注意箭头的位置,我们发现之前整个程序从00000000开始的16进制字节分别是4D 5A,但是这里却倒过来了变成了5A4D,这个玩意叫做字节序。
此外,signature的Data 00004550我们找到文件中相对应的位置000000B0,发现是50,45
也是倒序的,这个玩意也是因为字节序导致的。
再看左边:
IMAGE_DOS_HEADER:dos头
MS-DOS Stub Program:DOS存根
Signature:PE文件的标识
IMAGE_FILE_HEADER:文件头
IMAGE_OPTIONAL_HEADER:可选头(但不是可以不选的那种,是必须有的,只是其中某些东西只需要占位,不需要有具体数据)
IMAGE_SECTION_HEADER:节区,给出了三种数据在文件和在内存中的位置
- .text:代码
- .rdata: 只读数据
- .data:数据
SECTION:有三个,分别为.text,.rdata,.data,是真正的数据
文件中的数据不会变化,但是在映射到内存中后一些相对位置就变了
DOS头
DOS头是PE文件结构的第一个头,用来保持对DOS系统的兼容,并且用于定位真正的PE头。
因为DOS操作系统已经不存在了,DOS头现在的作用也就是保持一个兼容给出一个提示,没什么太大作用。
DOS头在winnt.h头文件中的定义如下(该文件头大小为40h,64d)
我们关注的主要是两个属性:e_magic 和 e_lfanew
TIPS:WORD是双字节,LONG是4字节
1 | typedef struct _IMAGE_DOS_HEADER { |
判断一个文件是否为PE文件
用16进制编辑器打开一个文件,就那之前编写的demo.exe来做例子吧。
由于文件头大小为40h,我们只关注前4行:
之前的DOS头的定义,我们知道,demo.exe的e_magic(前两个字节)是4D5A,即对应文本MZ,此外我们关注的
e_lfanew是B0 00 00 00 ,但是由于这玩意是小端序存储,所以PE头相对于文件的实际偏移为00 00 00 B0,因此我们找到000000B0处,
发现从这里开始正好是50 45 00 00对应PE..,而00000040到000000B0之间的这一段数据是没有用的。
因此判断一个文件是否为PE文件的步骤为
- 观察其前2字节是否为4D 5A(MZ)
- 找到e_lfanew(3C到3F的小端序)
- 根据e_lfanew找到地址,观察其前4字节是否为PE00
找到了PE的话一般都是PE文件了
不是所有MZ开头的都是可执行文件,但是所有可执行文件都是MZ开头
intel架构的cpu存储数据都是小端序,大端序存储一般在其他cpu架构或者网络传输数据时使用
网络上传输数据一般都是大尾方式
写个程序计算DOS_HEADER大小
1 |
|
输出结果为
即base10下为64,base16下为40。
一个简单实验
为了证明之前所说的在00000040到000000B0之间的数据是没用的,可以用16进制编辑器把这一段数据填充为00,然后尝试打开demo.exe。
填充后重新打开demo.exe,发现依然可以弹窗
同时,为了证明除了e_magic和e_lfanew这两个属性,其他属性都是没用的,我们也可以把除了这两个属性之外的属性全部填充为00,再次尝试运行demo.exe。
发现仍然可以正常运行
好,那么我们刚才填充为00的这些属性到底有啥用呢,为什么要留着他们呢?
我们把00000000到000000B0之间的数据拷贝下来粘贴进一个新文件,保存为dos.bin
然后我们用IDApro打开这个dos.bin文件。
这一块代码实际上是在编译-连接的时候自动添加进来的一个程序,被称为DOS存根
读一下汇编,它的作用就是输出”This program cannot be run in DOS mode.”这个字符串,然后功能吗是4C01,即退出程序。
小节
- 可执行文件开始的两个字节是MZ
- 学会找到e_lfanew偏移位置
- 学会逐字节对应DOS头的每个属性
文件头及编程分析
文件头定义
通过前面介绍的DOS头(IMAGE_DOS_HEADER)中的e_lfanew属性,我们可以找到真正的PE头(IMAGE_NT_HEADERS),IMAGE_NT_HEADERS的定义如下
1 |
|
我们发现这个HEADER加了个S,说明他不是单个头组成的,是由多个头组成的。
1 | typedef struct _IMAGE_NT_HEADERS64 { // 64位版本 |
这里重点介绍32位的(32和64位的只有属性宽度等不同)。
1 | typedef struct _IMAGE_NT_HEADERS { |
文件头结构体用于判断其是exe文件还是dll文件,其定义如下:
1 | // 该结构体可以用于判断文件是exe文件还是dll文件 |
重点关注Machine,NumberOfSections,SizeOfOptionalHeader和Characteristics
。
IMAGE_FILE_HEADER.MACHINE的常用取值:
1 |
IMAGE_FILE_HEADER.Characteristics的常用属性:
1 |
16进制窗口对照
用16进制编辑器打开之前的demo.exe
通过之前的分析我们知道真正的PE头是在00B0处开始的,我们找到相应的位置
前面4个字节是signature标识符PE00,接下来分别是:
Machine,长度为2字节,值为01 4C,即Intel 386,为32位平台。
0x014c为Intel 386,0x0200为Intel 64
NumberOfSections,2字节,由于小端序的原因,值为00 03,也就是3个节(区段)。
TimeDateStamp,4字节,值为F8 22 DD 61,61DD22F8代表文件编译的时间,源代码编译完后生成的是obj文件,再经过连接才生成了exe文件,这个时间戳就是给obj文件使用的。
PointerToSymbolTable,NumberOfSymbols,都是4字节,值也都是00,用于调试,不用管。
SizeOfOptionalHeader,2字节,由于小端序,值为00 E0, 说明他是32位文件 。
32位是E0,64位是F0
Characteristics,2字节,由于小端序,值为 01 0F,说明他是exe文件。
exe是010f,dll是210e
另外,010F = 1 + 2 + 4 + 8 + 0100,根据Characteristics的常用属性可以知道
- 它没有重定位的数
- 它是一个可执行文件
- 没有行号
- 没有本地符号
- 在32位机器上运行
文件解析
这里使用编程实现。
1 |
|
输入大概为(莫得Microsoft Visual Studio,直接网页截图了)
可选头
在第三节中我们了解到PE头是多个头的组合,其定义为
1 | typedef struct _IMAGE_NT_HEADERS { |
我们已经介绍了文件头,这一节我们来介绍可选头。
可选头是IMAGE_OPTIONAL_HEADER,它是PE头部中重要的头部,虽然被称为可选头,但是并不是它说的可有可无,而是指该结构体中的部分数据在不同的文件中是不同的。
可选头定义
1 | // 32位头的大小是e0h, 224d |
RVA是相对起始地址,后面会提到
建议装载地址:
如图为一个虚拟地址内存,比如我们在低2G装载一个exe文件,而加载的dll文件A,B的起始地址重合了,那么就系统会自动给已经被占用的dll文件重新分配。
对齐:32位默认对齐值4K=4096=0x1000,64位默认8K。是内存分页的一个对齐值,大概意思就是比如:A班有50人,坐在一间教师里,B班只有两个人,但是也要坐在相同大的教室里。
在16进制编辑器中查看可选头
由于之前的知识,我们知道00B0开始是PE头,长度为20字节,于是我们可以知道可选头即为选中部分。
Magic是0x010B,表示exe文件
要求最低的主版本号0x05,辅版本号0x0C
代码大小是0x00002000
包含的初始化数据大小是00004000
包含的未初始化数据大小是0
程序入口地址是00001000
代码起始地址是00001000
数据的起始地址是00001000
建议装载地址是00004000
……
后面还有一堆就不列举了。
节表解析与地址转换
在PE文件中经常会用到三种地址,分别是
- VA (Virtual Address): 虚拟地址
- RVA (Relatvie Virtual Address)∶ 相对虚拟地址
- FOA (File Offset Address): 文件偏移地址
1 | //Section header format. |
用LordPE解析节表
我们用LordPE载入之前写的demo.exe并打开节表
名称(Name) | 只是一个标识,可有可无,对数据没有影响,有些软件保护会把这个名称擦除、改名或者随机化 |
---|---|
在内存中的节区长度(VSize) | 是没有对齐的,在结构体中是union里的VirtualSize |
节区的起始RVA地址(VOffset) | 由于 |
所以起始RVA分别是1000,2000,3000,是结构体里的VirtualAddress |
| 在文件中的尺寸(Rsize) | 这个是对齐的,对应结构体中的SizeOfRawData |
| 在文件中的起始偏移(ROffset) | 由于
所以起始文件偏移为200,400,600 |
再打开编辑区段中的标志
右下角就是结构体中的Characteristics,这个值的得出方法为
1 | // Section characteristics. |
这些属性值相组合得出的就是最终的属性值,比如,当前值为
而对应的属性分别为:可执行,可读,包含代码,正好和上图能对应上。
用OD查看内存布局
用OD打开demo.exe,按M按钮打开内存布局
发现PE头的装载地址是0x00400000,后面跟着3个节。同时发现虚拟地址VA = 相对虚拟地址RVA + 装载地址。
字节转换
比如我们要找字符串Hello World!
在文件中的偏移地址FOA
我们发现该字符串的虚拟地址VA为0x00403006
同时我们也知道每个节的VA,RVA,FOA。那么由此计算该字符串的FOA有两种方法。
方法一
计算内存中的虚拟地址VA - 装载地址 = 相对起始地址RVA ,即0x00403006 - 0x00400000 = 0x00003006
找到RVA对应的节,由于.data的RVA为0x00003000,长度为0x00001000,所以该字符串对应的节为.data
计算.data的RVA和FOA的差值,即0x00003000 - 0x00000600 = 0x00002800(注意是16进制计算)
用该字符串的RVA减去该差值得到其FOA,即0x00003006 - 0x00002800 = 0x00000806。
方法二
- 计算内存中的虚拟地址VA - 装载地址 = 相对起始地址RVA ,即0x00403006 - 0x00400000 = 0x00003006
- 找到RVA对应的节,由于.data的RVA为0x00003000,长度为0x00001000,所以该字符串对应的节为.data
- 计算RVA在.data节内的偏移,即0x00003006 - 0x00003000 = 0x00000006
- 节内偏移加上该节的起始FOA为该字符串的起始FOA,即0x00000006 + 0x00000800 = 0x00000806
用16进制编辑器打开demo.exe,跳转到0x00000806地址。
发现正是字符串hello world!的起始位置。
用LordPE中的位置计算器验证,在RVA处输入3006,点击执行
发现结果也完全正确。
计算exe文件用VA来计算是可以的,但是如果是dll文件可能结果就不对了,因此一般用RVA来验算。
文件解析和之前一样,把结构体中的每个属性输出一遍即可。
添加节
添加节可以是一种软件保护措施,比如把可执行代码写入一个甚至两个节中打到保护的目的。
添加节的一般步骤
- 增加节表项
- 修正文件的映像长度
- 修正一个节的数量
- 增加文件的节数据
即:IMAGE_OPTIONAL_HEADER.SizeOfImage;
IMAGE_FILE_HEADER.NumberOfSections;
用16进制编辑器增加节表项
用16进制编辑器打开demo.exe,找到节的位置(.text,.data,.rdata就是开始的地方)
所以我们从0x220处开始添加节,长度为40字节。
根据上一节学到的节表构成:
1 | //Section header format. |
我们可以依次添加我们想要的属性,先添加第一个属性:节名称吧,注意第一个字符是0x2E对应字符.
第二个是节的大小,根据内存的对齐写0x1000即可,注意小端序。
第三个是起始位置,根据上一个节.data的起始位置和大小分别为0x3000和0x13以及对齐为0x1000
我们知道添加节的起始位置为0x3000 + 0x1000 = 0x4000
第四个是文件中的尺寸,根据对齐应该是0x200
第五个是文件中的起始位置,由于上一个.data节的起始位置为0x800,对齐为0x200,所以添加节的文件起始位置为0x0A00(16进制)
接下来的两个4字节和两个2字节是没用的,不填即可。
于是还剩最后一个Characteristic属性
0xE0000060,即包含代码和初始化数据,可读,可写,可执行。
修正文件的节数量和映像长度
先复习一下NT头,也就是文件头和可选头,要想修改节表数量和映像长度,我们只要找到文件头中的NumberOfSections
和可选头中的`SizeOfImage即可
先改节数量
然后是映像长度,可以用C32ASM中的查看->PE信息,在侧边栏中直接找到SizeOfImage
属性并定位
也可以用长度来推,稍微有些麻烦,找到之后,原来的大小为0x4000,我们由于增加了0x1000,改成0x5000即可
再在末尾插入512个00或者90(90对应的汇编是nop,同时文件对齐为0x200 = 512d)
保存后,文件仍可以正常运行
用PELord验证一下
区段数目,镜像大小,以及新添加的.zym节,都显示了出来。
导入表分析
PE文件结构是Windows操作系统用来管理可执行文件的一种格式。通过前面的学习,我们已经对PE文件格式有了一定的掌握,比如对于一个可执行的EXE文件来说,在被操作系统装载时,会被装载到哪个虚拟地址,装载后会从哪个虚拟地址开始执行;PE文件会按照数据的属性进行分节,每个节装载入内存的什么位置,每个节具有哪些属性等。
PE文件不只是有头部,它还有一些PE体来作为可执行文件运行的支撑部分。
一个问题
我们之前写过一个弹出对话框的程序
弹出对话框是因为我们用了一个API函数MessageBox,但其实这个API并不在我们写的程序中,
我们只是调用了它,而这个代码到底在哪里呢?
用OD分析MessageBox的调用过程
用OD打开demo.exe
一路F7到401012处,再按一次F7,发现跳转到了401024处
再按一次回车,发现就是MessageBoxA实现的代码地址处
再返回上一步
这个FF25是个操作码,后面的是一个小端序的内存0x00402008,我们查看该地址处存的是啥
发现是0x76660C90,这个地址正好是MessageBoxA对应的地址
也就是说,当需要调用一个API的时候,会先call到一个jmp语句(调表),然后jmp语句中保存的地址就是真正要跳转的地址,而这个保存的地址是由编译器和连接器来写入的,而真正的地址是由windows系统通过装载器来完成的。
我们打开内存模块,由上图我们知道该API的地址开头为76,据此找到user32的大致的虚拟内存地址
导入表获取
导入表的定位:通过IMAGE_OPTIONAL_HEADER.DataDirectory的第二项获取。
我们用PElord打开demo.exe,并打开目录,第二项就是导入表(Import table)
再点后面的三个点,查看导入表具体导入了哪些API
发现正好有我们使用过的kernel32.dll中的ExitProcess函数和user32.dll中的MessageBoxA函数
导入表具体字段分析
导入表定义如下
1 | typedef struct _IMAGE_IMPORT_DESCRIPTOR |
注意:FirstThunk
在内存中指向的是一个IAT(Import Address Table)表,保存的是所有函数的VA(地址),但是在文件中指向的是一个INT表,和OriginalFirstThunk相同,即在内存中会把这个表替换成真正函数的地址
具体区别如下图:
文件中:
可以发现OriginalFirstThunk,FirstThunk
均指向的是INT,有可能是一个RVA,也有可能是一个数值
内存中:
可以发现FirstThunk
指向的是真正的函数入口地址。
由上图我们发现FirstThunk指向IMAGE_THUNK_DATA32结构体,有可能是一个RVA,有可能是一个数值,这是因为其构成是一联合体(union)
1 | typedef struct _IMAGE_THUNK_DATA32 |
这个联合体(union) 中有4个字段,但是他所占的空间是其中最大的类型的空间(DWORD, 4字节)而不是4个字段之和(4 * 4 = 16)
如果他的值的最高位是1的话,那么他的低16位是导入的序号
而如果他的值最高位不是1的话,那么这个值指向的值是导入函数的名称,这个名称在 _IMAGE_IMPORT_BY_NAME这个结构体里。
1 | typedef struct _IMAGE_IMPORT_BY_NAME { |
这里再介绍一下序号导入:
我们用PElord打开一个较复杂的exe文件,找到导入表
发现再Thunk值不是1的时候,后面的API名称一栏就是以字符串形式出现的API名称,但是如果最高位是8(在16进制下的0x8转化成2进制就是1000,也就是说其最高位是1),后面的API名称就是序号。
文件解析
用C32ASM打开demo.exe,在查看->PE信息中找到IMAGE_DIRECTORY_ENTRY_IMPORT(导入表)
其RVA为0x2010,大小为0x3C也就是60,我们把它转化成FOA
发现其在文件中的地址FOA为0x610,我们跳转到该地址
前60个字节就是导入表,根据前面的导入表定义
1 | typedef struct _IMAGE_IMPORT_DESCRIPTOR |
4C 20 00 00
即OriginalFirstThunk
,是一个RVA值,指向的是一个INT(IMAGE_THUNK_DATA),这个值转化成FOA是0x64C
由于INT中的值都是DWORD型也就是4字节,这个值是一个RVA,其值为0x205C,我们再把这个RVA再转化为FOA,得到文件中的地址为0x65C,这个位置正好是ExitProcess
6A 20 00 00
中间的TimeDateStamp
和ForwarderChain
都是0,直接跳过,接下来是Name,0x206A转化出来的FOA是0x66A
是kernel32.dll
00 20 00 00
即FitstThunk,在文件中指向的也是是IMAGE_THUNK_DATA,0x2000转化出来是0x600
这个值也是一个RVA,其值为0x205C,发现和前面的4C 20 00 00一样,都是ExitProcess函数
86 20 00 00
0x2086转化成FOA是0x686
是user32.dll
剩下的导入表全是00。
内存解析
用OD打开demo.exe,导入表的RVA为0x2010,加上装载地址,把RVA转化成VA也就是0x402010
前面是一样的,我们直接看00 20 00 00,也就是FirstThunk
的位置,转化为VA为0x402000
我们发现这个值已经不是0x205C了,而是0x76394100,直接跳转到该地址处
发现就是ExitUserProcess这个函数在内存中的地址而不是一个字符串了,这就是导入表在文件和在内存中的区别。