PE 学习之路 —— DOS 头、NT 头
1. 前述
可执行文件的格式是操作系统本身执行机制的反映,理解它有助于对操作系统的深刻理解,掌握可执行文件的数据结构及其一些机理,是研究软件安全的必修课。`pe(portable executable file format)`是目前 windows 平台上的主流可执行文件格式。pe 文件衍生于早期的 coff 文件格式,描述 pe 格式及 coff 文件的主要地方在 winnt.h 这个头文件,其中有一节叫 image format,如下:
该节给出了 dos mz 格式和 windows 3.1 的 ne 格式文件头,之后就是 pe 文件的内容,在这个头文件中,几乎能找到关于 pe 文件的每一个数据结构的定义、枚举类型、常量定义。winnt.h 这个头文件是 pe 文件定义的最终决定者。dll 和 exe 文件之间的区别完全是语义上的,它们使用完全相同的 pe 格式。唯一的区别就是用一个字段标识出这个文件是 exe 还是 dll。同时也包括其它的 dll 扩展,比如 ocx 控件和控制面板程序(cpl 文件)。另外,64 位 windows 只是对 pe 格式做了一些简单的修饰,新格式叫 pe32+,没有新的结构加进去,其余的改变只是简单地将以前的 32 位字段扩展成64位,比如 `image_nt_headers`,如下:
2. pe 文件大体结构
结构的选择依赖于用户正在编译的模式(尤其是 `_win64` 是否被定义),在具体学习 pe 之前,先大概清楚下 pe 格式布局是怎样子的,如下:
pe 文件使用的是一个平面地址空间,所有代码和数据都被合并在一起,组成一个很大的结构,文件的内容被分割为不同的区块,区块包含代码和数据,各个区块按页边界来对齐,区块没有大小限制,是一个连续结构,每个块都有它自己在内存中的一套属性。pe 文件是由 pe 加载器加载到内存中的,这个 pe 加载器也就是 windows 加载器,它并不是将 pe 文件作为单一内存映射文件装入到内存中,而是去遍历 pe 文件,决定将哪一部分进行映射,这种映射方式是将文件较高的偏移位置映射到较高的内存地址,当磁盘文件装入到内存中,其数据结构布局是一致的,但是数据之间的相对位置可能会改变,如下:
3. 模块和基地址
下面需要理清两个概念,那就是 **模块** 和 **基地址**,当 pe 文件通过 windows 加载器加载到内存后,内存中的版本被称为模块(module),映射文件的起始地址被称为模块句柄(hmodule),可以通过模块句柄来访问在内存中其它的数据结构,这个初始地址也被称为基地址(imagebase)。在 32 位 windows 系统中可以直接调用 `getmodulehandle` 以取得指向 dll 的指针,通过指针访问该 dll module 的内容,函数原型为:`hmodule winapi getmodulehandle(lpctstr lpmodulename)`
功能:获取一个应用程序或动态链接库的模块句柄。
参数:传递一个可执行文件或 dll 文件名字符串
返回值:若执行成功,则返回模块的句柄,也就是加载的基地址,若返回零,则表示失败。如果传递参数为 null,则返回调用的可执行文件的基地址。
注意事项:只有在当前进程中,这个句柄才会有效,也就是说已映射到调用该函数的进程内,才会正确得到模块句柄。
1 #include <windows.h> 2 #include <iostream> 3 4 int main() 5 6 { 8 hmodule hmodule = getmodulehandle(null); 9 10 std::cout << hmodule << std::endl; 11 12 return 0; 14 }
pe文件加载的基地址(imagebase):exe 默认基地址为 `0x00400000h`,dll 默认基地址为 `0x10000000h`,这个值可以在链接应用时使用链接程序的 `/base` 选项设定,或者通过 rebase 应用程序进行设置。说完基地址,再来说下相对虚拟地址,由于 pe 文件中里的东西可以载入到空间的任何位置,所以不能依赖于 pe 的载入点,必须有一个方法来指定地址而不依赖于 pe 载入点的地址,所以出现相对虚拟地址(rva)概念,rva 只是内存中的一个简单的相对于 pe 文件装入地址的偏移位置,例如,假设一个 exe 文件从地址 `0x400000h` 处装入,并且它的代码区块开始于 `0x401000h`,代码区块的 rva 就是:`0x401000h - 0x400000h = 0x1000h`,在这里,`0x401000h` 是实际的内存地址,这个地址被称为虚拟内存地址(va),另外也可以把虚拟地址想象为加上首选装入地址的rva。
4. 文件偏移地址
当pe文件储存在磁盘上,某个数据的位置相对于文件头的偏移量,称为文件偏移地址或物理地址。文件偏移地址从pe文件的第一个字节开始计数,起始值为0,用十六进制文本编辑器打开文件,里头显示的就是文件偏移地址。
5. image_dos_header 结构
在这个结构体中,有两个字段非常重要,分别是第一个和最后一个,其它的不重要,其中第一个 e_magic 字段需要被设置为 0x5a4dh。它也被称为魔术数字。
这个值有个宏定义,名为 `image_dos_signature`,它的 ascii 值为 mz,是 ms-dos 的最初创建者之一 `mark zbikowski` 字母的缩写。
e_lfanew 字段是真正pe文件头的相对偏移(rva),那么,这个字段在哪呢?
上图已经说明了,为了验证是否正确,如下:
在 3ch 偏移处,显示 0x00000110h(由于 intel cpu 属于 little-endian 类,字符存储时低位在前,高位在后,反序排列,将顺序恢复后便是 0x00000110h),这个是 e_lfanew 字段所存储的值,它占4 个字节。后面就是 pe 头了。
6. image_nt_headers 结构
在一个有效的 pe 文件里,signature 字段被设置为 0x00004550h,ascii 码字符是 pe00
宏定义为 `image_nt_signature`
那么这两个重要的字段(e_lfanew 和 signature)有什么用呢?这个在以后解析pe文件,判断一个文件是否是一个 pe 文件时提供重要依据,即判断这两个字段的值是否为 0x5a4dh 和 0x00004550h,你也可以用它们的宏定义,分别为 `image_dos_signature` 和 `image_nt_signature`,如果相等,则为一个 pe 文件,如果不相等,则不是一个 pe 文件。
1 #include <windows.h> 2 #include <iostream> 3 4 int main() 5 { 6 // 1.首先须打开一个文件 7 handle hfile = createfile( 8 text("test.png"), 9 generic_all, 10 null, 11 null, 12 open_existing, 13 null, 14 null 15 ); 16 // 2.判断文件句柄是否有效,若无效则提示打开文件失败并退出 17 if (hfile == invalid_handle_value) 18 { 19 std::cout << "打开文件失败!" << std::endl; 20 closehandle(hfile); 21 exit(exit_success); 22 } 23 // 3.若打开文件成功,则获取文件的大小 24 dword dwfilesize = getfilesize(hfile, null); 25 // 4.申请内存空间,用于存放文件数据 26 byte * filebuffer = new byte[dwfilesize]; 27 // 5.读取文件内容 28 dword dwreadfile = 0; 29 readfile(hfile, filebuffer, dwfilesize, &dwreadfile, null); 30 // 6.判断这个文件是不是一个有效的pe文件 31 // 6.1 先检查dos头中的mz标记,判断e_magic字段是否为0x5a4d,或者是image_dos_signature 32 dword dwfileaddr = (dword)filebuffer; 33 auto dosheader = (pimage_dos_header)dwfileaddr; 34 if (dosheader->e_magic != image_dos_signature) 35 { 36 // 如果不是则提示用户,并立即结束 37 messagebox(null, text("这不是一个有效pe文件"), text("提示"), mb_ok); 38 delete filebuffer; 39 closehandle(hfile); 40 exit(exit_success); 41 } 42 // 6.2 若都通过的话再获取nt头所在的位置,并判断e_lfanew字段是否为0x00004550,或者是image_nt_signature 43 auto ntheader = (pimage_nt_headers)(dwfileaddr + dosheader->e_lfanew); 44 if (ntheader->signature != image_nt_signature) 45 { 46 // 如果不是则提示用户,并立即结束 47 messagebox(null, text("这不是一个有效pe文件"), text("提示"), mb_ok); 48 delete filebuffer; 49 closehandle(hfile); 50 exit(exit_success); 51 } 52 // 7.若上述都通过,则为一个有效的pe文件 53 messagebox(null, text("这是一个有效pe文件"), text("提示"), mb_ok); 54 delete filebuffer; 55 closehandle(hfile); 56 // 8.结束程序 57 return 0; 58 }
以上代码就是简单实现判断一个文件是不是有效的 pe 文件。在上述代码中,运用了 createfile()、getfilesize()、readfile() 来获取文件内容,得到文件的基址 dwfileaddr,只需将该变量转换成 `pimage_dos_header` 类型,那么就能获取到nt头的开始位置,nt头的位置可同 (pimage_nt_headers)((pimage_dos_header)dwfileaddr->e_lfanew + dwfileaddr) 获取,有了这个,后面的工作就变得简单多了。
7. image_file_header 结构
该结构体描述的是文件的一般性质,有 7 个字段,共占 20 个字节,20 相当于十六进制的 14h,下图已标出实际位置,如下:
- 这里标记的是 machine 字段,占两个字节,它的值为 0x014ch,代表的是 intel i386 平台。
- 这里标记的是 numberofsections 字段,占两个字节,它的值为 0x0006h,代表的是有 6 个区块,也可以说有 6 个节。
- 这里标记的是 timedatestamp 字段,占四个字节,它的值为 0x5c0748d5h,代表的是文件创建日期和时间。
由上图可以看出,该文件创建时间为 2018-12-05 / 11:41:09。
- 这个值以0填充,用不到。
- 这个值以0填充,用不到。
- 这个字段就比较重要,划重点,sizeofoptionalheader,占两个字节,它的值为 0x00e0h,代表的是 `image_optional_header32` 结构的大小,在 32 位系统,它的值为 0x00e0h,在 64 位系统,它的值为 0x00f0h,
- 最后一个字段 characteristics,占两个字节,它的值为 0x0102h,代表的是文件的属性。这个值是由 0x0100h 和 0x0002h 两者之和,0x0100h 这个值代表的是目标平台为 32 位机器,0x0002h 这个值代表文件可执行,如果为0,一般是链接出现了问题。
1 // 获取文件头 2 auto fileheader = ntheader->fileheader; 3 // 接下来就是解析各字段 4 std::cout << "运行平台:0x" << std::hex << fileheader.machine << std::endl; 5 std::cout << "区块数目:0x" << std::hex << fileheader.numberofsections << std::endl; 6 std::cout << "文件创建日期和时间:0x" << std::hex << fileheader.timedatestamp << std::endl; 7 std::cout << "image_optional_header32结构大小:0x" << std::hex << fileheader.sizeofoptionalheader << std::endl; 8 std::cout << "文件属性:0x" << std::hex << fileheader.characteristics << std::endl;
将上述代码插入到 return 0; 之前,运行如下:
再将上面文件创建日期和时间进行转换,代码如下:
1 // 获取文件头 2 auto fileheader = ntheader->fileheader; 3 // 进行时间转换 4 tm * filecreatetime = gmtime((time_t*)&fileheader.timedatestamp); 5 // 接下来就是解析各字段 6 std::cout << "运行平台:0x" << std::hex << fileheader.machine << std::endl; 7 std::cout << "区块数目:0x" << std::hex << fileheader.numberofsections << std::endl; 8 std::cout << "文件创建日期和时间:" << std::dec << filecreatetime->tm_year + 1900 << "-" 9 << filecreatetime->tm_mon + 1<< "-" 10 << filecreatetime->tm_mday << " " 11 << filecreatetime->tm_hour + 8 << ":" 12 << filecreatetime->tm_min << ":" 13 << filecreatetime->tm_sec << std::endl; 14 std::cout << "image_optional_header32结构大小:0x" << std::hex << fileheader.sizeofoptionalheader << std::endl;
上面是用到了tm的结构,以及 gmtime 这个函数进行转换,在用之前需要包含头文件 time.h,运行如下:
不过还是要注意下,首先 tm_year 这个值为十六进制,需转成十进制,而且要加上 1900,因为时间是从 1900 开始算,它的值为偏移,其次月是从 0 开始算的,所以要加 1,最后是时区问题,因为我这里位于东八区,所以小时需加上 8。另外关于调试的那两个字段,没有必要去对它深究,因为微软的vs已用了新的 debug 格式,这个只是用来设置 coff 符号,跟 coff 符号有关,一般这个值都为 0,所以不探讨它。关于运行平台代码和文件属性代码可以去网上查表就行,这里就省略。
8. image_optional_header 结构
上图展示的是 `image_optional_header32` 结构体各字段,这个结构体相对来说就比较大,我已经分析好了,这个是 32 位的,64 位的大体结构没变,只是有几个字段改成的 ulonglong 类型,那么它在实际内部是怎么样的呢?下面这张图是验证上面图片所叙述的。
上图所标记的,为 `image_optional_header32` 结构所有成员,你也注意到了,在结尾处,有 .text,说明已经到了该结构体的末尾了,算了一下,恰好占了 224 个字节,这个值其实在 `image_file_header` 中倒数第二个字段已经指出了,值为 0xe0,这个值相当于十进制中的 224。为了更好的说明,我用序号标记了各个字段,其中有一些为透明,一是没地方标,二是能看清实际数值大小,这样便于分析。
以下是各字段解析:
- magic:这个是一个标记,它的值为 0x010bh,代表的是普通的可执行映象,一般是 0x010bh,如果是 64 位,则为 0x020bh,如果为 rom 映象,该值为 0x0107h。
- majorlinkerversion:链接程序主版本号,值为 0x0eh。
- minorlinkerversion:链接程序次版本号,值为 0x00h。
- sizeofcode:所有含有代码区块的总大小,该值为 0x0031d000h,这个代码区块是带有 `image_scn_cnt_code` 属性,这个值是向上对齐某一个值的整数倍。通常情况下,多数文件只有一个 code 块,所以这个字段和 .text 块的大小匹配。
- sizeofinitializeddata:所有初始化数据区块总大小,该值为 0x000b4000h,这个是在编译时所构成的块的大小(不包括代码段),一般这个值是不准确的。
- sizeofuninitializeddata:所有未初始化数据区块总大小,该值为 0,这些块在程序开始运行时没有指定值,未初始化的数据通常在 .bss 块中。
- addressofentrypoint:程序执行入口 rva,该值为 0x002b56d0h。在大多数可执行文件中,这个地址并不直接指向 main、winmain 或者是 dllmain,而是指向运行库代码并由它来调用上述函数。对于 dll 来说,这个入口点是在程序初始化和关闭时以及线程创建和毁灭时被调用。
- baseofcode:代码段的起始 rva,该值为 0x00001000h,如果是用微软的链接器生成的,则该值通常是 0x00001000h。
- baseofdata:数据段的起始 rva,该值为 0x0031e000h,数据段通常在内存的末尾,对于不同版本的微软链接器,这个值是不一致的,在64位可执行文件中是不出现的。
- imagebase:程序默认装入地址,该值为 0x00400000h,加载器试图在这个地址表装入 pe 文件,如果可执行文件是在这个地址装入的,那么加载器将跳过应用基址重定位的步骤。
- sectionalignment:内存中区块对齐大小,值为 0x00001000h,默认对齐尺寸是目标 cpu 的页尺寸,最小的对齐尺寸是一页 1000h(4kb),在 ia-64 上,这个值是 8kb。每个区块装入地址必定是本字段指定数值的整数倍。
- filealignment:磁盘上 pe 文件内的区块对齐大小,值为 0x00000200h,对于 x86 的可执行文件,这个值通常是 200h 或 1000h,这是为了保证块总是从磁盘的扇区开始的,这个值必须是 2 的幂,最小为 200h。
- majoropreatingsystemversion:要求操作系统的最低版本号的主版本号,该值为 0x0006h,这个值似乎没什么用。
- 同上,没什么用。
- 同上,没什么用。
- 同上,没什么用。
- 同上,没什么用。
- 同上,没什么用。
- 同上,没什么用。
- sizeofimage:映象装入内存后的总尺寸,该值为 0x003d5000h,它指装入文件从 imagebase 到最后一个块的大小,最后一个块根据其大小往上取整。
- sizeofheaders:是 ms-dos 头部、pe 头部、区块表的组合尺寸。该值为 0x00000400h。
- checksum:校验和,imagehlp.dll 中的 checksummappedfile 函数可以计算这个值,一般的exe文件可以是 0,但一些内核模式的驱动程序和系统 dll 必须有一个校验和。
- subsystem:一个标明可执行文件所期望的子系统的枚举值,这个值只对 exe 是重要的。该值为 0x0003h。
- dllcharacteristics:dllmain() 函数何时被调用,默认为 0。
- sizeofstackreserve:在 exe 文件里,为线程保留的堆栈大小,它一开始只提交其中一部分,只有在必要时,才提交剩下的部分。
- sizeofstackcommit:在 exe 文件里,一开始即被委派堆栈的内存数量,默认值为 4kb。
- sizeheapreserve:在 exe 文件里,为进程的默认堆保留的内存,默认值为 1mb,但是在当前 windows 里,堆值在用户不干涉的情况下就能增长超过这个值。
- sizeofheapcommit:在 exe 文件里,委派给堆的内存大小,默认值是 4kb。
- loaderflag:与调试有关,默认为 0。
- numberofrvaandsizes:数据目录表的项数,这个字段一直以来都为 16。
- datadirectory[16]:数据目录表,由数个 `image_data_directory` 结构组成,指向输入表、输出表、资源等数据。
同样,将上述代码放置最后,对扩展头进行解析,因字段太多,没一一列举,运行后如下:
对于该结构的最后一个字段,它是一个数组,这个数组有 16 个成员,代表的是目录表中的项,遍历它也不是很难,代码如下:
运行后如下:
将上述与 loadpe 对照,看下是否正确,如下:
从上面可以看出,已经成功遍历出目录表中每项的 rva 和大小。
(本小节完)