PE文件格式
本文最后更新于1 天前,其中的信息可能已经过时,如有错误可以直接在文章下留言

今天通过《逆向工程核心原理》学习一下PE文件格式,先放一篇别的大佬写的

关于逆向工程核心原理-PE文件 – P.Z’s Blog (ppppz.net)

1.介绍

PE文件时Windows操作系统下使用的可执行文件格式。它的全称是Portable Executable。

Portable的意思是便携的、轻便的,Executable的意识是可执行的,据说由第一个单词是因为一开始设计PE文件是来提高程序在不同程序上的移植性,但最后只在Windows之中使用。

可以看到PE文件不仅仅局限于我们所熟知的exe文件。还有上图中的几种。

VA、RVA、RAW、ImageBase

在学习PE文件格式之前我们需要了解VA、RVA、RAW、ImageBase这四个概念。

PE格式第三讲扩展,VA,RVA,FA(RAW),模块地址的概念 – iBinary – 博客园

VA(Virtual Address)指的是进程虚拟内存的绝对地址,是程序被加载到内存中的地址,比如我在OD中打开一个PE文件,如图,左侧的地址即为VA

ImageBase即基准位置,就是PE文件加载到内存的时候,开头的地址,我们可以通过OD查看

如上图我编写的程序的ImageBase就是210000,这里开头会载入我们程序的PE文件头。当然ImageBase最经典的应该还是0x401000。

RVA(Relative Virtual Address)相对虚拟地址,指从某个基准地址开始的相对的地址,VA与RVA满足下列的换算关系:

RVA + ImageBase = VA

比如上面图片的程序,mian函数开头的RVA为0x211090 – 0x210000 = 0x1090

在PE头内部的信息大多以RVA的形式存在,因为PE文件(主要是DLL)加载到进程的虚拟地址时,这个位置可能已经加载了其他的PE文件,已经被占用了。此时必须通过重定位加载到内存其他的空白位置 ,使用VA当然无法做到这一点,因为VA处已经被占用,但是利用RVA,程序可以选择一个基准位置,然后重定位进行加载,相对于基准位置的相对地址没有变化,就能正常访问到指定信息。

讲解PE文件经常出现 “映像(Image)” 这一个术语,PE文件加载到内存中时,文件不会原封不动地加载,而要根据节区头中定义的节区起始地址、节区大小等加载。因此,磁盘文件当中的PE与内存当中的PE具有不同的形态,我们将加载到内存中的形态称为映像来区别。

PE文件加载到内存时,每个节区头都要能准确完成内存地址与文件偏移间的映射,这种映射一般称为RVA to RAW。

RVA可以理解为内存偏移,RAW可以理解为文件偏移。

根据后文的IMAGE_SECTION_HEADER结构体,RVA和RAW之间的转换符合以下关系

RAW – PointerToRawData = RVA – VirtualAddress

如图

左侧是文件偏移,从零开始,右侧的地址即使VA,从0x1000000开始,这个0x1000000就是ImageBase

假设RVA为5000,即右侧地址0x1005000处,位于第一个节区(“.text”)的范围0x1001000到0x100874B之间

RAW = 5000(RVA) – 1000(VirtualAddress) + 400(PointerToRawData) = 4400

VirtualAddress即内存中的节区起始地址(RVA),PointerToRawData即磁盘文件中节区起始位置

当然这里还有一种情况,比如当RVA为ABA8时,处于第二个节区(.data)

RAW = ABA8(RVA) – 9000(VirtualAddress) + 7C00(PointerToRawData) = 97A8

然而97A8在文件偏移中在第三个节区(.rsrc)的位置,这显然不对,而出现这种情况的原因是因为第二个节区的VirtualSize(内存中节区所占大小)要比SizeOfRawData(磁盘文件中节区所占大小)大

这里我们理解一下,上面的计算RAW的等式,其实说明在一个节区之内,RVA和内存节区起始地址之间的差值等于RAW和磁盘空间中节区起始地址的差值,一般情况下可能是VirtualSize小于SizeOfRawData,当出现相反情况时,磁盘空间中节区起始地址加上RVA和内存节区起始地址之间的差值之后就会越过节区的界限,用公式说明大概就是

PointerToRawData + ( RVA – VirtualAddress ) > PointerToRawData + SizeOfRawData

( RVA – VirtualAddress ) > SizeOfRawData

2.基本结构

这本书以自带的notepad.exe程序来分析它的PE格式

图片是程序加载到内存中的情形,从DOS头到节区头的部分是PE头部分,其下的节区都叫做PE体,图中的数据在文件中用偏移(offset),在内存中则用虚拟地址来表示(Virtual Address)来表示。文件加载到内存时,如图中所示,节区的大小和位置会发生改变,文件的内容分为代码、数据、资源节。

上面的虚拟地址和节区啥的在IDA中倒是有所体现。

图中的NULL是NULL填充,看下面的描述

这里的思想我记得在学习汇编语言的时候有提过

大概是类似的思想,为了寻址的方便还有提高寻址的效率,我觉得是这样的,如果不是最小单位的倍数,那可能CPU就无法用地址来表示数据的位置了。

3.DOS头

微软早期为了兼容DOS文件,也就是十六位操作系统上的文件,在PE头的最前端增加了一个结构体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;

这是在16位操作系统之下,所以总共有40个字节,该结构体有两个很重要的成员,就是第一个和最后一个需要重点关注,这两个的直接修改也会导致程序无法运行

WORD e_magic;		//DOS签名:4D5A("MZ")
LONG E_lfanew;		//指示NT头的偏移

这个”MZ”我们逆向中通常用来判断一个程序是否是Windows可执行程序,关于它的来源

DOS头中除了这两个之外的其他数据修改似乎不会影响程序的打开

4.DOS存根

DOS存根在DOS头下面,大小不固定,即使没有,也可以正常运行,由代码和数据混合而成,下图为即为书中提供程序的DOS存根

文件偏移40到4D区域是汇编指令,只有在DOS环境中运行该指令,才会执行这里的代码,而在Windows中并不会运行该命令,我们可以用DOSBox打开试试,如图

确实输出了存储在DOS存根中的字符串

5.NT头

NT头IMAGE_NT_HEADERS,结构体如下

typedef struct IMAGE_NT_HEADERS
  {
        DWORD Signature;      //PE Signature : 50450000("PE"00)
        IMAGE_FILE_HEADER FileHeader;
        IMAGE_OPTIONAL_HEADER32 OptionalHeader;
  }IMAGE_NT_HEADERS32,*PIMAGE_NT_HEADERS32;

结构体由签名、文件头、可选头组成

该结构体的大小为F8,相当大,签名的内容是固定的,如上图红圈内所示,即”PE”,和”MZ”的标识差不多。

NT头:文件头

文件头是表现文件大致属性的一个结构体

typedef struct _IMAGE_FILE_HEADER {

WORD Machine;//CPU的架构类型

WORD NumberOfSections;//文件的节区数目

DWORD TimeDateStamp;//文件创建的用时间戳标识的日期

DWORD PointerToSymbolTable;//指向符号表(用于调试)

DWORD NumberOfSymbols;//符号表中符号的个数

WORD SizeOfOptionalHeader;//IMAGE_OPTIONAL_HEADER32结构大小

WORD Characteristics;//文件属性

} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;

每个CPU都有唯一的Machine码,它指明了CPU的架构

然后是NumberOfSections,用来指出文件当中存在的节区数量,这个值一定要大于零,并且如果修改它,使它的数量与实际节区不同时,会发生运行错误。如图

NT头的最后一个结构体是IMAGE_OPTIONAL_HEADER32,而文件头的倒数第二个成员SizeOfOptionalHeader用来指明IMAGE_OPTIONAL_HEADER32结构体的大小,看它名字就知道了。

文件头的Characteristics字段用来标识文件的属性,文件是否是可运行的形态,,是否位DLL文件等信息。以bit OR的形式组合起来,就是所有的属性特征值进行OR运算,如图

TimeDateStamp成员的值用来记录文件创建的用时间戳标识的日期,它不影响文件的运行。

NT头:可选头

可选头,即IMAGE_OPTIONAL_HEADER32是PE头结构当中最大的。其结构如下

typedef struct _IMAGE_OPTIONAL_HEADER
{
    WORD   Magic;                            //* PE标志字:32位(0x10B),64位(0x20B)
    BYTE   MajorLinkerVersion;               //  主链接器版本号
    BYTE   MinorLinkerVersion;               //  副链接器版本号
    DWORD  SizeOfCode;                        //  代码所占空间大小(代码节大小)
    DWORD  SizeOfInitializedData;         //  已初始化数据所占空间大小
    DWORD  SizeOfUninitializedData;           //  未初始化数据所占空间大小
    DWORD  AddressOfEntryPoint;               //* 程序执行入口RVA,(w)(Win)mainCRTStartup:即0D首次断下来的自进程地址
    DWORD  BaseOfCode;                        //  代码段基址
    DWORD  BaseOfData;                        //  数据段基址
    DWORD  ImageBase;                     //* 内存加载基址,exe默认0x400000,dll默认0x10000000
    DWORD  SectionAlignment;              //* 节区数据在内存中的对齐值,一定是4的倍数,一般是0x1000(4096=4K)
    DWORD  FileAlignment;                 //* 节区数据在文件中的对齐值,一般是0x200(磁盘扇区大小512)
    WORD   MajorOperatingSystemVersion;      //  要求操作系统最低版本号的主版本号
    WORD   MinorOperatingSystemVersion;      //  要求操作系统最低版本号的副版本号
    WORD   MajorImageVersion;                //  可运行于操作系统的主版本号
    WORD   MinorImageVersion;                //  可运行于操作系统的次版本号
    WORD   MajorSubsystemVersion;            //  主子系统版本号:不可修改
    WORD   MinorSubsystemVersion;            //  副子系统版本号
    DWORD  Win32VersionValue;             //  版本号:不被病毒利用的话一般为0,XP中不可修改
    DWORD  SizeOfImage;                       //* PE文件在进程内存中的总大小,与SectionAlignment对齐
    DWORD  SizeOfHeaders;                 //* PE文件头部在文件中的按照文件对齐后的总大小(所有头 + 节表)
    DWORD  CheckSum;                      //  对文件做校验,判断文件是否被修改:3环无用,MapFileAndCheckSum获取
    WORD   Subsystem;                        //  子系统,与连接选项/system相关:1=驱动程序,2=图形界面,3=控制台/Dll
    WORD   DllCharacteristics;               //  文件特性
    DWORD  SizeOfStackReserve;                //  初始化时保留的栈大小
    DWORD  SizeOfStackCommit;             //  初始化时实际提交的栈大小
    DWORD  SizeOfHeapReserve;             //  初始化时保留的堆大小
    DWORD  SizeOfHeapCommit;              //  初始化时实际提交的堆大小
    DWORD  LoaderFlags;                       //  已废弃,与调试有关,默认为 0
    DWORD  NumberOfRvaAndSizes;               //  下边数据目录的项数,此字段自Windows NT发布以来,一直是16
    IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];// 数据目录表
} IMAGE_OPTIONAL_HEADER32, * PIMAGE_OPTIONAL_HEADER32;

第一个成员Magic,32位系统为IMAGE_OPTIONAL_HEADER32,此时Magic码为10B,64位系统为IMAGE_OPTIONAL_HEADER64,此时Magic码为20B。

6.节区头

节区头定义了各个节区的属性。

创建多个节区可以保证程序的安全性,可以防止不同节区的数据放在一起,写数据导致其他类别数据被覆盖的问题,每个节区有着不同的访问权限和特性

每一个节区都有它自己的结构体IMAGE_SERCTION_HEADER

typedef struct _IMAGE_SECTION_HEADER {
    BYTE    Name[IMAGE_SIZEOF_SHORT_NAME];//8字节的块名区块尺寸
    union {
            DWORD   PhysicalAddress;
            DWORD   VirtualSize;   //内存中节区所占大小
    } Misc;
    DWORD   VirtualAddress;//内存中的节区起始地址(RVA) 
    DWORD   SizeOfRawData;//磁盘文件中节区所占大小
    DWORD   PointerToRawData;//磁盘文件中节区起始位置
    DWORD   PointerToRelocations;//在 OBJ文件中使用,重定位的偏移
    DWORD   PointerToLinenumbers;//行号表的偏移(供调试用)
    WORD    NumberOfRelocations;//在OBJ文件中使用,重定位项数目
    WORD    NumberOfLinenumbers;//行号表中行号的数目
    DWORD   Characteristics;//区块的属性(bit OR)
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

Name字段不像C语言成员一样以NULL结束,没有必须ASCLL码的限制。PE规范没有明确节区的Name,所以可以向其中放入任何值,也可以填充NULL值。这里面的意思我理解为可以任意对节区的名字命名,存储数据的节区我们甚至也可以命名为.code,而且在CTF当中,也见过对节区名的改动。

7.IAT

IAT (Import Address Table,导人地址表), EAT(Export Address Table,导出地址表)

IAT保存的内容与Windows操作系统的核心进程、内存、DLL结构等有关。IAT用来记录程序正在使用哪些库中的哪些函数。

IAT是关于DLL的,DLL是啥在这里就不介绍了,加载DLL的方式实际有两种:一种是“显式链接” (Explicit Linking),程序使用DLL时加载,使用完毕后释放内存;另一种是“隐式链接” (Implicit Linking),程序开始时即一同加载DLL,程序终止时再释放占用的内存。IAT提供的机制即与隐式链接有关。

IMAGE_IMPORT_DESCRIPTOR

IMAGE_IMPORT_DESCRIPTOR结构体当中记录着PE文件要导入哪些库文件,其结构如下

typedef struct _IMAGE_IMPORT_DESCRIPTOR
{
	union 
	{
	DWORD	Characteristics;
        DWORD	OriginalFirstThunk;				// INT(Import Name Table) address 指向IMAGE_IMPORT_BY_NAME的地址(RVA)
	};
	DWORD	TimeDateStamp;
	DWORD	ForwarderChain;
	DWORD	Name;								// 库名称字符串的地址(RVA)
	DWORD	FirstThunk;							// IAT(Import Address Table) IAT的地址(RVA)
} IMAGE_IMPORT_DESCRIPTOR;

typedef struct _IMAGE_IMPORT_BY_NAME
{
	WORD Hint;
	BYTE Name[1];
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;

执行一个普通程序往往需要导入多个库,导入多少库就有多少个IMAGE_IMPORT_DESCRIPTOR结构体,这些结构体会构成一个数组,结构体最后以NULL结尾,IMAGE_IMPORT_DESCRIPTOR的结构体数组也叫做IMPORT Directory Table,如图

IMAGE_IMPORT_DESCRIPTOR中的重要成员如下

//OriginalFirstThunk    INT的地址(RVA)
//INT即Import Name Table
//Name                  库名称字符串的地址(RVA)
//FirstThunk            IAT的地址(RVA)

有如下提示

下面是PE装载器把导入函数输入至IAT的顺序。

  1. 读取IID的Name的成员,获取库名的字符串 (“kernel32.dll”)
  2. 装载相应库 -> LoadLibrary(“kernel32.dll”)
  3. 读取IID的OriginalFirstThunk成员,获取INT地址
  4. 逐一读取INT中数组的值,获取相应IMAGE_IMPORT_BY_NAME地址(RVA)
  5. 使用IMAGE_IMPORT_BY_NAME的Hint (ordinal) 或Name项,获取相应函数的起始地址。->GetProcAddress(“GetCurrentThreadId”)
  6. 读取IID的FirstThunk (IAT) 成员,获取IAT地址
  7. 将上面获得的函数地址输入相应的IAT数组值
  8. 重复4~7步骤,直到INT结束(遇到NULL)

接下来使用notepad进行练习

IMAGE_IMPORT_DESCRIPTOR的位置在PE体当中,但查找其位置信息要在PE头当中

IMAGE_OPTIONAL_HEADER32.DataDirectory[1].VirtualAddress的值即是IMAGE_IMPORT_DESCRIPTOR结构体数组的起始位置。

这里的RVA为7604,经计算则RAW为6A04,用到上面那个介绍RVA转换到RAW的图。

如图则为IMAGE_IMPORT_DESCRIPTOR结构体数组,大小为C8,其中开头到第二行的C4 12 00 00为结构体数组的第一个元素。

下面来具体看看结构体数组的第一个结构体的各个成员

这里有个Characteristics成员没有,是因为C语言中的union{}语法,似乎只能存在一个成员

1.Name

看文件偏移6EAC (RVA:7AAC->RAW:6EAC) 处

comdlg32.dll,可见Name就是库名称

2.OriginalFirstThunk – INT

INT是一个包含导入函数信息的结构体数组,获得了这些信息,才能在加载到进程内存的库中准确求得相应函数的起始地址,这需要参考后面的EAT讲解。

跟踪OriginalFirstThunk成员来到文件偏移6D90 (RVA:7990->RAW:6D90) 处,即为INT,由地址数组形式组成,每个地址值分别指向IMAGE_IMPORT_BY_NAME结构体,接下来我们跟踪数组的第一个值7A7A,进入该地址,可以看到导入的API函数的名称字符串。

3.IMAGE_IMPORT_BY_NAME

来到文件偏移6E7A (RVA:7A7A->RAW:6E7A) 处

前两个字节值 (000F) 为Ordinal,是库中函数的固有编号,后面为函数名称字符串PageSetupDlgW,字符串末尾同C语言一样,以NULL(‘\0’)结束

所以INT是IMAGE_IMPORT_BY_NAME结构体指针数组,数组的第一个元素指向函数的Ordinal值000f,名称为PageSetupDlgW。

4.FirstThunk – IAT(Import Address Table)

来到文件偏移6C4 (RVA:12C4->RAW:6C4) 处

6C4~6EB区域即为IAT数组区域,对应于comdlg32.dll库,它与INT类似,由结构体指针数组组成,且以NULL结尾

这里的四个字节的值,即为函数的地址值,比如76324906为PageSetupDlgW函数的准确地址值,但是这个值是当时作者使用系统的该函数的地址值,程序在不同系统运行时,PE装载器会使用相应API的起始地址替换该值。

EAT

EAT是一种核心机制,它使得不同的应用程序可以调用库文件中提供的函数。也就是说,只有通过EAT才能准确求得从相应库中导出函数的起始地址。与IAT一样,PE文件内的特定结构体 (IMAGE_EXPORT_DIRECTORY) 保存着导出信息,且PE文件中仅有一个用来说明库EAT的IMAGE_EXPORT_DIRECTORY结构体

可以在PE头当中查找该结构体的位置,和IAT一样,这里是指的在库文件当中查找其结构体。

IMAGE_EXPORT_DIRECTORY的结构体代码如下

typedef struct _IMAGE_EXPORT_DIRECTORY
{
    DWORD	Characteristics;
    DWORD	TimeDateStamp;					// creation time date stamp (创建时间日期戳)
    DWORD	MajorVersion;
    DWORD	MinorVersion;
    DWORD	Name;							// address of library file name (库文件名地址)
    DWORD	Base;							// ordinal base (序数基数)
    DWORD	NumberOfFunctions;				// number of functions
    DWORD	NumberOfNames;					// number of names
    DWORD	AddressOfFunctions;				// address of function start address array
    DWORD	AddressOfNames;					// address of function name string array
    DWORD	AddressOfOrdinals;				// address of ordinal array
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;

介绍其中的重要成员

//NumberOfFunctions      实际Export函数的个数
//NumberOfNames          Export函数中具名的函数个数
//AddressOfFunctions     Export函数地址数组 (数组元素个数=NumberOfFuntions)
//AddressOfNames         函数名称地址数组 (数组元素个数=NumberOfNames)
//AddressOfNameOrdinals  Ordinal地址数组 (数组元素个数=NumberOfNames)

从库中获取函数地址的API为 GetProcAddress() 函数,该API引用EAT来获取指定API的地址, GetProcAddress() API拥有函数名称下面讲解其如何获取函数地址

从库中获取函数地址的API为 GetProcAddress() 函数,该API引用EAT来获取指定API的地址,下面写下获取函数地址的流程

  1. 利用 AddressOfNames 成员转到 “函数名称数组”
  2. “函数名称数组” 中存储着字符串地址。通过比较(strcmp)字符串,查找指定的函数名称(此时数组的索引值称为name_index)
  3. 利用 AddressOfNameOrdinal 成员,转到 ordinal 数组
  4. 在 ordinal 数组中通过 name_index 查找相应 ordinal 值
  5. 利用 AddressOfFunctions 成员转到 “函数地址数组”(EAT)
  6. 在 “函数地址数组” 中将刚刚求得的 ordinal 用作数组索引,获得指定函数的起始地址

(也就是上面1到5步都是找获得确定索引值,然后从EAT起始地址通过索引值找到正确地址)

提示: 对于没有函数名称的导出函数,可以通过 Ordinal 查找它们的地址。从Ordinal值中减去IMAGE_EXPORT_DIRECTORY.Base成员后得到一个值,使用该值作为“函数地址数组”的索引。

下面使用kernel32.dll进行练习,从其EAT中查找AddAtomW函数,如图

来到文件偏移

文末附加内容
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇