pe解析

去年学了一遍pe,但是没有用代码实现,导致对pe的理解非常浅,今年又重新学了一遍,旨在加强对代码的掌握,并不是从0开始,只是方便本人理解,因此笔记内容很残缺,可以参考去年的笔记一起看。

代码仓库:

https://github.com/0range-x/windows/tree/main/pe

PE头解析

4gb = 2^32 ,寻址长度,

对齐的目的:查找速度更快,用空间换时间

硬盘对齐:200h

内存对齐:1000h

所以程序在内存执行的时候,会在内存中扩展。节和节之间用0填充。

dos头、pe头、pe可选头

dos头

1
2
e_magic			//5A 4D
e_lfanew //00 00 00 E8 pe头相对于文件的偏移,用于定位pe文件

从e8开始就是pe开始的地方,对应的 50 45 对应的ascii字符是pe,

NT_headers

包含signature+标准pe头(Image_File_Header)+可选pe头(IMAGE_OPTIONAL_HEADER)

signature为50 45 00 00 ,找完后不找nt_headers,去找 Image_File_Header,

Image_File_Header (标准PE头)

大小确定

1
2
3
4
5
WORD		Machine					//86 64 	//可以在什么机器上运行 
WORD NumberOfSections //07 00 //文件中存在的节的总数,如果要新增或者合并节需要修改该字段
DWORD TimeDateStamp //2b 8c 95 22 //文件编译时间戳

WORD SizeOfOptionalHeader //f0 00 可选pe头的大小,32位pe默认为E0h,64位pe为f0h,大小可以自定义
Image_Optional_Header(可选pe头)

大小不确定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
WORD		Magic						//0b 02   //10b为32位的文件,20b为64位的文件
DWORD SizeOfCode //00 60 02 00
DWORD SizeOfInitializedData //00 00 03 00
DWORD SizeOfUNinitializedData //00 00 00 00
DWORD AddressOfEntryPoint //90 1b 00 00 //程序入口 != 代码入口 ,程序在内存中真正的运行地址为 基址+oep (入口点)

DWORD BaseOfCode //00 10 00 00
DWORD BaseOfData //00 00 00 40
DWORD ImageBase //01 00 00 00 //内存映像基址
DWORD SectionAlignment //00 10 00 00 //内存对齐,内存中整个pe文件的尺寸,可以比实际值大,但必须是SectionAlignment的整数倍
DWORD FileAlignment //00 10 00 00 //文件对齐
DWORD SizeofImage //00 70 05 00 //内存中 整个PE文件的映射的大小,可以比实际的值大,但必须是SectionAlignment的整数倍
DOWRD SizeofHeaders //00 10 00 00 //所有头(DOS+...+PE标记+标准PE+可选PE)+节表 按照文件对齐后lao 否则加载会出错
DWORD SizeOfStackReverse //00 00 08 00
DWORD SizeofStackCommit //00 00 00 00
DWORD SizeOfHeapReverse //00 10 01 00
DWORD SizeOfHeapCommit //00 00 00 00

将pe文件从硬盘中读到内存中,是原封不动的读进去,拷贝到内存中,存储到 FileBuffer,但这个时候还没有办法运行,需要peloader修改 FileBuffer为内存中可执行的过程,就是内存拉伸的过程。写到的地址(内存运行的起始地址)叫ImageBuffer(文件映像)

修改OEP

pe后面的20个字节为标准pe头

剩下的为可选pe头,从0b 02 开始。 修改 ImageBase(程序入口点 EntryBase),保存后程序仍然正常运行

image-20220925134144246

节表

相当于节中的目录 描述 节中的概要信息

定位节表:

可选pe头的大小是不确定的,标准pe头里有个成员变量 SizeofOptionalHeader 表示可选pe头的大小,32位默认是 e0, 64位默认是 f0

dos+4+pe+可选pe

1
ifanew + 4(signature) + 20(标准pe头大小) +E0(32位的 SizeOfOptionalHeader) 

Image_File_Header(标准pe) 里的 NumberOfSections 是节表的数量

pe后面的第二个成员,就是节表数量,这里是5个。

image-20220926143523309

1
#define IMAGE_SIZEOF_SHORT_NAME				8

image-20220926141455399

1
2
3
4
5
6
7
8
9
10
1.Name 占8个字节,一般情况下是以"\0"结尾的ASCII码字符串来标识的名词。但是该名称并不遵守以"\0"结尾的规律,如果不是以 "\0"结尾,系统会截取8个字节的长度进行处理,有时候会导致越界乱码。
所以不用`char*`来解析, char* 会自动找 "\0",用数组来解析。

2.Misc.VirtualSize表示在文件对齐前,实际的大小,该值可以修改,不影响,所以不一定准确

3.VirtualAddress 是节区在内存中的偏移地址,加上 ImageBase 才是在内存中的真正地址。 VirtualAddress是距离 ImageBase(ImageBuffer的dos头)的距离

4.SizeOfRawData 节在文件中对齐后的尺寸

5.PointerToRawData 节区在文件中的偏移,即在文件中距离dos头的距离

代码空白区一般指的是 VirtualSize – SizeOfRawData 中的大小,

FileBuffer –> ImageBuffer

image-20220926134140064

FileBuffer 是在文件中的内容,Image Buffer是在内存中的内容,在内存中扩展一下

sizeofheaders 包括 dos头+标准PE头+可选PE头+节表

1
2
3
4
5
1.根据 SizeofImage 的大小,开辟一块缓冲区(ImageBuffer)
2.根据SizeOfHeader 的大小,将头信息从FileBuffer 拷贝到 ImageBuffer
3.根据节表的信息循环将 FileBuffer 中的节拷贝到 ImageBuffer
复制到什么地方,由节中的 VirtualAddress 决定,每个节copy Siz
4

将Filebuffer 读到 ImageBuffer,先分配 SizeOfImage 大小的内存,在可选pe头里

转VirtualAddress eg:

1
2
3
4
5
1.  501234 - 500000(ImageBase) = 1234 (RVA)
2. 1234 > VirtualAddress(1000)
1234 < VirtualAddress + misc.VirtualAddress
3. 1234 - 1000 = 234
4. 400(PointerToRawData) + 234 = (在文件中的地址 )

代码节空白区添加代码

关于修正E8的理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
公式:X = 要跳转的地址 - (E8当前的地址 + 5);
要跳转的地址,就是我们要加入代码MessageBox的地址;
然后要减去E8当前的地址+5的位置,这里不是太好理解;
我们的目的是要将E8后面的4个字节计算出来,然后写入到E8后面,也就是公式中X;
上面公式E8当前地址+5 ,而在此情况要定位到这个位置就要从代码的Dos开始通过指针相加;
进行位置偏移到E8当前地址+5的位置;
所以定位codeBegin的位置是:pImageBuffer指针最开始的位置(Dos头位置)通过内存中偏移的宽度移动到第一个节表的位置;
也就是上面的pSectionHeader->VirtualAddress 操作形式;
然后再偏移第一个节表在内存中对齐前实际的宽度(尺寸)pSectionHeader->Misc.VirtualSize;
上述一番操作之后就到了第一个节表没有对齐前的位置,这个位置就是我们可以添加ShellCode代码的起始位置;
到了添加ShellCode代码的起始位置之后,就要想办法添加E8位置后面的4个字节,此时根据ShellCode代码的宽度;
进行计算,确认0x6A 00 0x6A 00 0x6A 00 0x6A 00 E8 00 00 00 00 刚好向后面数13个位置,按照十六进制看;
就是0xD,所以在codeBegin偏移0xD个位置即可到达E9的位置,这也就是我们说的(E8当前的地址 + 5);
到了上面的位置之后,由于我们最终是需要在程序运行之后在内存中添加ShellCode代码;所以这里一定要计算出;
其准确的偏移地址,这样不管怎么拉伸到哪个位置,都能准确找到位置;
注意:这里需要注意一点理解,上面说的pImageBuffer这个是我们加载程序到我们申请的内存中,绝不是程序在;
运行中的那个内存,这里一定要理解清楚,她们是不一样的,理解了这个就能理解上面代码为什么要减去Dos头的;
首地址,(DWORD)(codeBegin + 0xD) - (DWORD)pImageBuffer)

关于e9修正的理解:

1
2
3
4
5
公式:X = 要跳转的地址 - (E9当前的地址 + 5)
这里同样是要计算出E9后面4个字节的地址,我们的目的是在这里添加OEP的地址,让其执行完成MessageBox之后跳转;
OEP的地址,那么这里就要先计算出OEP地址,就是pOptionHeader->ImageBase + pOptionHeader->AddressOfEntryPoint;
再减去(E9当前的地址 + 5) 0x6A 00 0x6A 00 0x6A 00 0x6A 00 E8 00 00 00 00 E9 00 00 00 00;
(DWORD)codeBegin + SHELLCODELENGTH 就是加上ShellCode总长度,偏移完成之后减去ImageBuffer首地址再加上ImageBase;

修正oep:

1
修正OEP好理解,就是定位到OEP地址,然后直接通过codeBegin地址减去pImageBuffer的首地址即可
实操

image-20220928223839801

messagebox 的地址为 75 5e 06 60

找硬编码

1
2
3
4
5
6
6A  00
6A 00
6A 00
6A 00
E8 00 00 00 00 ;call
E9 00 00 00 00 ;jmp

查找pe信息 win10 的 32位的calc

需要注意的字段

1
2
3
4
5
6
7
8
9
AddressOfEntryPoint:	1B90
ImageBase: 400000
Section Alignment: 1000
File Alignment: 200

Section: .text
VirtualSize: f2c(对齐前的长度)
VirtualAddress: 1000(内存中的偏移)
PointerToRawData: 400 (文件中的偏移)

代码空白区的起始地址为 : PointerToRawData+VirtualSize

计算FileBuffer 代码节的结束地址

Formulas: PointerToRawData + VirtualSize = 132c

image-20220929092350689

在空白区填充代码的硬编码

image-20220929092703113

当程序中的文件对齐和内存对齐不一致时需要进行转换

1
Foa_shellcodeAddr - PointerToRawData + VirtualAddress + ImageBase = Rva_shellcodeAddr

132c - 400 + 1000 + 400000 = 40192c

e8和e9 调用地址 = 跳转的目标地址- (指令地址+ 指令长度)

计算E8后的值:

e8后的值= 真正跳转的地址 - E8 下一条指令地址

ImageBuffer 中 插入代码的地址(rva_shellcodeaddr) : ImageBase + Virtualsize+ VirtualAddress = 400000 +f2c +1000 = 401f2c

插入了 messagebox 对应的硬编码 8 个字节,所以 E8 的地址为 401f2c + 8 = 421f34

e8下一行地址为 : 401f34(内存中的值) +5 = 401f39

E8 后的值: 75 94 06 60(messagebox函数运行时起始地址)-401f39(e8下一行地址)= 7553 E727

image-20220929092703113

计算e9后的值:

真正要跳转的地址: ImageBase + EntryPoint = 400000 +1b90 = 401b90

E9的下一条指令的地址:401f39(e8下一行地址)+5 = 401f3e

e9后的值 = 401b90 - 401f3e= ff ff fc 52

修改 OEP

1
OEP = RVA_shellcodeAddr - ImageBase

421f34 - 400000 = 21f34

21860 改为 227b8

60 18 02 b8 27 02

image-20220929002230234

image-20220929002252829

任意代码空白区添加代码

1
2
3
4
5
6
1. 根据sizeofIMage分配空间
2. copy 头 (sizeofheaders)
3.根据节表中的PointerToRawData 确定文件中开始拷贝的位置,根据节表属性VirtualAddress 知道要复制到的位置
4. 拷贝节的大小, 拷贝sizeofRawData
5.申请new buffer 分配 最后一个节起始大小 + sizeofRawData()

新增节-添加代码

判断

sizeofHeader - (DOS + 垃圾数据 + PE标记 + 标准PE头 + 可选PE头 + 已存在节表)>= 2个节表的大小

因为节表中需要有空间装得下2个节表,其中一个节表是我们自己新增的,其中一个是00填充(windows判断一个结构体结束,是判断和结构体相同大小的空间 都为0,规定,写为1也可以运行,但不知道写为几不能运行)

节表中的信息指的是 .text .rdata .data .rsrc 中的数据

需要修改的数据

1
2
3
4
5
6
1.添加一个新的节
2.在新增节后面 填充一个节大小的0000 (40个字节)
3.修改pe头中节的数量 (4+1)
4.修改sizeofIMage的大小 (在可选pe头里)
5.在原有数据的最后,新增一个节的数据(内存对齐的整数倍)
6.修正新增节表的属性

修改virtualSize时, 直接修改为和文件中对齐后的长度一样,1000h

VirtualAddreess + sizeofRawData //最后一个节开始的地方 + max (Virtualsize,sizeofRawdata)

加完以后按照内存对齐

如果节后面有编译器新增的数据,把dos头后面的stub 删除,将NT头提前,修改 e_lfanew

3.修改sizeofimage

因为节在内存对齐是 1000

6000 + 1000 = 7000

image-20221004004812121

image-20221004005156880

新增一个节表的数据

image-20221004005436127

image-20221004005453377

修正节表数据

1
2
3
4
1.VirtualAddress   按照上一个对齐
2.sizeofRawData
3.PointerToRawData
上一个节开始位置+节对齐后的大小 = RAW大小 + RAW偏移

image-20221004005944989

image-20221004011229479

扩大节–合并节–数据目录

扩大节

1
2
3
4
5
6
7
1.拉伸到内存
2.分配一块新的空间: sizeofImage + Ex
3.将最后一个节的sizeofRawData 和VirtualSize 改成N
SizeOfRawData = VirtualSize = N
N = (SizeOfRawData 或者 VirtualSize 内存对齐后的值) + Ex
4.修改SizeOfImage的大小
SizeofImage = SizeofImage + Ex

合并节

1
2
3
4
5
1.取最后一个节中 (sizeofRawData 和 VirtualSize)的值,谁大就取谁
即 max = sizeofRawData > VirtualSize > SizeOfRawDzata : VirtualSize
2.通过最后一个节的VirtualAddress + 上面的道德最后一个节max - 拉伸后的SizeOfHeaders内存对齐后的大小
3.SizeifRawData = VirtualSize
4.numberofSections = 1

静态链接库–动态链接库

使用dll

隐式链接

1
2
3
4
5
6
7
1.将*.dll *.lib 放到工程目录下面
2.将 #pragma comment(lib,"DLLming.lib") 添加到调用文件中
3.加入函数的声明
extern "C" __declspec(dllimport) __stdcall int Plus(int x,int y);
说明:
__declspec(dllimport) 告诉编译器此函数为导入函数
__stdcall 平衡堆栈

显式链接

1
2
3
4
5
6
7
1.定义函数指针
//typedef int
2.声明函数指针变量
3.动态加载dll到内存中 //告诉编译器用到哪个dll,加载到exe内存
LoadLibrary("DLLDemo.dll")
4.获取函数地址
GetProcessAddress

说明:

Handle HMODULE HINSTANCE HWND 都表示无符号类型的整数 4个字节

避免被直接分析到dll函数

使用 *.def 文件 达到隐藏函数名的目的

1
2
3
EXPORTS
Plus @12 //plus函数的导出序号是12
Sub @15 //导出序号15

导出表

真正导出表的结构

image-20221005132256650

只要见到RVA的地方,先转成foa 再去找

1
2
3
4
AddressOfFUnctions 指向了一张表,表里存储着pe 所有导出函数的地址
AddressOfNames 存储着所有导出函数的名字的地址
AddressOfNameOrdinals (函数序号表) + base(起始序号) 才是真正的序号表
按照序号 来找 用不到序号表,按照名字来找才有意义

定位数据目录的位置

pe标记往后数24(20+4)个字节 到达可选pe头,可选pe头往后数 224-128 = 96 个字节 (可选pe头– DataDirectory) = _IMAGE_DATA_DIRECTORY * 16 = 8*16

DataDirectory首地址= 可选pe头 + 96

image-20221005201309769

定位导出表

1
2
3
4
1.找到可选pe头的最后一个成员 DataDirectory
2.获取DataDirectory[0]
3.通过DataDirectory[0].VirtualAddress 得到导出表的RVA
4.将导出表的RVA转换为FOA,在文件中定位到导出表

1
numberOfFunctions = (编写dll时)最大序号-最小序号+1

根据函数名称获取导出函数

1
2
3
1.根据导出表的函数名称去AddressOfNames指向的每个名称字符串查询是否有匹配的字符串
2.找到匹配的字符串后,根据找到的顺序索引去AddressOfNameOrdinals中找到对应的索引
3.根据前面找到的Ordinals到 AddressOfFunctions中获取函数地址

根据函数序号获取导出函数

1
2
1.根据函数序号 - 导出表.base 获得导出函数的Ordinal
2.根据前面找到的Ordinals到 AddressOfFunctions中获取函数地址

总结

1
2
3
导出表中包含了3张小表:导出函数地址表,导出函数名称表,导出函数序号表
导出表存储了这3张表地址的指针,而不是直接存储表的内容
序号表并不是真正存储序号的表,只是用来给name中转的表

重定位表

绝大多数dll中,exe基本没有重定位表

数据目录项的第6个结构是重定位表

重定位表的结构是按照块来的

一堆需要修改的放在一个块中

如下图,只有高4位 为 0011 这个值才需要修改,后面的低12位+前面的x(VirtualAddress)

image-20221006155541354

每一块有多少个需要修改的值

1
(SizeofBlock - 8) /2

定位重定位表的流程

1
2
3
4
1.找到可选pe头的最后一个成员 DataDirectory
2.获取DataDirectory[5]
3.通过DataDirectory[5].VirtuallAddress 得到重定位表的RVA
4.转换FOA,在文件中定位到重定位表

image-20221006175628766

image-20221006175745262

重定位表块中的SizeOfBlock后面的数据部分是用来作为偏移使用的,每一个数据项的大小为WORD,两字节,只有后面12位是用来表示偏移的,高4位用来判定该地址是否需要偏移。这里为什么只有12位用来表示偏移呢?因为重定位表是根据物理内存来设计的,物理内存是以4kb = 2^12 为单位,也就是4kb为一个物理页,所以只需要12位就可以表示一个物理页内的所有偏移。

高4位为0的话,表示该数据为对齐用的填充的垃圾数据,不需要重定位

image-20221007101925829

移动导出表–重定位表

为什么要移动这些表:

是对程序加密/破解的基础

移动导出表步骤

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
1.在dll中新增一个节,并返回新增节后的FOA
2.复制AddressOfFunctions
长度: 4*NumberofFunctions
3.复制AddressofNameOrdinals
长度:2*NumberOfNames
4.复制AddressOfNames
长度:4*NumberOfNames

移动函数名字
5.复制所有的函数名
长度不确定,复制时直接修复AddressOfNames
6.复制IMAGE_EXPORT_DIRECTORY结构
7.修复IMAGE_EXPORT_DIRECTORY 结构中的
AddressOfFunctions
AddressOfNameOrdinals

8.修复目录项的值,指向新的IMAGE_EXPORT_DIRECTORY

image-20221007124323902

移动重定位表步骤

tip: DATA_DIRECTORY 里面的值不能修改

1
2
3
4
1.首先移动重定位表
2.修改重定位表结构指向移动后的重定位的位置
3.手动imagebase自增1000
4.最后修复重定位表

IAT表

image-20221008133831339

运行时:

call 4070bc : [77d5050b] user32.dll -> messagebox

运行前:

call 4070bc: [7604] —>messagebox的字符串

因为重定位表的关系,call后面的地址不能写死,所以调用dll的时候,call后面都是跟的地址,而不是写死的函数值

导入表

image-20221008133346306

image-20221008134133377

导入表两个重要的结构

1
2
OriginalFirstThunk       //RVA   指向IMAGE_THUNK_DATA结构数组    INT表
FIrstThunk //RVA 指向IMAGE_THUNK_DATA结构数组 IAT表

导入表中的FirstThunk 属性 指向IAT表

pe文件加载前:

INT表和IAT表中存储的值是一样的,可能是存储函数名称 或者函数序号

image-20210408162001903

pe文件加载后:

IAT表中存储函数地址

image-20210408162937094

在pe加载的时候,系统会调用GetProcessAddress(), 循环遍历INT表,添加到IAT表的对应地址

INT表和IAT表在文件中是一样的(没有绑定导入表的情况下)

导出表只有一个,但是导入表可能有多个

定位导入表

1
2
3
4
1.DATADirectory 中的第二个是导入表
2.通过DataDirectory[1].VirtualAddress得到导入表的RVA
3.得到 IMAGE_IMPORT_DESSRIPTOR
4.遍历FirstThunk, IMAGE_THUNK_DATA32 是一个联合体,把它当成DWORD型。判断最高位是否为1,如果是,那么除去最高位的值就是函数的到处序号。如果不是,那么这个值是一个RVA,指向IMAGE_IMPORT_BY_NAME

imagebase 就是程序加载到模块中的句柄

需要根据导入表去找函数地址

绑定导入表

如果导入表的时间戳(TimeDateStamp)为0,代表这个dll的函数地址还没绑定

如果时间戳为 –1(FFFFFFFF),代表dll的函数已经绑定

绑定导入表位于数据目录的第12项

当IMAGE_BOUND_IMPORT_DESCRIPTOR 结构中的TimeDateStamp 与dll文件标准pe头中的TimeDateStamp值不相符时,或者dll需要重定位时,会重新计算IAT中的值