Post

手搓PE32可执行文件

本项目通过手动创建一个简单的exe文件,介绍PE32可执行文件的基本结构。作为入门级别的教程,本文尽可能把需要的前置知识将到最低。其他必要的知识在构建文件的时候讲解。

一、项目目标:手动创建简单的PE.exe程序。

功能:弹出一个如下图的对话框(图)

messagebox

二、基础知识

在开始正式创建PE文件之前,默认操作者掌握以下概念(不懂的,可以自行学习,不难):

  • 懂得二进制、十进制、十六进制的表示、计算和转换
  • 理解计算机中 bit 和 byte 的概念、组织方式和表示。
  • 理解BYTE、WORD、DWORD的概念。
  • 掌握 little ending 规范。
  • 了解 ASCII 编码和 C 字符串规则。
  • 学习过C语言,知道struct结构的尤佳,但不强求。
  • 会使用至少一种二进制编辑工具,推荐 ImHex,易上手开源无费用。

基础知识 1:进程虚拟地址空间

Widnows中运行一个程序,系统会首先为这个程序创建一个“进程”。进程是程序运行的容器,包含程序运行所需要的各种资源。
我们知道程序只有加载到内存才能运行,磁盘上的exe程序加载到内存哪里呢?
与我们想象的直接加载到计算机的物理内存不同,操作系统通过“进程”容器给程序提供了一个叫做“虚拟内存地址空间”的东西。磁盘上的exe文件是加载到这个“虚拟内存空间”中的。现代操作系统这么设计的主要目的就是屏蔽操作物理内存的底层细节,让程序员开发的程序面对一个一致的,完整的运行空间。
以手动创建的PE.exe为例,其运行时的虚拟内存空间环境大致如下图:

由于栈是基于线程的,为了让本项目涉及到的知识最简单,这里不做介绍。

基础知识 2:内存页和物理内存映射

现代操作系统都是多任务操作系统,可以同时运行很多进程。根据上面介绍的,每一个32位进程都有 4G 的虚拟内存空间,显然我们的计算机物理内存是不够的。通过观察上面的PE.exe虚拟内存分布,可以发现绝大多数虚拟内存是用不上的。而系统DLL其实每一个进程都要用到。所以操作系统巧妙的将虚拟内存“裁减”成一块一块同样大小的内存片,同时把物理内存也“裁减”成同样大小的内存片。只有需要使用的虚拟内存片,操作系统才会将其映射到实际的物理内存片上。这种机制可以轻松实现内存共享,比如系统DLL的物理内存片,可以映射到所有进程的虚拟内存空间中。如下图所示:

memory_map.drawio

“裁减”的内存片的大小很有讲究。裁小了,映射的开销就会增大,最终会得不偿失;裁大了,又会造成许多浪费。现代操作系统根据计算机一般配备的物理内存大小,普遍选用 4KB(4096) 字节大小的内存分片。

基础知识 3:PE32可执行文件基本结构

一个典型的exe可执行程序一般包含:

  • 文件头:PE32文件头是多个结构的组合
  • .code 节:包含用户代码编译后的二进制指令流
  • .data 节:包含代码运行前需要预初始化的数据,例如全局变量等
  • .idata 节:一般包含程序运行需要用到的系统函数等的导入表。

可以参考“基础知识 4”里面的图。本项目按照这种结构手动构建PE.exe。

基础知识 4:从磁盘 File 到内存 Image

将可执行程序从磁盘文件被加载到内存的程序,一般称为Loader。这个过程中Loader会做许多工作,十分复杂。就理解这个项目来说,需要了解以下两项:

PE.exe在磁盘上的时候我们称为可执行文件,但被加载到内存时,我们将其称为Image。

1、程序并不是原封不动拷贝的,而是有个“拉伸”过程。

file2image.drawio

图中可以看出section在文件中和加载到虚拟内存中,起始地址对齐的位置不同。为什么是512和4096呢?因为传统磁盘一个扇区是512个字节,而前面讲过内存页的大小是 4KB,也就是4096个字节。显然磁盘文件保存形式更紧凑,节约存储空间。而内存模式,因为是以内存页的形式将虚拟内存映射到物理内存,用不到的虚拟内存不映射到物理内存,所以也不存在“浪费”一说。

由于这种加载时候的“拉伸”过程存在,程序中变量/字段的相对位置(相对于文件/内存Image起始位置)会不一样。所以产生FOA和RVA的概念。

  • FOA:File Offset Address,字段在文件中,相对文件起始位置的偏移量。我们手动创建PE.exe时就需要使用这个偏移量来定位需要编辑的字段。
  • RVA:Relative Virtual Address,字段在内存Image中,相对Image起始位置的偏移量。编辑字段值时需要特别注意。

2、根据导入表加载相应的DLL,并将实际的函数虚拟地址更新到导入表的地址表中。

导入表是一个新概念,这里只简单提一下,不理解没关系。后面讲到 .idata节的时候,会详细介绍这一部分。这是理解windows可执行文件格式中很重要的内容,也是本项目知识点中理解起来最困难的部分。不过不要害怕,只要认真学习这个简化到及至的例子,其实也就那么回事。

三、创建PE32可执行文件框架

按照“基础知识 3”介绍的典型PE32可执行文件基本结构,准备创建的PE.exe文件,分为4个部分:文件头、.code 节、.data 节、.idata 节。

由于PE.exe功能简单,本项目设定每个部分大小都是512字节。

使用二进制编辑工具(如imHex),创建一个512*4=2048个字节,值全为0的二进制文件,保存为PE.exe

rawbinfile

四、编辑PE.exe文件头

PE32文件头十分复杂,本文尽可能只介绍必须用到的部分,尽量降低复杂度。相信只要有点耐心,看着复杂的文件头,其实际要编辑的地方并不是很多。

PE32文件头大致可以分为如图的几个部分:DOS Header、PE File Header、PE Optional Header、Section Table(Section Headers Array)

PE_Headers.drawio

下面我们来逐一编辑创建这些文件头。

1、DOS Header

FOARVASizeFieldValueDescription
002e_magic0x5A4DDOS头标志 ‘MZ’
2258e_xxx00DOS头字段,可以忽略
0x3C0x3C4e_lfanew0x80指向PE Header头部的偏移量
0x400x400x40DOS Stub00DOS 模式下的一段程序,可以忽略

2、PE File Header

FOARVASizeFieldValueDescription
0x800x804PE Signature0x4550PE头标志 ‘PE\x00\x00’
0x840x842Machine0x014C表示这个文件需要运行在 Intel 386 or later processors and compatible processors。
0x860x862NumberOfSections3本项目规划3个节:.code、.data、.idata
0x880x884TimeDateStamp00文件创建时间,可以忽略
0x8C0x8C4PointerToSymbolTable00指向符号表的偏移量,本项目没有符号表,可以忽略
0x900x904NumberOfSymbols00没有符号,可以忽略
0x940x942SizeOfOptionalHeader0xE0PE32 的 OptionalHeader 大小
0x960x962Characteristics0x0102IMAGE_FILE_EXECUTABLE_IMAGE | IMAGE_FILE_32BIT_MACHINE
文件的属性:32位的可执行文件

3、PE Optional Header

FOARVASizeFieldValueDescription
0x980x982Magic0x010B标识该文件是 PE32 文件(相对于64位的PE32+:0x20B)
0x9A0x9A1MajorLinkerVersion00链接器主版本号,可以忽略
0x9B0x9B1MinorLinkerVersion00链接器副版本号,可以忽略
0x9C0x9C4SizeOfCode0x1000.code 节需对齐到内存页边界,内存页大小为0x1000。实际代码只有十几个字节。
0xA00xA04SizeOfInitializedData00已初始化的数据(节)大小,可以忽略
0xA40xA44SizeOfUninitializedData00未初始化的数据(节)大小,可以忽略
0xA80xA84AddressOfEntryPoint0x1000程序的入口点RVA。这里设置成 .code 节在内存中的起始RVA
0xAC0xAC4BaseOfCode0x1000.code 节在内存Image中的起始地址RVA
0xB00xB04BaseOfData0x2000.data 节在内存Image中的起始地址RVA
0xB40xB44ImageBase0x400000文件加载到进程虚拟内存地址空间的起始位置
0xB80xB84SectionAlignment0x1000每个节在内存Image中需要对齐到内存页大小的边界上
0xBC0xBC4FileAlignment0x200每个节在磁盘文件中需要对其到512字节的边界上
0xC00xC02MajorOperatingSystemVersion00程序运行需要的操作系统主版本号,可以忽略
0xC20xC22MinorOperatingSystemVersion00程序运行需要的操作系统副版本号,可以忽略
0xC40xC42MajorImageVersion00程序的主版本号,可以忽略
0xC60xC62MinorImageVersion00程序的副版本号,可以忽略
0xC80xC82MajorSubsystemVersion6程序要求的子系统主版本号
0xCA0xCA2MinorSubsystemVersion1程序要求的子系统副版本号。Win7的版本号是 6.1
0xCC0xCC4Win32VersionValue00保留,必须是00
0xD00xD04SizeOfImage0x4000程序的内存Image大小,Headers+3Sections,每个对齐到内存页边界后就是4*0x1000
0xD40xD44SizeOfHeaders0x200不足512字节,对齐到512边界
0xD80xD84CheckSum00校验值,可以忽略
0xDC0xDC2Subsystem2IMAGE_SUBSYSTEM_WINDOWS_GUI Windows图形界面子系统
0xDE0xDE2DllCharacteristics00这是一个exe程序,不是一个DLL
0xE00xE04SizeOfStackReserve00使用系统默认值
0xE40xE44SizeOfStackCommit00使用系统默认值
0xE80xE84SizeOfHeapReserve00使用系统默认值
0xEC0xEC4SizeOfHeapCommit00使用系统默认值
0xF00xF04LoaderFlags00保留,必须是00
0xF40xF44NumberOfRvaAndSizes16data-diretory 结构的数组,每一项都代表一个重要的数据结构的RVA和Size。
0xF80xF88Export Table00导出表
0x1000x1004Import Table RVA0x3000导入表的结构在 .idata 节的起始处
0x1040x1044Import Table Size20导入表结构大小
0x1080x1088Resource Table00本项目用不到,可以忽略
0x1100x1108Exception Table00本项目用不到,可以忽略
0x1180x1188Certificate Table00本项目用不到,可以忽略
0x1200x1208Base Relocation Table00本项目用不到,可以忽略
0x1280x1288Debug00本项目用不到,可以忽略
0x1300x1308Architecture00本项目用不到,可以忽略
0x1380x1388Global Ptr00本项目用不到,可以忽略
0x1400x1408TLS Table00本项目用不到,可以忽略
0x1480x1488Load Config Table00本项目用不到,可以忽略
0x1500x1508Bound Import00本项目用不到,可以忽略
0x1580x1588IAT00本项目用不到,可以忽略
0x1600x1608Delay Import Descriptor00本项目用不到,可以忽略
0x1680x1688CLR Runtime Header00本项目用不到,可以忽略
0x1700x1708Reserved, must be zero00本项目用不到,可以忽略

data-diretory 结构的数组一共16个Entry,每一个Entry的含义都是固定的。本项目中只用到了导入表。

4、Section Table (Section Headers)

Section Table紧接着上面的各种 Headers,本质上是一个Section Header结构的数组,有几个 Section 就有几个数组元素。每个Section Header结构描述该节的一些基本属性。

补充知识:为了加强安全性,操作系统在CPU的配合下,给内存页设置了三个权限:READ、WRITE、EXECUTE。存放代码的内存页一般赋予READ和EXECUTE权限,防止代码被篡改。存放数据的内存页一般赋予READ、WRITE权限,万一攻击者写入恶意代码,页不能执行。

这个概念方便理解 Section Header 里的 Characteristics 字段的含义。Sections 都是要随着 File to Image 被加载到进程虚拟内存中的。

FOARVASizeFieldValueDescription
0x1780x1788Name“.code”字符串”.code”
0x1800x1804VirtualSize0x1000该节在内存Image中的大小
0x1840x1844VirtualAddress0x1000该节在内存Image中的起始地址RVA
0x1880x1884SizeOfRawData0x200该节在磁盘文件中的大小
0x18C0x18C4PointerToRawData0x200该节在磁盘文件中的起始地址FOA
0x1900x1904PointerToRelocations00本项目用不到,可以忽略
0x1940x1944PointerToLinenumbers00本项目用不到,可以忽略
0x1980x1982NumberOfRelocations00本项目用不到,可以忽略
0x19A0x19A2NumberOfLinenumbers00本项目用不到,可以忽略
0x19C0x19C4Characteristics0x60000020IMAGE_SCN_CNT_CODE 该节包含执行代码
IMAGE_SCN_MEM_EXECUTE 该节可以被执行
IMAGE_SCN_MEM_READ 该节可以被访问
0x1A00x1A08Name“.data”字符串”.data”
0x1A80x1A84VirtualSize0x1000该节在内存Image中的大小
0x1AC0x1AC4VirtualAddress0x2000该节在内存Image中的起始地址RVA
0x1B00x1B04SizeOfRawData0x200该节在磁盘文件中的大小
0x1B40x1B44PointerToRawData0x400该节在磁盘文件中的起始地址FOA
0x1B80x1B84PointerToRelocations00本项目用不到,可以忽略
0x1BC0x1BC4PointerToLinenumbers00本项目用不到,可以忽略
0x1C00x1C02NumberOfRelocations00本项目用不到,可以忽略
0x1C20x1C22NumberOfLinenumbers00本项目用不到,可以忽略
0x1C40x1C44Characteristics0xC0000040IIMAGE_SCN_CNT_INITIALIZED_DATA 该节包含初始化数据
IMAGE_SCN_MEM_READ 该节可以被访问
IMAGE_SCN_MEM_WRITE 该节可以被写入
0x1C80x1C88Name“.idata”字符串”.idata”
0x1D00x1D04VirtualSize0x1000该节在内存Image中的大小
0x1D40x1D44VirtualAddress0x3000该节在内存Image中的起始地址RVA
0x1D80x1D84SizeOfRawData0x200该节在磁盘文件中的大小
0x1DC0x1DC4PointerToRawData0x600该节在磁盘文件中的起始地址FOA
0x1E00x1E04PointerToRelocations00本项目用不到,可以忽略
0x1E40x1E44PointerToLinenumbers00本项目用不到,可以忽略
0x1E80x1E82NumberOfRelocations00本项目用不到,可以忽略
0x1EA0x1EA2NumberOfLinenumbers00本项目用不到,可以忽略
0x1EC0x1EC4Characteristics0xC0000040IIMAGE_SCN_CNT_INITIALIZED_DATA 该节包含初始化数据
IMAGE_SCN_MEM_READ 该节可以被访问
IMAGE_SCN_MEM_WRITE 该节可以被写入

至此PE.exe的所有头部数据结构都已经编辑完毕,一共 0x1F0 个字节大小。有兴趣的读者可以根据表格内的FOA偏移量和Value值进行编辑和保存。看着挺多,实际大部分地方都是0。

五、编辑.idata

之所以先编辑 .idata节,是因为本项目中这个节存放导入表。而.code节中的代码调用系统函数,需用用到Import Address Table中的存放该函数的地址。

下面我们就来详细介绍——导入表。

1、什么是导入表?

我们在编写程序,需要访问磁盘/网络、获取系统信息、进行程序之间的交互等等,只需要调用一些系统函数,而不需要自己去实现这些功能。这些由操作系统提供的函数,本质上是操作系统对用户提供的一系列服务,被封装成了Application Programming Interface(应用程序编程接口 API)。

这些API函数按照一定功能分类,被放在一个个系统的DLL文件里面。比如我们这个PE.exe就需要用到MessageBoxA这个API函数来显示对话框,MessageBoxA实现在user32.dll文件里面。那么PE.exe怎么在运行时知道,并调用user32.dll文件里面的MessageBoxA函数呢?实际上它不需要知道,这是一个复杂的机制。

  • 首先要在PE.exe文件里面保存需要用到的DLL及其函数的信息,并保留出存放运行时这些函数“实际地址”的存储空间。而保存这些信息的数据结构就是——导入表。
  • 操作系统的Loader在加载PE.exe时,查看导入表,并根据这些信息,将DLL加载到进程虚拟地址空间的高位地址空间中,然后将API函数在进程虚拟地址空间中的“实际地址”填入导入表为其保留的存放空间中。
  • PE.exe执行到调用API函数时,就会直接跳转到Import Address Table(IAT)对应表项中的实际地址。

简单来说,就是PE.exe在导入表中专门给MessageBoxA函数留了个位置,Loader根据导入表信息,加载DLL并用实际的MessageBoxA的地址更新这个位置。.code节中的代码只是调用这个位置的函数。(试想一下如果这个位置换一个其他函数,会是啥情况?这有个术语叫API劫持)

2、导入表的结构

这部分涉及到的知识比较专业,没有计算机编程方面基础的,可以跳过,直接跳到“构造导入表的部分。也可以结合构造导入表的过程,来加深理解。

IAT.drawio

这个图展示了一个有两个DLL,每个DLL通过函数名分别导入2个函数的导入表基本结构。(当然这只是最基本的导入方式,本项目只解释最基本的概念)

一眼看上去就懵了,是不是复杂到有点不知所云。没关系,我们只需要抓住上面结构的三个关键数据结构:

  • Directory Entry(Import Descriptor) 结构
  • Import Lookup Entry 结构
  • Hit-Name Entry 结构

所谓的Table都是这三种结构分别组成的数组。让我们来了解一下这三个结构:

(1)Directory Entry结构(20字节)

这个结构描述一个需要导入的DLL。

OffsetSizeFieldDescription
04Import Lookup Table RVAimport lookup table(ILT) 在内存image中的偏移量
44Time/Date Stamp初始为0,Loader 会将其更新为DLL加载成功的时间。
84Forwarder Chain设置为0就好。不用关心
124Name RVADLL名称字符串在内存image中的偏移量。
164Import Address Table RVA (Thunk Table)import address table(IAT) 在内存image中的偏移量。这个值和ILT RVA相同

结合导入表的图,Import Lookup Table RVA(ILT RVA)和 Import Address Table RVA(IAT RVA)实际指向同块内存区域。 这是一个设计上很巧妙的地方。

(2)Import Lookup Entry结构(4字节)

这个结构描述DLL中需要导入的一个函数。可以使用Ordinal或者函数名两种方式导入一个函数。这里只讲解最直观的函数名导入方式。

注意这个结构的大小是4字节,意味着本身就可以作为一个地址指针。

Bit(s)SizeBit fieldDescription
311Ordinal/Name Flag标志位,表示该函数是Ordinal还是函数名方式导入
15-016Ordinal Number如果是Ordinal方式导入函数,此16位是代表这个函数的数值。这种方式导入函数速度最快。
30-031Hint/Name Table RVA如果是函数名方式导入函数,此31位值代表指向 Hint/Name Entry 结构的内存image偏移量。

在PE.exe文件中,这个字段保存函数名称的偏移量。在被加载到进程虚拟内存后,Loader会根据函数名查找真实地址,然后存入这个字段。

在PE.exe文件中,这个字段叫 ILT(Import Lookup Table);在内存中,这个字段叫 IAT (Import Address Table)。区别就在于保存的内容变了。

(3)Hint/Name Entry结构(不定长)

OffsetSizeFieldDescription
02Hint可以看成是对函数名的一种散列值,用来快速匹配函数名对应的函数。
2variableName一个 ASCII 字符串,以NULL结尾。
*0 or 1Pad填充字节,保证下一个Entry的起始地址是偶数。

以函数名导入函数,需要涉及大量的字符串比较,效率不高。所以定义了一个 Hint来加快这种比对查找。

3、手动构造导入表

PE.exe只从user32.dll导入MessageBoxA,所以只有两个Directory Entry(最后一个是表示结束的NULL Entry),两个Import Lookup Table(最后一个是表示结束的NULL Entry)。我们在.idata节的起始位置开始布置导入表。

FOARVASizeFieldValueDescription
0x6000x30004Import Lookup Table RVA0x3028导入查询表位置偏移量
0x6040x30044Time/Date Stamp00时间戳,置0
0x6080x30084Forwarder Chain00用不到,置0
0x60C0x300C4DLL Name RVA0x3030DLL名字符串存放位置偏移量
0x6100x30104Import Address Table RVA (Thunk Table)0x3028导入地址表的位置偏移量
0x6140x301420Null Directory Entry00表示导入的DLL结束
0x6280x30284Import Lookup Table / Import Address Table0x303BImport Lookup Entries
0x62C0x302C4Null Import Lookup Entry00表示Import Lookup Table结束
0x6300x303011DLL Name“user32.dll\0”DLL名字符串
0x63B0x303B2Hint00用不上,置0
0x63D0x303D12Function Name“MessageBoxA\0”函数名字符串
0x6490x30491Pad00下一个Entry需要对齐到偶数地址

导入表涉及到许多地址偏移量,且都是以内存image中的偏移量表示。这些在文件编辑中,需要明白FOA和RVA的区别以及转换。可以参考前面的基础知识仔细比对,加深理解。实际的布局如下图(这是一个紧凑型的布局):

PE_IAT.drawio

六、编辑.data

PE.exe显示一个对话框。对话框需要定义“标题”和“显示内容”。这两个字符串保存在.data节中。

FOARVAValueDescription
0x4000x2000“Hello, snake!\0”对话框标题
0x40E0x200E“This is an example that created a PE file manually.”消息内容

七、编辑 .code 节

顾名思义,这个节就是存放实际代码的。这个项目我们就仅仅显示一个对话框,这需要调用user32.dll里面的MessageBoxA函数。查寻微软的帮助文档,这个函数长这样:

int MessageBoxA(
  [in, optional] HWND   hWnd,		// 要创建的消息框的所有者窗口的句柄。 如果此参数为 NULL,则消息框没有所有者窗口。
  [in, optional] LPCSTR lpText,		// 要显示的消息。 如果字符串由多行组成,则可以在每行之间使用回车符和/或换行符分隔这些行。
  [in, optional] LPCSTR lpCaption,	// 对话框标题。 如果此参数为 NULL,则默认标题为 Error。
  [in]           UINT   uType		// 对话框的内容和行为。 此参数可以是标志组中的标志的组合。
);

Windows API 遵循 __stdcall 规则:参数从右往左入栈,被调用者负责栈平衡。

我们可以根据下面的参数值设置,用汇编写一下这个函数的调用代码:

参数ValueDescription
uType0x40MB_OK 消息框包含一个按钮: “确定”。 这是默认值。
MB_ICONASTERISK 消息框中将显示一个由圆圈中的小写字母 i 组成的图标。
lpCaption0x402000对话框标题字符串的地址,存放在 .data 节的起始位置。Image Base Address + Title String RVA
lpText0x40200E对话框显示消息的地址,存放在 标题字符串之后。Image Base Address + Content String RVA
hWnd00NULL,对话框不需要所有者。
1
2
3
4
5
6
push 0x40
push 0x402000
push 0x40200E
push 0
call DWORD ptr ds:0x403028
ret

call DWORD ptr ds:0x403028这里的0x403028就是上面设置的MessageBoxA函数的 Import Address Table(IAT) 地址。

代码需要使用变量的“绝对地址”,所以我们编辑的变量字段的RVA偏移量,需要加上 Image Base Address 才能在代码中使用。程序才能正确找到变量字段。

关于 Image Base Address,可以参考“基础知识1”,一般程序的可执行Image会加载到进程虚拟内存空间的 0x00400000 位置。

可以使用 Online x86 / x64 Assembler and Disassembler 这个网站在线将汇编代码翻译成二进制。

将下列字节流写入 .code 节在文件中的起始位置 0x200。

{ 0x6A, 0x40, 0x68, 0x00, 0x20, 0x40, 0x00, 0x68, 0x0E, 0x20, 0x40, 0x00, 0x6A, 0x00, 0xFF, 0x15, 0x28, 0x30, 0x40, 0x00, 0xC3 }

八、收工验证

按照以上步骤手动编辑并保存好文件后,就可以执行PE.exe。应该能弹出文章开头的那个对话框。如果读者能认真操作一遍,是不是到这个时候有点小激动?

文章很长,但实际的操作部分其实并不多。操作看上去复杂,但存在大量值是00的情况,估计5-10分钟就能编辑好,只需要一点点耐心和足够的仔细。

除去操作部分,理解PE32格式最主要的还是对文章中介绍的基础知识的理解。这可能需要花一些时间,最好有一定的编程基础。

This post is licensed under CC BY 4.0 by the author.

Trending Tags