PE32格式学习之二——深入认识PE32基本结构--构造“最小”PE32可执行文件
1256710576241886 发表于 江苏 技术文章 1659浏览 · 2024-05-26 05:50

认识一个标准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 3
1
本项目规划3个节:.code、.data、.idata
精简成一个节:.mixed

因为我们三合一,所以原来的3个Sections,需要改成1个。其他不变。另外我们把这个唯一的节命名成 .mixed。(这个改动在后面的Section Table结构里面)

(2)PE Optional Header

FOA RVA Size Field Value Description
0xA8 0xA8 4 AddressOfEntryPoint 0x1000 程序的入口点RVA。这里设置成 .code .mixed节在内存中的起始RVA
由于我们没有改变Code的位置,所以这个值不变。读者的布局如果改变了Code位置,这个值需要指向新的Code位置。
0xB0 0xB0 4 BaseOfData 0x2000
0x1000
.data .mixed节在内存Image中的起始地址RVA
0xD0 0xD0 4 SizeOfImage 0x4000
0x2000
程序的内存Image大小,Headers+3Sections,每个对齐到内存页边界后就是4*0x1000
Headers + 1 Sections,对齐到内存页边界就是 2*0x1000
0x100 0x100 4 Import Table RVA 0x3000
0x1120
导入表的结构在 .idata 节的起始处
我们把导入表安排到 .mixed 节的 0x260 处。
导入表RVA = 0x1000 + 导入表FOA - 0x200

文件/Image大小变了,导入表的位置变了,所以更新相应字段的值。

(3)Section Table (Section Headers)

FOA RVA Size Field Value Description
0x178 0x178 8 Name ".code"
".mixed"
字符串".code"
字符串".mixed"
0x19C 0x19C 4 Characteristics 0x60000020
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 TableSection 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 16
2
data-diretory 结构的数组,每一项都代表一个重要的数据结构的RVA和Size。
后面用不到的14个结构可以省略掉。

按照表格中删除线标识的字段值,修改成新的值。后面用不到的14个Entry,本来的值就是0,不用修改。千万不能删除,否则整个文件的字节数就不对了,所有的对齐都会出错。

我们所说的压缩用不到的空间,或者说将用不到的空间从文件头部分释放出来,是通过下面这个字段来实现的。

2、修改 PE File Header

FOA RVA Size Field Value Description
0x94 0x94 2 SizeOfOptionalHeader 0xE0
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 0x1000
0x130
程序的入口点RVA。Code 块我们布置到了 0x130处。
0xAC 0xAC 4 BaseOfCode 0x1000
0x00
.code 节在内存Image中的起始地址RVA
PE Headers 和 .mixed 节共用同一块内存,设置基址为 File / Image 的起始位置 0x00
0xB0 0xB0 4 BaseOfData 0x1000
0x00
.data 节在内存Image中的起始地址RVA
PE Headers 和 .mixed 节共用同一块内存,设置基址为 File / Image 的起始位置 0x00
0xB4 0xB4 4 ImageBase 0x400000 文件加载到进程虚拟内存地址空间的起始位置
0xB8 0xB8 4 SectionAlignment 0x1000
0x10
每个节在内存Image中需要对齐到内存页大小的边界上
对齐的边界不能是 0x00,根据我们将各个数据块的布置位置,将其对齐到 0x10边界上。
0xBC 0xBC 4 FileAlignment 0x200
0x10
每个节在磁盘文件中需要对其到512字节的边界上
对齐的边界不能是 0x00,根据我们将各个数据块的布置位置,将其对齐到 0x10边界上。
0xD0 0xD0 4 SizeOfImage 0x2000
0x1000
程序的内存Image大小,文件大小再次减半。也可以设置成 0x400,文件的真实大小。
0xD4 0xD4 4 SizeOfHeaders 0x200
0x130
不足512字节,对齐到512边界
从布局图中可以看出,所有 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 0x1000
0xD0
从一开始的布局图可以看出,.mixed 节大小 = 0x200 - 0x130 = 0xD0
0x114 0x114 4 VirtualAddress 0x1000
0x130
三块数据从 0x130 开始
0x118 0x118 4 SizeOfRawData 0x200
0xD0
FOA = RVA
0x11C 0x11C 4 PointerToRawData 0x200
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,尽可能把三段数据排列的更加紧凑。

读者可以自行尝试一下,如果有兴趣,可以留言。

2 条评论
某人
表情
可输入 255