认识一个标准PE32可执行文件的基本结构,是我们学习二进制安全的第一步。在不增加新的知识和更多PE32格式的基础上,让我们换一个视角,来看看一个hacker是怎么认识这些数据结构的。个人认为hacker的最重要精神其实就是反对教条,对按部就班的教科书式的东西,喜欢折腾。
本文是在《PE32格式学习之手动创建一个简单的Windows程序》文章的基础上,通过打造更小的PE可执行文件,帮助读者深入理解PE32文件基本结构的本质。(实际上是windows不同版本下Loader
的实现特点)。
导读提示:
- “最小”PE32可执行文件,只适配Win10系统,其他windows版本不一定能运行。因为不同版本windows的Loader对PE32格式的要求存在差异。
- 本文分三部分,引导读者循序渐进的逐步缩小PE32可执行文件的体积,最终打造一个“最小”的PE可执行文件。有兴趣的读者请在《PE32格式学习之手动创建一个简单的Windows程序》中实现的PE32可执行文件的基础上进行操作。
- 本文表格结构和前文一致,但删除了不变的部分,有修改的地方用“删除线”标识,并给出修改后的值和说明。重点概念的深入理解部分会用文字详细描述。
第一步:深入认识 Section 的本质,Setion 三合一。
前文中我们手动构建了一个标准的PE32可执行文件,包含 PE Headers、.code 节、.data 节、.idata 节四部分。每个部分在PE文件中占 0x200 个字节。实际上我们通过构建过程可以发现,除了 PE Headers 部分,其他几个部分的有效数据非常少。
Section Name | Valid Bytes |
---|---|
.code | 0x15 = 21 |
.data | 0x42 = 66 |
.idata | 0x4A = 74 |
total | 0xA1 = 161 |
一共才 0xA1 = 161 个字节。远小于我们PE32文件中一个Section 512 个字节的大小。如果我们把三个节的有效内容都放到一个节里,不是可以让整个PE文件的大小减少一半吗!可PE32文件我们看到的都是由 .code、.data、.idata 这些节组成的,可以把不同的数据放到一起吗?
还真可以!让我们来破除这些将Section分门别类的“执念”,深入理解Section
的本质:
我们常见的教科书里讲的”.code、.data、.idata“等的约定,实际上是上层语言、编译器等的约定俗成的分类。在安全角度来说,不同的”格子“分类,也可以适用不同的安全策略。比如Section Table结构里的 Characteristics 字段。而我们这是手动编辑一个二进制文件,站在编译器、编程语言的更下层,完全可以抛弃这些“约定俗成”。
在 PE32 文件的基本结构设计和 Windows Loader 的角度考察,Section 仅仅相当于一个容器。提供存放用户“数据”(代码、资源、各种结构数据等)的一个空间。多个Section就提供了多个这样的空间。如同家里的衣柜,分了很多格子。至于这些格子放什么,基本上是由用户决定的。重要的Loader需要的数据实际上都在头文件里面设置了“指针”字段,并不是依靠Section的分类来获取的。比如 PE Optional Header 的 AddressOfEntryPoint
字段,指向“用户代码”(高级语言编译器会加入前置代码,在这个场景下,也属于“用户代码”)的入口点。比如 data-diretory
结构的数组,每一项都指向 Loader 需要的重要数据。
根据上面的分析,我们就知道,既然手动编译一个PE32可执行文件,我们完全可以把3个Section的数据都放到1个Section里面,只要能放得下。这样可以将我们的PE32文件大小减小一半。来让我们动手干吧。
1、重新布局内容
- 创建一个 0x400 大小的全零的二进制文件 min_PE1.0.exe
- 拷贝前文创建PE32可执行文件的前 0x400 个字节。(File Headers + .code Section)
- 将 .data 和 .idata 节的数据拷贝到 .code 节中
我的布局是将 .idata 的导入表数据放到了 0x320 处,.data 的字符串数据放到了 0x260 处。(图中红色部分)
读者也可以自行安排,甚至改变 code 的位置。
2、修改 PE Headers 相关偏移量以适应新的布局
(1)PE File Header
FOA | RVA | Size | Field | Value | Description |
---|---|---|---|---|---|
0x86 | 0x86 | 2 | NumberOfSections |
1 |
精简成一个节:.mixed |
因为我们三合一,所以原来的3个Sections,需要改成1个。其他不变。另外我们把这个唯一的节命名成 .mixed。(这个改动在后面的Section Table结构里面)
(2)PE Optional Header
FOA | RVA | Size | Field | Value | Description |
---|---|---|---|---|---|
0xA8 | 0xA8 | 4 | AddressOfEntryPoint | 0x1000 | 程序的入口点RVA。这里设置成 由于我们没有改变Code的位置,所以这个值不变。读者的布局如果改变了Code位置,这个值需要指向新的Code位置。 |
0xB0 | 0xB0 | 4 | BaseOfData |
0x1000 |
|
0xD0 | 0xD0 | 4 | SizeOfImage |
0x2000 |
程序的内存Image大小, Headers + 1 Sections,对齐到内存页边界就是 2*0x1000 |
0x100 | 0x100 | 4 | Import Table RVA |
0x1120 |
我们把导入表安排到 .mixed 节的 0x260 处。 导入表RVA = 0x1000 + 导入表FOA - 0x200 |
文件/Image大小变了,导入表的位置变了,所以更新相应字段的值。
(3)Section Table (Section Headers)
FOA | RVA | Size | Field | Value | Description |
---|---|---|---|---|---|
0x178 | 0x178 | 8 | Name |
".mixed" |
字符串".mixed" |
0x19C | 0x19C | 4 | Characteristics |
0xE0000060 |
IMAGE_SCN_CNT_CODE 该节包含执行代码 IMAGE_SCN_CNT_INITIALIZED_DATA 该节包含初始化数据 IMAGE_SCN_MEM_EXECUTE 该节可以被执行 IMAGE_SCN_MEM_READ 该节可以被访问 IMAGE_SCN_MEM_WRITE 该节可以被写入 |
Characteristics的值是下列5个值“或”运算组合起来的。代表节的内存访问控制。
#define IMAGE_SCN_CNT_CODE 0x00000020 // Section contains code.
#define IMAGE_SCN_CNT_INITIALIZED_DATA 0x00000040 // Section contains initialized data.
#define IMAGE_SCN_MEM_EXECUTE 0x20000000 // Section is executable.
#define IMAGE_SCN_MEM_READ 0x40000000 // Section is readable.
#define IMAGE_SCN_MEM_WRITE 0x80000000 // Section is writeable.
Section Table
是Section Header
结构的数组,每个数组元素代表一个Section
。由于只有1个 .mixed 节,所以清零(注意不是删除)后面两个Section Header
结构。一个Section Header
结构占40个字节,所以将 0x1A0 后面的 80 个字节(.data 和 .idata 节的 Section Header )清零。
至此min_PE1.0.exe的所有头部数据结构已经修改完毕。看起来是这个样子的:
注意红色部分就是变动的部分。
3、修改导入表相应的偏移量
由于导入表的位置整体从 FOA 0x600 迁移到了 FOA 0x320,所以表的FOA、RVA都发生了变化。大家需要在新的地址上进行编辑修改。
字段地址转换公式:
FOA = 原FOA - 0x600 + 0x320
RVA = 0x1000 + FOA - 0x200 (因为只有一个.mixed节在文件中在0x200处,在image中映射到RVA 0x1000处)
FOA | RVA | Size | Field | Value | Description |
---|---|---|---|---|---|
0x320 | 0x1120 | 4 | Import Lookup Table RVA | 0x1148 | 导入查询表位置偏移量 |
0x324 | 0x1124 | 4 | Time/Date Stamp | 00 | 时间戳,置0 |
0x328 | 0x1128 | 4 | Forwarder Chain | 00 | 用不到,置0 |
0x32C | 0x112C | 4 | DLL Name RVA | 0x1150 | DLL名字符串存放位置偏移量 |
0x330 | 0x1130 | 4 | Import Address Table RVA (Thunk Table) | 0x1148 | 导入地址表的位置偏移量 |
0x334 | 0x1134 | 20 | Null Directory Entry | 00 | 表示导入的DLL结束 |
0x348 | 0x1148 | 4 | Import Lookup Table / Import Address Table | 0x115B | Import Lookup Entries |
0x34C | 0x114C | 4 | Null Import Lookup Entry | 00 | 表示Import Lookup Table结束 |
0x350 | 0x1150 | 11 | DLL Name | "user32.dll\0" | DLL名字符串 |
0x35B | 0x115B | 2 | Hint | 00 | 用不上,置0 |
0x35D | 0x115D | 12 | Function Name | “MessageBoxA\0" | 函数名字符串 |
0x369 | 0x1169 | 1 | Pad | 00 | 下一个Entry需要对齐到偶数地址 |
各个字段value值的计算容易让人迷糊。有两个方法:
- 深刻理解各个字段的指向,参考上一篇文章的图示,直接从RVA列读取。
- 观察各个字段在整个导入表结构中的相对偏移量,然后加上导入表的在image中的基准地址。
如果读者是按照我上面说的整体复制,那么导入表中,实际上只有 4 个非0的偏移量需要修改(下图中红色部分)。其他的字符串和全是00的地方不变。
4、修改 Code 中的指令访问地址
原来 .data 节存放的对话框显示的字符串,将其部署到 FOA 的 0x260 位置处,调整后字符串布局如下表:
转换公示:RVA = 0x1000 + FOA - 0x200
FOA | RVA | Value | Description |
---|---|---|---|
0x260 | 0x1060 | "Hello, snake!\0" | 对话框标题 |
0x26E | 0x106E | “This is an example that created a PE file manually." | 消息内容 |
显示对话框的代码需要用到字符串和iat的地址,根据前面的修改,我们可以调整 code 如下:
push 0x40
push 0x401060 ; 对话框标题字符串地址
push 0x40106E ; 对话框内容字符串地址
push 0
call DWORD ptr ds:0x401148 ; IAT user32.dll->MessageBoxA 函数的地址
ret
使用 Online x86 / x64 Assembler and Disassembler 这个网站在线将汇编代码翻译成二进制。
{ 0x6A, 0x40, 0x68, 0x60, 0x10, 0x40, 0x00, 0x68, 0x6E, 0x10, 0x40, 0x00, 0x6A, 0x00, 0xFF, 0x15, 0x48, 0x11, 0x40, 0x00, 0xC3 }
对比前文生成的代码,实际上熟悉汇编二进制的可以发现,0x68 对应 push
指令,后面四个字节就是 little-ending 格式的地址。0xFF,0x15 是Call
指令,后面跟四字节 little-ending 格式的地址,这个地址存放着实际要跳转到的函数的地址。所以有一定经验的,可以直接修改 Code 中的三处地址就可以了。结果如下图,红色部分就是修改的地址:
保存所有修改后,运行 min_PE1.exe。熟悉的对话框出现了。
说明事项:
由于显示对话框的代码没有退出进程的调用,一旦关闭对话框,程序实际上不会退出。这会导致再次编辑这个二进制文件时,编辑器显示为“只读”状态。需要在任务管理器中,关闭进程后才能继续编辑。
本文只列出了需要修改的相关字段,读者如果想要深入理解 PE32 基本格式中字段的含义,最好还是对照前一篇文章中完整的字段结构,仔细分析一下,有些字段为什么不需要修改。特别是各种 size 和 base、align 相关的字段。
第二步:认识data-diretory 结构数组,压缩用不到的字段,进一步释放空间。
我们已经把PE.exe的大小从 2k 缩小到了 1K,缩小了一半。还能不能再缩小一半,从 1k 缩小到 512 个字节呢?
前文介绍的3个Section的PE32文件,文件头就占用了差不多512字节的空间。要把所有内容都放到头部的 512个字节中,就需要进一步压缩 PE32 文件头占用空间的大小。
第一步中,我们把3个 Section 压缩到1个 Section 后,释放了2*40=80
个字节的 Section Headers 空间。还有什么地方可以压缩?
我们把目光放到了了data-diretory
结构的数组上面。data-diretory
一共16个Entry,每一个Entry的含义都是固定的。而本项目中只用到了导入表。导入表结构排在数组的第二位,第一位是导出表。虽然用不到,但位置还是要保留的。所以似乎只需要保留前面两个就可以了。后面 14 个结构可以节省出来 14 * 8 = 112 个字节。
下面的操作都是在第一步编辑成功的 min_PE1.0.exe 的基础上进行。读者可以将其拷贝一个 min_PE1.1.exe 在这个文件上进行编辑。
1、修改 PE Optional Header
FOA | RVA | Size | Field | Value | Description |
---|---|---|---|---|---|
0xF4 | 0xF4 | 4 | NumberOfRvaAndSizes |
2 |
data-diretory 结构的数组,每一项都代表一个重要的数据结构的RVA和Size。 后面用不到的14个结构可以省略掉。 |
按照表格中删除线标识的字段值,修改成新的值。后面用不到的14个Entry,本来的值就是0,不用修改。千万不能删除,否则整个文件的字节数就不对了,所有的对齐都会出错。
我们所说的压缩用不到的空间,或者说将用不到的空间从文件头部分释放出来,是通过下面这个字段来实现的。
2、修改 PE File Header
FOA | RVA | Size | Field | Value | Description |
---|---|---|---|---|---|
0x94 | 0x94 | 2 | SizeOfOptionalHeader |
0x70 |
PE32 的 OptionalHeader 大小 少用14个ENTRY,0xE0 - 8*14 = 0xE0 - 0x70 = 0x70。正好一半 |
PE File Header 结构中的 SizeOfOptionalHeader 字段,指出了 PE Optional Header 结构的大小。PE Optional Header 结构就管到 data-diretory 数组的结束。 PE Optional Header 结构最后的 NumberOfRvaAndSizes 字段规定了 data-diretory 数组的大小,这导致 PE Optional Header 的大小也会随之变化。这种变化的结果用 PE File Header 的 SizeOfOptionalHeader 在记录跟踪。细心的读者可以根据上一篇文件介绍的头文件的基础知识,仔细体会一下。
3、将 Section Table 提前到紧接 PE Optional Header 新的结尾处
PE头规定的 Section Table 是要紧接着 PE Optional Header 结构的,所以我们需要将 Section Table 提前。(读者可以重温前一篇文章对PE头结构布局的介绍)
原 Section Table 就一个成员,在 0x178 处 40 个字节。将其整体拷贝到 0x178 - 0x70 = 0x108 处(因为 PE Optional Header 少了 0x70 个字节)。然后将原来 0x178处的 40 个字节清零。
做好这三步后的结果看起来是这样子的(红色是修改的,相对与白色):
保存后,运行一下试试,熟悉的对话框又出现了。
经过压缩后的PE头部看起来是下图这个样子的:
图中已选择区域,和两个红框标识出来的区域,都是PE Headers里面实际用不到的,可以为我们所用,存放我们需要的“东西”。我们的 code、data、idata 本身的有效字节数也才 161 个,完全可以嵌入进头部512个字节中。来让我们实现它。
第三步:深入认识PE32结构,构建“最小”可执行文件。
将所有东西都放到头部的 512 个字节中,这不仅仅涉及对 PE32 基本文件结构本质的理解,更重要是 Windows Loader 在处理这些结构和字段时的方式,不同版本也是不同的。所以这一步的构建是在 Win10 下编辑测试通过的,Win7下是不能运行的。其他windows版本没有测试。
Win10下导入表必须放在一个 Section 里面,不然就会认定 PE32 文件不合规。
构建过程实际上和第一步的操作一样,在上面第二步创建的 min_PE1.1.exe 的基础上进行。
最小构建是建立在 Section 和 PE Headers 在同一块内存区域的基础上的。需要深刻理解“对齐到边界”(align)的概念。
此时的 FOA = RVA
1、布局
- 创建一个新的 512 个字节的全零 min_PE2.0.exe 文件。
- 拷贝 min_PE1.1.exe 开头的 512 个字节到 min_PE2.0.exe。
- 将 min_PE1.1.exe 中的 Code块 、字符串数据块、导入表数据块,分别拷贝到 min_PE2.0.exe 的 0x130、0x150、0x1A0 处。
2、修改 PE Optional Header 相应字段
FOA | RVA | Size | Field | Value | Description |
---|---|---|---|---|---|
0x9C | 0x9C | 4 | SizeOfCode | 0x1000 | 实际上这个值不要比实际的 Code 块小就行。 |
0xA8 | 0xA8 | 4 | AddressOfEntryPoint |
0x130 |
程序的入口点RVA。Code 块我们布置到了 0x130处。 |
0xAC | 0xAC | 4 | BaseOfCode |
0x00 |
PE Headers 和 .mixed 节共用同一块内存,设置基址为 File / Image 的起始位置 0x00 |
0xB0 | 0xB0 | 4 | BaseOfData |
0x00 |
PE Headers 和 .mixed 节共用同一块内存,设置基址为 File / Image 的起始位置 0x00 |
0xB4 | 0xB4 | 4 | ImageBase | 0x400000 | 文件加载到进程虚拟内存地址空间的起始位置 |
0xB8 | 0xB8 | 4 | SectionAlignment |
0x10 |
对齐的边界不能是 0x00,根据我们将各个数据块的布置位置,将其对齐到 0x10边界上。 |
0xBC | 0xBC | 4 | FileAlignment |
0x10 |
对齐的边界不能是 0x00,根据我们将各个数据块的布置位置,将其对齐到 0x10边界上。 |
0xD0 | 0xD0 | 4 | SizeOfImage |
0x1000 |
程序的内存Image大小,文件大小再次减半。也可以设置成 0x400,文件的真实大小。 |
0xD4 | 0xD4 | 4 | SizeOfHeaders |
0x130 |
从布局图中可以看出,所有 Headers 到 0x12F 为止,共 0x130 个字节。 实际上 win10 loader 只要不超过真实 Headers 字节数就行,设置 0 都行。但超过 1 个字节都不行。 |
0x100 | 0x100 | 4 | Import Table RVA | 0x3000 0x1A0 |
导入表数据块我们布置到了 0x1A0 处。 |
SectionAlignment、FileAlignment 两个字段规定 Section 在 Image、File中的对齐边界。计算 Section 是否对齐在某个边界上是用
Section Address / Section Alignment 是否整除来判断的,分母不能为 0。所以根据 Section 的布置地址,取一个非 0 值就可以。
在 hacker 眼中,什么对齐到内存页,对齐到扇区这种性能上的考虑,统统都是浮云,不是必须的。
修改完后文件头看起来是这样的:
3、修改 Section Table 相应字段
由于我们重新布局了 .mixed 节的位置,需要在 SectionTable 对应的 Section Header 中指定其新的位置和大小。
FOA | RVA | Size | Field | Value | Description |
---|---|---|---|---|---|
0x108 | 0x108 | 8 | Name | “.mixed” | 字符串”.mixed” |
0x110 | 0x110 | 4 | VirtualSize |
0xD0 |
从一开始的布局图可以看出,.mixed 节大小 = 0x200 - 0x130 = 0xD0 |
0x114 | 0x114 | 4 | VirtualAddress |
0x130 |
三块数据从 0x130 开始 |
0x118 | 0x118 | 4 | SizeOfRawData |
0xD0 |
FOA = RVA |
0x11C | 0x11C | 4 | PointerToRawData |
0x130 |
FOA = RVA |
此时的文件头看起来是这个样子的:
4、修改导入表相应字段
导入表部署到 0x1A0 处:
FOA | RVA | Size | Field | Value | Description |
---|---|---|---|---|---|
0x1A0 | 0x1A0 | 4 | Import Lookup Table RVA | 0x1C8 | 导入查询表位置偏移量 |
0x1AC | 0x1AC | 4 | DLL Name RVA | 0x1D0 | DLL名字符串存放位置偏移量 |
0x1B0 | 0x1B0 | 4 | Import Address Table RVA (Thunk Table) | 0x1C8 | 导入地址表的位置偏移量 |
0x1C8 | 0x1C8 | 4 | Import Lookup Table / Import Address Table | 0x1DB | Import Lookup Entries |
此时导入表数据块是这个样子的:
iat表结构有些复杂,如果脑子里没有清晰的概念,非常容易搞混乱。起始修改这4个地址也很简单,看下图:
这两张图箭头的出发字段填入箭头指向位置的地址(偏移量)就可以了。读者可以参考上一篇文章详细介绍IAT结构的图,仔细理解一下。
5、修改Code中各个地址值
对话框显示的字符串,部署在 0x150 处:
FOA | RVA | Value | Description |
---|---|---|---|
0x150 | 0x150 | "Hello, snake!\0" | 对话框标题 |
0x15E | 0x15E | “This is an example that created a PE file manually." | 消息内容 |
push 0x40
push 0x400150
push 0x40015E
push 0
call DWORD ptr ds:0x4001C8
ret
操作了许多遍,我们也许比较熟悉这段代码对应的二进制了。实际上按照上面的步骤操作到这里,只需要改变6个字节:
{ 0x6A, 0x40, 0x68,0x50, 0x01, 0x40, 0x00, 0x68, 0x5E, 0x01, 0x40, 0x00, 0x6A, 0x00, 0xFF, 0x15, 0xC8, 0x01, 0x40, 0x00, 0xC3 }
至此所有修改完成,保存,运行!熟悉的对话框又出现了。
最终的文件是这样的:
总结
本文的内容从 hacker 角度理解PE32文件基本结构,各个字段的内容都是第一篇文章中介绍过的,并没有增加什么新的东西。关键字段的修改,通过反复练习,可以加深理解。思维不要被教科书式的标准答案给限定住了。
虽然标题用了“最小PE”,实际上,经过两次减半,这个PE32文件已经缩小到原来的一个头文件的大小。但肉眼可见的,头部还是有一些空间的,比如 DOS Header,以及 DOS Header 到 PE Header 之间的空隙等等。仍然存在进一步缩小文件体积的空间。
简单介绍一下进一步缩小文件体积的思路:
- PE Header overlapped DOS Header
- 调整 IAT 的结构,把标识 Directory Table 结束的全0字段,留到文件最后,和文件最后补齐的00字节重叠。
- 调整 Section Alignment,尽可能把三段数据排列的更加紧凑。
读者可以自行尝试一下,如果有兴趣,可以留言。
-
-
-
-