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 | e_magic //5A 4D |
从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 | WORD Machine //86 64 //可以在什么机器上运行 |
Image_Optional_Header(可选pe头)
大小不确定
1 | WORD Magic //0b 02 //10b为32位的文件,20b为64位的文件 |
将pe文件从硬盘中读到内存中,是原封不动的读进去,拷贝到内存中,存储到 FileBuffer
,但这个时候还没有办法运行,需要peloader修改 FileBuffer
为内存中可执行的过程,就是内存拉伸的过程。写到的地址(内存运行的起始地址)叫ImageBuffer
(文件映像)
修改OEP
pe后面的20个字节为标准pe头
剩下的为可选pe头,从0b 02 开始。 修改 ImageBase
(程序入口点 EntryBase),保存后程序仍然正常运行
节表
相当于节中的目录 描述 节中的概要信息
定位节表:
可选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个。
1 | #define IMAGE_SIZEOF_SHORT_NAME 8 |
1 | 1.Name 占8个字节,一般情况下是以"\0"结尾的ASCII码字符串来标识的名词。但是该名称并不遵守以"\0"结尾的规律,如果不是以 "\0"结尾,系统会截取8个字节的长度进行处理,有时候会导致越界乱码。 |
代码空白区一般指的是 VirtualSize – SizeOfRawData 中的大小,
FileBuffer –> ImageBuffer
FileBuffer 是在文件中的内容,Image Buffer是在内存中的内容,在内存中扩展一下
sizeofheaders 包括 dos头+标准PE头+可选PE头+节表
1 | 1.根据 SizeofImage 的大小,开辟一块缓冲区(ImageBuffer) |
将Filebuffer 读到 ImageBuffer,先分配 SizeOfImage 大小的内存,在可选pe头里
转VirtualAddress eg:
1 | 1. 501234 - 500000(ImageBase) = 1234 (RVA) |
代码节空白区添加代码
关于修正E8的理解:
1 | 公式:X = 要跳转的地址 - (E8当前的地址 + 5); |
关于e9修正的理解:
1 | 公式:X = 要跳转的地址 - (E9当前的地址 + 5) |
修正oep:
1 | 修正OEP好理解,就是定位到OEP地址,然后直接通过codeBegin地址减去pImageBuffer的首地址即可 |
实操
messagebox 的地址为 75 5e 06 60
找硬编码
1 | 6A 00 |
查找pe信息 win10 的 32位的calc
需要注意的字段
1 | AddressOfEntryPoint: 1B90 |
代码空白区的起始地址为 : PointerToRawData+VirtualSize
计算FileBuffer 代码节的结束地址
Formulas: PointerToRawData + VirtualSize = 132c
在空白区填充代码的硬编码
当程序中的文件对齐和内存对齐不一致时需要进行转换
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
计算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
任意代码空白区添加代码
1 | 1. 根据sizeofIMage分配空间 |
新增节-添加代码
判断
sizeofHeader - (DOS + 垃圾数据 + PE标记 + 标准PE头 + 可选PE头 + 已存在节表)>= 2个节表的大小
因为节表中需要有空间装得下2个节表,其中一个节表是我们自己新增的,其中一个是00填充(windows判断一个结构体结束,是判断和结构体相同大小的空间 都为0,规定,写为1也可以运行,但不知道写为几不能运行)
节表中的信息指的是 .text .rdata .data .rsrc 中的数据
需要修改的数据
1 | 1.添加一个新的节 |
修改virtualSize时, 直接修改为和文件中对齐后的长度一样,1000h
VirtualAddreess + sizeofRawData //最后一个节开始的地方 + max (Virtualsize,sizeofRawdata)
加完以后按照内存对齐
如果节后面有编译器新增的数据,把dos头后面的stub 删除,将NT头提前,修改 e_lfanew
3.修改sizeofimage
因为节在内存对齐是 1000
6000 + 1000 = 7000
新增一个节表的数据
修正节表数据
1 | 1.VirtualAddress 按照上一个对齐 |
扩大节–合并节–数据目录
扩大节
1 | 1.拉伸到内存 |
合并节
1 | 1.取最后一个节中 (sizeofRawData 和 VirtualSize)的值,谁大就取谁 |
静态链接库–动态链接库
使用dll
隐式链接
1 | 1.将*.dll *.lib 放到工程目录下面 |
显式链接
1 | 1.定义函数指针 |
说明:
Handle HMODULE HINSTANCE HWND 都表示无符号类型的整数 4个字节
避免被直接分析到dll函数
使用 *.def 文件 达到隐藏函数名的目的
1 | EXPORTS |
导出表
真正导出表的结构
只要见到RVA的地方,先转成foa 再去找
1 | AddressOfFUnctions 指向了一张表,表里存储着pe 所有导出函数的地址 |
定位数据目录的位置
pe标记往后数24(20+4)个字节 到达可选pe头,可选pe头往后数 224-128 = 96 个字节 (可选pe头– DataDirectory) = _IMAGE_DATA_DIRECTORY * 16 = 8*16
DataDirectory首地址= 可选pe头 + 96
定位导出表
1 | 1.找到可选pe头的最后一个成员 DataDirectory |
1 | numberOfFunctions = (编写dll时)最大序号-最小序号+1 |
根据函数名称获取导出函数
1 | 1.根据导出表的函数名称去AddressOfNames指向的每个名称字符串查询是否有匹配的字符串 |
根据函数序号获取导出函数
1 | 1.根据函数序号 - 导出表.base 获得导出函数的Ordinal |
总结
1 | 导出表中包含了3张小表:导出函数地址表,导出函数名称表,导出函数序号表 |
重定位表
绝大多数dll中,exe基本没有重定位表
数据目录项的第6个结构是重定位表
重定位表的结构是按照块来的
一堆需要修改的放在一个块中
如下图,只有高4位 为 0011 这个值才需要修改,后面的低12位+前面的x(VirtualAddress)
每一块有多少个需要修改的值
1 | (SizeofBlock - 8) /2 |
定位重定位表的流程
1 | 1.找到可选pe头的最后一个成员 DataDirectory |
重定位表块中的SizeOfBlock后面的数据部分是用来作为偏移使用的,每一个数据项的大小为WORD,两字节,只有后面12位是用来表示偏移的,高4位用来判定该地址是否需要偏移。这里为什么只有12位用来表示偏移呢?因为重定位表是根据物理内存来设计的,物理内存是以4kb = 2^12 为单位,也就是4kb为一个物理页,所以只需要12位就可以表示一个物理页内的所有偏移。
高4位为0的话,表示该数据为对齐用的填充的垃圾数据,不需要重定位
移动导出表–重定位表
为什么要移动这些表:
是对程序加密/破解的基础
移动导出表步骤
1 | 1.在dll中新增一个节,并返回新增节后的FOA |
移动重定位表步骤
tip: DATA_DIRECTORY 里面的值不能修改
1 | 1.首先移动重定位表 |
IAT表
运行时:
call 4070bc : [77d5050b] user32.dll -> messagebox
运行前:
call 4070bc: [7604] —>messagebox的字符串
因为重定位表的关系,call后面的地址不能写死,所以调用dll的时候,call后面都是跟的地址,而不是写死的函数值
导入表
导入表两个重要的结构
1 | OriginalFirstThunk //RVA 指向IMAGE_THUNK_DATA结构数组 INT表 |
导入表中的FirstThunk 属性 指向IAT表
pe文件加载前:
INT表和IAT表中存储的值是一样的,可能是存储函数名称 或者函数序号
pe文件加载后:
IAT表中存储函数地址
在pe加载的时候,系统会调用GetProcessAddress(), 循环遍历INT表,添加到IAT表的对应地址
INT表和IAT表在文件中是一样的(没有绑定导入表的情况下)
导出表只有一个,但是导入表可能有多个
定位导入表
1 | 1.DATADirectory 中的第二个是导入表 |
imagebase 就是程序加载到模块中的句柄
需要根据导入表去找函数地址
绑定导入表
如果导入表的时间戳(TimeDateStamp)为0,代表这个dll的函数地址还没绑定
如果时间戳为 –1(FFFFFFFF),代表dll的函数已经绑定
绑定导入表位于数据目录的第12项
当IMAGE_BOUND_IMPORT_DESCRIPTOR 结构中的TimeDateStamp 与dll文件标准pe头中的TimeDateStamp值不相符时,或者dll需要重定位时,会重新计算IAT中的值