PE文件文件笔记

发布时间 2023-10-29 23:20:47作者: yangtt57

PE

PE简介

可执行文件(executable file)指的是可以由操作系统进行加载执行的文件。

大致有两种可执行文件的格式:

  1. PE 文件格式(Windows 平台);
  2. ELF 文件格式(Linux 平台)。

其中常见的PE文件格式的可执行文件有:exe, sys, dll 等。

PE文件格式与win32汇编的关系

由于EXE文件被执行、传播的可能性最大,因此Win32 病毒感染文件时,基本上都是将EXE 文件作为目标。

通常而言,Win32 病毒的运行流程为:

  1. 用户点击或系统自动运行 HOST 程序;
  2. 装载HOST 程序到内存;
  3. 通过 PE 文件中的 AddressOfEntryPoint 和 ImageBase 之和来定位第一条语句的位置;
  4. 从第一条语句开始执行(病毒代码可能在此时或HOST代码运行过程中获得控制权);
  5. 病毒主体代码执行完毕,将控制权交还给 HOST 程序;
  6. HOST 程序继续运行。

相关概念

  1. 虚拟内存地址(VA)PE 文件中的指令被装入内存后的地址。

  2. 相对虚拟内存地址(RVA)相对于可执行文件映射到内存的基地址的偏移量。

  3. 装载基址(ImageBase)PE 装入内存时的基地址。默认情况下,EXE 文件在内存中的基地址为 0x0040 0000, DLL 文件为 0x1000 0000.

    $$VA=ImageBase+RVA$$

文件结构

image-20231007213716766

  1. MZ 文件头: 前两个字节 4D5A 表示是PE文件格式;

  2. DOS 插桩程序: 向下兼容部分,C代码结构体如下:

    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 指出PE头的偏移地址
      } IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
    
  3. NT 头

    该部分结构对应winnt.h中的_IMAGE_NT_HEADERS结构体

        typedef struct _IMAGE_NT_HEADERS64 {
          DWORD Signature;
          IMAGE_FILE_HEADER FileHeader;
          IMAGE_OPTIONAL_HEADER64 OptionalHeader;
        } IMAGE_NT_HEADERS64,*PIMAGE_NT_HEADERS64;
    
        typedef struct _IMAGE_NT_HEADERS {
          DWORD Signature;
          IMAGE_FILE_HEADER FileHeader;
          IMAGE_OPTIONAL_HEADER32 OptionalHeader;
        } IMAGE_NT_HEADERS32,*PIMAGE_NT_HEADERS32;
    
        typedef struct _IMAGE_ROM_HEADERS {
          IMAGE_FILE_HEADER FileHeader;
          IMAGE_ROM_OPTIONAL_HEADER OptionalHeader;
        } IMAGE_ROM_HEADERS,*PIMAGE_ROM_HEADERS;
    
    • Signature: 即PE\0\0,它标志着NT映像头的开始,也是PE文件中与Windows有关内容的开始.

    • Fileheader: 映像文件头即影响头的主要部分,它包含PE文件最基本的信息,它的结构定义如下:

          typedef struct _IMAGE_FILE_HEADER {
            2 WORD Machine;	机器类型
            2 WORD NumberOfSections;	文件中节的个数
            4 DWORD TimeDateStamp;	生成文件的时间
            4 DWORD PointerToSymbolTable;	COFF符号表的偏移
            4 DWORD NumberOfSymbols; 	符号数目
            2 WORD SizeOfOptionalHeader;	可选头的大小
            2 WORD Characteristics;	标记
          } IMAGE_FILE_HEADER,*PIMAGE_FILE_HEADER;
      
    • OptionalHeader: 可选的结构,C语言结构体定义为:

          typedef struct _IMAGE_OPTIONAL_HEADER {
      
            WORD Magic;
            BYTE MajorLinkerVersion;
            BYTE MinorLinkerVersion;
            DWORD SizeOfCode;
            DWORD SizeOfInitializedData;
            DWORD SizeOfUninitializedData;
            DWORD AddressOfEntryPoint;
            DWORD BaseOfCode;
            DWORD BaseOfData;
            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;
      

      Datadirectory[16]:数据目录表,指向输入表、输出表、资源块等数据,他的成员如下表所示。

      image-20231023200215995

      PE文件在定位输入表、输出表和资源等重要数据时就是从IMAGE_DATA_DIRECTORY结构开始的。若表示该项的8个字节均为0,则表示不存在该表。

  4. 节表(section table): 结构数组,其中每个结构包含一个节的具体信息,每个结构占用 28H字节

    节表是PE文件后续节的描述,Windows根据节表的描述加载每个节。PE文件中所有节的属性都被定义在节表中,节表由一系列的IMAGE_SECTION_HEADER结构排列而成,每个结构用来描述一个节,结构的排列顺序和它们描述的节在文件中的排列顺序是一致的。全部有效结构的最后以一个空的IMAGE_SECTION_HEADER结构作为结束,所以节表中IMAGE_SECTION_HEADER结构数量等于节的数量加一。

    #define IMAGE_SIZEOF_SHORT_NAME 8
    
        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;
    

    块表主要用来表示当前文件一共分为几个部分,和后面的块相对应.

    块表决定了后面的块,每一块从哪里开始,里面存储的数据是什么等等.

    域 名 含义
    VirtualSize 该节的实际字节数,这是区块的数据在没有进行对齐处理前的实际大小。
    VirtualAddress 该区块装载到内存中的RVA地址。这个地址是按照内存页来对齐的,因此它的数值总是SectionAlignment的值的整数倍。
    SizeOfRawData 该区块在磁盘中所占的大小,这个数值等于VirtualSize字段的值按照FileAlignment的值对齐以后的大小。
    PointerToRawData 该节在磁盘文件中的偏移,程序经编译或汇编后生成原始数据,这个字段用于给出原始数据在文件中的偏移。
    PointerToRelocations PE 文件在调入内存后该节的存放位置 。(在EXE文件中无意义)
    Characteristics 节的属性,该字段是按位来指出区块的属性(如代码/数据/可读/可写等)的标志。这个字段的值是将多个标志值求或

    代码节的属性一般为60000020H,也就是可执行、可读;

    数据节的属性一般为C0000040H,即可读、可写;

    image-20231023200657301

  5. 每个节实际上是一个容器,可以包含代码、数据等等,每个节可以有独立的内存权限,比如代码节默认有读/执行权限,节的名字和数量可以自己定义。

    通常,区块中的数据在逻辑上是关联的。PE 文件一般至少都会有两个区块:一个是代码块,另一个是数据块。每一个区块都需要有一个截然不同的名字,这个名字主要是用来表达区块的用途。例如有一个区块叫.rdata,表明他是一个只读区块。注意:区块在映像中是按起始地址(RVA)来排列的,而不是按字母表顺序

    另外,使用区块名字只是人们为了认识和编程的方便,而对操作系统来说这些是无关紧要的。微软给这些区块取了个有特色的名字,但这不是必须的。当编程从PE 文件中读取需要的内容时,如输入表、输出表,不能以区块名字作为参考,正确的方法是按照数据目录表中的字段来进行定位。

    这里给出常见的区块:

    image-20231023201544401

    image-20231023201601756

    image-20210315144652675

可以使用 010editor中的模板来查看PE格式的文件

image-20231020204159912

区块的对齐

区块的大小是需要对齐的,有两种对齐值:一种用于磁盘文件内,另一种用于内存中。这两个值通常是不同的,在这个时候就需要进行地址间的转换。

下面这一段描述摘自《加密与解密(第四版)》

在PE文件头里,FileAlignment定义了磁盘区块的对齐值。每一个区块从对齐值的倍数的偏移位置开始,而区块的实际代码或数据的大小不一定刚好是这么多,所以在不足的地方一般以00来填充,这就是区块的间隙。例如,在PE文件中,一个典型的对齐值是200h,这样每个区块从200h的倍数的文件偏移位置开始。假设区块的第1个节在400h处,长度为90h,那么400h~490h为这一区块的内容,而文件对齐值是200h,为了使这一节的长度为FileAlignment的整数倍,490h~600h会被0填充,这段空间称为区块间隙,下一个区块的开始地址为600。

在PE文件头里,SectionAlignment定义了内存中区块的对齐值。当PE文件被映射到内存中时,区块总是至少从一个页边界处开始。也就是说,当一个PE文件被映射到内存中时,每个区块的第1个字节对应于某个内存页。在x86系列CPU中,内存页是按4KB(1000h)排列的;在x64中,内存页是按8KB(2000h)排列的。所以,在x86系统中,PE文件区块的内存对齐值一般为1000h,每个区块从1000h的倍数的内存偏移位置开始。

当磁盘对齐值和内存对齐值不同时,需要进行转换,方式如下:

image-20231023202514306

在上图中可以观察到,文件被映射到内存中时,MS-DOS头部、PE文件头和块表的偏移与大小均没有发生变化,而之后的区块的偏移位置则发生了变化。

$FileOffset=RVA-(add2-add1)$

add2:该节的RVA;add1:该节在文件中的偏移。

实际操作中推荐使用 Stud_PE 进行转换:

image-20231023203327645

DLL文件

DLL 文件为动态链接文件,又被称为“应用程序拓展”,时软件文件类型。在Windows中,许多应用程序并不是一个完整的可执行文件,他们被分割成一些相对独立的动态链接库,即DLL文件,放置于系统中。当我们执行某一个程序时,相应的DLL文件就会被调用。一个应用程序可以使用多个DLL文件,一个DLL文件也可能被多个应用程序使用(共享DLL文件)。

image-20231023204916585

这里如果向创建DLL文件可以可以使用DEV创建DLL项目,然后上网搜教程就OK了。

image-20231023214001838

之后把DLL文件拷贝到需要使用的目录,然后进行调用。

// #include <iostream>
#include <windows.h>
/*
Windows头文件中包含可以加载DLL的函数。 
*/
typedef void(*ptrSub)();
/*
在调用DLL函数之前,要定义函数指针,用来调用函数。
可以看出,函数指针的类型与DLL中的要一致。
*/

/*
    调用LoadLibrary函数加载DLL文件。加载成功,hMod指针不为空。
    这里也可以是一个地址加文件名
*/
int main()
{
    HMODULE hMod = LoadLibrary("DLLProject.dll");
    if (hMod != NULL) 
    {
        /*
        如果加载成功,则可通过GetProcAddress函数获取DLL中需要调用的函数的地址。
        获取成功,sum指针不为空。
        */
        ptrSub hello ;
		hello=(ptrSub)GetProcAddress(hMod, "HelloWorld");
        if (hello != NULL)
        {
            hello();
            /*获取地址成功后,通过sum调用函数功能。*/
        }
        FreeLibrary(hMod);
        /*在完成调用功能后,不在需要DLL支持,则可以通过FreeLibrary函数释放DLL。*/
    }
}

输入表

可执行文件使用来自其他DLL的代码或数据的动作称为输入。当PE文件被载入时,Windows加载器的工作之一就是定位所有被输入的函数或数据,并让正在载入的文件可以使用这些地址。这个过程就是通过PE文件的输入表(导入表)完成的。输入表中保存的时函数名和其驻留的DLL名等动态链接所需要的信息。

下面这一段内容摘自《加密与解密(第四版)》

导入函数的调用

在代码分析或编程中经常会遇到输入函数(Import Functions,或称导人函数)。输人函数就是被程序调用但其执行代码不在程序中的函数,这些函数的代码位于相关的DLL文件中,在调用者程序中只保留相关的函数信息,例如函数名、DLL文件名等。对磁盘上的PE文件来说,它无法得知这些输入函数在内存中的地址。只有当PE文件载人内存后,Windows加载器才将相关DLL载人,并将调用输人函数的指令和函数实际所处的地址联系起来。

当应用程序调用一个DLL的代码和数据时,它正在被隐式地链接到DLL,这个过程完全由Windows加载器完成。另一种链接是运行期的显式链接,这意味着必须确定目标DLL已经被加载,然后寻找API的地址,这几乎总是通过调用LoadLibrary和GetProcAddress完成的。

当隐含地链接一个API时,类似LoadLibrary和GetProcAddress的代码始终在执行,只不过这是由Windows加载器自动完成的。Windows加载器还保证了PE文件所需的任何附加的DLL都已载入。例如,Windows2000/XP上每个由Visual C.++创建的正常程序都要链接KERNEL32.DLL,而它又从NTDLL.DLL中输人函数。同样,如果链接了GDI32.DLL,它又依赖USER32、ADVAPI32、NTDLL和KERNEL32等DLL的函数,那么都要由Windows加载器来保证载入并解决输入问题。

这也就是即使只有几行代码,但是生成的EXE文件依然不小的原因。

在PE文件内有一组数据结构,它们分别对应于被输人的DLL。每一个这样的结构都给出了被输入的DLL的名称并指向一组函数指针。这组函数指针称为输入地址表(Import Address Table,IAT)。每一个被引入的API在IAT里都有保留的位置,在那里它将被Windows加载器写人输人函数的地址。最后一点特别重要:一旦模块被载入,IAT中将包含所要调用输入函数的地址。

为了区分输入函数调用和普通函数调用,CALL 有两种形式

CALL DWORD PTR XXXXXXX  => 普通函数调用
CALL XXXXXXX;DWORD PTR YYYYYY; => 输入函数调用

image-20231023220754614

输入表的结构

输入表以一个IMAGE_IMPORT_DESCRIPTOR数组开始,它的结构如下:

image-20231023221540013

  1. OriginalFirstThunk(Characteristics):包含指向输入名称表(INT)的RVA。INT是一个IMAGE_THUNK_DATA结构的数组,数组中的每个IMAGE_THUNK_DATA结构都指向IMAGE_IMPORT_BY_NAME结构,数组以一个内容为O的IMAGE_THUNK DATA结构结束。
  2. TimeDateStamp:一个32位的时间标志,可以忽略。
  3. ForwarderChain:这是第1个被转向的API的索引,一般为0,在程序引用一个DLL中的API,而这个API又在引用其他DLL的API时使用(但这样的情况很少出现)。
  4. Name:DLL名字的指针。它是一个以“O0”结尾的ASCII字符的RVA地址,该字符串包含输人的DLL名,例如“KERNEL32.DLL”“USER32.DLL”。
  5. FirstThunk:包含指向输人地址表(IAT)的RVA。IAT是一个IMAGE_THUNK_DATA结构
    的数组。

image-20231023223349846

每个IMAGE_THUNK_DATA元素对应于一个从可执行文件输人的函数。两个数组的结束都是由一个值为O的IMAGE_THUNK_DATA元素表示的。IMAGE_THUNK_DATA结构实际上是一个双字,该结构在不同时刻有不同的含义,具体如下。

image-20231023223512514

当IMAGE_THUNK_DATA值的最高位为1时,表示函数以序号方式输入,这时低31位(或者一个64位可执行文件的低63位)被看成一个函数序号。当双字的最高位为0时,表示函数以字符串类型的函数名方式输入,这时双字的值是一个RVA,指向一个IMAGE_IMPORT_BY_NAME结构。

image-20231023223812505

为什么会有两个并行的指针数组指向IMAGE_IMPORT_BY_NAME结构呢?第1个数组(由OriginalFirstThunk所指向)是单独的一项,不可改写,称为INT,有时也称为提示名表(Hint-nameTable)。第2个数组(由FirstThunk所指向)是由PE装载器重写的。PE装载器先搜索OriginalFirstThunk,如果找到,加载程序就迭代搜索数组中的每个指针,找出每个IMAGE_IMPORT_BY_NAME结构所指向的输人函数的地址。然后,加载器用函数真正的入口地址来替代由FirstThunk指向的IMAGE_THUNK_.DATA数组里元素的值。“Jmp dword ptr[XXXXXXXX]”语句中的“[XXXXXXXx]”是指FirstThunk数组中的一个人口,因此称为输入地址表(Import Address Table,IAT)。所以,当PE文件装载内存后准备执行时,图11.13已转换成如图11.14所示的状态,所有函数入口地址排列在一起。此时,输入表中的其他部分就不重要了,程序依靠IAT提供的函数地址就可以正常运行。

image-20231023224845975

输出表的结构

引出函数节一般名为 .edata,这是本文件向其他程序提供调用函数的列表所在的“索引”及具体代码实现(引出目录表)。

我们可以通过其索引部分来找到对应函数的具体地址。

输出表是数据目录项的第一个成员,指向 IMAGE_EXPORT_DIRECTORY结构,结构如下图所示:

image-20231029203739790

其各个字段的含义分别为:

image-20231029203822733

通过函数名定位函数的导出地址,这里以ExitProcess 为例:

  1. 从AddressOfNames 指向的指针数组中找到ExitProcess ,并记下该数组的序号x;
  2. 从AddressofNameOrdinals 指向的数组中,定位第x项成员,得到序号y;
  3. 从AddressOfFunction 指向的数组中定位第y项获得该函数的RVA。

资源节

Windows程序的各种界面称为资源,包括加速键、位图、光标、对话框、图标、菜单、串表、工具栏和版本信息等。在PE文件的所有结构中,资源部分是最复杂的。

资源结构

资源用类似磁盘目录结构的方式保存,目录通常包含三层。目录结构如下图所示:

image-20231029210415322

资源目录结构中的每一个节点都是由IMAGE_RESOURCE_DIRECTORY 结构和紧随其后的数个 IMAGE_RESOURCE_DIRECTORY_ENTRY 结构组成的,这两种结构组成一个目录块。

IMAGE_RESOURCE_DIRECTORY 结构:

image-20231029210711747

NumberOfNamedEntries+NumberOfIdEntries = 该目录项IMAGE_RESOURCE_DIRECTORY_ENTRY 结构的数量

IMAGE_RESOURCE_DIRECTORY_ENTRY 结构

image-20231029210913765

image-20231029211547046

  1. name 字段定义目录项的名称或ID。
  2. offsetToData: 是一个指针。当最高位为1时,低位数据指向下一层目录块的起始地址;当最高位是0时,指针指向IMAGE_RESOURCE_DATA_ENTRY 结构。

image-20231029211936625

重定位表

当链接器生成一个PE文件时,会假设这个文件在执行时会被装载到默认的基地址处,并将code和data的相关地址都写入PE文件。如果载入时将默认的值作为基地址载入,则不需要重定位。但是,如果PE文件被载入到虚拟内存的另外一个地址中国,链接器登记的那个地址就是错误的,这时候就需要重定位表来调整(.reloc)。

详细介绍可以看这个链接,写的挺详细的

实战

向空白区添加代码

通常而言,PE文件在文件中向200H对其,在内存中向1000H对其,因此在PE文件中会出现较多的00字节,我们就可以利用这些区域来写入代码,这里利用的是代码节。

本次实验目的是在目标程序执行前执行MessageBoxA(0, 0, 0, 0)

基本思路:

准备MessageBoxA所需要的参数,call MessageBoxA ,调用完之后返回到正常执行的代码部分。

  1. 获取MessageBoxA的地址

    使用dp MessageBoxA即可在函数入口位置加入断点,进而可以获取函数入口地址 77D707EA。

    image-20231020223501017

  2. 计算E8 E9

    E8 是call 指令的硬编码,他们之间的对应形式为

    aaa: E8 xxx : call yyy

    bbb: next insruction

    其中$$yyy=xxx+bbb$$,即跳转地址为下一条指令的地址加上偏移值。

    E9 是 jmp 指令的硬编码,使用形式和call一致。

    并且FOA与RVA的值不同,要进行转换:FOA = n.PointerToRawData + 偏移(即在RVA中所属节+偏移,因为在内存中节空隙之间会被拉伸,但节中的数据并不会被拉伸)

    E8后跟随的硬编码 = 要跳转的地址- E8下条指令所在的地址

    E9后跟随的硬编码 = 要跳转的地址- E9下条指令所在的地址

    假设我们尝试在这个位置写入代码,即0x0450,对应的虚拟地址为 400000 + 0050(相对400的偏移)+ 1000 = 401050 :

    image-20231020224106966

    那么 E8 后面的数值为 77D507EA - 40105D = 7794F78D

    程序的入口点为 401000h,那么E9 后面的值为 401000h - 401062h = A1

  3. 将结果按小端序写入

    image-20231020230557633

  4. 修改OEP使其指向我们加入的代码

    image-20231020234535400

  5. 结果展示

    image-20231020234515921

  6. 心得体会

    当文件页面大小与内存页面大小不一致时,需要进行FOA与RVA的转换 => 使用偏移地址和基地址。

DLL 注入:静态修改PE文件

处理并加载输入表中的DLL模块是进程创建阶段一项非常重要的工作。当一个进程被创建后,不会直接到EXE本身的入口处执行,首先被执行的是ntdll.dl中的LdrInitializeThunk函数(ntdll是Windows操作系统中一个非常重要的基础模块,它在进程创建阶段就已经被映射到新进程中了)。LdrInitializeThunk会调用LdrpInitializeProcess对进程的一些必要内容进行初始化,LdrpInitializeProcess会继续调用LdrpWalkImportDescriptor对输人表进行处理,即加载输人表中的模块,并填充应用程序的IAT。所以,只要在输人表被处理之前进行干预,为输入表增加一个项目,使其指向要加载的目标DLL,或者替换原输入表中的DLL并对调用进行转发,那么新进程的主线程在输入表初始化阶段就会主动加载目标DLL。

准备工作:一个自行编写的msg.dll

DLL 的文件内容如下:

#include <windows.h>
#include <stdio.h>
DWORD WINAPI ThreadShow(LPVOID lpParameter);
BOOL APIENTRY DllMain( HMODULE hModule,             //指向自身的句柄
                       DWORD  ul_reason_for_call,   //调用原因
                       LPVOID lpReserved            //静态加载为非NULL,动态加载为NULL
                     ) 
{
	if(ul_reason_for_call == DLL_PROCESS_ATTACH) {
		CreateThread(NULL,0,ThreadShow,NULL,0,NULL);
	}
	return TRUE;
}

DWORD WINAPI ThreadShow(LPVOID lpParameter) {
	char szPATH[MAX_PATH] ={0};
	char szBUF[1024]={0};
	
	// 获取宿主进程全路径
	GetModuleFileName(NULL,szPATH,MAX_PATH);
	sprintf(szBUF,"DLL 已注入到进程 %s [pid = %lu]\n",szPATH,GetCurrentProcessId());
	MessageBox(NULL,szBUF,"DLL Inject",MB_OK);
	printf("%s",szBUF);
	OutputDebugString(szBUF);
	return 0;
}
__declspec(dllexport) void Msg()
{
    return;
}
Msg()函数是msg.dll文件项外部提供服务的导出函数,它没有任何功能,为了保持形式上的完整性,是msg.dll能够顺利添加到hello.exe文件的导入表。
在PE文件中导入某个DLL,实质就是在文件伪造内调用该DLL提供的导出函数,PE文件头记录着DLL名称、函数名称等信息,因此,msg.dll至少要提供1个以上的导出函数才能保持形式上的完整性。

执行结果为:

image-20231024000117854

修改对象:hello.exe

(这个应该是软件安全实验最常用的?)

修改目标:让hello.exe启动时能够加载 msg.dll

修改流程

回顾输入表的结构如下:

image-20231024195608366

该结构简称IID,一个输入表就是一个IID数组,最后一项为全0,作为结束标志。要添加一个IID,就需要修改紧邻当前IID数组的一块内存,这块内存一般是与IID相关连的结构 OriginalFirstThunk和FirstThunk,不能被覆盖。也可以参考下面的关系图(注意指向都是靠的地址,只有在最后一部分才存的内容):

image-20231024200754992

因此,必须要整体移动IID数组到一个新的位置,此时就必须要考虑内存占用的问题。由于只添加了一个IDD,它所关联的OriginalFirstThunk和FirstThunk 不会占用太多的空间,直接使用原来IID数组所在的内存即可

  1. 寻找放置新IID数组的位置

    查看输入表的位置和大小:image-20231024201441453

    整理以下得到:

    ImportAddress RVA RowoOffset Size newIIDSize
    0x2014 0x614 0x60 0x60 + 0x14 = 0x74

    这里在引入函数节的部分有很多填充区域可以使用,就可以直接在这部分写入,否则就需要新增节或拓展节。

    image-20231024201951853

    这里选择写入的地址为:

    文件偏移(Raw Offset):0x06c0

    内存偏移(RVA):0x06c0 - 0x0600 + 0x2000 = 0x20c0

  2. 备份原IID数组结构

    原输入表部分:image-20231024202326939

    将上面的内容复制到 0x06c0开始的空白区域,如下图:

    image-20231024202728613

    这里选中的部分就是后面需要新填入的IDD。

  3. 在原IID区域构造新IID的OriginalFirstThunk、FirstThunk和Name等

    OriginalFirstThunk和FirstThunk 都是偏移地址表,比较容易对齐,而Name的长度可能不确定,所以把两个Thunk放在前面,并留一个全0向作为结束标记(Thunk数组的规定),然后填写DLL的名称,最后填写一个Name结构。

    结果如下:

    区域 Raw Offset RVA
    DLL Name 0x0624 0x0624-0x0600+0x2000=0x2024
    IMPORT BY NAME 0x0630 0x2030
    OriginalFirstThunk 0x0614 0x2014
    FirstThunk 0x061c 0x201c

    image-20231024204252219

    在手动修改数据时,一定要注意字节序的问题

  4. 填充新输入表项的IID结构

    根据刚才填充的两个结构和Name的偏移,填写新的IID项,这里填充的都是内存偏移(RVA)。

    image-20231024204921634

  5. 修正PE文件头的信息

    由于修改了输入表的位置和大小,PE文件头中输入表指向的位置也需要修正,修正后的结果为:

    image-20231024205226289

    因为这是在引入函数节添加的,所以不用修改属性。

提取最大的图标

本次实验分析的程序为 zoomlt.exe 文件,可以使用PEview直接查看资源文件

image-20231029220312362

可以看到ICON 0005 0409 的尺寸最大,需要将其提取出来,可以跳转到他的文件偏移地址然后从该位置提取EA8大小的内容即:

image-20231029220602535

然后将其复制到另一个文件,需要注意的是这里不能直接保存成ico文件,因为还没有文件头!

自己去网上找一个ico文件,然后仿照格式添加即可:

image-20231029220726182

重定位修复

当将可执行文件的ImageBase从0x400000修改为0x600000时,需要进行重定位(relocation)操作来修复程序中涉及到ImageBase的地址引用。

重定位的过程如下:

  1. 获取PE文件的基地址(原ImageBase)和新的ImageBase。在这种情况下,原ImageBase为0x400000,新的ImageBase为0x600000。

  2. 遍历PE文件的重定位表(relocation table)。重定位表位于可选头(optional header)中的数据目录(data directory)中。

  3. 对于每个重定位项,计算需要修复的地址。重定位项中记录了需要修复的地址的偏移量。

  4. 根据偏移量,找到需要修复的地址所在的位置。这个位置通常是指令中的立即数、全局变量的地址等。

  5. 将原地址加上新的ImageBase与原ImageBase的差值,得到修复后的地址。

  6. 将修复后的地址写回到原地址所在的位置。

参考链接

  1. https://www.52pojie.cn/thread-1391994-1-1.html
  2. https://www.52pojie.cn/thread-1765357-1-1.html
  3. PE在空白区域添加代码
  4. 通过修改PE加载DLL
  5. 《加密与解密 (第四版)》