PE文件格式学习
感觉这博客写起来和抄书差不多。。。
1|0PE文件结构概述
PE文件,即Portable Executable File Format,可移植的执行体,Windows下的所有可执行文件都是PE文件格式,比如.exe,.dll,.sys等
PE文件格式是一种对文件组织管理的方式
1|1用RadASM编写一个简单的可执行程序做为分析的对象
(工程类型win32(nores))
这玩意好像没法写注释语句,那我就按c的语法写注释了
编译,连接,然后运行.exe
这就是这段代码的含义
1|2用WinHex来对比可执行文件在文件和内存中的差异
打开WinHex并打开刚刚编译的pe.exe,并且不关闭对话框,然后在winhex里打开ram
找到PE
下面的dll文件就是该exe所依赖的dll文件,不管他们,我们直接点PE.exe点确定
左边这个是在磁盘打开的,右边这个是从内存打开的
第一个区别,左边的文件Offset(偏移)是从0000000开始的,而右边的文件Offset是从00400000开始的
磁盘内的文件是根据一些规范映射到内存中的,所以这个偏移量是不同的
第二个区别,从400220开始两个文件都是00,但是左边的文件到400就有数据了,而右边的要到1000才有数据
并且这两坨数据是一样的
在左边的600,右边的2000处,可以看到调用的dll是一样的,但是数据不同了
还有左边的800,右边的3000是我们定义的字符串
剩下的全是00
1|3用PEView查看可执行文件的结构
用PEView打开PE.exe
pFile是文件中的偏移,Raw Data是原始数据,Value是字符串形式显示,不能显示的用'.'代替
在左边打开IMAGE_DOS_HEADER,这东西对该文件进行了解析
注意到
而原来我们看到第一行前2个数字是4D 5A,他倒过来了,这种玩意叫“字节序”
在IMAGE_NT_HEADERS里面点Signature
我们跟着找一下这个偏移
这个数据也是倒着的,也是字节序导致的
我们再看看左边这串英文
- IMAGE_DOS_HEADER:dos头
- MS-DOS Stub Program:DOS存根
- Signature:PE文件的标识
- IMAGE_FILE_HEADER:文件头
- IMAGE_OPTIONAL_HEADER:可选头(但不是可以不选的那种,只是其中某些东西只需要占位,不需要有具体数据)
- IMAGE_SECTION_HEADER:节区,给出了三种数据在文件和在内存中的位置
- .text:代码
- .rdata: 只读数据
- .data:数据
- SECTION:真正的数据
文件中的数据不会变化,但是在映射到内存中后一些相对位置就变了
2|0DOS头
DOS头是PE文件结构的第一个头,用来保持对DOS系统的兼容,并且用于定位真正的PE头
DOS头在winnt.h头文件中的定义如下(该文件头大小为40h,64d)
其中我们最关心的是e_magic和e_lfanew(MZ其实是一个开发人员的名字的缩写,被保留了下来)
2|1如何判断文件是否为PE结构的文件
用C32ASM打开上次编写的那个PE.exe
这几行其实就是DOS头
WORD e_magic; // 0x00 EXE标志MZ
WORD在windows下是2个字节
前2个字节4D 5A就是e_magic,win下所有可执行文件前2个字节都是他们,其ASCII码是MZ
LONG e_lfanew;
LONG在windows下是4个字节
最后4个字节是B0 00 00 00,它们指向了我们PE头的偏移
但是,此处存储方式是小端序存储,也就是低地址保存低位数据,高地址保存高位数据,实际上他指向的位置是00 00 00 B0
intel架构的cpu存储数据都是小端序,大端序存储一般在其他cpu架构或者网络传输数据时使用
B0行的开头是50 45 00 00,前2个字节翻译成字符串是PE,这就是PE文件头
总结一下,判断一个文件是否为PE文件的步骤
- 观察其前2字节是否为MZ
- 找到e_lfanew
- 根据e_lfanew找到地址,观察其前2字节是否为PE
找到了PE的话一般都是PE文件了
2|2计算IMAGE_DOS_HEADER结构体大小
10进制是64,16进制是40
2|3一个小实验
在刚刚的PE.exe中,在B0 00 00 00 到 50 45 00 00中间的数据实际上是完全没用的
实际上这些是DOS的代码
将其全部填充为00,保存,然后打开PE.exe
他还是可以运行的
我们最关心的是e_magic和e_lfanew
那我们尝试把DOS头其他的数据全部填充为00
再次运行
还是可以运行的,也就是说我们改的数据其实是完全不需要的,那他们有些啥用呢
在c32asm中新建一个文件,把00-A0的代码复制下来,保存为dos.bin
扔进IDA打开
这一块代码实际上是在编译-连接的时候自动添加进来的一个程序,被称为DOS存根
读一下汇编,它的作用就是输出"This program cannot be run in DOS mode.",然后关闭程序。
3|0文件头及编程解析
3|1文件头定义与分析
真正的PE头,即IMAGE_NT_HEADERS,其定义如下
- Signature:PE标识符
- FileHeader:文件头
- OptionalHeader:可选头
其中FileHeader的定义如下
IMAGE_FILE_HEADER.MACHINE的常用取值:
IMAGE_FILE_HEADER.Characteristics的常用属性:
用c32asm打开PE.exe
选中的部分就是FILE_HEADER
我们一个一个来看
MACHINE对应的是4C 01,014C表示是386平台的(32位的)
#define lMAGE_FILE_MACHINE_1386 0x014c // Intel 386
NumberOfSections对应的是03 00,0003就是有3个节
扔进LordPE可以发现是.text,.rdata,.data
TimeDateStamp对应的是F8 22 DD 61,61DD22F8代表文件编译的时间
源代码编译完后生成的是obj文件,再经过连接才生成了exe文件。这个时间戳就是给obj文件使用的
之后的PointerToSymbolTable和NumberOfSymbols都是调试用,这里也全是00,不管他
SizeOfOptionalHeader对应的是E0 00,00E0说明他是32位文件
WORD SizeOfOptionalHeader; // 0x14 可选头IMAGE_OPTIONAL_HEADER结构体的长度 32位是E0 64位是F0
Characteristics对应的是0F 01,010F说明他是exe文件
WORD Characteristics; // 0x16 文件的属性 exe是010f dll是210e
另外,010F = 1 + 2 + 4 + 8 + 0100,根据Characteristics的常用属性可以知道
- 它没有重定位的数
- 它是一个可执行文件
- 没有行号
- 没有本地符号
- 在32位机器上运行
3|2编程实现文件头解析
4|0可选头
4|1定义
其定义如下
- ImageBase:只是建议使用的装载地址。若该地址已被使用,则系统会为其重定向一个地址
- 对齐:"A班有50人,B班只有5人,但是两个班都分别坐在一样大的教室里"
一些重要的属性
4|2用C32ASM看数据
还是打开PE.exe
这些是可选头
- Magic是010B,也就是exe文件
- 主版本好05,次版本号0C
- 代码大小是00002000
- 包含的初始化数据大小是00004000
- 包含的未初始化数据大小是0
- 程序入口地址是00001000
- 代码起始地址是00001000
- 数据的起始地址是00001000
- 建议装载地址是00004000
- ......
剩下的也是一个意思,就不多写了
编程解析和前面那个差不多就懒得写了
5|0节表以及地址转换
在PE文件中经常会用到三种地址,分别是
- VA (Virtual Address): 虚拟地址
- RVA (Relatvie Virtual Address)∶ 相对虚拟地址
- FOA (File Offset Address): 文件偏移地址
5|1用LordPE进行解析
用LordPE打开PE.exe,对照着进行理解
.text这一列就是节名称,这只是一个标识,并不影响内部数据的使用,有些保护措施就会将这些节名称给擦除或者重命名,以此达到软件保护的效果
VSize就是结构体中的VirtualSize,它并没有对齐,有多长就是多长
VOffset就是起始RVA地址
注意到文件中的对齐值(FileAlignment)是200,内存中的对齐值是(SectionAlignment,就是那个块对齐)1000,可以看到RSize就是200 200加上去的
ROffset就是PointerToRawData,.rdata的起始位置就是.text的起始位置加上他的大小RSize
右键点编辑区段再点flag右边的点点
右下角的当前值就是Characteristics,这个数字会随着节的属性的更改而更改,比如这个可读可写可执行,它们各有一个值,加起来就是60000020,这个数字就可以表示有且仅有这3个属性为真
5|2用OD来看内存布局
扔进OD,点上面的M,进入内存布局
已经可以在00400000PE文件头和下面的三个节了
并且我们发现,每个节在内存中的地址(VA)是起始地址(RVA)加上装载地址
5|3地址转换
就以这个为例吧,我们想找到下面这一串字符串在文件中的偏移地址
其起始地址是400036
方法一
- 通过VA减装载地址转化出RVA:403006 - 40000 = 3006
- 找到RVA所在的节:.data(.data是3000开始)
- 计算.data节起始RVA和起始FOA的差值:3000 - 800 = 2800(Hex)
- 通过RVA减去差值:3006 - 2800 = 806
用c32asm验证一下,直接跳转到806
很正确
方法二
前两步是一样的,后面不一样
- 通过VA减装载地址转化出RVA:403006 - 40000 = 3006
- 找到RVA所在的节:.data(.data是3000开始)
- 计算RVA在节内的偏移:3006 - 3000 = 6
- 节内偏移加上该节的起始FOA:6 + 800 = 806
用LordPE算一下,很正确
(注意用RVA来算,VA算.exe大概率正确,但是其他的很可能计算错误)
这个的编程解析和文件头的也是一样的,懒得写了呜呜呜
6|0添加节
添加节的一般步骤
- 增加节表项
- 修正文件的映像长度
- 修正一个节的数量
- 增加文件的节数据
即:IMAGE_OPTIONAL_HEADER.SizeOfImage;
IMAGE_FILE_HEADER.NumberOfSections;
用c32asm打开PE.exe,找到节的位置
6|1增加节表项
我们添加的节应该在下一行开始,一共2.5行
对照着来吧,首先是节的名字,写个www吧
BYTE Name[IMAGE_SIZEOF_SHORT_NAME];
然后在第8个字节处添加节长度,直接按照内存对齐写个1000
DWORD PhysicalAddress;
DWORD VirtualSize;
上面的.data节的长度是13,起始位置是3000
按照对齐,新增节的起始位置是4000
DWORD VirtualAddress;
节内长度给个200
DWORD SizeOfRawData;
上面的.data节节内长度200,起始偏移800,新增节的起始偏移就是0A00
DWORD PointerToRawData;
后面8个字节(2个DWORD)对我们来说没用
DWORD PointerToRelocations;
DWORD PointerToLinenumbers;
再后面4个字节(2个WORD)也没用
WORD NumberOfRelocations;
WORD NumberOfLinenumbers;
最后节的属性给E0000060,意思是包含可执行代码,可读可写,包含初始化代码(可以在LordPE里面把flag改成这个去看看)
DWORD Characteristics;
6|2修正节的数量
6|3修正文件的映像长度
可以通过查看 -> PE信息来辅助查找
找到SizeOfImage,双击就找到了,给他改成5000
然后在末尾插入512个00(新增节的长度是200),保存然后运行
没有问题
在LordPE看一眼
7|0导入表分析
PE文件不只有头部,还有一些PE体作为可执行文件运行的支撑部分
7|1分析MessageBox函数的调用过程
F7单步执行,在401012的call处执行后会跳转到401024处,下一步他将会跳转到MessageBox函数内。
我们在此时按回车进入该函数
可以发现,入口地址是75BB3670,并且模块是user32
把左边的地址拖长一点,可以发现
那么这一坨就是真正的MessageBoxA的代码
回到jmp那里,这个FF25就是jmp的机器码,08204000(实际上是00402008)是一个内存地址,我们跳转去看看
和MessageBoxA的入口地址是一样的,也就是jmp进行了一次内存寻址,它保存的值就是跳转的地址,也就是API函数真正的虚拟地址
打开内存模块
可以看到user32的虚拟地址是从75B3开始的,这些地址就是通过导入表装载进来的
7|2导入表分析
导入表的定位:通过IMAGE_OPTIONAL_HEADER.DataDirectory的第二项获取
LordPE的目录可以看到导入表(就是那个输入表)
点旁边的...,可以看到装载了kernel32.dll和user32.dll
并且kernel32导入了ExitProcess这个API函数,而user32导入了MessageBoxA这个API函数
其中一些重要的如下
内存中:
- Name: 保存的是一个RVA,这个RVA指向的内容是DLL的文件名
- OriginalFirstThunk: RVA,指向的是一个INT表(Import Name Table),这个表中保存的是所有导入函数名称的RVA
- FirstThunk: RVA,指向的是一个IAT表(Import Address Table),这个表中保存的是所有导入函数的地址(VA)
文件中:
- Name: 保存的是一个RVA,这个RVA指向的内容是DLL的文件名
- OriginalFirstThunk: RVA,指向的是一个INT表(Import Name Table),这个表中保存的是所有导入函数名称的RVA
- FirstThunk: RVA,指向的是一个INT表(Import Name Table),这个表中保存的是所有导入函数名称的RVA
另外,FirstThunk指向的是
这个联合体(union) 中有4个字段,但是他所占的空间是其中最大的类型的空间(DWORD, 4字节)而不是空间之和
如果他的值的最高位是1的话,那么他的低16位是导入的序号
而如果他的值最高位不是1的话,那么这个值指向的值是导入函数的名称
我们看看什么是序号导入
随便扔一个比较复杂的软件进LordPe
注意,对于2进制最高位是1,那么对于16进制最高位是8
那么这里这个东西导入的序号就是2A
这些东西就是通过名称导入的
7|3文件解析
用c32asm看PE.exe
在PE信息找DataDirectory -> IMAGE_DIRECTORY_ENTRY_IMPORT,可以找到他的RVA是2010
计算出他的FOA是610
导入表长度是3C,这一串就是导入表
4C 20 00 00
4C 20 00 00 是OriginalFirstThunk,是一个RVA值,指向的是一个INT(IMAGE_THUNK_DATA)
204C转换后是64C, 也就是这里
IMAGE_THUNK_DATA的值就是 5C 20 00 00 00 00 00 00
205C转换后是65C,
FOA是65C,转化出来是ExitProcess,这里就已经解析出了第一个API函数
6A 20 00 00
接下来6A 20 00 00是Name,206A转化出来FOA是66A
那么他是kernel32.dll
00 20 00 00
下一个00 20 00 00是FitstThunk,指向的也是IMAGE_THUNK_DATA,2000转化出来是600
和上面的OriginalFirstThunk是一样的
OriginalFirstThunk和FitstThunk在文件中指向的是一个东西,在内存中不一样
86 20 00 00
2086转化出来是686,
剩下的导入表全是00,那么解析完成
7|4内存解析
在OD里面看,最开始的RVA是2010,转化成VA是204010
前面是一样的,我们直接看00 20 00 00,转化成虚拟地址直接加400000就行
这里的值是77 32 4E 10,和以前不一样了。
直接跳转过去
是ExitProcess在内存中的地址。在文件中是通过名字找到的,内存中就不是指向字符串了。
__EOF__
作 者:iPlayForSG
出 处:https://www.cnblogs.com/Here-is-SG/p/15784584.html
关于博主:编程路上的小学生,热爱技术,喜欢专研。评论和私信会在第一时间回复。或者直接私信我。
版权声明:署名 - 非商业性使用 - 禁止演绎,协议普通文本 | 协议法律文本。
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角【推荐】一下。您的鼓励是博主的最大动力!
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 一个费力不讨好的项目,让我损失了近一半的绩效!
· 清华大学推出第四讲使用 DeepSeek + DeepResearch 让科研像聊天一样简单!
· 实操Deepseek接入个人知识库
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库