三年前,我曾经写了一个手工打造可执行程序的文章,可是因为时间关系,我的那篇文章还是有很多模糊的地方,我一直惦记着什么时候再写一篇完美的,没想到一等就等了三年。因为各种原因直到三年后的今天我终于完成了它。现在把它分享给大家,希望大家批评指正。
我们这里将不依赖任何编译器,仅仅使用一个十六进制编辑器逐个字节的手工编写一个可执行程序。以这种方式讲解PE结构,通过这个过程读者可以学习PE结构中的PE头、节表以及导入表相关方面的知识。
为了简单而又令所有学习程序开发的人感到亲切,我们将完成一个Hello World! 程序。功能仅仅是运行后弹出一个消息框,消息框的内容是Hello World!。
首先了解一下Win32可执行程序的大体结构,就是通常所说的PE结构。
如图1所示PE结构示意图:
图1 标准PE结构图
由图中可以看出PE结构分为几个部分:
MS-DOS MZ 头部:
所有PE文件必须以一个简单的DOS MZ 头开始。有了它,一旦程序在DOS下执行,DOS就能识别出这是有效的执行体,然后运行紧随MZ header 之后的DOS程序。以此达到对Dos系统的兼容。(通常情况DOS MZ header总共占用64byte)。
MS-DOS 实模式残余程序:
实际上是个有效的EXE,在不支持PE文件格式的操作系统中,它将简单显示一个错误提示,大多数情况下它是由汇编编译器自动生成。通常,它简单调用中断21h,服务9来显示字符串"This program cannot run in DOS mode"。(在我们写的程序中,他不是必须的,可以不予以实现,但是要保留其大小,大小为112byte,为了简洁,可以使用00来填充。)
PE文件标志:
是PE文件结构的起始标志。(长度4byte, Windows程序此值必须为0x50450000)
PE文件头:
是PE相关结构 IMAGE_NT_HEADERS 的简称,其中包含了许多PE装载器用到的重要域。执行体在支持PE文件结构的操作系统中执行时,PE装载器将从DOS MZ header中找到PE header的起始偏移量,跳过了MS-DOS 实模式残余程序 ,直接定位到真正的文件头PE header,长度20byte。
PE文件可选头:
虽然它的名字是“可选头部”,但是请确信:这个头部并非“可选”,而是“必需”的。(长度 224byte )。
各段头部:
又称节头部,一个Windows NT的应用程序典型地拥有9个预定义段(节),它们是“.text”、“.bss”、“.rdata”、“.data”、“.rsrc”、“.edata”、“.idata”、“.pdata”和“.debug”。一些应用程序不需要所有的这些段,同样还有些应用程序为了自己特殊的需要而定义了更多的段。(每个段头部占40byte,我们这里也不需要所有的段,仅需3个段。)
通常我们是将PE整个结构分成四个部分,把MS-DOS MZ 头部和MS-DOS 实模式残余程序作为第一部分,可以称他为DOS部分,而PE文件标志、PE文件头、PE文件可选头三个部分作为第二部分,称之为PE头部分,因为这部分才是Windows下真正需要的部分,所以从PE文件标志开始才是真正的PE部分。各段头部是第三部分,称之为节表。它详细描述了PE文件中各个节的详细信息。最后就是各个节的实体部分了,称为节数据。
以上仅仅是对PE结构各部分的大体讲解。接下来再手写这个Hello World!程序过程中,我将详细介绍每个部分的含义。
首先准备一下工具,一个十六进制编辑器足以。我们这里使用VC++ 6.0所携带的十六进制编辑器,您也可以使用如WinHex等十六进制编辑工具。
打开VC,选择文件,新建菜单项,然后选择一个二进制文件,单击确定。一切就绪了,下面就开始手写可执行程序,如图2所示:
图2 VC6.0下的十六进制编辑器
首先来完成“DOS MZ header”部分。“DOS MZ header”的功能前面已经讲过,在这里不再重述,直接实现他。“DOS MZ header”总共64byte,他对应的结构是IMAGE_DOS_HEADER ,在WINNT.H文件中有定义。通过这个结构我们可以看到,这64字节被分成19个成员,每个成员都有特殊的含义,与其说我们是在逐字节的手写可执行程序,倒不如说我们是在逐个成员的写。因为单独的一个字节并不一定具有什么意义。我们在学习过程中,就是要按照官方的定义,将整个部分拆分成若干个成员,然后逐个成员的去学习。
(提示: 如果安装有VC开发环境,那么在其安装目录下有一个头文件WINNT.H,在这个头文件中定义了所有PE结构相关的各部分结构体。如图3所示:)
图3 VC安装目录下的WINNT.H头文件
使用VC开发环境打开此文件,然后按快捷键Ctrl+F输入IMAGE_DOS_HEADER进行搜索,如图4所示:
图4 VC下查找文件
单击Find Next按钮即可得到如下搜索结果,如图5所示:
图5
可以看出IMAGE_DOS_HEADER,结构体的定义如下:
typedef struct _IMAGE_DOS_HEADER { // DOS .EXE header
WORD e_magic; // Magic number
WORD e_cblp; // Bytes on last page of file
WORD e_cp; // Pages in file
WORD e_crlc; // Relocations
WORD e_cparhdr; // Size of header in paragraphs
WORD e_minalloc; // Minimum extra paragraphs needed
WORD e_maxalloc; // Maximum extra paragraphs needed
WORD e_ss; // Initial (relative) SS value
WORD e_sp; // Initial SP value
WORD e_csum; // Checksum
WORD e_ip; // Initial IP value
WORD e_cs; // Initial (relative) CS value
WORD e_lfarlc; // File address of relocation table
WORD e_ovno; // Overlay number
WORD e_res[4]; // Reserved words
WORD e_oemid; // OEM identifier (for e_oeminfo)
WORD e_oeminfo; // OEM information; e_oemid specific
WORD e_res2[10]; // Reserved words
LONG e_lfanew; // File address of new exe header
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
按照它的定义,我们分别完成各个成员。
第一个成员(e_magic)是个WORD类型,占2个字节,它被用于表示一个MS-DOS兼容的文件类型,他的值是固定的0x5A4D,所以在十六进制编辑器中输入“4D5A”。
(注意:
因为我们是在十六进制编辑器下写数据,所以所有的数据格式都是十六进制式的。但是我们在开发环境中通常在数据前添加“0x”用来表示十六进制数 ,即:0x5A4D。而在十六进制编辑器中,直接写成“4D5A”即可。后面内容都照此规定书写。有一点需要说明,为什么十六进制值0x5A4D输入到十六进制编辑器中是4D5A呢?这是因为一个内存值,无论是占两个字节的WORD类型,还是占四个字节的DWORD类型等,如同我们学习数学中的十进制数值一样,都是有高低位之分的,从右向左位越来越高。然而在十六进制编辑器中,十六进制位是自左向右依次增高。因此按照高低位对齐的原则,值0x5A4D中,低位0x4D应该应该放到左边,0x5A应该放到右边。也就得到了编辑器中的4D5A。)
第2个成员到第18个成员总共58个字节,是对DOS程序环境的初始化等操作,对于我们这个程序来说,没什么影响,我们通通用“00”来填充。(如果您想对其进行详细了解,请查阅相关书籍。)
(提示:
我们在此不可能把PE结构所有的知识点都面面俱到,因为他十分的庞大。当然也没有必要对他作完全彻底的掌握,只需掌握关键的地方就可以了。以后我们都将把不影响程序执行的成员填充为零,这样做,一方面使程序看起来简洁,另一方面可以使您快速定位PE结构中要重点掌握的地方。)
第19(e_lfanew)个成员非常重要,他是一个LONG类型,占4个字节,用来表示“PE文件标志”在文件中的偏移,单位是byte。而从图5-1中可以看到“PE文件标志”紧随“MS-DOS 实模式残余程序”其后。知道这一点,我们就可以计算一下,我们的“DOS MZ header”总共64 byte,后面的“MS-DOS 实模式残余程序”占112 byte, 64 + 112 = 176 byte。但是要注意,我们这里的176是十进制的,转化成十六进制是0xB0。因为是4个字节,其余三位字节应该以00补齐,所以最终的值为0x000000B0。所以在我们的十六进制编辑器中按照高低对齐的原则应该填写“B0000000”。
接下来完成“MS-DOS 实模式残余程序”,笔者已经介绍,他是用在DOS下执行的,而我们所完成的HelloWorld程序是在win32下执行的。所以这里的内容并不影响我们程序的执行。因此这里直接用“00”来填充,注意总共112 byte。 这两部分完成之后代码如图6所示:
图6 完成PE结构中Dos部分的编写
接下来便进入真正主题,开始写真正的PE结构部分:微软将“PE文件标志”,“PE文件头 ”,“PE文件可选头 ”这三个部分用一个结构来定义,即:IMAGE_NT_HEADERS32在WINNT.H中可以搜索其定义,定义如下:
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER32 OptionalHeader;
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
可以看出这个结构含有3个成员:
第一个成员(Signature)表示“PE文件标志”,是一个DWORD类型,占4个字节,它是PE开始的标记,对于Windows程序这个值必须为0x00004550,所以编辑器中填写“50450000”。
第二个成员(FileHeader)表示“PE文件头 ”,他的类型是一个IMAGE_FILE_HEADER的结构。也就是说“PE文件头”的20个字节被定义为IMAGE_FILE_HEADER结构,定义如下:
typedef struct _IMAGE_FILE_HEADER {
WORD Machine;
WORD NumberOfSections;
DWORD TimeDateStamp;
DWORD PointerToSymbolTable;
DWORD NumberOfSymbols;
WORD SizeOfOptionalHeader;
WORD Characteristics;
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
这个结构具有7个成员:
成员1(Machine),占2个字节,表示PE文件运行所要求的CPU。对于Intel平台,该值是0x014C,所以编辑器中应该填写“4C01”。
成员2(NumberOfSections),占2个字节,表示PE文件中段(节)的总数,在我们这个程序中,计划完成3个段,(.text(代码段)、.rdata(只读数据段)、.data(全局变量数据段))。所以此处值是0x0003,因此填写“0300”。
成员3(TimeDateStamp),占4个字节,表示文件创建日期和时间,从1970.1.1 00:00:00以来的秒数,我们这里填“0000”即可。
成员4(PointerToSymbolTable),占4个字节,表示符号表的指针,主要用于调试,在这里填“0000”。
成员5(NumberOfSymbols),占4个字节,表示符号的数目,主要用于调试,在这里填“0000”。
成员6(SizeOfOptionalHeader),占2个字节,表示后面的“PE文件可选头 ”部分所占空间大小,我们已经知道“PE文件可选头 ”的大小是224 byte,转换成十六进制就是0xE0,此成员占两个字节,所以需要补齐一位00,即0x00E0。在编辑器中应该填写“E000”。
成员7(Characteristics),占2个字节,表示关于文件信息的标记,比如文件是exe还是dll。这个值实际上是二进制位进行或运算得到的值。各二进制位表示的意义如下:
Bit 0 :置1表示文件中没有重定向信息。每个段都有它们自己的重定向信息。这个标志在可执行文件中没有使用,在可执行文件中是用一个叫做基址重定向目录表来表示重定向信息的。
Bit 1 :置1表示该文件是可执行文件。
Bit 2 :置1表示没有行数信息;在可执行文件中没有使用。
Bit 3 :置1表示没有局部符号信息;在可执行文件中没有使用。
Bit 4 :未公开
Bit 7 :未公开
Bit 8 :表示希望机器为32位机。这个值永远为1。
Bit 9 :表示没有调试信息,在可执行文件中没有使用。
Bit 10:置1表示该程序不能运行于可移动介质中(如软驱或CD-ROM)。在这种情况下,OS必须把文件拷贝到交换文件中执行。
Bit 11:置1表示程序不能在网上运行。在这种情况下,OS必须把文件拷贝到交换文件中执行。
Bit 12:置1表示文件是一个系统文件例如驱动程序。在可执行文件中没有使用。
Bit 13:置1表示文件是一个动态链接库(DLL)。
Bit 14:表示文件被设计成不能运行于多处理器系统中。
Bit 15:表示文件的字节顺序如果不是机器所期望的,那么在读出之前要进行交换。在可执行文件中它们是不可信的(操作系统期望按正确的字节顺序执行程序)。
对于我们的程序,因为它是可执行程序,所以Bit 1必须置为1,其他位按照需要置位即可。在我们的程序中只需将第二位置1表示是可执行程序。也就得到二进制值“0000000000000010”,将其转换为十六进制形式为0x02,而该成员占两个字节,补齐一位00由此得到成员7的值为0x0002。因此在编辑器中填写“0200”。如果是dll,那么得到的二进制值应该是“0010000000000000”,转换成十六进制为0x2000。如果填写编辑器中应该填写“0020”。
第三个成员(OptionalHeader),表示“PE文件可选头”,他的类型是一个IMAGE_OPTIONAL_HEADER32结构。也就是说PE文件头的224个字节被定义为IMAGE_OPTIONAL_HEADER32结构,其结构定义如下:
typedef struct _IMAGE_OPTIONAL_HEADER {
//
// Standard fields.
//
WORD Magic;
BYTE MajorLinkerVersion;
BYTE MinorLinkerVersion;
DWORD SizeOfCode;
DWORD SizeOfInitializedData;
DWORD SizeOfUninitializedData;
DWORD AddressOfEntryPoint;
DWORD BaseOfCode;
DWORD BaseOfData;
//
// NT additional fields.
//
DWORD ImageBase;
DWORD SectionAlignment;
DWORD FileAlignment;
WORD MajorOperatingSystemVersion;
WORD MinorOperatingSystemVersion;
WORD MajorImageVersion;
WORD MinorImageVersion;
WORD MajorSubsystemVersion;
WORD MinorSubsystemVersion;
DWORD Win32VersionValue;
DWORD SizeOfImage;
DWORD SizeOfHeaders;
DWORD CheckSum;
WORD Subsystem;
WORD DllCharacteristics;
DWORD SizeOfStackReserve;
DWORD SizeOfStackCommit;
DWORD SizeOfHeapReserve;
DWORD SizeOfHeapCommit;
DWORD LoaderFlags;
DWORD NumberOfRvaAndSizes;
IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;
该结构总共具有31个成员,我们分别实现它:
成员1(Magic),占2个字节,表示文件的格式,值为0x010B表示.EXE文件,为0x0107表示ROM映像,因为我们写的是一个可执行程序,所以此处应该填写“0B01”。
成员2(MajorLinkerVersion),占1个字节,表示链接器的主版本号,此值不会影响程序的执行,我们这里填充零,此值为“00”。
成员3(MinorLinkerVersion),占1个字节,表示链接器的幅版本号,此值不会影响程序的执行,我们这里填充零,此值为“00”。
成员4(SizeOfCode),占4个字节,表示可执行代码的长度,此值不会影响程序的执行,我们这里填充零,此值为“00000000”。
成员5(SizeOfInitializedData),占4个字节,表示初始化数据的长度(数据段)。此值不会影响程序的执行,我们这里填充零,此值为“00000000”。
成员6(SizeOfUninitializedData),占4个字节,表示未初始化数据的长度(bss段)。此值不会影响程序的执行,我们这里填充零,此值为“00000000”。
(说明:
在介绍成员7之前,有必要了解一个很重要的知识------文件映射到内存。在可执行程序运行之前,PE加载器将把PE文件加载到进程空间的内存中去,并且初始化每个段实体。那么加载到内存中的哪个地址去呢?这将由IMAGE_OPTIONAL_HEADER32结构的成员10的值指出加载的起始地址(又叫基地址)。这个值通常是“00400000”, 那么PE文件的首地址“00000”就被映射到内存地址“00400000”处,那么相对于文件偏移10个字节的地址为“00010”,被映射到内存后的偏移也应该是10个字节,映射后的地址应该为“00400010”。PE加载器就是按照此种方法将文件映射到内存中的。)
成员7(AddressOfEntryPoint),4个字节,表示代码入口的RVA地址。
(说明:
RVA是指PE加载器将文件映射到内存后,某个物理地址距离加载基址的偏移地址。)
所谓代码的入口是指程序从这儿开始执行。成员7实际上是PE装载器准备运行的PE文件中的第一条指令的RVA值。若您要改变整个程序执行的流程,可以将该值指定到新的RVA,这样新RVA处的指令首先被执行。
知道成员7的含义后,我们又如何来填充它呢?如何得知我们的程序将使用哪个地址作为入口呢?前面已经提到,一般在PE文件中总会有个.text段,这个段通常是用来填写代码的。按照一般规律,我们也将实现这么一个段,将我们这个程序中的所有代码指令写到此段中。我们在完成此程序的代码时,是从.text段起始地址开始写起。所以.text段的起始地址就将是我们程序的入口地址。那么又出现另外一个问题,如何得到.text段的起始地址呢?在PE结构中,所有段都对应有一个段头部,而在段头部中将指定该段的起始地址。那么这个值要等待我们完成.text头部后才能够得到,所以此处首先用“aaaaaaaa”填写,待完成.text段头部后再计算填写它。
成员8(BaseOfCode),4个字节,表示可执行代码起始位置。当然就是.text段的首地址,此值不会影响程序的执行,我们这里填充零,此值为“00000000”。
成员9(BaseOfData),4个字节,表示初始化数据的起始位置,此值不会影响程序的执行,我们这里填充零,此值为“00000000”。
成员10(ImageBase),4个字节,就是上面所讲的文件映射到内存后的基地址。PE文件的优先装载地址。通常为0x00400000。因为PE装载器默认情况下优先将尝试把文件装到虚拟地址空间的0x00400000处。字眼“优先”表示若该地址区域已被其他模块占用,那PE装载器会选用其他空闲地址。我们这里的值设为“00004000”。
成员11(SectionAlignment),4个字节,表示段加载后在内存中的对齐方式,即内存中节对齐的粒度。例如,如果该值是4096 (1000h),那么每节的起始地址必须是4096的倍数。若第一节从401000h开始,大小是10个字节,下一个节并不是从401011开始,因为要经过节对齐,那么下一节必定从402000h开始,即使401000h和402000h之间还有很多空间没被使用。因为Windows管理内存采用分页管理的方式,而每页的大小为4k,也就是1000h。一般情况下程序的内存节对齐粒度都为0x00001000,我们这个值也填充为“00100000”。
成员12(FileAlignment),4个字节,表示段在文件中的对齐方式。文件中节对齐的粒度。例如,如果该值是(200h),,那么每节的起始地址必须是512(十六进制为200h)的倍数。若第一节从文件偏移量200h开始且大小是10个字节,则下一节必定位于偏移量400h处。即使偏移量512和1024之间还有很多空间没被使用。一般情况下程序的文件节对齐粒度都为200h,所以我们在此将此值设为“00020000”。
成员13(MajorOperatingSystemVersion),2个字节,表示操作系统主版本号,此值不会影响程序的执行,我们这里填充零,此值为“0000”。
成员14(MinorOperatingSystemVersion),2个字节,表示操作系统副版本号,此值不会影响程序的执行,我们这里填充零,此值为“0000”。
成员15(MajorImageVersion),2个字节,表示程序主版本号,此值不会影响程序的执行,我们这里填充零,此值为“0000”。
成员16(MinorImageVersion),2个字节,表示程序副版本号,此值不会影响程序的执行,我们这里填充零,此值为“0000”。
成员17(MajorSubsystemVersion),2个字节,表示子系统主版本号。win32子系统版本。PE文件是专门为Win32设计的,该子系统版本必定是4.0,那么此处值为“04”。
成员18(MinorSubsystemVersion),2个字节,表示子系统副版本号,根据上面所说,此值应为“00”。
成员19(Win32VersionValue),2个字节,此值一般为“00”。
成员20(SizeOfImage),4个字节,表示程序载入内存后占用内存的大小(单位字节),即等于所有段的长度之和---------所有头和节经过节对齐处理后的大小。我们知道,我们文件PE结构总长小于1000h,但是内存中的对齐粒度是1000h,所以PE结构被映射后要占1000h,尽管很多空间没有使用,另外我们有3个段,每个段的长度小于1000h,但是被映射后同样要占1000h,所以总共占用内存的大小为1000h + 3 * 1000h = 4000h,因此此值为“00400000”。
成员21(SizeOfHeaders),4个字节,表示所有文件头的长度之和(从文件开始到第一个段之间的大小)。所有头即PE头加所有节表头的大小,也就等于文件尺寸减去文件中所有节的尺寸。可以以此值作为PE文件第一节的文件偏移量。那么我们怎么得到这个值呢?我们的PE文件头总大小为:64 + 112 + 4 + 20 + 224 = 424,3个节表头的总大小 3 * 40 =120。424 + 120 = 544 byte 转化成十六进制为220h,那么此值就填写220h吗?不是的,因为我们文件中的对齐粒度是200h,那么220h经过文件对齐后实际上要占用400h的空间,所以此值为“00040000”。
成员22(CheckSum),4个字节,表示校验和。它仅用在驱动程序中,在可执行文件中可能为0。它的计算方法Microsoft没有公开,在imagehelp.dll中的CheckSumMappedFile()函数可以计算它,此处我们设为填充零,此值为“00000000”。
成员23(Subsystem),2个字节,表示NT子系统,可能是以下的值:
IMAGE_SUBSYSTEM_NATIVE (1) 不需要子系统。用在驱动程序中。
IMAGE_SUBSYSTEM_WINDOWS_GUI(2) WIN32 graphical程序(它可用AllocConsole()来打开一个控制台,但是不能在一开始自动得到)。
IMAGE_SUBSYSTEM_WINDOWS_CUI(3) WIN32 console程序(它可以一开始自动建立)。
IMAGE_SUBSYSTEM_OS2_CUI(5) OS/2 console程序(因为程序是OS/2格式,所以它很少用在PE)。
IMAGE_SUBSYSTEM_POSIX_CUI(7) POSIX console程序。
Windows程序总是用WIN32子系统,所以只有2和3是合法的值。也就是说此值必须为2或3,如果是3,那么程序运行后会自动打开一个控制台,我们为了看一下效果,这里设为3,此值为“0300”。
成员24(DllCharacteristics),2个字节,表示Dll属性,我们这里填充零,此值为“0000”。
成员25(SizeOfStackReserve),4个字节,保留堆栈大小,我们这里填充零,此值为“00000000”。
成员26(SizeOfStackCommit),4个字节,启动后实际申请的堆栈数,可随实际情况变大,我们这里填充零,此值为“00000000”。
成员27(SizeOfHeapReserve),4个字节,保留堆大小,我们这里填充零,此值为“00000000”。
成员28(SizeOfHeapCommit),4个字节,实际堆大小,我们这里填充零,此值为“00000000”。
成员29(LoaderFlags),4个字节,装载标志,我们这里填充零,此值为“00000000”。
成员30(NumberOfRvaAndSizes),4个字节,在讲这个成员之前,我们应该先了解
成员31,成员31实际上是一个IMAGE_DATA_DIRECTORY结构的数组,成员30的值就是表示该数组的大小。通常有16个元素,也就是十六进制的0x00000010,所以此值填为:“10000000”。 IMAGE_DATA_DIRECTORY结构定义如下:
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
[招生]科锐逆向工程师培训(2025年3月11日实地,远程教学同时开班, 第52期)!
上传的附件: