PE文件格式之win32应用程序初探
汉字最好--2008.1
看雪论坛首发
几年前曾打算学习PE文件格式,但满篇的英语和c语言令人声畏。一直到上个月因为需要才硬着头皮学下来。于是参考了几篇1.9版译文,同时对数个文件反编译、修改,最后自己组装了一个小exe,总算对PE文件有了一个初步的认识。
本文仅仅研究的是win32GUI程序中重要的内容,对dll,驱动,控制台没有涉及,适合最初学的、不懂c语言和英语的学习者学习。
工具:ollice,超级工具,本文用来PE文件在内存中的情况。
Winhex,二进制编辑工具,本文用来查看、编辑、创建PE文件。
Restool,资源工具,本文用来验证资源数据。
以上工具都有前辈的汉化修正版,对不懂鸟语学习者来说是天大的福音。译者易也,我能理解翻译者的难处,给计算机英语取个好中文名有点难度。是他们给我节约了学习一门外语的时间,让我不懂英语一样也学到了知识。
本文中不正确的地方望指正!
一 序言
PE文件是微软弄出来的可执行文件格式。Pe文件中的数据是一节节整齐的排列下去的,所以我们把一节数据称为‘节’。PE文件的第一个节叫头节,但为了与其他节区别开,我把头节还是叫文件头。文件头依次包括dos头、pe头、选项头、节头表四个部分。
执行pe文件实际上是系统的pe载入器把PE文件的各节映射到一个虚拟的进程空间后再执行代码的。这个虚拟的进程空间在32位下有$FFFFFFFF($表示十六进制,相当于c的0x,汇编的H),即4GB大小,即所谓的虚拟内存,里面的地址也就是虚拟地址.这个pe文件在虚拟空间里称为映象文件,映象文件开始的地方叫映象文件基址.而本文后面提到的虚拟地址都是相对虚拟地址,即相对于映象文件基址的偏移量.而pe文件中的偏移量称为物理地址.
本章给出了很多重要定义,有一些和1.9版译文不一样.其实起什么名不重要,重要的是能不能理解.另外,本文中称的’**表’除特别说明外指**的数组.如:节头表,就是节头的数组.
下面用winhex打开附件中的st.exe对照学习.
二 dos头
Pe文件的开始是文件头,文件头的开始是dos头,dos头的开始是’MZ’.如下:
物理地址 | 占用字节 | 含义 | 常用值 | 在文件中形式 |
0 | 2 | Dos头开始标志 | ‘MZ’ | 40 5A |
2 | 58 | Dos下的东西 | Windows下都置0吧 | |
$3C | 4 | PE头的物理地址 | 如果后面没有dos根就取$40,否则还要加上dos根大小.下文将这项取值用ppe表示. |
(数值和字符串在文件中的形式不用我提了吧?)
下面一般有一个dos根结构,是一小段dos程序,对我们初学者没有用,本文就不提了.
三 PE头
PE头是本文的第二部分,共$18字节.
物理地址 | 占用字节 | 含义 | 常用值 | 在文件中形式 |
Ppe+0 | 4 | PE头开始的标志 | ‘PE’ | 50 45 00 00 |
Ppe+4 | 2 | Cpu要求 | $14C(386以上) | 4C 01 |
Ppe+6 | 2 | 节数目 | 后面节头表声明了几个就是几 | |
Ppe+8 | 4 | 日期时间 | 多半是乱填 |
|
Ppe+$C | 8 | 没什么用 | 都置0 |
|
Ppe+$14 | 2 | 选项头大小 | $E0 | E0 00 |
Ppe+$16 | 2 | PE属性 | $10F或$818E,互改了都能运行 | 0F 01 |
PE头中最重要的是节数目,其他值都用常用值就可以.
四 选项头
选项头是文件头的第三部分.选项头中的许多选项是告知系统如何在虚拟内存中执行的数据.共$E0字节
物理地址 | 占用字节 | 含义 | 常用值 | 在文件中形式 |
Ppe+$18 | 2 | 选项头标志 | $10B | 0B 01 |
Ppe+$1A | 2 | 链接器版本号 | 可随便写 |
|
Ppe+$1C | 4 | 代码段长度 | 用不上 |
|
Ppe+$20 | 4 | 初始化数据长度 | 用不上 |
|
Ppe+$24 | 4 | 未初始化数据长度 | 可能有用吧 |
|
Ppe+$28 | 4 | 程序入口(OEP),程序开始执行的地方.这是虚拟地址 | 如果加了壳,就是壳的入口 | |
Ppe+$2C | 4 | 代码段基址 | 好像用不上 | |
Ppe+$30 | 4 | 数据段基址 | ||
Ppe+$34 | 4 | 映象文件基址.改成$200000没影响.改成$100000就与栈冲突了.如果载入这个基址出了问题,系统就会从基址重定位表中找. | $400000 | 00 00 40 00 |
Ppe+$38 | 4 | 节对齐.节被映射到虚拟内存后,占用节对齐的整数倍.如:节有$2400字节,在虚拟内存中占$3000. | $1000也就是4KB | 00 10 00 00 |
Ppe+$3C | 4 | 文件对齐.节在文件中占用文件对齐的整数倍,不足的补0,包括文件头(头节) | $200也就是512字节 | 00 02 00 00 |
Ppe+$40 | 4 | 这三项是操作系统及子系统版本号 | 4 | 04 00 00 00 |
Ppe+$44 | 4 | 4 | 04 00 00 00 | |
Ppe+$48 | 4 | 4 | 04 00 00 00 | |
Ppe+$4C | 4 | 没用的 | 0 |
|
Ppe+$50 | 4 | 映象文件大小.是所有节映射到虚拟内存后的大小.别忘了计算文件头和未初始化数据节.文件头一般占一个节对齐,节映射到虚拟内存后是节对齐的整数倍,所以映象文件大小也是节对齐的整数倍. |
|
|
Ppe+$54 | 4 | 文件头大小.随便写也没问题 | $400 | 00 04 00 00 |
Ppe+$58 | 4 | 校验和.可能用得上. | 0 |
|
Ppe+$5C | 2 | NT子系统(控制台选3) | 2 | 02 00 |
2 | Dll状态 | 0 | 00 00 | |
Ppe+$60 | 4 | 保留栈 | 1MB | 00 00 10 00 |
Ppe+$64 | 4 | 初始栈 | 4KB | 00 10 00 00 |
Ppe+$68 | 4 | 保留堆 | 1MB | 00 00 10 00 |
Ppe+$6C | 4 | 初始堆 | 4KB | 00 10 00 00 |
Ppe+$70 | 4 | 载入风格 | 0 |
|
Ppe+$74 | 4 | 数据索引表的索引数 | 16 | 10 00 00 00 |
上面共占用$60字节.下面紧跟一个数据索引表,占用$80字节.
数据索引表由16条索引组成,索引的前4字节是某种数据的虚拟地址,后4字节是数据的大小.数据的类型由索引在表中的位置决定,各位置的意义如下:
0:输出表(DLL必用) | 1:输入表 | 2:资源数据 | 3:异常 |
4:安全 | 5:基址重定位表 | 6:调试 | 7:描述文字 |
8;机器值 | 9:线程存储地址(TLS) | 10:载入配置 | 11:绑定输入表 |
12:输入地址表 | 13-15未见定义 |
St.exe定义了第1,2,12项,没有用到的都置0,
选项头到这里就结束了.其实也只有程序入口,映象文件基址,数据索引表让你斟酌一下,其他都有默认值.
五 节头表
节头表由一串节头组成.每个节头都声明如何把一个节映射到虚拟内存中去.一个节头有$28字节,结构如下:
占用字节 | 含义 | 说明 |
8 | 节的名称 | Asni字符.可不写 |
4 | 物理地址或大小 | 既然‘或’了,那随便吧 |
4 | 节的虚拟地址 | 别定义到已经定义了地方.如$300,显然已经被文件头占有. |
4 | 节数据大小 | 注意:肯定是文件对齐的整数倍,即使你的一个节实际使用2个字节也要占一个文件对齐.未初始化数据是文件对齐0倍 |
4 | 节的物理地址 |
|
12 | 无用的,都置0 |
|
4 | 节属性 | 是个32位数据,具体查1.9版译文 |
常见节属性:
1:代码$00000002 | 2:已初始化数据$00000004 | 3:未初始化数据$00000008 |
4:可共享$10000000 | 5:可执行$20000000 | 6:可读$40000000 |
7:可写$80000000 |
|
|
$60000020 1 和 5 和 6 (算术或运算) 通常是代码段 | ||
$C0000040 2 和 6 和 7 通常是输入表 | ||
$40000040 2 和 6 通常是资源数据 |
我们一起看st.exe,红色标记虚拟地址:
00000060 00 00 00 00 00 00 00 00 30 10 00 00 00 00 00 00 ........0.......
00000070 00 00 00 00 00 00 40 00 00 10 00 00 00 02 00 00 ......@.........
$6C处是入口=$1030,$70处是映象文件基址=$400000.
00000130 2E 74 65 78 74 00 00 00 .text...
00000140 00 01 00 00 00 10 00 00 00 02 00 00 00 02 00 00 ................
00000150 00 00 00 00 00 00 00 00 00 00 00 00 20 00 00 60 ............ ..`
00000160 2E 72 64 61 74 61 00 00 6A 01 00 00 00 20 00 00 .rdata..j.... ..
00000170 00 02 00 00 00 04 00 00 00 00 00 00 00 00 00 00 ................
00000180 00 00 00 00 40 00 00 40 2E 64 61 74 61 00 00 00 ....@..@.data...
00000190 04 00 00 00 00 30 00 00 00 00 00 00 00 00 00 00 .....0..........
000001A0 00 00 00 00 00 00 00 00 00 00 00 00 40 00 00 C0 ............@..?
000001B0 2E 72 73 72 63 00 00 00 60 02 00 00 00 40 00 00 .rsrc...`....@..
000001C0 00 04 00 00 00 06 00 00 00 00 00 00 00 00 00 00 ................
000001D0 00 00 00 00 40 00 00 40 ....@..@
上面就是节头表,
名称 | 虚拟地址 | 大小 | 物理地址 | 属性 |
.text | $1000 | $200 | $200 | $60000020 |
.rdata | $2000 | $200 | $400 | $40000040 |
.data | $3000 | $0 | 0 | $400000C0 |
.rsrc | $4000 | $400 | $600 | $40000040 |
看这些节都没有超过节对齐,所以都只占一个节对齐..data只是预定了空间,适合变量用.
虚拟地址转物理地址:
我们看程序入口正好落到了.text节的+$30($1030-$1000)处,来到$200+$30:
Offset 0 1 2 3 4 5 6 7 8 9 A B C D E F
00000230 6A 00 E8 35 01 00 00 A3 00 30 40 00 E8 4F 01 00 j.?...?0@.鐿..
00000240 00 6A 00 68 5E 10 40 00 6A 00 6A 65 FF 35 00 30 .j.h^.@.j.je5.0
可在OLLICE中查看此处代码含义($00401030).
在节头表部分,每个节头的虚拟地址,大小,物理地址,属性都要仔细设定.
文件头到这里就结束了,不足文件对齐整数倍的要补足0.文件后面紧跟节头表定义的各种节.
当然后面也可能只有一个节.
六 输入表
输入表在虚拟内存中的地址和大小由选项头的数据索引表中第二条索引指定.输入表有点回调函数的味道,是给系统调用的.通过输入表,系统了解程序需要哪些动态库的函数,在加载PE文件时把这些动态库也映射到进程虚拟空间,并把这些函数的地址写到程序指定的地方供调用.
输入表实际上是一个‘输入说明结构’的数组.该数组的最后一个成员置0以表结束.
一个输入说明结构由5个双字组成,占用20字节,对应一个动态库.如下:
| 占用字节 | 名称 | 含义 |
1 | 4 | 地址一 | 指向函数索引地址表的地址 |
2 | 4 | 时间戳 | 用于验证dll或绑定输入 |
3 | 4 | 中转链 |
|
4 | 4 | 动态库名 | 指向动态库名的指针(即Pchar或char*),动态库名是标准字符串 |
5 | 4 | 地址二 | 指向地址表的指针.这个地址表可能与其他输入说明结构指向的地址表组成一个连续的输入地址表.(见数据索引表) |
标准字符串指以空字符结束的ansi字符串.
上述结构中第2,3项没有找到实例所以跳过,呵呵.
动态库名也好理解,关键是第1,5项.地址一是个指针,指向函数索引地址表.这个表里都是函数索引的地址,以空地址(0)表示结束.函数索引由一个2个字节的索引号和进跟其后的一个标准字符串(即函数名)组成.
系统的工作就是首先获得动态库名,然后顺着地址一找到函数索引地址表,再一条条地读函数索引,将找到的函数的地址依次写到地址二指定的地址表中,直到读到空地址,再读取下一条输入说明结构,直到为0.
有点难于理解?再次用winhex和ollice打开st.exe:
000000C0 30 20 00 00 50 00 00 00 00 40 00 00 60 02 00 00 0 ..P....@..`...
来到$C0处,输入表的虚拟地址是$2030,大小$50.换成物理地址在$430,在.rdata节中.
来到$430处,可以读倒条输入说明结构:
00000430 88 20 00 00 00 00 00 00 00 00 00 00 F2 20 00 00 ?..........?..
00000440 08 20 00 00 9C 20 00 00 00 00 00 00 00 00 00 00 . ..?..........
00000450 3A 21 00 00 1C 20 00 00 80 20 00 00 00 00 00 00 :!... ..€ ......
00000460 00 00 00 00 5C 21 00 00 00 20 00 00 00 00 00 00 ..../!... ......
00000470 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
上面都是虚拟地址,转成物理地址时-$2000+$400.
以第一条为例,地址一为$2088->$488,地址二为$2008->$408,动态库名为$20F2->$4F2.
000004F0 00 00 6B 65 72 6E 65 6C 33 32 2E 64 6C 6C 00 00 ..kernel32.dll..
$4F2原来是kernel32.dll.
$488处:
00000480 E2 20 00 00 CE 20 00 00 ?..?..
00000490 BE 20 00 00 B0 20 00 00 00 00 00 00 ?..?......
有四个地址,也就是说有四个函数.来到$4E2($2012):
000004E0 00 00 3C 02 53 65 74 4C 61 73 74 45 72 72 6F 72 ..<.SetLastError
000004F0 00 00 ..
找到了一个函数,其它的就这么找.
在地址二指向的$408好像看不到什么,到OLLICE中,点’M’图标,,在列表中双击
输入表项(Memory map, 条目 21),在弹出的窗口中看$400000+$2008处已经写入了函数地址,红色字体表示是载入时写入的.
多数情况下程序并不是直接CALL这个地址,而是先CALL一个跳转表,然后JMP到这里:
00401030 >/$ 6A 00 push 0 ; /pModule = NULL
00401032 |. E8 35010000 call <jmp.&kernel32.GetModuleHandleA> ; /GetModuleHandleA
0040116C $- FF25 0C204000 jmp dword ptr [<&kernel32.GetModuleH>; kernel32.GetModuleHandleA
00401172 $- FF25 08204000 jmp dword ptr [<&kernel32.SetLastErr>; ntdll.RtlSetLastWin32Error
一些编译器将地址一都置0,这时系统用地址二代替地址一,此时,地址二指向的地址表要符合函数索引地址表的规范,即以空地址表结束.以Project1.exe(delphi编写的)为例:
输入表
00002C00 00 00 00 00 00 00 00 00 00 00 00 00 08 61 00 00 .............a..
00002C10 78 60 00 00 00 00 00 00 00 00 00 00 00 00 00 00 x`..............
00002C20 90 62 00 00 D0 60 00 00 00 00 00 00 00 00 00 00 恇..衊..........
00002C30 00 00 00 00 BC 62 00 00 DC 60 00 00 00 00 00 00 ....糱..躟......
00002C40 00 00 00 00 00 00 00 00 FC 62 00 00 EC 60 00 00 ........黚..靈..
00002C50 00 00 00 00 00 00 00 00 00 00 00 00 48 63 00 00 ............Hc..
00002C60 00 61 00 00 00 00 00 00 00 00 00 00 00 00 00 00 .a..............
00002C70 00 00 00 00 00 00 00 00
地址二指向的地址表
00002CD0 9C 62 00 00 AE 62 00 00 00 00 00 00 CA 62 00 00 渂..産......蔮..
00002CE0 DE 62 00 00 EE 62 00 00 00 00 00 00 0A 63 00 00 辀..頱.......c..
00002CF0 18 63 00 00 26 63 00 00 34 63 00 00 00 00 00 00 .c..&c..4c......
00002D00 54 63 00 00 00 00 00 00 Tc......
再看看ollice中相应的地方,明白了吗?
多打开几个文件看看就会加深映象。
七 资源数据
其实有些程序除了程序图标并不使用其他的标准资源数据,标准的资源数据在链接到PE文件中前是一个单独的资源文件,通常以.res为后缀.
资源数据的虚拟地址和大小由数据索引表第三项指定.
标准的资源数据是一个树状结构,共分5层:
一层
_______|_______________
| | |
二层
_____________|__________________
| | | |
三层
_____________|__________________
| | | |
四层
_____________|__________________
| | | |
五层
一、二、三层是相似的结构,四层是资源数据的描叙,五层是具体的资源数据(如光标、位图).
一、二、三层都由一个或几个资源干组成.当然一层只能有一个资源干(是这个资源树的主干嘛).一个资源干由16字节的项目干和数目不等的项目组成,项目干决定项目的数目.另外,标准资源中的字符都是unicode字符.
资源干=项目干+项目1+……+项目n.
项目干结构如下:
4字节 | 特征 |
4字节 | 时间 |
4字节 | 版本 |
2字节 | 已命名项目数.使用名称标识资源的项目数目 |
2字节 | ID项目数.使用ID(数字编号)标识资源的项目数目 |
项目的结构如下:
| 占用字节 | 最高位 | 含义 |
第1个32位数据 | 4 | 1 | 剩下31位是资源名称的偏移量 |
0 | ID | ||
第2个32位数据 | 4 | 1 | 剩下31位是下层某资源干的偏移量 |
0 | 四层某资源描叙的偏移量 |
下面的内容很重要:
项目中的偏移量指相对于资源数据起始位置(一层的开头)的偏移.
项目中的ID在一层指资源类型,二层指具体资源的ID号,在三层指语言ID(如04 09是美国英语).
一层中资源类型ID如下:
1: 光标 2: 位图3: 图标 4: 菜单5: 对话框6: 字串表 7: 字体目录8: 字体 9: 快捷键
10: 未格式化资源数据11: 信息表12: 组光标 14: 组图标 16: 版本信息
ID为10时可以用来导入任何文件
程序可以利用二层的ID或资源名称来调用相应的资源。如例程中st.exe用函数调用了ID号为$65的对话框资源.(在$00401052调用dialogboxparama时)
三层的项目给出了四层某资源描叙的偏移量.资源描叙的结构如下:
占用字节 | 含义 |
4 | 具体资源的虚拟地址(当然是相对映象文件基址的偏移量) |
4 | 具体资源的大小 |
4 | 代码页(不知道有什么用) |
4 | 未用. |
具体资源就是五层了.具体资源的格式就不在本文讨论范围内了.太阳的,俺连对话框格式还没有完全弄明白呢.本来还想说说数据索引表指定的其他几种数据(如重定位,TLS),可实在找不到有价值的资料,遗憾.
还是以st.exe为例:
$C8处指定资源数据开始于$4000,$1B0处.rsrc节也开始于$4000,所以这一节就是资源.
来到其物理地址$600处,读资源干,原来有两个项目一个是对话框(5),一个是版本($10).下面我用颜色标识对话框这条分支.
00000600 00 00 00 00 00 00 00 00 00 00 00 00 00 00 02 00 ................
00000610 05 00 00 00 20 00 00 80 10 00 00 00 38 00 00 80 .... ..€....8..€
00000620 00 00 00 00 00 00 00 00 00 00 00 00 00 00 01 00 ................
00000630 65 00 00 00 50 00 00 80 00 00 00 00 00 00 00 00 e...P..€........
00000640 00 00 00 00 00 00 01 00 01 00 00 00 68 00 00 80 ............h..€
00000650 00 00 00 00 00 00 00 00 00 00 00 00 00 00 01 00 ................
00000660 09 04 00 00 80 00 00 00 00 00 00 00 00 00 00 00 ....€...........
00000670 00 00 00 00 00 00 01 00 04 08 00 00 90 00 00 00 ............?..
00000680 A0 40 00 00 82 00 00 00 00 00 00 00 00 00 00 00 燖..?..........
00000690 28 41 00 00 38 01 00 00 00 00 00 00 00 00 00 00 (A..8...........
上面红色标出ID($65就是上面提到的资源ID号),紫色标出偏移量(注意($80就是最高位为1 ),蓝色标出具体资源的虚拟地址,换成物理地址就是$6A0.有兴趣到$6A0处研究下对话框格式.
八 组装PE文件
学习到这里就告一段落了,这章我们一起来组装一个PE文件.不知道为什么1.9版译文中组装的控制台程序在我的xp下总报错.重新弄个例子吧.
用winhex新建一个文件,大小位$600字节,命名为’测试1’,开工:
偏移 | 写入值 |
0 | 4D 5A//’MZ’ |
$3C | 40 00//PE头偏移 |
$40 | 50 45//’PE’ |
$44 | 4C 01//cpu |
$46 | 02 00//节数目 |
$54 | E0 00//选项头大小 |
$56 | 0F 01//PE属性 |
$58 | 0B 01//标志 |
$68 | 00 10 00 00//入口 |
$74 | 00 00 04 00//映象文件基址 |
$78 | 00 10//节对齐 |
$7C | 00 02//文件对齐 |
$80 | 04 00 |
$84 | 04 00 |
$88 | 04 00//版本号 |
$90 | 00 30//映象文件大小(两个节加一文件头,各占一个节对齐) |
$94 | 00 02//文件头大小 |
$9C | 02 00//NT子系统 |
$A4 | 00 00 10 00//栈 |
$A8 | 00 10 |
$AC | 00 00 10 00//堆 |
$B0 | 00 10 |
$B4 | 10 00//数据索引数 |
数据索引表只有输入表索引(这里把输入表放到第二节) | |
$C0 | 00 20 00 00//输入表虚拟地址 |
$C4 | 28 00 00 00//输入表大小 |
下面定义第一个节,包含代码和两条字符串 | |
$138 | 63 6F 64 65//节名 |
$144 | 00 10 00 00//虚拟地址 |
$148 | 00 02 00 00//节大小 |
$14C | 00 02//物理地址 |
$15C | 20 00 00 60//节属性, |
下面定义第二个节,是输入表 | |
$160 | 64 61 74 61//节名 |
$16C | 00 20 00 00//虚拟地址 |
$170 | 00 02//大小 |
$174 | 00 04//物理地址 |
$184 | 40 00 00 C0//属性 |
文件头写完了.先完成第二个节的具体数据:在$460处
00000460 75 73 65 72 33 32 2E 64 6C 6C 00 00 00 00 4D 65 user32.dll....Me
00000470 73 73 61 67 65 42 6F 78 41 00 ssageBoxA
只输入一个函数,索引号没有写.记下库名和函数名的地址,换成虚拟地址分别是$2060和$206C.现在可以填函数地址索引表了,只有一个,在$430处写入6C 20($206C).然后填输入表了,也只有一条输入说明结构:在$400处写地址一30 20($2030->$430),$40C处写库文件名60 20($2060),$410处写地址二30 20(呵呵跟地址一一样哦).载入后messageboxa的地址就会写到地址二,我们在代码中就可以调用这个函数了.
下面完成第一个节,先写两条字符:
00000220 B3 AC D0 A1 B3 CC D0 F2 00 00 00 00 00 00 00 00 超小程序........
00000230 50 45 CE C4 BC FE D1 A7 CF B0 B3 C9 B9 FB 00 00 PE文件学习成果..
记下它们的虚拟地址$1020和$1030.
在$200处开始写代码:
55 push ebp
8BEC mov ebp, esp
6A 00 push 0
68 20104000 push 00401020
68 30104000 push 00401030
6A 00 push 0
FF15 30204000 call dword ptr [$00402030] ; user32.MessageBoxA
61 popad
保存收工.运行看看.不对头?对照附件中的测试1.exe看看哪里写错了.
有兴趣看看测试2.exe,代码和输入表写到一个节里,节属性要改,用的是跳转方式.
完了,请斧正!
参考资料: “PE文件格式”1.9版 完整译文(附注释)
感谢俺老婆打了一半的文字