引言

想开发反病毒引擎,第一步总是对PE文件进行解析,虽然有点落伍,但这是基础,想开发病毒也同理,很多的病毒样本在使用Indirect Syscall + 调用栈欺骗前,都需要动态调用API,这个时候就需要你了解PE文件的结构,通过解析PE文件找到IAT,进而找到PEB来动态获取地址,所以不想学也得学~

本文会介绍从书中学到的部分知识,以及通过C++和汇编分别实现一个PE文件解析器

本篇博客的学习参考罗云彬老师的《Windows环境下32位汇编语言程序设计》,尤其是第17章的“PE文件”

正文

借用一下罗云彬老师的图,PE文件从文件头到文件尾依次是:DOS部分、PE头、节表、节数据
DOS部分:包括DOS标识和DOS代码,为了兼容DOS系统而保留的部分
PE头:包括PE标识、PE头、PE可选头
节表:下面节数据的起始位置、大小
节数据:就是代码段、数据段
image
上面是用通俗的语言描述,在代码中是下面的样子

DOS部分

1
2
3
4
5
IMAGE_DOS_HEADER {
e_magic 2字节 // DOS标识
...
e_lfanew 4字节 // PE头相对文件头的偏移量
};

常用的就是e_magic和e_lfanew,DOS标识转换成字符就是常见的MZ(MZ是DOS创始人名字的首字符)

PE头

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
IMAGE_NT_HEADERS {
Signature 4字节 // PE标识
IMAGE_FILE_HEADER
IMAGE_OPTIONAL_HEADER32
}

IMAGE_FILE_HEADER {
Machine 2字节 // 运行平台
NumberOfSections 2字节 // 文件的节数目
TimeDateStamp 4字节 // 文件创建的时间和日期
...
SizeOfOptionalHeader 2字节 // IMAGE_OPTIONAL_HEADER32的大小
Characteristics 2字节 // 这是一个EXE文件还是DLL文件还是别的类型的文件
}

IMAGE_OPTIONAL_HEADER32 {
...
AddressOfEntryPoint 4字节 // 程序装入内存后从这个地址开始执行
ImageBase 4字节 // 程序装入内存后在内存中的起始地址,默认起始地址就是调试器中常看到的0x00400000
SectionAlignment 4字节 // 内存中对齐的最小单位
FileAlignment 4字节 // 文件中对齐的最小单位
Subsystem 2字节 // 指定是GUI程序还是CLI程序
DataDirectory IMAGE_DATA_DIRECTORY * 16 // 这是一个数组,里面包含16个IMAGE_DATA_DIRECTORY
}

IMAGE_DATA_DIRECTORY {
VirtualAddress 4字节 // 起始地址
isize 4字节 // 大小
}

可以看到PE头就复杂多了,上面的结构体和注释已经展示了大部分字段的含义,再补充一下DataDirectory,Windows PE文件中,代码段和数据段是按照装入内存后的属性划分的,装入内存后这段数据是可读可执行,那这段数据在装入内存前就位于代码段,装入内存后这段数据是只读,那这段数据在装入内存前就位于只读数据段,可是问题来了,像导出表、导入表装入内存后都是只读,所以都位于数据段,那我们改如何区分它们呢,就是利用DataDirectory,16个IMAGE_DATA_DIRECTORY,序号是0-15,依次表示

1
2
3
4
5
6
0    导出表
1 导入表
2 资源
...
5 重定位表
...

还需要注意,这里的偏移指的是程序被装载到内存后,在内存中的偏移,由于内存对齐的最小单位和文件对齐的最小单位不同(还有其他因素),内存中的偏移和文件中的偏移并不一致

节表

1
2
3
4
5
6
7
8
9
10
11
12
IMAGE_SECTION_HEADER {
Name 8字节 // Byte[8],8个字节的数组
union {
PhysicalAddress 4字节 //
VirtualSize 4字节 // 被文件对齐和内存对齐前的实际大小
}
PointerOfRawData 4字节 // 在文件中相对于文件头的偏移
SizeOfRawData 4字节 // 被文件对齐后的大小
VirtualAddress 4字节 // 在内存中相对于内存基址的便宜
Characteristics 4字节 // 节的各种属性,是否可读、可写、可执行等等
...
}

例1,想要计算只读数据段中偏移20个字节的数据A在内存中的地址,假如PE被装载的起始地址是0x00400000,那么A在内存中的地址就是0x00400000+VirtualAddress+20Byte
例2,已知一个RVA,想计算在文件中的偏移地址:
1、通过例1的方式分别得到每个节在内存中的地址,节1在内存中的地址Addr1,节2在内存中的地址Addr2
2、Addr1 <= 0x00400000+VirtualAddress+20Byte < Addr1+VirtualSize,通过这个方式判断A在哪个节中
3、RVA - 对应节的起始地址 + PointerOfRawData就是在文件中的偏移
有人可能会问,节在内存中的大小是经过SectionAlignment对齐的,不等于VirtualSize,为什么是Addr1+VirtualSize,假如VirtualSize是0x523,在内存中经过SectionAlignment是0x1000,从0x523开始到后面都被填充0,所以在比较的时候可以忽略后面的部分,只计算Addr1+VirtualSize

节数据

就是代码段、数据段、只读数据段、等等

导入表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
IMAGE_IMPORT_DESCRIPTOR {
union {
...
OriginalFirstThunk 4字节 // 指向包含IMAGE_THUNK_DATA的数组
}
...
Name1 4字节 // DLL文件名称
FirstThunk 4字节 // 指向包含IMAGE_THUNK_DATA的数组
}

IMAGE_THUNK_DATA {
union {
ForwarderString 4字节
Function 4字节
Ordinal 4字节
AddressOfData 4字节
}
}

IMAGE_IMPORT_BY_NAME {
...
Name1 1字节 // 导入函数名称字符串
}

程序要使用的函数及相关信息,存储在导入表中

导入表地址可以从IMAGE_NT_HEADERS中的IMAGE_OPTIONAL_HEADER32中的IMAGE_DATA_DIRECTORY中的索引为5的结构体中的VirtualAddress和isize获取地址偏移和大小,一个DLL对应一个IMAGE_IMPORT_DESCRIPTOR,也就是说有多少个DLL就有多少个IMAGE_IMPORT_DESCRIPTOR,最后以一个值全为0的IMAGE_IMPORT_DESCRIPTOR结构结束,同样,包含IMAGE_THUNK_DATA的数组也是,一个IMAGE_THUNK_DATA对应一个导入函数,最后以一个值全为0的IMAGE_THUNK_DAT结构结束,IMAGE_THUNK_DATA本质上是一个4字节,当最高位为1时,表示函数以序号方式导入,这时4字节的低位就是函数的序号,当最高位为0时,表示函数以名称方式导入,这时4字节的值是一个RVA,指向IMAGE_IMPORT_BY_NAME,IMAGE_IMPORT_BY_NAME中的Name1包含导入函数的名称

导出表

1
2
3
4
5
6
7
8
9
10
IMAGE_EXPORT_DIRECTORY {
...
nName 4字节 // 指向包含文件名字符串的RVA,即使更改了DLL的文件名为Ker.dll,也能从这个字段中获取编译时的DLL文件名Kernel32.dll
nBase 4字节 // 导出函数的起始序号
NumberOfFunctions 4字节 // 导出函数的总个数
NumberOfNames 4字节 // 有名称的导出函数总个数
AddressOfFunctions 4字节 // 指向包含导出函数地址数组的RVA
AddressOfNames 4字节 // 指向包含导出函数名称数组的RVA
AddressOfNameOrdinals 4字节 // 指向包含函数名称序号数组的RVA
}

和导入表相对应,存储要给别的程序使用的函数

重定位表

1
2
3
4
IMAGE_BASE_RELOCATION {
VirtualAddress 4字节 // 包含要重定位的内存页的起始RVA
SizeOfBlock 4字节 // 重定位块的长度
}

没有意外的情况下,程序的建议装载地址就是实际装载地址,但是对于EXE文件,如果启用了ASLR(地址空间布局随机化),则实际装载地址会不同,对于DLL文件,如果建议装载地址已经被其他DLL占用了,则实际装载地址会不同,所以需要对代码进行重定位

直接寻址指令的地址 + (实际装载地址 - 建议装载地址),实际装载地址由Windows装载器提供不用我们操心,建议装载地址存储在PE文件的IMAGE_OPTIONAL_HEADER32中的ImageBase中,所以重定位表中只需要存储要修改的直接寻址指令的地址

重定位表由一系列重定位块组成,一个重定位块对应一个内存页,用来描述这个内存页中需要重定位的项,每个重定位块以一个IMAGE_BASE_RELOCATION开头,后面跟着一系列重定位项,每个重定位项占2个字节,SizeOfBlock = 4 + 4 + 2*重定位项数,所以重定位项数 = (SizeOfBlock - 8) / 2,每个重定位项占用2个字节共16位,VirtualAddress + 16位中的低12位就是要重定位指令的最终RVA,16位中的高4位用来表示属性,所有重定位块最终以一个VirtualAddress字段为0的IMAGE_BASE_RELOCATION结构结束。还有一个问题可以借此解答,假如PE的装载地址是0x00400000,代码的起始地址如果和装载地址一样的话,那第一页的重定位块的IMAGE_BASE_RELOCATION结构中的VirtualAddress的RVA就为0x00400000 - 0x00400000 = 0,此时 系统就会认为当前的重定位块结束了,导致重定位失败