逆向学习笔记4
逆向学习笔记4
学习PE文件结构(按照《逆向工程核心原理》学习,如下代指“书”均指此书)
PE文件基本结构
(一)基本概念
PE文件是Windows操作系统下使用的可执行文件格式。它是微软在UNIX平台的COFF(Common Object File Format,通用对象文件格式)基础上制作而成的。最初(正如Portable这个单词所代表的那样)设计用来提高程序在不同操作系统上的移植性,但实际上这种文件格式仅用在Windows系列的操作系统下。
PE文件是指32位的可执行文件,也称为PE32。64位的可执行文件称为PE+或PE32+,是PE( PE32)文件的一种扩展形式(请注意不是PE64 )。
常见种类
种 类 | 主扩展名 | 种 类 | 主扩展名 |
---|---|---|---|
可执行系列 | EXE、SCR | 驱动程序系列 | SYS、VXD |
库系列 | DLL、OCX、CPL、DRV | 对象文件系列 | OBJ |
严格地说,OBJ(对象)文件之外的所有文件都是可执行的。DLL、SYS文件等虽然不能直接在Shell ( Explorer.exe)中运行,但可以使用其他方法(调试器、服务等)执行。
提示
根据PE正式规范,编译结果OBJ文件也视为PE文件。但是OBJ文件本身不能以任何形式执行,在代码逆向分析中几乎不需要关注它。
基本结构
下面以记事本( notepad.exe)程序进行简单说明,首先使用Hex Editor打开记事本程序。
下图是notepad.exe文件的起始部分,也是PE文件的头部分(PE header )。notepad.exe文件运行需要的所有信息就存储在这个PE头中。如何加载到内存、从何处开始运行、运行中需要的DLL有哪些、需要多大的栈/堆内存等,大量信息以结构体形式存储在PE头中。换言之,学习PE文件格式就是学习PE头中的结构体。
notepad.exe具有普通PE文件的基本结构。上图描述了notepad.exe文件加载到内存时的情形。其中包含了许多内容。
从DOS头 ( DOS header )到节区头 (Section header )是PE头部分,其下的节区合称PE体。文件中使用偏移(offset ),内存中使用VA (Virtual Address,虚拟地址)来表示位置。文件加载到内存时,情况就会发生变化(节区的大小、位置等)。文件的内容一般可分为代码(.text)、数据( .data)、资源( .rsrc)节,分别保存。
根据所用的不同开发工具(VB/VC++/Delphi/etc )与编译选项,节区的名称、大小、个数、存储的内容等都是不同的。最重要的是它们按照不同的用途分类保存到不同的节中。
各节区头定义了各节区在文件或内存中的大小、位置、属性等。
PE头与各节区的尾部存在一个区域,称为NULL填充(NULL padding )。计算机中,为了提高处理文件、内存、网络包的效率,使用“最小基本单位”这一概念,PE文件中也类似。文件/内存中节区的起始位置应该在各文件/内存最小单位的倍数位置上,空白区域将用NULL填充(看下图,可以看到各节区起始地址的截断都遵循一定规则)。
VA&RVA
VA指的是进程虚拟内存的绝对地址,RVA(Relative Virtual Address,相对虚拟地址)指从某个基准位置(ImageBase)开始的相对地址。VA与RVA满足下面的换算关系。
RVA+ImageBase=VA
PE头内部信息大多以RVA形式存在。原因在于,PE文件(主要是DLL)加载到进程虚拟内存的特定位置时,该位置可能已经加载了其他PE文件(DLL)。此时必须通过重定位(Relocation)将其加载到其他空白的位置,若PE头信息使用的是VA,则无法正常访问。因此使用RVA来定位信息,即使发生了重定位,只要相对于基准位置的相对地址没有变化,就能正常访问到指定信息,不会出现任何问题。
32位Windows OS中,各进程分配有4GB的虚拟内存,因此进程中VA值的范围是00000000~FFFFFFFF。
DOS头
微软创建PE文件格式时,人们正广泛使用DOS文件,所以微软充分考虑了PE文件对DOS文件的兼容性。其结果是在PE头的最前面添加了一个IMAGE_DOS_HEADER结构体,用来扩展已有的DOS EXE头。
IMAGE_DOS_HEADER结构体的大小为40个字节。在该结构体中必须知道2个重要成员:e_magic与e_lfanew。
e_magic: DOS签名( signature,4D5A=>ASCII值“MZ”)。(一个名叫 Mark Zbikowski的开发人员在微软设计了DOS可执行文件,MZ即取自其名字的首字母)
e_lfanew:指示NT头的偏移(根据不同文件拥有可变值)。
所有PE文件在开始部分( e_magic)都有DOS签名(“MZ”)。e_lfanew值指向NT头所在位置(NT头的名称为IMAGE_NT_HEADERS)。
注:
根据PE规范,文件开始的2个字节为4D5A,e_lfanew值为000000EO(不是E0000000 )。
DOS存根
DOS存根(stub)在DOS头下方,是个可选项,且大小不固定(即使没有DOS存根,文件也能正常运行)。DOS存根由代码与数据混合而成。
NT头(IMAGE_NT_HEADERS)
IMAGE_NT_HEADERS结构体由3个成员组成,第一个成员为签名(Signature)结构体,其值为50450000h(“PE”00)。另外两个成员分别为文件头( File Header )与可选头( Optional Header)结构体。
NT头中的文件头(对应书上95~97页)
文件头是表现文件大致属性的IMAGE_FILE_HEADER结构体。
IMAGE_FILE_HEADERS结构体中有如下4种重要成员(若它们设置不正确,将导致文件无法正常运行)。
1.Machine
每个CPU都拥有唯一的Machine码,兼容32位Intel x86芯片的Machine码为14C。以下是定义在winnt.h文件中的Machine码。
2.NumberOfSections
前面提到过,PE文件把代码、数据、资源等依据属性分类到各节区中存储。
NumberOfSections用来指出文件中存在的节区数量。该值一定要大于0,且当定义的节区数量与实际节区不同时,将发生运行错误。
3.SizeOfOptionalHeader
IMAGE_NT_HEADER结构体的最后一个成员为IMAGE_OPTIONAL_HEADER32结构体。SizeOfOptionaHeader成员用来指出IMAGE_OPTIONAL_HEADER32结构体的长度。IMAGE_OPTIONAL_HEADER32结构体由C语言编写而成,故其大小已经确定。但是Windows的PE装载器需要查看IMAGE_FILE_HEADER的SizeOfOptionalHeader值,从而识别出IMAGE_OPTIONAL_HEADER32结构体的大小。
PE32+格式的文件中使用的是IMAGE_OPTIONAL_HEADER64结构体,而不是IMAGE_OPTIONAL_HEADER32结构体。2个结构体的尺寸是不同的,所以需要在SizeOfOptionalHeader成员中明确指出结构体的大小。
提示
借助 IMAGE_DOS_HEADER的e_lfanew成员与 IMAGE_FILE_HEADER的SizeOfOptionalHeader成员,可以创建出一种脱离常规的PE文件(PE Patch )(也有人称之为“麻花”PE文件)。
4.Characteristics
该字段用于标识文件的属性,文件是否是可运行的形态、是否为DLL文件等信息,以bit OR形式组合起来。
NT头中的可选头
OPTIONAL_ HEADER32结构体中需要关注下列成员。这些值是文件运行必需的,设置错误将导致文件无法正常运行。
1.Magic
为IMAGE_OPTIONAL_HEADER32结构体时,Magic码为10B;为IMAGE_OPTIONAL_HEADER64结构体时,Magic码为20B。
2.AddressOfEntryPoint
AddressOfEntryPoint持有EP的RVA值。该值指出程序最先执行的代码起始地址,相当重要。
3.ImageBase
进程虚拟内存的范围是0FFFFFFF ( 32位系统)。PE文件被加载到如此大的内存中时,ImageBase指出文件的优先装人地址。
EXE、DLL文件被装载到用户内存的O~FFFFF中,SYS文件被载人内核内存的800000-FFFFFF中。一般而言,使用开发工具( VB/VC++/Delphi)创建好EXE文件后,其ImageBase的值为00400000,DLL 文件的ImageBase值为10000000 (当然也可以指定为其他值)。执行PE文件时,PE装载器先创建进程,再将文件载人内存,然后把EIP寄存器的值设置为ImageBase+AddressOfEntryPoint。
4.SectionAlignment, FileAlignment
PE文件的Body部分划分为若干节区,这些节存储着不同类别的数据。FileAlignment指定了节区在磁盘文件中的最小单位,而SectionAlignment则指定了节区在内存中的最小单位(一个文件中,FileAlignment与SectionAlignment的值可能相同,也可能不同)。磁盘文件或内存的节区大小必定为FileAlignment或SectionAlignment值的整数倍。
5.SizeOflmage
加载PE文件到内存时,SizeOflmage指定了PE Image在虚拟内存中所占空间的大小。一般而言,文件的大小与加载到内存中的大小是不同的(节区头中定义了各节装载的位置与占有内存的大小,后面会讲到)。
6.SizeOfHeader
SizeOfHeader用来指出整个PE头的大小。该值也必须是FileAlignment的整数倍。第一节区所在位置与SizeOfHeader距文件开始偏移的量相同。
7.Subsystem
该Subsystem值用来区分系统驱动文件(.sys)与普通的可执行文件(.exe,*.dll)。Subsystem成员可拥有的值如下表所示。
8.NumberOfRvaAndSizes
NumberOfRvaAndSizes用来指定DataDirectory( IMAGE_ OPTIONAL_ HEADER32结构体的最后一个成员)数组的个数。虽然结构体定义中明确指出了数组个数为IMAGE NUMBEROF_DIRECTORY ENTRIES(16),但是PE装载器通过查看NumberOfRvaAndSizes值来识别数组大小,换言之,数组大小也可能不是16。
9.DataDirectory
DataDirectory是由IMAGE DATA_ DIRECTORY结构体组成的数组,数组的每项都有被定义的值。
(注:以上文字最好配合书98~101页图片一起看)
节区头
节区头中定义了各节区属性。看节区头之前先思考- -下:前面提到过,PE文件中的code(代码)、data(数据)、resource(资源)等按照属性分类存储在不同节区,设计PE文件格式的工程师们之所以这样做,一定有着某些好处。
我认为把PE文件创建成多个节区结构的好处是,这样可以保证程序的安全性。若把code与data放在一个节区中相互纠缠(实际上完全可以这样做)很容易引发安全问题,即使忽略过程的烦琐。
假如向字符串data写数据时,由于某个原因导致溢出(输入超过缓冲区大小时),那么其下的code(指令)就会被覆盖,应用程序就会崩溃。因此,PE文件格式的设计者们决定把具有相似属性的数据统一保存在一个被称为“节区”的地方,然后需要把各节区属性记录在节区头中(节区属性中有文件/内存的起始位置、大小、访问权限等)。
换言之,需要为每个code/data/resource分别设置不同的特性、访问权限等
IMAGE_SECTION_HEADER
节区头是由IMAGE_ SECTION_ HEADER结构体组成的数组,每个结构体对应一个节区。
IMAGE_SECTION_HEADER结构体的重要成员:
特别注意:讲解PE文件时经常出现“映像”( Image)这一术语,希望各位牢记。PE文件加载到内存时,文件不会原封不动地加载,而要根据节区头中定义的节区起始地址、节区大小等加载。因此,磁盘文件中的PE与内存中的PE具有不同形态。将装载到内存中的形态称为“映像”以示区别,使用这一术语能够很好地区分二者。
RVA to RAW
RVA to RAW:PE文件加载到内存时,每个节区准确完成的内存地址与文件偏移间的映射
- 查找RVA所在节区
- 计算文件偏移(RAW),公式如下:
4、IAT
导入地址表(Import Address Table,IAT)是一种表格,记录程序正在使用哪些库中的哪些函数
(1)DLL
动态链接库(Dynamic Linked Library, DLL)
32位时引入
库单独组成DLL,需要时调用
内存映射技术使得DLL代码在多个进程中共享
更新库的时候只要更新DLL文件
加载方式
显式链接:程序使用DLL时加载,使用完释放内存
隐式链接:程序开始时一同加载DLL,程序终止时释放内存
IAT就是隐式链接,几个原因:
不同环境,函数和DLL的位置不同
DLL重定位问题使得无法对地址硬编码
PE头中用的是RVA
(2)IMAGE_IMPORT_DESCRIPTOR
IMAGE_IMPORT_DESCRIPTOR结构体记录了PE文件要导入哪些库文件,导入多少个库就有多少个IMAGE_IMPORT_DESCRIPTOR结构体,形成以NULL结尾的数组。IMAGE_IMPORT_DESCRIPTOR结构体具体如下图所示:
5、EAT(书112~114建议配图观看)
EAT使不同的应用程序可以调用库文件中提供的函数,即通过EAT才能得到从相应库中导出函数的起始地址,由IMAGE_EXPORT_DIRECTORY结构体保存导出信息
可以在PE头中找到IMAGE_EXPORT_DIRECTORY结构体的位置,起始地址是IMAGE_OPTIONAL_HEADER32.DataDirectory[0].VirtualAddress值(即RVA值)
PE文件的加载(大致流程)
- 读取PE文件,按照PE格式进行解析
- 申请内存,ImageBase作为内存基地址,SizeOfImage作为长度
- 将PE文件头复制到内存中
- 解析Section的地址并将Section复制到内存中
- 基于重定位表修改内存
- 解析导入表,加载所需的Dll
- 跳转到入口地址AddressOfEntryPoint,执行PE文件
PE结构,不同地址之间的转换
在可执行文件PE文件结构中,通常我们需要用到地址转换相关知识,PE文件针对地址的规范有三种,其中就包括了VA,RVA,FOA三种,这三种该地址之间的灵活转换也是非常有用的
如下是三种格式的异同点:
- VA(Virtual Address,虚拟地址):它是在进程的虚拟地址空间中的地址,用于在运行时访问内存中的数据和代码。VA是相对于进程基址的偏移量。在不同的进程中,相同的VA可能映射到不同的物理地址。
- RVA(Relative Virtual Address,相对虚拟地址):它是相对于模块基址(Module Base Address)的偏移量,用于定位模块内部的数据和代码。RVA是相对于模块基址的偏移量,通过将模块基址和RVA相加,可以计算出相应的VA。
- FOA(File Offset Address,文件偏移地址):它是相对于文件起始位置的偏移量,用于定位可执行文件中的数据和代码在文件中的位置。通过将文件偏移地址和节表中的指定节的起始位置相加,可以计算出相应的FOA。
VA虚拟地址转换为FOA文件偏移
VA地址代指的是程序加载到内存后的内存地址,而FOA
地址则代表文件内的物理地址,通过编写VA_To_FOA
则可实现将一个虚拟地址转换为文件偏移地址,该函数的实现方式,首先得到ImageBase
镜像基地址,并得到NumberOfSections
节数量,有了该数量以后直接循环,通过判断语句将节限定在一个区间内该区间dwVA >= Section_Start && dwVA <= Section_Ends
,当找到后,首先通过VA-ImageBase
得到当前的RVA
地址,接着通过该地址减去VirtualAddress
并加上PointerToRawData
文件指针,即可获取到文件内的偏移。
RVA相对地址转换为FOA文件偏移
所谓的相对地址则是内存地址减去基址所获得的地址,该地址的计算同样可以使用代码实现,如下RVA_To_FOA
函数可用于将一个相对地址转换为文件偏移,如果内存VA
地址是0x401000
而基址是0x400000
那么相对地址就是0x1000
,将相对地址转换为FOA
文件偏移,首相要将相对地址加上基址,我们通过相对地址减去PointerToRawData
数据指针即可获取到文件偏移。
FOA文件偏移转换为VA虚拟地址
将文件内的偏移地址FOA
转换为内存虚拟地址,在转换时首先通过VirtualAddress
节虚拟地址加上,文件偏移地址减去PointerToRawData
数据域指针,得到相对地址,再次加上ImageBase
基地址即可获取到实际虚拟地址。
PE文件的函数导出
在PE文件中,函数的导出是通过导出表(Export Table)来实现的。导出表是一个数据结构,用于记录可供外部程序调用的函数和变量的信息。
1、导出表的结构
导出表是option可选PE头的数据目录项的第一个,数据目录项的结构如下
其中VirtualAddress 在内存中的偏移,Size为大小。可以通过数据目录项找到RVA(内存偏移),然后转换到FOA(文件偏移),来定位导出表
2、函数地址定位过程
其中需要注意的点是:
1、AddressofName 为函数名称表的RVA,需要转为FOA,每个表存的为名字的RVA地址,也需要转为FOA。
2、通过名称定位函数地址的过程如下:
通过名字在名称表找到Ordinals表下标,然后通过下标在Ordinals表中找到Function表中的下标,Function对应下标的内容即为函数地址。
3、通过序号定位函数地址的过程如下:
序号-Base =Function表中的下标,然后拿着下标去Function表中找到对应的函数地址
打印PE头
要打印出PE头,可以通过读取PE文件的文件头和可选头来获取相关信息。
如下代码:
1、打开文件,并判断是否打开成功
1 |
|
2、读取文件大小
1 | fseek(pFile,0,SEEK_END); //将指针从开始的位置移动到末尾 |
3、申请内存,并判断是否申请成功
1 |
|
4、将文件读取到内存中
1 | fseek(pFile,0,SEEK_SET); //将指针指向开始 |
5、关闭文件
1 | fclose(pFile); |
6、判断是否有效MZ标识
1 | //定义几个变量 |
7、打印DOS头
1 | pDosHeader = (PIMAGE_DOS_HEADER)pFileBuffer; //类型转换 |
8、判断是否为PE标识
1 | if(*((PDWORD)((DWORD)pFileBuffer + pDosHeader->e_lfanew)) != IMAGE_NT_SIGNATURE) |
9、打印PE文件标识
1 | pNTHeader = (PIMAGE_NT_HEADERS)((DWORD)pFileBuffer + pDosHeader->e_lfanew); //将指针移动到PE头开始位置,并且类型转换 |
10、打印标准PE头
1 | pPEHeader = (PIMAGE_FILE_HEADER)(((DWORD)pNTHeader) + 4); |
11、打印可选PE头
1 | printf("*************************可选PE头*************************\n"); |
12、释放内存
1 | free(pFileBuffer); |
完整代码:
1 |
|
总结一下,大致流程为:
1、打开文件,并判断是否打开成功
2、读取文件大小
3、申请内存,并判断是否申请成功
4、将文件读取到内存中
5、关闭文件
6、判断是否有效MZ标识
7、打印DOS头
8、判断是否为PE标识
9、打印PE文件标识
10、打印标准PE头
11、打印可选PE头
12、释放内存
PE文件修改节区头、节区
pe文件节区的删除和增加节区(.reloc)(按照《逆核》P142,书中的步骤来进行)
整个过程需要四个步骤:
1.删除节区头
2.删除节区
3.修改节区数(number of section)
4.修改映像大小(size of image)
删除节区头
在hxd中找到rva从270到297区域,用0填充
删除节区内容
在hxd中找到poiner to raw data(磁盘中地址c000),删除其后面的所有内容
修改节区数量
找到文件头中的 number of section
同样在hxd中修改其值为4
修改映像大小
因为该文件的section alignment 大小是1000(他指定了节区在内存中的最小单位),而.reloc 节区的大小是E40,所以就要自动补齐为最小单位1000,所以删除该节区就要把image减去1000
将减去的结果在hxd中修改
修改成功
试一下reloc.exe是否能正常运行
增加节区
现在来增加一个名字为new的节区
步骤
1.增加节区头
2.修改节区数量
3.修改image大小
4.增加节区内容
5.修改新增节区的属性(VirtualSize,VirtualAddress,SizeOfRawData,PointerToRawData)
一,增加节区头
hxd中找到之前节区头最后的位置,判断是否有足够空间(看characteristics的第一个数据:40)存放一个节区头
然后填充节区头内容
二,修改节区头数
与删除节区操作一样,将5改为6
三,修改image大小
与之前相同,image大小增加1000 = 12000
四,增加节区内容
在原有的最后一个节区的末尾,增加一个节区的随机内容
五,修改新节区属性大小
现在需要将此时此刻修改一部分的文件保存,再载入peview查看
此时.new节区的属性还没有确定
修改VirtualSize
修改为1000
修改RVA
这里要按照上一个节区的大小来修改(注意对齐)
上一个节区的VirtualSize是E40,对齐后是1000,所以新节区的RVA就是10000+1000=11000,修改它
修改SizeOfRawData
这个大小取决于我们增加的节区内容大小(1000)
修改pointer to raw data
修改的依据是上面两个值相加=D000
修改成功
重新载入peview查看
总结
第五周主要的内容还是很多的,很多东西只是学到了表面,所以我决定在之后的一段时间内,多读《逆向工程核心原理》,争取把现在囫囵吞枣学到的知识全部消化下去(这段时间被队友压力打极客去了,时间有点紧,交报告有点晚QAQ,希望学长原谅,ORZ)