实验五——手工编写PE文件

【实验名称】 手工编写PE文件
【实验目的】

1.了解PE文件的概念、结构
2.熟悉PE编辑查看工具,详细了解PE文件格式
3.重点分析PE文件文件头、引入表、引出表,以及资源表
【实验原理】
1.PE文件概述
PE文件的全称是Portable Executable,意为可移植的可执行的文件,常见的EXE、DLL、OCX、SYS、COM都是PE文件,PE文件是微软Windows操作系统上的程序文件(可能是间接被执行,如DLL)

2.PE文件结构
在这里插入图片描述

3.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个段。)

【实验内容】
1.按照《文件夹“手工打造pe文件-操作步骤”》中的网页描述,手工打造一个PE文件hello.exe。
首先了解一下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个段。)
打开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;

是不是所有的目录表都要关心呢?其实要把这些目录表都研究清楚是个很大的课题,对于我们这个程序,只需关心第2个元素,导入目录,它标识了我们的程序从其他模块导入的函数信息。因为我们要显示一个消息框,所以要导入user32.dll库中的MessageBoxA函数。程序要正常退出,又要导入kernel32.dll库中的ExitProcess函数。因此需要构造这个目录表。然而上面已说明每个目录是一个IMAGE_DATA_DIRECTORY结构,该结构具有两个成员,第一个成员表示目录表的起始RVA地址,第二个成员表示目录表的长度。我们将把这个目录表构造到.rdata段中,所以暂时先不填写。但是要留出空位来,为了记住该位置,我们先都填写为a,即:“aaaaaaaa”,“aaaaaaaa”。注意因为要文件对齐,所以其余的统统添零直到地址1a7h处。此时完成的代码如图7所示:
在这里插入图片描述

                  图7 完成PE结构中的PE头部分

接下来完成各段头部,又称为节表。一个程序中用到的所有代码、资源、全局数据等信息分布在各个节中,而各个节的信息,如节加载位置,节大小,节属性等信息都由紧跟PE头之后的节表所指出。它实际上就是紧挨着 PE 头的一个结构数组,该数组成员的数目由 file header (IMAGE_FILE_HEADER) 结构中 NumberOfSections 域的域值来决定。节表结构又命名为 IMAGE_SECTION_HEADER。
我们这里有3个段,.text(代码段), .rdata(只读数据段),data(全局变量数据段)。每段是一个IMAGE_SECTION_HEADER 结构,具有10个成员。IMAGE_SECTION_HEADER结构定义如下:

扫描二维码关注公众号,回复: 11957192 查看本文章
typedef struct _IMAGE_SECTION_HEADER {
    
    
      BYTE    Name[IMAGE_SIZEOF_SHORT_NAME];
      union {
    
    
              DWORD   PhysicalAddress;
              DWORD   VirtualSize;
      } Misc;
      DWORD   VirtualAddress;
      DWORD   SizeOfRawData;
      DWORD   PointerToRawData;
      DWORD   PointerToRelocations;
      DWORD   PointerToLinenumbers;
      WORD    NumberOfRelocations;
      WORD    NumberOfLinenumbers;
      DWORD   Characteristics;
  } IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

在导入表后输入的内容如下:
“00004D657373616765426F7841007573657233322E646C6C0080004578697450726F63657373006B65726E656C33322E646C6C00”。
如图9所示:
在这里插入图片描述

                   图9 导入表用到的字符串

完成函数名称表后就可以构造函数名称地址表。紧随名称表之后,函数名称表起始地址为文件偏移63ch处,长度是34h。所以其后的函数名称地址表的起始应该为:63ch+34h==670h。函数名称地址表中保存了从某一个DLL库中导入的所有函数名称所在内存地址的RVA值,并且以一个0x00000000作结束。导入了几个DLL库,那么就有几个函数名称地址表。我们这里总共导入了两个DLL库,那么就有两个函数名称地址表。我们分别完成它。首先是user32.dll库中导入了MessageBoxA,因为函数名称表已经构造完毕,所以我们可以得到它的文件偏移,是63ch,怎样由文件偏移计算得到RVA值呢?这取决于内存对齐粒度和文件对齐粒度。PE加载器将PE文件加载入内存是按照内存对其粒度进行加载的。让们看看从文件首到0x063C处内容如何加载入内存。首先PE头经过文件对齐占400h,而此部分内容经过内存对齐加载入内存后占1000h。然后是.text节经文件对齐占200h,而此节内容经过内存对齐加载如内存后占1000h,也就是说文件偏移600h对应内存RVA是2000h,因此文件地址0x63C对应的RVA应该是0x203C。所以函数名称地址表中填写0x0000203C,即“3C200000”,user32.dll库中只导入了一个函数,所以后面填写一个全零的DWORD值0x00000000,即“00000000”表示结束。接着完成kernel32.dll库的导入函数地址表。由函数名称表中可以得到被导入的ExitProcess的文件偏移为0x655。它对应的RVA值为0x2055,那么紧随前一个函数名称地址表填写“55200000”,由于也只导入了一个函数,所以后面填写全零的DWORD值表示此表的结束。这样完成了整个导入表所需的两个库函数名称地址表,如图10所示:
在这里插入图片描述

                   图10导入名称地址表

由此可知user32.dll库函数名称地址表的起始文件偏移为0x670,对应的RVA值为0x2070。而kernel32.dll库函数的名称地址表的起始文件偏移为0x678,对应的RVA值为0x2078。此时可以完成关于user32.dll库的导入表的第一个成员,就是指由user32.dll库导入的函数名称地址表起始地址的RVA值,应该是0x2070,因为此成员占4个字节,所以编辑器中应该填写“70200000”。
成员2,成员3各4各字节,用处不大,我们用零填充。
成员4,4个字节,是指向DLL名字的RVA。由user32.dll导入函数名称表可以得知此DLL名称所在地址的文件偏移为0x64A,对应的RVA值应该为0x204A,所以此成员应该填写0x0000204A,即“4A200000”。
成员5,4个字节,指向一个 IMAGE_THUNK_DATA 结构数组的RVA,同成员1一样。但是此IMAGE_THUNK_DATA 数组结构含义确和成员1完全不同。它将保存所有导入函数的真实调用地址。换句话说实际上它也指向一个地址表,这个地址不再像成员1一样是函数名称地址表,而是函数真实调用地址表,这个表又称为导入地址表,简称为IAT。既然这个表存放的是函数调用的真实地址,在设计PE文件时还没有得到导入函数的调用地址,所以无法填写此表。该表由PE文件被装载到内存时,PE加载器获得导入函数的真实地址来填充这个表。同样这个表以一个全零的DWORD作为结束标志。同样为了紧凑,我们将函数调用地址表安排在函数名称地址表之后,函数名称地址表的起始文件偏移是0x670,总共0x10个字节,那么其后的函数调用地址表的起始文件偏移为0x670+0x10=0x680。转换为RVA应该为0x2080,所以成员5应该填写“80200000”。
(注意:
虽然函数调用地址最终由PE加载器来填充,我们只需指定该表的位置。但是由于该表以全零的DWORD值作为结束标记。所以如果我们开始也填充全零将使PE加载器填充失败,所以我们需要随便填入一个非零值,这里笔者填入0x00000011。之后再填写结束标记0x00000000。紧随其后是第二导入库的函数调用地址表,导入了几个函数就需要填写几个非零的DWORD值,然后填写结束标记0x00000000。最终完成的导入函数调用地址表如图11所示:)
在这里插入图片描述

                          图 11 IAT

至此,完成了导入表中的关于导入库user32.dll的部分,按照相同的方法继续完成关于导入库kernel32.dll部分。最终导入表如图12所示:
在这里插入图片描述

                       图12 导入表

.rdata的其余部分用00填充,直到文件偏移0x800处。
最后是.data段,这个段非常简单,就是MessageBoxA所需的参数,消息框的标题和内容:即“消息框”、“Hello World !”两个字符串的AscII值。其余部分用00填充,直到0xA00处。如图13所示:
在这里插入图片描述

                         图13 导入表

最后我们继续完成.text段的程序执行代码。代码如下:

push    0          ; MessageBoxA的第四个参数,即消息框的风格,这里传入0。                      
push    0x403000   ;第三个参数,消息框的标题字符串所在的地址。                   
push    0x403007   ;第二个参数,消息框的内容字符串所在的地址。                     
push    0          ;第一个参数,消息框所属窗口句柄,这里填0。                      
call    ???? ;调用MessageBoxA,实际是跳转到该函数的跳转指令所在地址。     
push    0       ;ExitProcess函数的参数,程序退出码,传入0.                         
call    ???? ;调用ExitProcess,实际是跳转到该函数的跳转指令所在地址。
jmp    ???? ;跳转到MessageBoxA的真正地址处。
jmp    ???? ;跳转到ExitProcess的真正地址处。

看上面的两个jmp跳转,它是跳转到函数的真正地址处,然而函数真正地址是由PE加载得到并填充的,我们如何知道呢?通过函数导入表的构造,我们指定了各个导入函数真正地址所要填充的位置,PE加载器将把函数调用的真实地址填充到此处,那么我们只需这样来设计:jmp dword ptr [被填充地址],也就是跳转到被填充地址所指向的内容处即可。通过导入表可以轻松得到MessageBoxA的填充地址为0x2080,这是RVA值。得到绝对地址还需要加上基址。即:0x400000+0x2080=0x402080。同理,ExitProcess函数的填充地址为0x402088。
更新后的执行代码如下:

push    0          ; MessageBoxA的第四个参数,即消息框的风格,这里传入0。                      
push    0x403000   ;第三个参数,消息框的标题字符串所在的地址。                   
push    0x403007   ;第二个参数,消息框的内容字符串所在的地址。                     
push    0          ;第一个参数,消息框所属窗口句柄,这里填0。                      
call    ???? ;调用MessageBoxA,实际是跳转到该函数的跳转指令所在地址。     
push    0       ;ExitProcess函数的参数,程序退出码,传入0.                         
call    ???? ;调用ExitProcess,实际是跳转到该函数的跳转指令所在地址。
jmp   dword ptr [0x402080] ;跳转到MessageBoxA的真正地址处。
jmp   dword ptr [0x402088] ;跳转到ExitProcess的真正地址处。
还剩两个call的地址没有确定,她们表示该函数的跳转指令所在地址。也就是指令 jmp   dword ptr [0x402080] 和 jmp   dword ptr [0x402088]两条指令所在的地址。如何得到他们呢?因为执行代码起始地址为.text段的起始地址,偏移为0x1000。而后面的各条指令长度分别为:
push    0 ;指令长度为2。
push    0x403000  ;指令长度为5。           
push    0x403007   ;指令长度为5。    
push    0          ;指令长度为2。 
call    ???? ;指令长度为5。  
push    0       ;指令长度为2。                         
call    ???? ;指令长度为5。
总长度为2+5+5+2+5+2+5=26。转换为十六进制为1A,那么紧随其后的两条jmp指令的地址偏移应该为0x101A0x1020。加上基址后得到其绝对地址分别为0x4010410x401020.。更新后的执行代码如下:
push    0          ; MessageBoxA的第四个参数,即消息框的风格,这里传入0。                      
push    0x403000   ;第三个参数,消息框的标题字符串所在的地址。                   
push    0x403007   ;第二个参数,消息框的内容字符串所在的地址。                     
push    0          ;第一个参数,消息框所属窗口句柄,这里填0。                      
call    40101A ;调用MessageBoxA,实际是跳转到该函数的跳转指令所在地址。     
push    0       ;ExitProcess函数的参数,程序退出码,传入0.                         
call    401020 ;调用ExitProcess,实际是跳转到该函数的跳转指令所在地址。
jmp   dword ptr [0x402080] ;跳转到MessageBoxA的真正地址处。
jmp   dword ptr [0x402088] ;跳转到ExitProcess的真正地址处。

将这些指令翻译成机器码后如下:
6A00680030400068073040006A00E8070000006A00E806000000FF2580204000FF2588204000
最后将其填入.text段,其余部分用00填充,直到600h处。如图14所示:
在这里插入图片描述

                        图 14导入表

到此为止一个完整的显示Hello World!的可执行程序就完成了,按Ctrl+S键将编写完毕的内容保存成文件,如图15所示:
在这里插入图片描述

                       图 15保存文件

我们将其保存到桌面即可,并且命名为HelloWorld.exe。双击运行该程序,运行结果如图16所示:
在这里插入图片描述

                       图 16运行文件

程序成功运行。
这样,没有依赖任何编译器,按照PE结构的原理,纯手工成功打造了一个Win32可执行程序。通过这个过程,我们学习了PE头的结构和节表的结构,掌握了导入表结构。

2.使用PE文件查看器pe explorer.exe,查看hello.exe的结构,填写下面的表格:

OEP ImageBase
hello.exe 00401000h 00400000h

注:OEP是PE文件的IMAGE_OPTIONAL_HEADER结构的AddressOfEntryPoint成员
在这里插入图片描述

3.如果在hello.exe中,如果添加一个idata段,需要修改哪些字段?
在这里插入图片描述

现在只有这三个段.text(代码段)、.rdata(只读数据段)、.data(全局变量数据段)。输入数据Section,
通常命名为:.idata。.idata包含了PE文件的输入目录和输入地址表。.idata等于 IMAGE_SCN_MEM_WRITE |
IMAGE_SCN_MEM_READ | IMAGE_SCN_CNT_INITIALIZED_DATA,因此 .idata 节是
writeable/readable 的并且是已初始化的的数据,实际上 .idata 节就是 import table 节。
又上图中的.rdata指向目录为import table节,所以我们如果添加一个idata段,首先需要在. data字段后添加40字节的. idata
字段,包括在内存中的大小、虚拟地址、在文件中的大小、在文件中的偏移地址、原地址中的偏移、特征等部分,多余部分用0填充。其次需修改PE文件头的Number
0f Section(节表)部分,由三个段变成了四个段4*40h,所以值从3改为4。最后PE文件可选头的Size Of
Image(程序载入内存占用内存的大小。PE文件可选头部中定义)部分因为新增一个头部,应将00400000改为00500000。当然在文件的最后还需要开辟0x1000大小的空间放置.
idata段的具体内容。

4.用UltraEdit打开并分析hl.exe文件,并将hl.exe文件的输出改为“ReverseMe!!”
在这里插入图片描述

修改:
在这里插入图片描述

保存运行:
在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/Onlyone_1314/article/details/108817429