第11章 PE文件格式深入剖析

PE(Portable Executable,可移植的执行体)是微软可执行文件(.exe或.dll)采用的格式,PE格式的目标是使可执行文件可以在不同的CPU工作指令下运行,该格式可以兼容于Windows操作系统的多个版本。PE格式是操作系统工作方式的写照,PE文件头的数据供操作系统装载可执行文件使用,操作系统会根据PE文件头的信息把可执行文件载入内存,加载导入表中提供的动态链接库,根据重定位表修正代码等。不同操作系统的运行方式各不相同,所以PE格式也有所不同,PE格式是Win32可执行文件采用的文件格式,Win64可执行文件对PE格式稍有修改,称为PE32+。如果对加密解密感兴趣,必须学习PE文件格式。PE文件格式的官方文档参见Chapter11\PECoff_V81.pdf。

PE文件格式的基本结构如原书的图11.1所示,通过这幅图可以直观地了解PE文件格式,后面会展开讲解各个组成部分。

PE文件头由DOS头、PE头和节表组成。

(1)DOS头由DOS MZ头(IMAGE_DOS_HEADER结构,64字节)和DOS Stub块(DOS部分的可执行代码,代码字节数不确定)组成。

(2)PE头实际上是一个IMAGE_NT_HEADERS32结构,包含4字节的PE头标志(PE\0\0)、一个IMAGE_FILE_HEADER结构和一个IMAGE_OPTIONAL_HEADER32结构。

(3)节表中节区信息结构IMAGE_SECTION_HEADER的个数不固定,节区信息结构的个数与节区个数相同。

介绍一下节区,程序执行需要节区数据来支撑。节区是PE文件数据的一种组织形式,在PE文件中,可执行代码、只读数据、导入表、导出表、可读可写数据、资源和重定位表等按照页面保护属性分类存放在不同的Section(节、节区、段、区段)中,每个节区的信息(名称、地址、大小和属性等)使用一个IMAGE_SECTION_HEADER结构来描述,有多少个节区就需要多少个IMAGE_SECTION_HEADER结构,IMAGE_SECTION_HEADER结构列表或者说数组组成节表。但是,具有不同用途但是页面保护属性相同的数据可能存放在同一个节区中,例如只读数据、导入表、导出表等可能都在.rdata(字面意思是只读数据)节区中,节区名称只是一个记号,程序员可以随意命名,不同编译器对节区的命名可能不同,例如可执行代码节区可以命名为.text或.code等。

在接下来的学习过程中,建议读者使用WinHex打开Release版本的HelloWindows_32.exe、HelloWindows_64.exe、DllSample_32.dll和DllSample_64.dll等文件,结合可执行文件的具体十六进制数据进行学习。

可执行文件载入内存以后称为PE内存映像,先来了解几个相关术语。

(1)虚拟地址(Virtual Address,VA),就是数据在进程地址空间中的内存地址。

(2)相对虚拟地址(Relative Virtual Address,RVA),就是数据相对于模块基地址的偏移。

(3)文件偏移地址(File Offset Address,FOA),就是文件中数据相对于文件头的偏移。

11.1 DOS头(DOS MZ头和DOS Stub块)

为了保持与16位系统的兼容,PE格式依旧保留了16位系统下执行标准可执行程序时所必需的文件头(DOS MZ头)和可执行代码(DOS Stub块)。DOS MZ头是一个IMAGE_DOS_HEADER结构,64字节大小,但是DOS Stub可执行代码的大小并不固定,因此整个DOS头的大小也是不固定的。如果程序运行在DOS系统环境下,会简单地显示一句“This program cannot be run in DOS mode”,上述语句是编译器自动生成的,程序员可以在DOS Stub块中嵌入任何DOS可执行代码。

DOS MZ头是一个IMAGE_DOS_HEADER结构,定义如下:

typedef struct _IMAGE_DOS_HEADER {    // DOS MZ头,64(0x40)字节
     WORD   e_magic;                  // 偏移0x00,值为IMAGE_DOS_SIGNATURE(0x5A4D),即字符MZ
     WORD   e_cblp;                    // 偏移0x02,最后页中的字节数
     WORD   e_cp;                      // 偏移0x04,文件中的全部和部分页数
     WORD   e_crlc;                   // 偏移0x06,重定位表中的指针数
     WORD   e_cparhdr;               // 偏移0x08,DOS MZ头的长度,64(0x40)字节
     WORD   e_minalloc;              // 偏移0x0A,所需的最小附加段
     WORD   e_maxalloc;              // 偏移0x0C,所需的最大附加段
     WORD   e_ss;                     // 偏移0x0E,DOS代码的初始SS值
     WORD   e_sp;                     // 偏移0x10,DOS代码的初始SP值
     WORD   e_csum;                  // 偏移0x12,补码校验值
     WORD   e_ip;                    // 偏移0x14,DOS代码的初始IP值
     WORD   e_cs;                    // 偏移0x16,DOS代码的初始CS值
     WORD   e_lfarlc;               // 偏移0x18,重定位表的偏移量
     WORD   e_ovno;                 // 偏移0x1A,覆盖号
     WORD   e_res[4];                  // 偏移0x1C,保留字段
     WORD   e_oemid;                   // 偏移0x24,OEM标识符
     WORD   e_oeminfo;                // 偏移0x26,OEM信息
     WORD   e_res2[10];               // 偏移0x28,保留字段
     LONG   e_lfanew;                 // 偏移0x3C,PE头的偏移地址
} IMAGE_DOS_HEADER, * PIMAGE_DOS_HEADER;

DOS头(DOS MZ头和DOS Stub块)是Windows向下兼容的遗留产物,因此DOS头并不重要。重要的只有DOS MZ头IMAGE_DOS_HEADER结构的e_magic和e_lfanew字段,前者是DOS MZ头标志“MZ”(Mark Zbikowski先生是DOS操作系统的开发者之一,MZ由此而来),后者指出了PE头(IMAGE_NT_HEADERS32结构)的偏移地址,PE头的开始位置总是以8字节为单位对齐,PE头是PE文件格式的重要数据。DOS MZ头中除e_magic和e_lfanew字段外,全部填充为0也不影响程序的运行。

以HelloWindows_32.exe程序为例,文件偏移0x40~0x107的部分是DOS Stub块,即程序运行在DOS环境中时的可执行代码部分,如果把这一部分全部填充为0也绝不影响该程序的运行。需要注意的是DOS Stub可执行代码的大小并不固定,程序员可以在DOS Stub块中嵌入任何DOS可执行代码。

11.2 PE头(IMAGE_NT_HEADER32结构)

IMAGE_DOS_HEADER结构偏移0x3C的LONG型数据就是PE头IMAGE_NT_HEADERS32结构的偏移地址,PE头的开始位置总是以8字节为单位对齐。IMAGE_NT_HEADERS32结构的定义如下:

typedef struct _IMAGE_NT_HEADERS {        // PE头,3个字段共248(0xF8)字节
     DWORD                   Signature;       // PE头标志,值为IMAGE_NT_SIGNATURE(0x00004550)
     IMAGE_FILE_HEADER       FileHeader;       // 标准PE头(也称COFF头)IMAGE_FILE_HEADER结构,20字节
     IMAGE_OPTIONAL_HEADER32 OptionalHeader;// 扩展PE头IMAGE_OPTIONAL_HEADER32结构,通常是224字节
} IMAGE_NT_HEADERS32, * PIMAGE_NT_HEADERS32;

PE头IMAGE_NT_HEADERS32结构的第1个字段Signature是PE头标志,值为IMAGE_NT_SIGNATURE(0x00004550),即字符PE\0\0,4字节,这也是“PE”这个名称的由来,可以通过该字段的值确定一个文件是不是PE文件。

IMAGE_FILE_HEADER结构的定义如下:

typedef struct _IMAGE_FILE_HEADER { // 标准PE头,20(0x14)字节
     WORD    Machine;                     // 偏移0x00,PE文件的运行平台
     WORD    NumberOfSections;          // 偏移0x02,节区的个数,节区个数不固定
     DWORD   TimeDateStamp;              // 偏移0x04,编译器创建PE文件时的日期时间,协调世界时
     DWORD   PointerToSymbolTable;     // 偏移0x08,供调试用,COFF符号表的偏移
     DWORD   NumberOfSymbols;          // 偏移0x0C,供调试用,COFF符号表中的符号数量
     WORD    SizeOfOptionalHeader;    // 偏移0x10,扩展PE头结构的长度,默认是0xE0,但是也可能不固定
    WORD    Characteristics;          // 偏移0x12,文件属性
} IMAGE_FILE_HEADER, * PIMAGE_FILE_HEADER;
  • Machine字段偏移0x00,表示PE文件的运行平台。Windows可以运行在x86、x64和IA-64等多种硬件平台上,但是各种不同硬件平台的机器码并不相同,因此在不同的硬件平台中编译的exe是无法通用的。Machine字段的常见值如表11.1所示。

表11.1

常量含义
IMAGE_FILE_MACHINE_I3860x014Cx86
IMAGE_FILE_MACHINE_IA640x0200IA-64
IMAGE_FILE_MACHINE_AMD640x8664x64

Machine字段的值不可以随便修改,否则程序无法正常运行。

  • NumberOfSections字段偏移0x02,表示节区信息IMAGE_SECTION_HEADER结构的个数,也就是节区(.text、.rdata、.data、.rsrc、.reloc等)的个数,节区的个数不固定,最大为96个。HelloWindows_32.exe程序有5个节区,因此该字段的值为0x0005。

  • TimeDateStamp字段偏移0x04,表示编译器创建PE文件时的日期时间,该值表示从1970年1月1日午夜(00:00:00)以来经过的秒数。该字段的值可以随意修改而不会影响程序运行,有的链接器在这里填入固定值,有的则随意写入任何值,该时间值与操作系统文件属性里看到的三个时间(创建时间、修改时间和访问时间)没有任何联系。

  • PointerToSymbolTable字段偏移0x08,供调试用,表示COFF符号表的偏移,如果不存在COFF符号表则该字段的值为0x00000000,该字段不重要。

  • NumberOfSymbols字段偏移0x0C,供调试用,表示COFF符号表中的符号数量,该字段不重要。

  • SizeOfOptionalHeader字段偏移0x10,表示扩展PE头结构IMAGE_OPTIONAL_HEADER32的长度,对于32位PE文件默认是0x00E0,对于64位PE文件默认是0x00F0,但是也可能不固定。用户可以自行定义这个值的大小,不过修改完后需要注意两点:需要自行将文件中IMAGE_OPITONAL_HEADER32结构的大小扩充为指定的值(一般以0补足);要维持PE文件的对齐特性。

  • Characteristics字段偏移0x12,表示文件属性。该字段可以是表11.2所示的一个或多个值。

表11.2

常量表示第多少位为1含义
IMAGE_FILE_RELOCS_STRIPPED0PE文件中不存在重定位信息,该位的值通常为0表示有重定位信息
IMAGE_FILE_EXECUTABLE_IMAGE1该文件是可执行文件
IMAGE_FILE_LINE_NUMS_STRIPPED2PE文件中不存在COFF行号
IMAGE_FILE_LOCAL_SYMS_STRIPPED3PE文件中不存在COFF符号表
IMAGE_FILE_AGGRESIVE_WS_TRIM4该位已过时
IMAGE_FILE_LARGE_ADDRESS_AWARE5该应用程序可以处理大于2GB的内存地址
IMAGE_FILE_BYTES_REVERSED_LO7该位已过时
IMAGE_FILE_32BIT_MACHINE8只能在32位平台上运行
IMAGE_FILE_DEBUG_STRIPPED9PE文件中不存在调试信息
IMAGE_FILE_REMOVABLE_RUN_FROM_SWAP10如果PE文件位于可移动媒体上,将其复制到交换文件并从交换文件运行
IMAGE_FILE_NET_RUN_FROM_SWAP11如果PE文件位于网络上,将其复制到交换文件并从交换文件运行
IMAGE_FILE_SYSTEM12该PE文件是系统文件
IMAGE_FILE_DLL13该PE文件是一个DLL文件
IMAGE_FILE_UP_SYSTEM_ONLY14该PE文件只能在单处理器计算机上运行
IMAGE_FILE_BYTES_REVERSED_HI15该位已过时

对32位.exe和.dll文件来说,该字段的值通常如表11.3所示。

表11.3

.exe.dll
000
111
200
300
400
500
600
700
811
900
1000
1100
1200
1301
1400
1500

也就是说32位.exe文件的该字段的值通常为0x0102,32位.dll文件的该字段的值通常为0x2102。64位.exe文件的该字段的值通常为0x0022,64位.dll文件的该字段的值通常为0x2022。

Characteristics字段是一个比较重要的字段,不同的定义将影响系统对PE文件的装载方式,比如,当位13为1时表示这是一个.dll文件,系统将调用DLL的入口函数,否则表示这是一个普通的可执行文件,系统会直接跳转到入口地址处执行。

IMAGE_OPTIONAL_HEADER32结构的定义如下:

typedef struct _IMAGE_OPTIONAL_HEADER { // 扩展PE头,224(0xE0)字节
  // 标准字段(属于原COFF字段)
  WORD    Magic;                        // 偏移0x00,PE文件格式标志
  BYTE    MajorLinkerVersion;         // 偏移0x02,链接器的主版本号
  BYTE    MinorLinkerVersion;         // 偏移0x03,链接器的次版本号
  DWORD   SizeOfCode;                 // 偏移0x04,所有可执行代码的总大小(基于文件对齐后的大小)
  DWORD   SizeOfInitializedData;       // 偏移0x08,所有已初始化数据的总大小(基于文件对齐后的大小)
  DWORD   SizeOfUninitializedData;     // 偏移0x0C,所有未初始化数据的总大小(基于文件对齐后的大小)
  DWORD   AddressOfEntryPoint;        // 偏移0x10,程序执行入口点RVA
  DWORD   BaseOfCode;                   // 偏移0x14,代码节的RVA
  DWORD   BaseOfData;                   // 偏移0x18,数据节的RVA

  // 扩展字段
  DWORD   ImageBase;                  // 偏移0x1C,程序的建议装载地址
  DWORD   SectionAlignment;          // 偏移0x20,内存中节的对齐粒度
  DWORD   FileAlignment;              // 偏移0x24,文件中节的对齐粒度
  WORD    MajorOperatingSystemVersion;    // 偏移0x28,所需操作系统的主版本号
  WORD    MinorOperatingSystemVersion;    // 偏移0x2A,所需操作系统的次版本号
  WORD    MajorImageVersion;          // 偏移0x2C,PE的主版本号
  WORD    MinorImageVersion;          // 偏移0x2E,PE的次版本号
  WORD    MajorSubsystemVersion;     // 偏移0x30,所需子系统的主版本号
  WORD    MinorSubsystemVersion;     // 偏移0x32,所需子系统的次版本号
  DWORD   Win32VersionValue;          // 偏移0x34,保留字段
  DWORD   SizeOfImage;                // 偏移0x38,PE内存映像大小(基于内存对齐后的大小)
  DWORD   SizeOfHeaders;              // 偏移0x3C,DOS头+PE头+节表的大小(基于内存对齐后的大小)
  DWORD   CheckSum;                    // 偏移0x40,校验和
  WORD    Subsystem;                   // 偏移0x44,所需的界面子系统
  WORD    DllCharacteristics;        // 偏移0x46,DLL文件属性(实际上针对所有PE文件)
  DWORD   SizeOfStackReserve;        // 偏移0x48,初始化时的栈大小
  DWORD   SizeOfStackCommit;         // 偏移0x4C,初始化时实际提交的栈大小
  DWORD   SizeOfHeapReserve;         // 偏移0x50,初始化时的堆大小
  DWORD   SizeOfHeapCommit;          // 偏移0x54,初始化时实际提交的堆大小
  DWORD   LoaderFlags;                // 偏移0x58,供调试用,加载标志,该字段已过时
  DWORD   NumberOfRvaAndSizes;      // 偏移0x5C,下面的数据目录结构的个数,通常是16(0x00000010)
  IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];// 偏移0x60,数据目  
                                                                                     // 录结构数组 
} IMAGE_OPTIONAL_HEADER32, * PIMAGE_OPTIONAL_HEADER32;
  • Magic字段偏移0x00,表示PE文件格式标志,可以是表11.4所示的值之一。

表11.4

常量含义
IMAGE_NT_OPTIONAL_HDR_MAGIC0x010B或0x020B该文件是可执行映像。在32位程序中该常量定义为IMAGE_NT_OPTIONAL_HDR32_MAGIC(0x010B);在64位程序中该常量定义为IMAGE_NT_OPTIONAL_HDR64_MAGIC(0x020B)
IMAGE_NT_OPTIONAL_HDR32_MAGIC0x010B该文件是32位可执行映像(PE)
IMAGE_NT_OPTIONAL_HDR64_MAGIC0x020B该文件是64位可执行映像(PE32+)
IMAGE_ROM_OPTIONAL_HDR_MAGIC0x0107该文件是ROM映像

IMAGE_OPTIONAL_HEADER32.Magic字段的值如果是IMAGE_NT_OPTIONAL_HDR32_MAGIC(0x010B)说明是一个32位可执行文件,IMAGE_OPTIONAL_HEADER32.Magic字段的值如果是IMAGE_NT_OPTIONAL_HDR64_MAGIC(0x020B)说明是一个64位可执行文件。

  • MajorLinkerVersion字段偏移0x02,表示链接器的主版本号,该字段不重要。

  • MinorLinkerVersion字段偏移0x03,表示链接器的次版本号,该字段不重要。

  • SizeOfCode字段偏移0x04,表示所有可执行代码的总大小(基于文件对齐后的大小),该字段不重要。

  • SizeOfInitializedData字段偏移0x08,表示所有已初始化数据的总大小(基于文件对齐后的大小),该字段不重要。

  • SizeOfUninitializedData字段偏移0x0C,表示所有未初始化数据的总大小(基于文件对齐后的大小)。未初始化数据在文件中不占用空间,但是在被加载到内存中后,PE加载程序会为这些数据分配适当大小的虚拟内存空间,该字段不重要。

  • AddressOfEntryPoint字段偏移0x10,对于可执行文件,这是程序执行的起始地址的RVA;对于设备驱动程序,这是初始化函数地址的RVA;对于动态链接库,这是入口点函数地址的RVA(动态链接库的入口点函数是可选的)。打开OD的选项菜单 → 调试设置 → 事件选项卡,在主模块入口点设置第一次暂停,然后OD载入HelloWindows_32.exe,即可看到可执行代码暂停在“PE内存映像基地址 + AddressOfEntryPoint字段的值”的地方。如果希望可执行文件在运行时首先执行一段自定义代码,然后再继续程序的正常执行流程,那么可以修改该字段的值使之指向自定义代码的位置,许多病毒程序、加密程序或补丁程序都会劫持该字段的值使其指向其他用途的代码地址。

  • BaseOfCode字段偏移0x14,表示代码节的RVA,即代码节例如.text相对于PE内存映像的偏移地址,代码节通常紧跟在PE文件头后面。

  • BaseOfData字段偏移0x18,表示数据节的RVA,即数据节例如.data、.rdata相对于PE内存映像的偏移地址。

  • ImageBase字段偏移0x1C,表示程序的建议装载地址(PE内存映像基地址),但是从Windows Vista开始PE文件支持动态基地址地址空间布局随机化(Address Space Layout Randomization,ASLR)技术,为了增强系统安全性,可执行文件每次加载到的内存基地址都会随机变化,程序中用到的动DLL文件加载到的内存基地址也会随机变化。如果不采用ASLR,可执行文件加载的默认基地址为0x00400000,DLL文件加载的默认基地址为0x10000000,通过ASLR技术增加了恶意用户编写漏洞代码的难度。

  • SectionAlignment字段偏移0x20,表示内存中节的对齐粒度,即每个节被载入的内存地址必须是该字段指定数值的整数倍。此值必须大于或等于FileAlignment字段的值,默认值为系统的页面大小即4KB(0x00001000字节),在PE32+中该字段的值默认为8KB(0x00002000字节)。内存中的数据存取以页面为单位,内存对齐是为了提高程序内存访问速度。

  • FileAlignment字段偏移0x24,表示文件中节的对齐粒度,即每个节在文件中的偏移地址必须是该字段指定数值的整数倍。该值可以是512字节到64KB的任意值,默认值为一个扇区的大小即512字节(0x00000200字节)。扇区是硬盘物理存取的最小单位,每个扇区通常可以存放512字节的数据(以后可能发展为4096字节),文件对齐是为了提高文件从磁盘加载的效率。如果SectionAlignment字段的值小于系统页面大小,那么该字段的值必须与SectionAlignment相同。

  • MajorOperatingSystemVersion字段偏移0x28,表示所需操作系统的主版本号,该字段不重要。

  • MinorOperatingSystemVersion字段偏移0x2A,表示所需操作系统的次版本号,该字段不重要。

  • MajorImageVersion字段偏移0x2C,表示PE的主版本号,该字段不重要。

  • MinorImageVersion字段偏移0x2E,表示PE的次版本号,该字段不重要。

  • MajorSubsystemVersion字段偏移0x30,表示所需子系统的主版本号。

  • MinorSubsystemVersion字段偏移0x32,表示所需子系统的次版本号。

  • Win32VersionValue字段偏移0x34,是保留字段,必须为0x00000000。

  • SizeOfImage字段偏移0x38,表示PE内存映像大小(基于内存对齐后的大小),即整个PE内存映像在内存中占用的内存大小,必须是SectionAlignment字段的倍数。

  • SizeOfHeaders字段偏移0x3C,表示DOS头 + PE头 + 节表的大小(基于文件对齐后的大小),必须是FileAlignment字段的倍数。

  • CheckSum字段偏移0x40,表示PE文件校验和,默认情况下该字段的值为0x00000000。可以通过项目属性→配置属性→链接器→高级,设置校验和设置为是(/RELEASE),这样生成的可执行文件就会存在校验和。后面会详细介绍校验和的相关内容。

  • Subsystem字段偏移0x44,表示所需的界面子系统,该字段决定了系统如何为程序创建初始界面,可以通过项目属性→配置属性→链接器→系统→子系统进行设置。该字段可以是表11.5所示的值之一。

表11.5

常量含义
IMAGE_SUBSYSTEM_UNKNOWN0未知子系统
IMAGE_SUBSYSTEM_NATIVE1无须子系统(设备驱动程序和原生系统进程)
IMAGE_SUBSYSTEM_WINDOWS_GUI2Windows图形用户界面(GUI)子系统
IMAGE_SUBSYSTEM_WINDOWS_CUI3Windows字符模式(控制台)用户界面(CUI)子系统
IMAGE_SUBSYSTEM_OS2_CUI5OS / 2 CUI子系统
IMAGE_SUBSYSTEM_POSIX_CUI7POSIX CUI子系统
IMAGE_SUBSYSTEM_WINDOWS_CE_GUI9Windows CE系统
IMAGE_SUBSYSTEM_EFI_APPLICATION10可扩展固件接口(EFI)应用程序
IMAGE_SUBSYSTEM_EFI_BOOT_SERVICE_DRIVER11具有启动服务的EFI驱动程序
IMAGE_SUBSYSTEM_EFI_RUNTIME_DRIVER12具有运行时服务的EFI驱动程序
IMAGE_SUBSYSTEM_EFI_ROM13EFI ROM映像
IMAGE_SUBSYSTEM_XBOX14Xbox系统
IMAGE_SUBSYSTEM_WINDOWS_BOOT_APPLICATION16引导程序
  • DllCharacteristics字段偏移0x46,表示DLL文件属性(实际上针对所有PE文件),可以是表11.6所示的值的组合。

表11.6

常量含义
IMAGE_DLLCHARACTERISTICS_HIGH_ENTROPY_VA0x0020可以处理64位虚拟地址(PE32+)
IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE0x0040可以在加载时重定位,该标志设为0即可取消PE的ASLR功能,如把DllCharacteristics字段由0x8140改为0x8100,每次程序运行就会加载到建议装载地址处
IMAGE_DLLCHARACTERISTICS_FORCE_INTEGRITY0x0080强制执行代码完整性检查
IMAGE_DLLCHARACTERISTICS_NX_COMPAT0x0100该PE映像与数据执行保护(DEP)兼容
IMAGE_DLLCHARACTERISTICS_NO_ISOLATION0x0200该映像可识别隔离,但不应隔离
IMAGE_DLLCHARACTERISTICS_NO_SEH0x0400该映像不使用结构化异常处理(SHE)
IMAGE_DLLCHARACTERISTICS_NO_BIND0x0800不要绑定PE映像
IMAGE_DLLCHARACTERISTICS_WDM_DRIVER0x2000WDM驱动程序
IMAGE_DLLCHARACTERISTICS_TERMINAL_SERVER_AWARE0x8000该映像可识别终端服务器
  • SizeOfStackReserve字段偏移0x48,表示初始化时保留的栈大小,该字段的默认值为0x00100000(1MB),如果调用CreateThread函数创建线程时dwStackSize参数设置为0,那么为新线程保留的栈空间大小也是1MB。

  • SizeOfStackCommit字段偏移0x4C,表示初始化时实际提交的栈大小,该字段的默认值为0x00001000(4KB)。

  • SizeOfHeapReserve字段偏移0x50,表示初始化时保留的堆大小,即默认堆。进程初始化时,系统会在进程的地址空间中创建一个堆,这个堆称为进程的默认堆,初始情况下默认堆的内存空间大小为0x00100000(1MB),默认堆是在进程开始运行前由系统自动创建的,在进程的生命周期中永远不会被删除。

  • SizeOfHeapCommit字段偏移0x54,表示初始化时实际提交的堆大小,该字段的默认值为0x00001000(4KB)。可以通过项目属性→配置属性→链接器→系统,设置堆保留大小、堆提交大小、栈保留大小和栈提交大小。

  • LoaderFlags字段偏移0x58,供调试用,表示加载标志,该字段已过时。

  • NumberOfRvaAndSizes字段偏移0x5C,表示下面紧挨着的数据目录结构IMAGE_DATA_DIRECTORY的个数,通常是16(0x00000010),实际应用中该字段的值可以为2~16。标准PE头结构IMAGE_FILE_HEADER.SizeOfOptionalHeader字段表示扩展PE头结构IMAGE_OPTIONAL_HEADER32的长度,默认是0xE0,但是也可能不固定,因为IMAGE_OPTIONAL_HEADER32.NumberOfRvaAndSizes字段的值并不固定,所以数据目录结构IMAGE_DATA_DIRECTORY的个数不固定。

  • DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES]字段偏移0x60,表示数据目录结构数组。该字段可以说是重要的字段之一,它由16个相同的IMAGE_DATA_DIRECTORY结构组成。虽然PE文件中的数据是按照载入内存后的页属性归类存放在不同的节区中,但是这些处于各个节区中的数据按照用途可以分为导出表、导入表、资源表和重定位表等数据块,同一节区中可能存在多种具有同一页属性的不同类型的数据,这16个IMAGE_DATA_DIRECTORY结构就是用来定义多种不同用途的数据块的,通过数据目录结构数组可以很容易地定位到具体用途的。

IMAGE_DATA_DIRECTORY结构的定义如下:

typedef struct _IMAGE_DATA_DIRECTORY {      // 数据目录结构,每个结构8字节 * 16个结构
     DWORD   VirtualAddress;                   // 数据的RVA地址
     DWORD   Size;                             // 数据的长度
} IMAGE_DATA_DIRECTORY, * PIMAGE_DATA_DIRECTORY;

数据目录列表如下,其中的偏移地址是相对于IMAGE_OPTIONAL_HEADER32结构的偏移(见表11.7)。

表11.7

偏移地址数组索引常量(常量的值就是数组索引值)含义
0x600IMAGE_DIRECTORY_ENTRY_EXPORT导出表
0x681IMAGE_DIRECTORY_ENTRY_IMPORT导入表
0x702IMAGE_DIRECTORY_ENTRY_RESOURCE资源表
0x783IMAGE_DIRECTORY_ENTRY_EXCEPTION异常表
0x804IMAGE_DIRECTORY_ENTRY_SECURITY属性证书表
0x885IMAGE_DIRECTORY_ENTRY_BASERELOC重定位表
0x906IMAGE_DIRECTORY_ENTRY_DEBUG调试信息
0x987IMAGE_DIRECTORY_ENTRY_ARCHITECTURE与平台相关的数据
0xA08IMAGE_DIRECTORY_ENTRY_GLOBALPTR指向全局指针寄存器的值
0xA89IMAGE_DIRECTORY_ENTRY_TLS线程局部存储
0xB010IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG加载配置信息表
0xB811IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT绑定导入表
0xC012IMAGE_DIRECTORY_ENTRY_IAT导入函数地址表IAT
0xC813IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT延迟加载导入表
0xD014IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTORCLR运行时头部数据
0xD815系统保留

在PE文件中查找特定类型的数据时需要通过IMAGE_DATA_DIRECTORY结构数组,例如通过索引为0的IMAGE_DATA_DIRECTORY结构可以得到导出表的RVA地址和导出表的大小,通过索引为1的IMAGE_DATA_DIRECTORY结构可以得到导入表的RVA地址和导入表的大小,通过索引为12的IMAGE_DATA_DIRECTORY结构可以得到导入函数地址表IAT的RVA地址和导入函数地址表IAT的大小等。

后面还会对一些重要的数据例如导出表、导入表、重定位表、资源表等展开介绍,这里先大致说明一下表11.7中的16种数据。

[0] 导出表所在的节区通常被命名为.edata,可执行文件通常不存在导出表,DLL文件通常都会存在导出表。在包含导出函数的DLL文件中,导出信息保存在导出表中,通过导出表可以得到导出函数的名称、序数和入口地址等信息,PE加载器通过这些信息来完成动态链接的过程。有些DLL文件也可能不存在导出函数,例如用作纯资源的DLL文件中就没有导出函数,当然也不存在导出表;另外,可能也会出现包含导出函数和导出表的可执行文件。

[1] 导入表所在的节区通常被命名为.idata。导入函数是指在程序中被调用但是其可执行代码不在程序中的函数,导入函数的可执行代码位于DLL文件中,通过导入表,可以得到程序所需的DLL文件名、函数名等,当运行一个可执行文件时,PE加载器会解析可执行文件的导入表,把导入表中列出的每个DLL映射到进程的地址空间中,并根据函数名在每个DLL中寻找导出函数,将程序中调用导入函数的指令与函数实际所在的内存地址联系起来。可执行文件和DLL文件中通常都会存在导入表。

[2] 资源表所在的节区通常被命名为.rsrc。几乎所有的PE文件中都包含资源,包括图标、光标、位图和菜单等标准类型,另外还可以使用自定义类型。资源表是一个多层二叉排序树,该树的节点指向PE中各种类型的资源,例如图标、光标、位图和菜单等,树的深度可达231层,但是PE中经常使用的只有3层,即资源类型层、资源ID层和语言代码页层(简体中文、繁体中文及英语等)。

[3] 异常表所在的节区通常被命名为.pdata。异常表是由异常处理函数组成的数组,这部分数据主要用于基于表的异常处理,适用于除x86外所有类型的CPU,即Win32系统中并不存在异常表。

[4] 属性证书表的作用类似于PE文件的校验和或MD5码,通过属性证书可以验证一个PE文件是否被非法修改过,为PE文件添加属性证书表可以使该PE与属性证书相关联。

[5] 重定位表所在的节区通常被命名为.reloc,可执行代码中涉及直接寻址的指令都需要重定位,这一点读者已经有所了解。重定位信息在编译时由编译器生成并保存在可执行文件的重定位表中,在程序被执行以前由操作系统根据重定位信息对代码进行修正。

[6] 调试数据通常位于一个可丢弃的名为.debug的节中,也可以位于PE文件的其他节中,或者不在任何节中,调试数据描述了PE中的一些调试信息。在默认情况下,调试信息并不会映射到进程的虚拟地址空间中。

[7] 指向与平台相关的数据,x86、x64和IA-64平台通常不使用这部分数据。

[8] 指向全局指针寄存器的值,这部分数据通常用于IA-64。

[9] 线程局部存储表所在的节区通常被命名为.tls。

[10] 加载配置信息表中存放着基于结构化异常处理SEH的各种异常句柄,如果在程序运行过程中发生异常,操作系统会根据异常类別对异常进行分发处理,并根据这些句柄实施程序流程的转向。

[11] 当运行一个可执行文件时,PE加载器会解析可执行文件的导入表,把导入表中列出的每个DLL映射到进程的地址空间中,并根据函数名在每个DLL中寻找导出函数,将程序中调用导入函数的指令与函数实际所在的内存地址联系起来。为了提高PE的加载效率,可以对一个模块进行绑定,即使用模块中导入函数的虚拟地址来对导入表进行预处理,绑定技术替代PE加载器完成了一部分对导入表的处理工作。

[12] 导入函数地址表IAT,是导入表的一部分,这个双字数组里存放着所有导入函数的VA,调用API函数时就会跳转到该VA处执行。

[13] 延迟加载导入表,与延迟加载DLL相关。

[14] CLR运行时头部数据所在的节通常被命名为.cormeta,该信息是.NET框架的一个重要组成部分,所有基于.NET框架开发的程序,其初始化部分都是通过访问这部分定义而实现的。PE加载时将通过该结构加载代码托管机制需要的所有DLL文件,并完成与CLR有关的其他操作。

[15] 系统保留。

11.3 节表(节区信息结构IMAGE_SECTION_HEADER列表)

运行一个可执行文件时,Windows并不会把整个文件全部载入虚拟内存(页面交换文件和物理内存)中,而是使用内存映射文件技术,这节省了页面交换文件的空间以及应用程序启动所需的时间。PE加载器建立好虚拟地址和PE文件之间的映射关系,只有当真正执行某个内存页中的指令或访问某一内存页中的数据时,这个页面才会被从磁盘提交到物理内存。

但是,Windows加载可执行文件的方式也不完全等同于内存映射文件,内存映射文件与磁盘上文件的数据内容、数据的偏移位置完全相同。而加载可执行文件时,有些数据在加载前会被预先处理(例如需要重定位的数据),载入内存后数据之间的相对位置也可能会发生改变,例如一个节区的偏移地址和大小在载入内存前后通常是不同的,如原书的图11.2所示。

PE文件映射到内存时PE文件头的情况:加载 DOS 头(DOS MZ 头和 DOS Stub 块)、PE 头(IMAGE_NT_HEADERS32结构)和节表(节区信息结构IMAGE_SECTION_HEADER列表)时,不需要进行额外处理,即PE文件中的PE文件头的数据内容、数据的偏移位置和PE内存映像中完全相同。但是,通过 IMAGE_OPTIONAL_HEADER32.SectionAlignment 和 IMAGE_OPTIONAL_HEADER32. FileAlignment两个字段我们了解到数据在内存中的对齐粒度和在文件中的对齐粒度通常不同,内存对齐粒度通常是0x1000字节,而文件对齐粒度通常是0x200字节,一般的PE文件的PE文件头只需要对齐到FOA是0x400的位置(其实通常PE文件头不足0x400字节,但是后面必须填充为0以补足0x400),而在PE内存映像中,PE文件头必须对齐到0x1000(后面必须填充为0以补足0x1000)。OD载入HelloWindows_32.exe,打开内存窗口,可以看到原书的图11.3所示的界面。

另外,用鼠标右键单击圈出来的PE文件头这一行,然后选择在CPU数据窗口中查看,再用鼠标右键单击数据窗口,然后选择指定→PE文件头,可以看到PE文件头数据以及解释。

PE文件映射到内存时各个节区的情况如下:同样,每个节区的偏移地址在文件中是按照IMAGE_OPTIONAL_HEADER32.FileAlignment字段指定的值进行对齐的,而在PE内存映像中是按照IMAGE_OPTIONAL_HEADER32.SectionAlignment字段指定的值进行对齐的,即节区的RVA和FOA通常是不同的;当然,PE文件和PE内存映像的每个节区的大小也会因为文件对齐、内存对齐单位的不同而发生变化;还有,对未初始化数据来说,通常不会在PE文件中为之预留空间,但是加载到内存后,则需要为之分配空间。

PE头IMAGE_NT_HEADERS32结构的后面是节区信息结构IMAGE_SECTION_HEADER列表,节区信息结构的个数与节区个数相同,IMAGE_FILE_HEADER.NumberOfSections字段指出了节区的个数,IMAGE_SECTION_HEADER结构列表或者说数组组成节表。节表中的每一个IMAGE_SECTION_HEADER结构是对一个节区的描述,接下来我们将介绍IMAGE_SECTION_HEADER结构。为了对PE文件格式构造有一个更深的了解,我们将结合HelloWindows_32.exe的十六进制数据说明各种数据块都在哪个节区,节区就相当于是一个容器,具体的数据块例如导入表、导出表和重定位表等才是最重要的。介绍完节表,我们将详细介绍导入表、导出表和重定位表等重要数据,这是本章的主要内容,很多对PE文件进行加密的软件就是针对这些重要数据做文章。

节区信息结构IMAGE_SECTION_HEADER用于描述节区的信息,该结构的定义如下:

typedef struct _IMAGE_SECTION_HEADER {        // 节表信息结构,每个结构40(0x28)字节
     BYTE    Name[IMAGE_SIZEOF_SHORT_NAME];     // 偏移0x00,8字节的节区名称,UTF-8字符串
     union {
          DWORD   PhysicalAddress;             // 偏移0x08,
          DWORD   VirtualSize;                  // 偏移0x08,节区的大小(没有进行文件对齐的实际大小)
     } Misc;
     DWORD   VirtualAddress;                  // 偏移0x0C,节区的RVA地址
     DWORD   SizeOfRawData;                   // 偏移0x10,节区的大小(基于文件对齐后的大小)
     DWORD   PointerToRawData;                // 偏移0x14,节区的文件偏移地址FOA
     DWORD   PointerToRelocations;            // 偏移0x18,在.obj文件中使用,指向重定位表的指针
     DWORD   PointerToLinenumbers;            // 偏移0x1C,供调试用,行号表的指针
     WORD    NumberOfRelocations;             // 偏移0x20,在.obj文件中使用,重定位表的个数
     WORD    NumberOfLinenumbers;             // 偏移0x22,行号表中行号的数量
     DWORD   Characteristics;                  // 偏移0x24,节区的属性
} IMAGE_SECTION_HEADER, * PIMAGE_SECTION_HEADER;
  • Name字段偏移0x00,是8字节的节区名称,UTF-8字符。节区名称并不规定以零结尾,例如节区名称正好是8字节时,如果节区名称超过8字节会执行截断处理。节区名称只是一个记号,程序员可以随意命名,不同编译器对节区的命名可能不同,例如同是可执行代码节区可以命名为.text或.code等。另外,不能通过节区名称来定位数据,例如资源节区的名称通常为.rsrc,通过节表或许可以正确定位到程序资源数据,但是为了确保准确性,应该使用IMAGE_OPTIONAL_HEADER32结构中的数据目录数组来定位各种数据。

  • Misc.PhysicalAddress字段偏移0x08。

  • Misc.VirtualSize字段偏移0x08,表示节区的大小(没有进行文件对齐的实际大小)。

  • VirtualAddress字段偏移0x0C,表示节区的RVA地址,IMAGE_OPTIONAL_HEADER32. SectionAlignment字段指定的值的整数倍。

  • SizeOfRawData字段偏移0x10,表示节区的大小(基于文件对齐后的大小),该字段的值等于VirtualSize字段的值按照IMAGE_OPTIONAL_HEADER32.FileAlignment字段的值对齐以后的大小。

  • PointerToRawData字段偏移0x14,节区的文件偏移地址FOA。

PE加载器通过从PointerToRawData字段指定的FOA开始,找到SizeOfRawData字段指定的基于文件对齐后的节区大小,映射到可执行模块的RVA为VirtualAddress的地方,当然映射后要通过在尾部填充0的方式扩展为IMAGE_OPTIONAL_HEADER32.SectionAlignment字段指定的值的整数倍。

  • PointerToRelocations字段偏移0x18,在.obj文件中使用,指向重定位表的指针,该字段不重要。

  • PointerToLinenumbers字段偏移0x1C,供调试用,表示行号表的指针,该字段不重要。

  • NumberOfRelocations字段偏移0x20,在.obj文件中使用,表示重定位表的个数,该字段不重要。

  • NumberOfLinenumbers字段偏移0x22,表示行号表中行号的数量,该字段不重要。

  • Characteristics字段偏移0x24,表示节区的属性,可以是表11.8所示的值的组合(常见值)。

表11.8

常量含义
IMAGE_SCN_CNT_CODE0x000000205节区包含可执行代码
IMAGE_SCN_CNT_INITIALIZED_DATA0x000000406节区包含已初始化数据
IMAGE_SCN_CNT_UNINITIALIZED_DATA0x000000807节区包含未初始化数据
IMAGE_SCN_GPREL0x0000800011节区包含通过全局指针引用的数据
MAGE_SCN_LNK_NRELOC_OVFL0x0100000024节区包含扩展的重定位
IMAGE_SCN_MEM_DISCARDABLE0x0200000025节区可以根据需要丢弃
IMAGE_SCN_MEM_NOT_CACHED0x0400000026节区无法缓存
IMAGE_SCN_MEM_NOT_PAGED0x0800000027节区无法分页
IMAGE_SCN_MEM_SHARED0x1000000028节区可以在内存中共享
IMAGE_SCN_MEM_EXECUTE0x2000000029节区包含可执行属性
IMAGE_SCN_MEM_READ0x4000000030节区包含可读属性
IMAGE_SCN_MEM_WRITE0x8000000031节区包含可写属性

以HelloWindows_32.exe程序为例,各个节区的属性值的其含义如表11.9所示。

表11.9

节区名称属性值含义
可执行代码.text0x60000020节区包含可执行代码、节区包含可执行属性、节区包含可读属性
只读数据.rdata0x40000040节区包含已初始化数据、节区包含可读属性
已初始化数据.data0xC0000040节区包含已初始化数据、节区包含可读属性、节区包含可写属性
程序资源.rsrc0x40000040节区包含已初始化数据、节区包含可读属性
重定位信息.reloc0x42000040节区包含已初始化数据、节区可以根据需要丢弃、节区包含可读属性

当然,节区属性也可以是其他值,例如当PE文件被加壳工具压缩后,包含可执行代码的节区往往具有可执行、可读和可写属性,因为解压代码需要将解压以后的可执行代码回写到代码节区中。例如,Chapter4\LoadTest_UPX\Debug\Test_UPX.exe文件的UPX0节区具有:节区包含未初始化数据、节区包含可执行属性、节区包含可读属性、节区包含可写属性。

因为无法确定可执行模块的加载基地址,所以PE文件中很多字段都是使用RVA相对虚拟内存地址,模块基地址+RVA就是PE文件加载到内存中后数据的真实虚拟内存地址。还有一个FOA文件偏移地址,就是文件中数据相对于文件头的偏移。通过前面的学习,我们了解到同一数据在PE文件和PE内存映像中的偏移地址是不同的,当然这主要是指PE文件的节区部分,PE文件中的PE文件头的数据内容、数据的偏移位置与PE内存映像中的完全相同。

通过RVA可以很容易地在PE内存映像中定位到需要的数据,但是在PE文件中定位数据则不是很方便,比如通过IMAGE_DATA_DIRECTORY.VirtualAddress字段可以很容易地在PE内存映像中定位到导入表、导出表、重定位表等数据。

在实际编程过程中,为了能够在PE文件或者PE内存映射文件中定位到需要的数据,需要经过RVA到FOA的换算,这种换算并没有一个简单的公式,可以采取以下方式。

(1)遍历节表。通过IMAGE_SECTION_HEADER.VirtualAddress字段得到一个节区的起始RVA,节区的起始RVA + IMAGE_SECTION_HEADER.SizeOfRawData等于节区的结束RVA,然后判断目标数据(例如导入表、导出表、重定位表等)的RVA是否位于这个节区的内存范围以内。

(2)如果目标数据的RVA位于某个节区的内存范围以内,则使用目标数据的RVA减去这个节区的起始RVA,得到目标数据相对于节区起始地址的偏移量RVA’。

(3)通过IMAGE_SECTION_HEADER.PointerToRawData字段可以得到一个节区在PE文件中的文件偏移地址FOA,这个节区的FOA + RVA’等于目标数据在文件中的偏移地址FOA。

接下来封装两个自定义函数,RVAToFOA函数用于通过指定类型数据(例如导入表、导出表和重定位表等)的RVA得到FOA,GetSectionNameByRVA函数用于通过一个RVA值获取所在节区的名称。两个函数的定义如下:

/**************************************************************************
  * 函数功能:通过指定类型数据(例如导入表、导出表、重定位表等)的RVA得到FOA
  * 输入参数的说明:
    1. pImageDosHeader参数表示PE内存映射文件对象在内存中的起始地址,必须指定
    2. dwTargetRVA参数表示目标类型数据的RVA,必须指定
  * 返回值:  返回−1表示函数执行失败
**************************************************************************/
INT RVAToFOA(PIMAGE_DOS_HEADER pImageDosHeader, DWORD dwTargetRVA)
{
     INT iTargetFOA = -1;

     // PE头的地址
     PIMAGE_NT_HEADERS32 pImageNtHeader32 =
          (PIMAGE_NT_HEADERS32)((LPBYTE)pImageDosHeader + pImageDosHeader->e_lfanew);

     // PE头的地址 + sizeof(IMAGE_NT_HEADERS32)等于节表地址
     PIMAGE_SECTION_HEADER pImageSectionHeader =
          (PIMAGE_SECTION_HEADER)((LPBYTE)pImageNtHeader32 + sizeof(IMAGE_NT_HEADERS32));

     // 遍历节表
     for (int i = 0; i < pImageNtHeader32->FileHeader.NumberOfSections; i++)
     {
          if ((dwTargetRVA >= pImageSectionHeader->VirtualAddress) &&
            (dwTargetRVA <= (pImageSectionHeader->VirtualAddress + pImageSectionHeader-> SizeOfRawData)))
          {
               iTargetFOA = dwTargetRVA - pImageSectionHeader->VirtualAddress;
               iTargetFOA += pImageSectionHeader->PointerToRawData;
          }

          // 指向下一个节区信息结构
          pImageSectionHeader++;
     }

     return iTargetFOA;
}

/**************************************************************************
  * 函数功能:通过一个RVA值获取所在节区的名称
  * 输入参数的说明:
    1. pImageDosHeader参数表示PE内存映射文件对象在内存中的起始地址,必须指定
    2. dwRVA参数表示一个RVA值,必须指定
  * 返回值:返回NULL表示函数执行失败,注意返回的节区名称字符串并不一定以零结尾
**************************************************************************/
LPSTR GetSectionNameByRVA(PIMAGE_DOS_HEADER pImageDosHeader, DWORD dwRVA)
{
     LPSTR lpSectionName = NULL;

     // PE头的地址
     PIMAGE_NT_HEADERS32 pImageNtHeader32 =
          (PIMAGE_NT_HEADERS32)((LPBYTE)pImageDosHeader + pImageDosHeader->e_lfanew);

     // PE头的地址 + sizeof(IMAGE_NT_HEADERS32)等于节表地址
     PIMAGE_SECTION_HEADER pImageSectionHeader =
          (PIMAGE_SECTION_HEADER)((LPBYTE)pImageNtHeader32 + sizeof(IMAGE_NT_HEADERS32));

     // 遍历节表
     for (int i = 0; i < pImageNtHeader32->FileHeader.NumberOfSections; i++)
     {
          if ((dwRVA >= pImageSectionHeader->VirtualAddress) &&
              (dwRVA <= (pImageSectionHeader->VirtualAddress + pImageSectionHeader->   
SizeOfRawData)))
          {
               lpSectionName = (LPSTR)pImageSectionHeader;
          }

          // 指向下一个节区信息结构
          pImageSectionHeader++;
     }

     return lpSectionName;
}

接下来实现一个查看PE文件头基本信息的程序PEInfo,结合前面对PE文件头的介绍,很容易理解本程序,具体代码参见Chapter11\PEInfo项目。PEInfo程序界面如原书的图11.4所示(PE文件中不存在的数据目录没有列出)。

11.4 64位可执行文件格式PE32+

PE32+的DOS头同样由DOS MZ头(IMAGE_DOS_HEADER结构,64字节)和DOS Stub块(DOS部分的可执行代码,代码字节数不确定)组成。DOS MZ头IMAGE_DOS_HEADER结构中只有e_magic和e_lfanew字段比较重要,DOS Stub块也同样不重要。

PE文件中的PE头是一个IMAGE_NT_HEADERS32结构,条件编译定义如下:

#ifdef _WIN64
     typedef IMAGE_NT_HEADERS64                  IMAGE_NT_HEADERS;
     typedef PIMAGE_NT_HEADERS64                 PIMAGE_NT_HEADERS;
#else
     typedef IMAGE_NT_HEADERS32                  IMAGE_NT_HEADERS;
     typedef PIMAGE_NT_HEADERS32                 PIMAGE_NT_HEADERS;

为了实现Win32/Win64系统通用编程,可以使用IMAGE_NT_HEADERS结构,如果定义了_WIN64,那么IMAGE_NT_HEADERS被定义为IMAGE_NT_HEADERS64;否则被定义为IMAGE_NT_HEADERS32。

IMAGE_NT_HEADERS64结构的定义如下:

typedef struct _IMAGE_NT_HEADERS64 {
     DWORD                    Signature;      // 同IMAGE_NT_HEADERS32.Signature
     IMAGE_FILE_HEADER     FileHeader;     // 同IMAGE_NT_HEADERS32.FileHeader
     IMAGE_OPTIONAL_HEADER64 OptionalHeader; 
} IMAGE_NT_HEADERS64, * PIMAGE_NT_HEADERS64;

不同的只是OptionalHeader字段是一个IMAGE_OPTIONAL_HEADER64结构。另外IMAGE_NT_HEADERS64.FileHeader.SizeOfOptionalHeader字段的值默认是0x00F0(PE格式的默认是0x00E0)。

IMAGE_OPTIONAL_HEADER64结构的定义如下:

typedef struct _IMAGE_OPTIONAL_HEADER64 {
     WORD         Magic;
     BYTE         MajorLinkerVersion;
     BYTE         MinorLinkerVersion;
     DWORD        SizeOfCode;
     DWORD        SizeOfInitializedData;
     DWORD        SizeOfUninitializedData;
     DWORD        AddressOfEntryPoint;
     DWORD        BaseOfCode;      // PE格式中该字段的下面是BaseOfData字段,表示数据节的RVA
                                    // PE32+中不存在BaseOfData字段
     ULONGLONG      ImageBase;      // 程序的建议装载地址,ULONGLONG类型,而PE格式是DWORD类型
     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;
     ULONGLONG      SizeOfStackReserve; // 该字段是一个ULONGLONG类型,而PE格式是DWORD类型
     ULONGLONG      SizeOfStackCommit;      // 该字段是一个ULONGLONG类型,而PE格式是DWORD类型
     ULONGLONG      SizeOfHeapReserve;      // 该字段是一个ULONGLONG类型,而PE格式是DWORD类型
     ULONGLONG      SizeOfHeapCommit;      // 该字段是一个ULONGLONG类型,而PE格式是DWORD类型
     DWORD           LoaderFlags;
     DWORD           NumberOfRvaAndSizes;
     IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
} IMAGE_OPTIONAL_HEADER64, * PIMAGE_OPTIONAL_HEADER64;

IMAGE_NT_HEADERS64.FileHeader.SizeOfOptionalHeader字段的值默认是0x00F0,而PE格式的默认值是0x00E0,多了0x10字节,因为SizeOfStackReserve、SizeOfStackCommit、SizeOfHeapReserve、SizeOfHeapCommit 这 4 个字段是ULONGLONG类型。在64位可执行文件中,初始化时保留的栈大小、初始化时保留的堆大小(默认堆)也是0x0000000000100000(1MB),初始化时实际提交的栈大小、初始化时实际提交的堆大小也是0x0000000000001000(4KB)。

判断一个可执行文件是 32 位还是 64 位的方式是:IMAGE_NT_HEADERS. OptionalHeader.Magic字段的值如果是IMAGE_NT_OPTIONAL_HDR32_MAGIC(0x010B),则说明是一个 32 位可执行文件,如果是IMAGE_NT_OPTIONAL_HDR64_MAGIC(0x020B),则说明是一个64位可执行文件。了解这些内容后,把PEInfo程序改写为既可以查看PE文件也可以查看PE32+文件就会比较简单,PEInfo项目既可以编译为32位又可以编译为64位,参见Chapter11\PEInfo3264项目。

另外,笔者编写了一个把PE文件的RVA转换为FOA的RVAToFOA程序,程序中用到了前面介绍的自定义函数RVAToFOA,但是对该函数进行改进后,既可以用于PE文件也可以用于PE32+文件,具体代码参见Chapter11\RVAToFOA项目。

节表、节区信息结构IMAGE_SECTION_HEADER的定义与PE格式相同。

11.5 导入表

16种数据块才是程序执行过程中所需的重要数据,每种数据块的数据组织形式各不相同,接下来我们介绍几种比较重要的数据块,例如导入表、导出表、重定位表、资源表等。

导入表中存放着一个可执行文件需要调用的其他模块中的导出函数。当PE文件被载入内存执行时,PE加载器才将所需的动态链接库载入程序的地址空间中,将调用导入函数的指令和函数实际所处的内存地址联系起来,这就是动态链接的概念,动态链接通过PE文件中的导入表(Import Table)来实现,导入表中保存有动态链接库名称和导入函数名称等的相关信息。

下面以一个简单的程序为例进行分析,OD载入Chapter11\HelloWorld\Debug\HelloWorld.exe(见原书的图11.5)。

在反汇编窗口中选中call dword ptr [<&USER32.MessageBoxW>]这一行,用鼠标右键单击数据窗口中跟随,然后选择内存地址,数据窗口中就会自动定位到0x11ED100开始的数据。然后,确保在反汇编窗口中选中call dword ptr [<&USER32.MessageBoxW>]这一行,用鼠标右键单击跟随或者直接按Enter键,即可定位到USER32.MessageBoxW函数的实现代码地址处,如原书的图11.6所示。

可以发现[0x11ED100]地址处存放的就是USER32.MessageBoxW函数的内存地址。

我们再从PE文件角度分析该程序。call dword ptr [<&USER32.MessageBoxW>]指令实际上就是call dword ptr [0x11ED100],通过OD的内存窗口可以发现0x11ED100属于.rdata节区。HelloWorld.exe的内存基地址为0x011E0000,RVA:0x11ED100−0x011E0000 = 0xD100,通过RVAToFOA程序计算可以得到FOA为0xBB00,使用WinHex打开HelloWorld.exe,定位到FOA为0xBB00的位置(见原书的图11.7)。

该处的值为0x00012548,实际上该值也是一个RVA(也位于.rdata节区),通过RVAToFOA程序计算可以得到该RVA的FOA为0x10F48,在WinHex中定位到0x10F48(见原书的图11.8)。

FOA 为0x10F48 的位置首先是一个值为 0x0286 的 WORD 值,然后就是MessageBoxW函数名称。

PE内存映像中0x11ED100地址处存放的是USER32.MessageBoxW函数的内存地址,而PE文件中相同位置处存放的是一个指向“WORD值+MessageBoxW函数名称”的RVA。实际上,PE加载器根据PE文件中FOA为0xBB00的地方的RVA值得到“WORD值+MessageBoxW函数名称”,然后根据函数名称得到函数的实际内存地址,再把函数的实际内存地址写入0x11ED100地址处。

查看HelloWorld.exe的call MessageBoxW指令行:

011E1011  FF15 00D11E01  call dword ptr [<&USER32.MessageBoxW>]

该指令所处的内存地址为0x011E1011,RVA为0x1011,FOA为0x411,在WinHex中定位到0x411(见原书的图11.9)。

可以发现,程序加载到内存中后是call [0x011ED100];而在文件中是call [0x0040D100],这是因为在编译程序的时候设置的建议装载地址是0x00400000,在采用ASLR技术前,程序的加载基地址通常是0x00400000,exe程序也通常没有重定位表。不过,建议装载地址和实际装载地址不相同并没有关系,因为数据定位依靠RVA来完成。

接下来介绍导入表。以Chapter11\HelloWindows_32.exe程序为例,通过PEInfo程序可以得知该程序的导入表的RVA为0x0001249C,FOA 为0x1109C(修改PEInfo程序使之显示数据的FOA,Chapter11\PEInfo3264)。

导入表是一个导入表描述符结构IMAGE_IMPORT_DESCRIPTOR(IID)数组,结构的个数取决于程序要加载的DLL文件的数量,每个结构对应一个 DLL 文件,例如一个PE文件如果使用了10个DLL中的导出函数,就会存在10个IMAGE_IMPORT_DESCRIPTOR结构来描述这些DLL文件,在所有IMAGE_IMPORT_DESCRIPTOR结构的最后以一个内容全为0的IMAGE_IMPORT_DESCRIPTOR结构作为结束。

IMAGE_IMPORT_DESCRIPTOR结构的定义如下:

typedef struct _IMAGE_IMPORT_DESCRIPTOR {   // 20字节
     union {
          DWORD   Characteristics;     // 偏移0x00 
          DWORD   OriginalFirstThunk; // 偏移0x00,IMAGE_THUNK_DATA32结构数组,是一个RVA
     } DUMMYUNIONNAME;
     DWORD   TimeDateStamp;  // 偏移0x04,与绑定有关的时间戳,通常不用
     DWORD   ForwarderChain; // 偏移0x08,第一个被转发函数的索引,如果没有转发则为-1
     DWORD   Name;             // 偏移0x0C,指向以0结尾的动态链接库名称字符串,是一个RVA,UTF-8字符串
     DWORD   FirstThunk;      // 偏移0x10,IMAGE_THUNK_DATA32结构数组,是一个RVA
} IMAGE_IMPORT_DESCRIPTOR;

3个重要字段如下。

  • OriginalFirstThunk字段偏移0x00,指向一个IMAGE_THUNK_DATA32结构数组,是一个RVA。

  • Name字段偏移0x0C,表示指向以零结尾的动态链接库名称字符串,是一个RVA,UTF-8字符串。

  • FirstThunk字段偏移0x10,指向一个IMAGE_THUNK_DATA32结构数组,是一个RVA。

OriginalFirstThunk 和 FirstThunk 两个字段都指向一个 IMAGE_THUNK_DATA32结构数组,是一个RVA,每一个IMAGE_THUNK_DATA32结构表示一个导入函数的信息,数组以一个内容全为0的IMAGE_THUNK_DATA32结构作为结尾。OriginalFirstThunk字段的重要性较低。

IMAGE_THUNK_DATA32结构实际上只是一个DWORD类型的双字,把它定义成结构(内部只有一个联合体字段)是因为它在不同的时刻有不同的含义,该结构的定义如下:

typedef struct _IMAGE_THUNK_DATA32 {
     union {
          DWORD ForwarderString;       
          DWORD Function;              // 导入函数的内存地址
          DWORD Ordinal;               // 导入函数的序数
          DWORD AddressOfData;        // IMAGE_IMPORT_BY_NAME结构的RVA
     } u1;
} IMAGE_THUNK_DATA32;

当IMAGE_THUNK_DATA32结构(DWORD值)的最高位为1时,表示函数以序数的方式进行导入,这时DWORD值的低位字就是函数的序数,可以使用常量IMAGE_ORDINAL_FLAG32(0x80000000)进行测试;当DWORD值的最高位为0时,表示函数以函数名称字符串的方式进行导入,这时DWORD值是一个指向IMAGE_IMPORT_BY_NAME结构的RVA。

IMAGE_IMPORT_BY_NAME结构的定义如下:

typedef struct _IMAGE_IMPORT_BY_NAME {  // 结构大小不确定,因为函数名称长度不固定
     WORD    Hint;                           // 函数编号(用于提高搜索函数的速度),可以为0
     CHAR    Name[1];                       // 以零结尾的函数名称字符串,UTF-8字符串
} IMAGE_IMPORT_BY_NAME, * PIMAGE_IMPORT_BY_NAME;

总结:IMAGE_IMPORT_DESCRIPTOR.FirstThunk字段是指向一个IMAGE_THUNK_DATA32结构数组的RVA,每个IMAGE_THUNK_DATA32结构表示一个导入函数的信息,IMAGE_THUNK_DATA32.AddressOfData字段是指向IMAGE_IMPORT_BY_NAME结构的RVA,IMAGE_IMPORT_BY_NAME结构包含函数编号和函数名称。

IMAGE_IMPORT_DESCRIPTOR.OriginalFirstThunk 和 IMAGE_IMPORT_DESCRIPTOR.FirstThunk这两个字段都是指向一个IMAGE_THUNK_DATA32结构数组的RVA,在PE文件中这两个结构数组的数据内容完全相同(但是位置不同),这称为双桥结构。原书的图11.10中展示了从User32.dll中导入DispatchMessageW、ShowWindow、RegisterClassExW和一个以序数为导入方式的共4个导入函数的导入表的双桥结构。

图11.10中,IMAGE_IMPORT_DESCRIPTOR.Name字段指向字符串User32.dll,表示要从User32.dll中导入函数;OriginalFirstThunk和FirstThunk字段指向两个完全相同的IMAGE_THUNK_DATA32数组,因为要导入4个函数,所以IMAGE_THUNK_DATA32数组中包含4个有效结构,最后以一个内容全为0的结构作为结束。前3个函数以函数名称方式进行导入,IMAGE_THUNK_DATA32结构的DWORD值是一个RVA,分别指向3个IMAGE_IMPORT_BY_NAME结构,每一个IMAGE_IMPORT_BY_NAME结构的第1个字段是函数的编号,第二个字段是函数名称字符串;第4个函数以序数方式导入,因此IMAGE_THUNK_DATA32结构的DWORD值的最高位为1,序数为0x116,组合起来的值是0x80000116。

在PE文件中IMAGE_IMPORT_DESCRIPTOR.OriginalFirstThunk和IMAGE_IMPORT_DESCRIPTOR. FirstThunk这两个字段指向的IMAGE_THUNK_DATA32结构数组数据内容完全相同,但到了PE内存映像中是不同的。当PE文件载入内存后,原书的图11.10所示的双桥结构变为原书的图11.11所示的载入内存后的双桥结构。

分析HelloWorld.exe实例时曾介绍过:PE内存映像中0x11ED100地址处存放的是USER32. MessageBoxW函数的内存地址,而PE文件中相同位置处存放的是一个指向“WORD值 + MessageBoxW函数名称”的RVA。当 PE 文件载入内存后,PE 加载器根据 IMAGE_IMPORT_DESCRIPTOR. OriginalFirstThunk或IMAGE_IMPORT_DESCRIPTOR.FirstThunk最终指向的函数名称获取到DLL中每个函数的实际内存地址VA,然后写入IMAGE_IMPORT_DESCRIPTOR.FirstThunk所指向IMAGE_THUNK_DATA32数组中的每个数组元素中。PE 文件中存在两份IMAGE_THUNK_DATA32数组并修改其中的一份,是为了最后可以留下一份用来反向查询函数地址所对应的导入函数名,部分编译器只使用一份IMAGE_THUNK_DATA32数组,称为单桥结构。通常情况下,把导入表的IMAGE_IMPORT_DESCRIPTOR.OriginalFirstThunk字段及其指向的IMAGE_THUNK_DATA32数组填充为0不会影响程序运行。

在PE内存映像中,IMAGE_IMPORT_DESCRIPTOR.FirstThunk指向的是导入函数内存地址,一个DLL中的所有导入函数内存地址顺序排列在一起,形成一个导入函数内存地址数组,所有DLL的导入函数内存地址数组通常也会顺序排列在一起,形成导入函数地址表(Import Address Table,IAT)。导入表中第一个IMAGE_IMPORT_DESCRIPTOR结构的FirstThunk字段通常指向IAT的起始地址(但也很可能不是),只有通过数据目录表的索引为12的IMAGE_DATA_DIRECTORY结构来定位IAT才是可靠的。

IMAGE_IMPORT_DESCRIPTOR.OriginalFirstThunk最终指向的函数名称形成一个导入函数名称数组,所有DLL的导入函数名称数组通常也会顺序排列在一起,形成导入函数名称表(Import Name Table,INT)。IMAGE_IMPORT_DESCRIPTOR.OriginalFirstThunk字段指向的IMAGE_THUNK_DATA32结构数组在PE文件加载前后没有任何变化。

导入表主要涉及IMAGE_IMPORT_DESCRIPTOR、IMAGE_THUNK_DATA32和IMAGE_IMPORT_BY_NAME共3个结构,在PE32+中IMAGE_IMPORT_DESCRIPTOR和IMAGE_IMPORT_BY_NAME这两个结构的定义和在PE中是完全相同的。不同的是IMAGE_THUNK_DATA32结构,条件编译定义如下:

#ifdef _WIN64
     #define IMAGE_ORDINAL_FLAG              IMAGE_ORDINAL_FLAG64
     typedef IMAGE_THUNK_DATA64              IMAGE_THUNK_DATA;
#else
     #define IMAGE_ORDINAL_FLAG              IMAGE_ORDINAL_FLAG32
     typedef IMAGE_THUNK_DATA32              IMAGE_THUNK_DATA;
#endif

为了实现Win32系统和Win64系统通用编程,可以使用IMAGE_THUNK_DATA结构。如果定义了_WIN64,那么IMAGE_THUNK_DATA被定义为IMAGE_THUNK_DATA64;否则被定义为IMAGE_THUNK_DATA32。

IMAGE_ORDINAL_FLAG64用于判断IMAGE_THUNK_DATA是否是按序数导入的宏,定义如下:

#define IMAGE_ORDINAL_FLAG64 0x8000000000000000
IMAGE_THUNK_DATA64结构的定义如下所示:
typedef struct _IMAGE_THUNK_DATA64 {
     union {
          ULONGLONG ForwarderString;
          ULONGLONG Function;
          ULONGLONG Ordinal;
          ULONGLONG AddressOfData;
     } u1;
} IMAGE_THUNK_DATA64;

与IMAGE_THUNK_DATA32不同的仅仅是每个字段的数据类型由DWORD变为ULONGLONG。

接下来我们编程获取一个可执行文件的导入表中所有DLL的导入函数的函数编号和函数名称,效果如原书的图11.12所示(HelloWindows_32.exe中的一部分导入函数)。

这里把实现该部分功能的代码封装为自定义函数GetImportTable,代码很简单,但是有的代码行比较长,不易阅读。另外为了可以编译为32位或64位,以及查看PE或PE32+,代码需要多一些判断,完整代码参见Chapter11\PEInfo3264_2项目:

BOOL GetImportTable(PIMAGE_DOS_HEADER pImageDosHeader)
{
   PIMAGE_NT_HEADERS pImageNtHeader;                       // PE头起始地址
   PIMAGE_IMPORT_DESCRIPTOR pImageImportDescriptor;    // 导入表起始地址
   PIMAGE_THUNK_DATA32 pImageThunkData32;             // IMAGE_THUNK_DATA32数组起始地址
   PIMAGE_THUNK_DATA64 pImageThunkData64;            // IMAGE_THUNK_DATA64数组起始地址
   PIMAGE_IMPORT_BY_NAME pImageImportByName;         // IMAGE_IMPORT_BY_NAME结构指针
   TCHAR szDllName[128] = { 0 };                       // 动态链接库名称
   TCHAR szFuncName[128] = { 0 };                      // 函数名称
   TCHAR szBuf[256] = { 0 };
   TCHAR szImportTableHead[] = TEXT("\r\n\r\n导入表信息:\r\ndll文件名\t\t\t\t\t函数编号\t函数名称\r\n");

   // PE头起始地址
   pImageNtHeader = (PIMAGE_NT_HEADERS)((LPBYTE)pImageDosHeader +   
pImageDosHeader->e_lfanew);

   // 如果是PE32+,则把pImageNtHeader强制转换为PIMAGE_NT_HEADERS64
   if (pImageNtHeader->OptionalHeader.Magic == IMAGE_NT_OPTIONAL_HDR64_MAGIC)
   {
      // 是否有导入表(当然,没有的可能性不大)
      if (((PIMAGE_NT_HEADERS64)pImageNtHeader)->OptionalHeader.DataDirectory[1].Size == 0)
         return FALSE;

      // 导入表起始地址
      pImageImportDescriptor = (PIMAGE_IMPORT_DESCRIPTOR)((LPBYTE)pImageDosHeader + RVAToFOA  
(pImageNtHeader, ((PIMAGE_NT_HEADERS64)pImageNtHeader)->OptionalHeader.DataDirectory[1].  
VirtualAddress));

      SendMessage(g_hwndEdit, EM_SETSEL, -1, -1);
      SendMessage(g_hwndEdit, EM_REPLACESEL, TRUE, (LPARAM)szImportTableHead);
      // 遍历导入表
      while (pImageImportDescriptor->OriginalFirstThunk ||
         pImageImportDescriptor->TimeDateStamp || pImageImportDescriptor-> ForwarderChain ||
         pImageImportDescriptor->Name || pImageImportDescriptor->FirstThunk)
      {
         // 动态链接库名称
         MultiByteToWideChar(CP_UTF8, 0, (LPSTR)((LPBYTE)pImageDosHeader + RVAToFOA   
(pImageNtHeader, pImageImportDescriptor->Name)), -1, szDllName, _countof (szDllName));

         // IMAGE_THUNK_DATA64数组起始地址
         pImageThunkData64 = (PIMAGE_THUNK_DATA64)((LPBYTE)pImageDosHeader + RVAToFOA  
(pImageNtHeader, pImageImportDescriptor->FirstThunk));
         while (pImageThunkData64->u1.AddressOfData != 0)
         {
            // 按序号导入还是按函数名称导入
            // IMAGE_IMPORT_BY_NAME结构指针
            pImageImportByName = (PIMAGE_IMPORT_BY_NAME)((LPBYTE)pImageDosHeader + RVAToFOA  
(pImageNtHeader, pImageThunkData64->u1.AddressOfData));

            if (pImageThunkData64->u1.AddressOfData & IMAGE_ORDINAL_FLAG64)
            {
               wsprintf(szFuncName, TEXT("按序号 0x%04X"), pImageThunkData64-> u1.AddressOfData   
& 0xFFFF);
               wsprintf(szBuf, TEXT("%-48s%s\r\n"), szDllName, szFuncName);
            }
            else
            {
               MultiByteToWideChar(CP_UTF8, 0, pImageImportByName->Name, -1, szFuncName, _countof(szFuncName));
               wsprintf(szBuf, TEXT("%-48s0x%04X\t\t%s\r\n"), szDllName, pImageImportByName->  
Hint, szFuncName);
            }
            SendMessage(g_hwndEdit, EM_SETSEL, -1, -1);
            SendMessage(g_hwndEdit, EM_REPLACESEL, TRUE, (LPARAM)szBuf);

            // 指向下一个IMAGE_THUNK_DATA64结构
            pImageThunkData64++;
         }

         SendMessage(g_hwndEdit, EM_SETSEL, -1, -1);
         SendMessage(g_hwndEdit, EM_REPLACESEL, TRUE, (LPARAM)TEXT("\r\n"));
         // 指向下一个导入表描述符
         pImageImportDescriptor++;
      }
   }
   // 如果是PE则把pImageNtHeader强制转换为PIMAGE_NT_HEADERS32
   else
   {
      // 是否有导入表(当然,没有的可能性不大)
      if (((PIMAGE_NT_HEADERS32)pImageNtHeader)->OptionalHeader.DataDirectory[1].Size == 0)
         return FALSE;

      // 导入表起始地址
      pImageImportDescriptor = (PIMAGE_IMPORT_DESCRIPTOR)((LPBYTE)pImageDosHeader + RVAToFOA  
(pImageNtHeader, ((PIMAGE_NT_HEADERS32)pImageNtHeader)->OptionalHeader.DataDirectory[1].  
VirtualAddress));

      SendMessage(g_hwndEdit, EM_SETSEL, -1, -1);
      SendMessage(g_hwndEdit, EM_REPLACESEL, TRUE, (LPARAM)szImportTableHead);
      // 遍历导入表
      while (pImageImportDescriptor->OriginalFirstThunk ||
         pImageImportDescriptor->TimeDateStamp || pImageImportDescriptor->  
ForwarderChain ||
         pImageImportDescriptor->Name || pImageImportDescriptor->FirstThunk)
      {
         // 动态链接库名称
         MultiByteToWideChar(CP_UTF8, 0, (LPSTR)((LPBYTE)pImageDosHeader + RVAToFOA  
(pImageNtHeader, pImageImportDescriptor->Name)), -1, szDllName, _countof(szDllName));

         // IMAGE_THUNK_DATA32数组起始地址
         pImageThunkData32 = (PIMAGE_THUNK_DATA32)((LPBYTE)pImageDosHeader + RVAToFOA  
(pImageNtHeader, pImageImportDescriptor->FirstThunk));
         while (pImageThunkData32->u1.AddressOfData != 0)
         {
            // 按序号导入还是按函数名称导入
            // IMAGE_IMPORT_BY_NAME结构指针
            pImageImportByName = (PIMAGE_IMPORT_BY_NAME)((LPBYTE)pImageDosHeader + RVAToFOA  
(pImageNtHeader, pImageThunkData32->u1.AddressOfData));

            if (pImageThunkData32->u1.AddressOfData & IMAGE_ORDINAL_FLAG32)
            {
               wsprintf(szFuncName, TEXT("按序号 0x%04X"), pImageThunkData32->u1.   
AddressOfData & 0xFFFF);
               wsprintf(szBuf, TEXT("%-48s%s\r\n"), szDllName, szFuncName);
            }
            else
            {
               MultiByteToWideChar(CP_UTF8, 0, pImageImportByName->Name, -1, szFuncName,   
_countof(szFuncName));
               wsprintf(szBuf, TEXT("%-48s0x%04X\t\t%s\r\n"), szDllName, pImageImportByName->  
Hint, szFuncName);
            }
            SendMessage(g_hwndEdit, EM_SETSEL, -1, -1);
            SendMessage(g_hwndEdit, EM_REPLACESEL, TRUE, (LPARAM)szBuf);

            // 指向下一个IMAGE_THUNK_DATA32结构
            pImageThunkData32++;
         }

         SendMessage(g_hwndEdit, EM_SETSEL, -1, -1);
         SendMessage(g_hwndEdit, EM_REPLACESEL, TRUE, (LPARAM)TEXT("\r\n"));
         // 指向下一个导入表描述符
         pImageImportDescriptor++;
      }
   }

   return TRUE;
}

11.6 导出表

通过导出表可以得到导出函数的函数名称、函数序数和入口地址等信息,PE加载器通过这些信息来完成动态链接的过程。可执行文件中通常不存在导出表,DLL文件中通常都会存在导出表,但是也有特殊情况,例如用作纯资源的DLL文件不需要提供导出函数,也不存在导出表。另外,有的可执行文件也可以包含导出函数和导出表。

在PE文件中,导出表与导入表配合使用,既然在导入表中可以使用函数名称或函数序数来进行导入,那么导出表中必然也可以使用函数名称或函数序数这两种方式来导出函数。对定义了函数名的函数来说,既可以使用函数名称进行导出,也可以使用函数序数进行导出;对没有定义函数名的函数来说,只能使用函数序数进行导出。

导出表的起始位置是一个导出表目录结构IMAGE_EXPORT_DIRECTORY,与导入表中有多个IMAGE_IMPORT_DESCRIPTOR结构不同,导出表中只有一个IMAGE_EXPORT_DIRECTORY结构,定义如下:

typedef struct _IMAGE_EXPORT_DIRECTORY {    // 40字节
     DWORD   Characteristics;           // 偏移0x00,保留字段
     DWORD   TimeDateStamp;             // 偏移0x04,时间戳,通常不用
     WORD    MajorVersion;              // 偏移0x08,保留字段
     WORD    MinorVersion;              // 偏移0x0A,保留字段
     DWORD   Name;                       // 偏移0x0C, 指向模块文件名称字符串的RVA,UTF-8字符串
     DWORD   Base;                        // 偏移0x10,导出函数的起始序数
     DWORD   NumberOfFunctions;          // 偏移0x14,导出函数的总个数
     DWORD   NumberOfNames;              // 偏移0x18,按函数名称导出函数的总数
     DWORD   AddressOfFunctions;          // 偏移0x1C,指向导出函数地址表的RVA(EAT)
     DWORD   AddressOfNames;            // 偏移0x20,指向函数名称地址表的RVA(ENT)
     DWORD   AddressOfNameOrdinals;      // 偏移0x24,指向函数序数表的RVA
} IMAGE_EXPORT_DIRECTORY, * PIMAGE_EXPORT_DIRECTORY;
  • Name字段偏移0x0C,是指向以零结尾的模块文件名称字符串的RVA,UTF-8字符串。模块文件名称字符串是模块的原始文件名,即使文件名被修改,也可以通过该字段得到编译时的原始文件名。

  • NumberOfFunctions字段偏移0x14,表示导出函数的总个数。

  • NumberOfNames字段偏移0x18,表示按函数名称导出函数的总个数。只有这个数量的函数既可以通过函数名称方式导出,也可以通过函数序数方式导出,剩下的NumberOfFunctions减去NumberOfNames数量的函数只能通过函数序数方式导出。该字段的值只会小于或等于NumberOfFunctions字段的值,如果该字段的值为0,则表示所有的函数都是以函数序数方式进行导出的。

  • AddressOfFunctions字段偏移0x1C,是指向导出函数地址表(EAT)的RVA。该字段指向的RVA处是全部导出函数入口地址的DWORD数组,数组中的每一个DWORD值表示一个导出函数的内存地址(RVA值),数组元素的个数等于NumberOfFunctions字段的值。

  • Base字段偏移0x10,是导出函数的起始序数。AddressOfFunctions字段指向的导出函数地址表中某一项的索引加上该字段的值就是对应的导出函数的函数序数。假设Base字段的值为x,则导出函数地址表中第1个导出函数的序数是x,第2个导出函数的序数是x + 1,以此类推。

  • AddressOfNames字段偏移0x20,是指向函数名称地址表(ENT)的RVA。该字段指向的RVA处是函数名称地址的DWORD数组,数组中的每一个DWORD值表示一个函数名称的RVA,数组元素的个数等于NumberOfNames字段的值,按函数名称导出的导出函数名称字符串都在这个ENT表中。

  • AddressOfNameOrdinals字段偏移0x24,是指向函数序数表的RVA。该字段指向的RVA处是一个WORD数组,数组中的每一个WORD值表示导出函数地址表EAT的索引。通过函数名称地址表ENT中的一个索引n,到函数序数表中查找索引n对应的WORD值(表示导出函数地址表EAT的索引),即可得到函数名称对应的函数入口地址,AddressOfNames和AddressOfNameOrdinals这两个字段是一一对应关系,如原书的图11.13所示。

例如,从函数名称地址表ENT中取出索引2,到函数序数表中查找索引2对应的WORD值为0x0003,再到导出函数地址表EAT中查找索引0x0003,即可得到Func4对应的函数入口地址的RVA。

要遍历导出表中的所有导出函数,可以循环IMAGE_EXPORT_DIRECTORY.NumberOfFunctions(导出函数的总个数)次,IMAGE_EXPORT_DIRECTORY.AddressOfFunctions字段指向的导出函数地址表的索引为0~IMAGE_EXPORT_DIRECTORY.NumberOfFunctions − 1,判断每个索引是否在IMAGE_EXPORT_DIRECTORY.AddressOfNameOrdinals字段指向的函数序数表中,如果在,则说明该函数是按函数名称导出,否则就是按函数序数导出。下面的自定义函数GetExportTable实现了获取导出表基本信息和获取导出表中所有导出函数的功能,效果如原书的图11.14所示(DllSample_32.dll)。

BOOL GetExportTable(PIMAGE_DOS_HEADER pImageDosHeader)
{
   PIMAGE_NT_HEADERS pImageNtHeader;                       // PE头起始地址
   PIMAGE_EXPORT_DIRECTORY pImageExportDirectory;    // 导出表目录结构的起始地址
   PDWORD pAddressOfFunctions;                           // 导出函数地址表的起始地址
   PWORD pAddressOfNameOrdinals;                        // 函数序数表的起始地址
   PDWORD pAddressOfNames;                                   // 函数名称地址表的起始地址
   TCHAR szModuleName[128] = { 0 };                        // 模块的原始文件名
   TCHAR szFuncName[128] = { 0 };                          // 函数名称
   TCHAR szBuf[512] = { 0 };
   TCHAR szExportTableHead[] = TEXT("\r\n导出表信息:\r\n");
   TCHAR szExportTableFuncs[] = TEXT("函数序数\t函数地址\t函数名称\r\n");

   // PE头起始地址
   pImageNtHeader = (PIMAGE_NT_HEADERS)((LPBYTE)pImageDosHeader + pImageDosHeader ->e_lfanew);

   // PE和PE32+的导出表目录结构定位不同
   if (pImageNtHeader->OptionalHeader.Magic == IMAGE_NT_OPTIONAL_HDR64_MAGIC)
   {
      // 是否有导出表
      if (((PIMAGE_NT_HEADERS64)pImageNtHeader)->OptionalHeader.DataDirectory[0].Size == 0)
         return FALSE;
      pImageExportDirectory = (PIMAGE_EXPORT_DIRECTORY)((LPBYTE)pImageDosHeader + RVAToFOA  
(pImageNtHeader, ((PIMAGE_NT_HEADERS64)pImageNtHeader)->OptionalHeader.DataDirectory[0].  
VirtualAddress));
   }
   else
   {
      // 是否有导出表
      if (((PIMAGE_NT_HEADERS32)pImageNtHeader)->OptionalHeader.DataDirectory[0].Size == 0)
         return FALSE;
      pImageExportDirectory = (PIMAGE_EXPORT_DIRECTORY)((LPBYTE)pImageDosHeader + RVAToFOA  
(pImageNtHeader, ((PIMAGE_NT_HEADERS32)pImageNtHeader)->OptionalHeader.DataDirectory[0].  
VirtualAddress));
   }
   // 导出函数地址表的起始地址
   pAddressOfFunctions = (PDWORD)((LPBYTE)pImageDosHeader + RVAToFOA  
(pImageNtHeader, pImageExportDirectory->AddressOfFunctions));
   // 函数序数表的起始地址
   pAddressOfNameOrdinals = (PWORD)((LPBYTE)pImageDosHeader + RVAToFOA(pImage NtHeader, pImageExportDirectory->AddressOfNameOrdinals));
   // 函数名称地址表的起始地址
   pAddressOfNames = (PDWORD)((LPBYTE)pImageDosHeader + RVAToFOA  
(pImageNtHeader, pImageExportDirectory->AddressOfNames));

   SendMessage(g_hwndEdit, EM_SETSEL, -1, -1);
   SendMessage(g_hwndEdit, EM_REPLACESEL, TRUE, (LPARAM)szExportTableHead);
   // 导出表基本信息
   MultiByteToWideChar(CP_UTF8, 0, (LPSTR)((LPBYTE)pImageDosHeader + RVAToFOA  
(pImageNtHeader, pImageExportDirectory->Name)), -1, szModuleName, _countof (szModuleName));
   wsprintf(szBuf, TEXT("模块原始文件名\t\t%s\r\n导出函数的起始序数\t0x%08X\r\n导出函数的总个数\t0x%08X\r\n按名称导出函数的个数\t0x%08X\r\n导出函数地址表的RVA\t0x% 08X\r\n函数名称地址表的RVA\t0x%08X\r\n指向函数序数表的RVA\t0x%08X\r\n\r\n"),
      szModuleName,
      pImageExportDirectory->Base,
      pImageExportDirectory->NumberOfFunctions,
      pImageExportDirectory->NumberOfNames,
      pImageExportDirectory->AddressOfFunctions,
      pImageExportDirectory->AddressOfNames,
      pImageExportDirectory->AddressOfNameOrdinals);
   SendMessage(g_hwndEdit, EM_SETSEL, -1, -1);
   SendMessage(g_hwndEdit, EM_REPLACESEL, TRUE, (LPARAM)szBuf);

   SendMessage(g_hwndEdit, EM_SETSEL, -1, -1);
   SendMessage(g_hwndEdit, EM_REPLACESEL, TRUE, (LPARAM)szExportTableFuncs);
   // 遍历导出表中的所有导出函数
   for (DWORD i = 0; i < pImageExportDirectory->NumberOfFunctions; i++)
   {
      // 是否是按函数名称导出,遍历函数序数表
      DWORD j;
      for (j = 0; j < pImageExportDirectory->NumberOfNames; j++)
      {
         if (i == pAddressOfNameOrdinals[j])
         {
            // 获取函数名称
            MultiByteToWideChar(CP_UTF8, 0, (LPSTR)((LPBYTE)pImageDosHeader + RVAToFOA  
(pImageNtHeader, pAddressOfNames[j])), -1, szFuncName, _countof(szFuncName));
            break;
         }
      }
      // 如果遍历完函数序数表也没找到索引i,则按函数序数导出
      if (j == pImageExportDirectory->NumberOfNames)
         wsprintf(szFuncName, TEXT("按序数导出"));

      if (pAddressOfFunctions[i])
      {
         wsprintf(szBuf, TEXT("0x%08X\t0x%08X\t%s\r\n"),
            pImageExportDirectory->Base + i, pAddressOfFunctions[i], szFuncName);
         SendMessage(g_hwndEdit, EM_SETSEL, -1, -1);
         SendMessage(g_hwndEdit, EM_REPLACESEL, TRUE, (LPARAM)szBuf);
      }
   }

   return TRUE;
}

完整代码参见Chapter11\PEInfo3264_3项目。

IMAGE_EXPORT_DIRECTORY.NumberOfFunctions字段表示导出函数的总个数,通常情况下导出函数的起始序数是1,最后一个导出函数的序数等于IMAGE_EXPORT_DIRECTORY.NumberOf Functions字段的值,但是也可能存在例外。编写动态链接库时,模块定义文件(*.def)不仅可以指定要导出的函数名,还可以指定该函数的导出序数,例如,如果把Chapter6\DllSample项目的DllSample.def文件改写为如下形式:

EXPORTS
      funAdd @1
      funMul @30

则导出表的情况如原书的图11.15所示。

因此,在自定义函数GetExportTable中,只有导出函数的地址不为0的情况下才输出导出函数的信息(if (pAddressOfFunctions[i])语句)。

另外,PE和PE32+的导出表数据结构是相同的,不同的只是导出表目录结构IMAGE_EXPORT_DIRECTORY的定位。

11.7 重定位表

在采用ASLR技术前,可执行文件中通常不需要重定位表,但是一个可执行文件中通常需要加载多个DLL,每个DLL都无法保证加载到模块建议装载地址处,因此DLL文件中通常都需要重定位表。在采用ASLR技术后,可执行和DLL文件通常都需要重定位表。

程序中涉及绝对地址的操作数(例如函数、全局变量)都需要进行重定位,重定位信息是在编译时由编译器生成并保存在可执行文件中的,在可执行文件被执行以前由PE加载器根据重定位信息修正代码。其实,重定位的算法很简单,即操作数的绝对地址 + (模块实际载入地址−模块建议装载地址),模块建议装载地址已经在PE头中定义过,PE加载器在加载可执行文件时,模块实际载入地址也可以确定,因此重定位表中只需要保存需要修正的操作数绝对地址的地址。

重定位表中保存有需要修正的操作数绝对地址的地址,但是为了节省空间,PE文件对绝对地址的地址的存放方式做了一些优化。一个32位的内存地址需要4字节,假设有n个重定位项,则重定位表的总大小是4×n字节大小。绝对地址相邻的重定位项的高位地址是相同的,假设以页为单位(4096字节)在一个页面中寻址,只需要12位的内存地址。PE文件采用的方式是:使用一个DWORD值来表示页的起始地址,后面紧跟着一个DWORD值表示重定位项的个数,再往后是一个WORD(16位)数组来表示每个重定位项,这样一来占用的字节数是4 + 4 + 2×n。当重定位项的个数超过4项时,这种方法可以节省空间,事实上,每个程序中需要重定位的绝对地址个数是非常多的。

重定位表是一个重定位块结构IMAGE_BASE_RELOCATION数组,IMAGE_BASE_RELOCATION结构的定义如下:

typedef struct _IMAGE_BASE_RELOCATION {     // 8字节
     DWORD   VirtualAddress;                 // 重定位内存页的起始RVA
     DWORD   SizeOfBlock;                     // 本页中重定位块的长度(包括本结构的大小),以字节为单位
} IMAGE_BASE_RELOCATION;

该结构的后面是一个WORD数组来表示每个重定位项,WORD值的高4位用于表示重定位项的类型,低12位才是相对地址(相对于页起始地址)。操作数绝对地址的地址的RVA等于VirtualAddress字段的值 + WORD值的低12位。重定位块中重定位项的个数等于(SizeOfBlock字段的值−sizeof (IMAGE_BASE_RELOCATION)) / sizeof(WORD)。另外,重定位表(重定位块结构数组)的最后以一个VirtualAddress字段为0x00000000的IMAGE_BASE_RELOCATION结构(或者说结构全为0)作为结束。

重定位项数组中WORD值的高4位用于表示重定位项的类型,可用的值如表11.10所示。

表11.10

常量含义
IMAGE_REL_BASED_ABSOLUTE0x0这个重定位项没有意义,仅作为按照DWORD对齐用
IMAGE_REL_BASED_HIGH0x1操作数绝对地址的高16位需要被修正
IMAGE_REL_BASED_LOW0x2操作数绝对地址的低16位需要被修正
IMAGE_REL_BASED_HIGHLOW0x3操作数绝对地址的32位都需要被修正
IMAGE_REL_BASED_HIGHADJ0x4重定位项需要32位,当前项作为高16位,下一个重定位项作为低16位,即该重定位项需要占用两个重定位项
IMAGE_REL_BASED_MACHINE_SPECIFIC_50x5对MIPS平台的跳转指令进行基地址重定位
IMAGE_REL_BASED_RESERVED0x6保留
IMAGE_REL_BASED_MACHINE_SPECIFIC_70x7保留
IMAGE_REL_BASED_MACHINE_SPECIFIC_80x8保留
IMAGE_REL_BASED_MACHINE_SPECIFIC_90x9对MIPS16平台的跳转指令进行基地址重定位
IMAGE_REL_BASED_DIR640xA用于64位程序的64位的操作数绝对地址,即(模块实际载入地址−模块建议装载地址)+ 64位的操作数绝对地址

对32位程序的重定位项来说,重定位项类型通常为3,有时为了对齐可能为0;对64位程序的重定位项来说,重定位项类型通常为A,有时候为了对齐可能为0。PE和PE32+的重定位表数据结构是相同的,不同的只是重定位块结构IMAGE_BASE_RELOCATION数组的定位不同。

接下来我们编程获取重定位表中所有需要重定位的操作数绝对地址的地址(RVA值),效果如原书的图11.16所示(HelloWindows_32.exe),需要注意的是,一个程序的重定位项可能会非常多,因此程序执行会有些慢。

BOOL GetRelocationTable(PIMAGE_DOS_HEADER pImageDosHeader)
{
   PIMAGE_NT_HEADERS pImageNtHeader;                  // PE头起始地址
   PIMAGE_BASE_RELOCATION pImageBaseRelocation;     // 重定位表的起始地址
   PWORD pRelocationItem;                                // 重定位项数组的起始地址
   DWORD dwRelocationItem;                               // 重定位项的个数
   TCHAR szBuf[64] = { 0 };
   TCHAR szRelocationTableHead[] = TEXT("\r\n重定位表信息:\r\n");
   TCHAR szRelocationItemInfo[] = TEXT("类型\t重定位地址\t类型\t重定位地址\t类型\t重定位地址\t类型\t重定位地址\t");

   // PE头起始地址
   pImageNtHeader = (PIMAGE_NT_HEADERS)((LPBYTE)pImageDosHeader + pImageDosHeader ->e_lfanew);

   // PE和PE32+的重定位表的定位不同
   if (pImageNtHeader->OptionalHeader.Magic == IMAGE_NT_OPTIONAL_HDR64_MAGIC)
   {
      // 是否有重定位表
      if (((PIMAGE_NT_HEADERS64)pImageNtHeader)->OptionalHeader.DataDirectory[5].Size == 0)
         return FALSE;
      pImageBaseRelocation = (PIMAGE_BASE_RELOCATION)((LPBYTE)pImageDosHeader + RVAToFOA  
(pImageNtHeader, ((PIMAGE_NT_HEADERS64)pImageNtHeader)->OptionalHeader.DataDirectory[5].  
VirtualAddress));
   }
   else
   {
      // 是否有重定位表
      if (((PIMAGE_NT_HEADERS32)pImageNtHeader)->OptionalHeader.DataDirectory[5].Size == 0)
         return FALSE;
      pImageBaseRelocation = (PIMAGE_BASE_RELOCATION)((LPBYTE)pImageDosHeader + RVAToFOA  
(pImageNtHeader, ((PIMAGE_NT_HEADERS32)pImageNtHeader)->OptionalHeader. DataDirectory[5].  
VirtualAddress));
   }

   SendMessage(g_hwndEdit, EM_SETSEL, -1, -1);
   SendMessage(g_hwndEdit, EM_REPLACESEL, TRUE, (LPARAM)szRelocationTableHead);
   SendMessage(g_hwndEdit, EM_SETSEL, -1, -1);
   SendMessage(g_hwndEdit, EM_REPLACESEL, TRUE, (LPARAM)szRelocationItemInfo);

   // 遍历重定位表
   while (pImageBaseRelocation->VirtualAddress != 0)
   {
      // 重定位项数组的起始地址
      pRelocationItem = (PWORD)((LPBYTE)pImageBaseRelocation + sizeof(IMAGE_ BASE_RELOCATION));
      // 重定位项的个数
      dwRelocationItem = (pImageBaseRelocation->SizeOfBlock - sizeof(IMAGE_ BASE_RELOCATION)) /  
 sizeof(WORD);

      for (DWORD i = 0; i < dwRelocationItem; i++)
      {
         wsprintf(szBuf, TEXT("0x%X\t0x%08X\t"), pRelocationItem[i] >> 12, pImageBaseRelocation->  
VirtualAddress + (pRelocationItem[i] & 0x0FFF));
         // 4组一行
         if (i % 4 == 0)
         {
            SendMessage(g_hwndEdit, EM_SETSEL, -1, -1);
            SendMessage(g_hwndEdit, EM_REPLACESEL, TRUE, (LPARAM)TEXT("\r\n"));
         }
         SendMessage(g_hwndEdit, EM_SETSEL, -1, -1);
         SendMessage(g_hwndEdit, EM_REPLACESEL, TRUE, (LPARAM)szBuf);
      }
      // 页与页之间隔一行
      SendMessage(g_hwndEdit, EM_SETSEL, -1, -1);
      SendMessage(g_hwndEdit, EM_REPLACESEL, TRUE, (LPARAM)TEXT("\r\n"));

      // 指向下一个重定位块结构
      pImageBaseRelocation = (PIMAGE_BASE_RELOCATION)((LPBYTE)pImageBaseRelocation +   
pImageBaseRelocation->SizeOfBlock);
   }

   return TRUE;
}

完整代码参见 Chapter11\PEInfo3264_4项目。读者可以 OD 载入 HelloWindows_32.exe,自行测试几个重定位地址判断是否都是绝对地址,重定位地址是一个RVA,需要加上HelloWindows_32.exe的模块基地址。

11.8 模拟PE加载器直接加载可执行文件到进程内存中执行

许多病毒木马都具有模拟PE加载器的功能,它们把可执行文件直接加载到内存中执行,以此逃避杀毒软件的拦截检测。另外,对DLL来说,用鼠标右键单击OD的反汇编窗口,然后选择查找→当前模块中的名称(标签),可以看到程序中调用了哪些DLL中的哪些API,并对可疑的API设置断点,而如果采用上述内存加载执行技术,就不会暴露这些信息。最好的方法是把PE文件存放在程序的资源中并进行加密,获取资源句柄、资源数据指针、解密,然后模拟PE加载器进行加载,而不需要先把可执行文件释放到本地。

要模拟PE加载器,至少涉及对PE内存映像中重定位表、导入表和导出表等的操作,需要以下步骤。

(1)把程序资源中或磁盘上的目标可执行文件读取到进程内存中,得到一个可执行文件数据指针lpMemory,如果可执行文件已经加密还需要解密操作。

(2)调用VirtualAlloc函数在进程的内存地址空间中分配合适大小的可读可写可执行内存lpBaseAddress,把lpMemory指向的可执行文件数据按照内存对齐粒度写入lpBaseAddress,现在进程中已经具有了PE内存映像。

(3)对PE内存映像的重定位表中的所有重定位项进行修正。

(4)遍历导入表,加载目标可执行文件所需的DLL,获取所有导入函数的内存地址,填充PE内存映像的导入函数地址表IAT。

(5)修改PE内存映像的建议装载地址为lpBaseAddress。

(6)计算PE内存映像的入口地址。

(7)根据每个节区的属性设置其对应内存页的内存保护属性。

(8)从入口地址处开始执行(可执行文件和DLL文件的执行方法不同)。

完成上述步骤,目标可执行文件通常都可以正常运行,但是对于一些经过特别处理的可执行文件,上述操作可能还不够,因为大多数情况下我们加载自己制作的熟悉的可执行文件,所以处理起来并没有问题。

RunExecutableInMemory程序可以加载一个可执行文件或DLL文件到RunExecutableInMemory的进程地址空间中执行。注意,RunExecutableInMemory程序如果编译为32位,只能加载PE文件;如果编译为64位,只能加载PE32+,因此程序少了许多可执行文件是PE或PE32+的判断,但是为了使程序可以编译为32位或64位,依然存在部分PE或PE32+的判断。通过本程序可以透彻地理解PE文件格式。程序的执行效果如原书的图11.17所示。

RunExecutableInMemory程序也可以加载DLL文件,这里编写了一个DLL测试文件DllTest.dll,程序首先执行了DLL的入口点函数DllMain,然后调用了DllTest.dll中的导出函数ShowMessage。程序首先弹出左边的正在执行DllMain入口点函数消息框,单击确定按钮后弹出右边的“我是导出函数”消息框(见原书的图11.18)。

模拟PE加载器直接加载可执行文件到进程内存中执行的核心是自定义函数LoadExecutable:

BOOL LoadExecutable(LPVOID lpMemory)     // lpMemory是PE内存映射文件基地址
{
    PIMAGE_DOS_HEADER pImageDosHeader;    // 内存映射文件中的DOS头起始地址
    PIMAGE_NT_HEADERS pImageNtHeader;     // 内存映射文件中的PE头起始地址
    SIZE_T nSizeOfImage;                    // PE内存映像大小(基于内存对齐后的大小)
    LPVOID lpBaseAddress;                 // 在本进程中分配内存用于装载可执行文件
    DWORD dwSizeOfHeaders;                // DOS头+PE头+节表的大小(基于内存对齐后的大小)
    WORD wNumberOfSections;               // 可执行文件的节区个数
    PIMAGE_SECTION_HEADER pImageSectionHeader;  // 节表的起始地址

    // 获取PE内存映像大小
    pImageDosHeader = (PIMAGE_DOS_HEADER)lpMemory;
    pImageNtHeader = (PIMAGE_NT_HEADERS)((LPBYTE)pImageDosHeader + pImageDosHeader->e_lfanew);
    nSizeOfImage = pImageNtHeader->OptionalHeader.SizeOfImage;

    // 在本进程的内存地址空间中分配nSizeOfImage + 20字节大小的可读可写可执行内存
    // 多出的20字节后面会用到
    lpBaseAddress = VirtualAlloc(NULL, nSizeOfImage + 20, MEM_COMMIT, PAGE_EXECUTE_ READWRITE);
    ZeroMemory(lpBaseAddress, nSizeOfImage + 20);

    // *********************************************************************
    // 把可执行文件按pImageNtHeader.OptionalHeader.SectionAlignment对齐粒度映射到分配的内存中
    dwSizeOfHeaders = pImageNtHeader->OptionalHeader.SizeOfHeaders;
    wNumberOfSections = pImageNtHeader->FileHeader.NumberOfSections;

    // 获取节表的起始地址
    pImageSectionHeader =
        (PIMAGE_SECTION_HEADER)((LPBYTE)pImageNtHeader + sizeof(IMAGE_NT_HEADERS));

    // 加载DOS头 + PE头 + 节表
    memcpy_s(lpBaseAddress, dwSizeOfHeaders, (LPVOID)pImageDosHeader, dwSizeOfHeaders);

    // 加载所有节区到节表中指定的RVA处
    for (int i = 0; i < wNumberOfSections; i++)
    {
        if (pImageSectionHeader->VirtualAddress == 0 || pImageSectionHeader-> SizeOfRawData == 0)
        {
            pImageSectionHeader++;
            continue;
        }

        memcpy_s((LPBYTE)lpBaseAddress + pImageSectionHeader->VirtualAddress,
            pImageSectionHeader->SizeOfRawData,
            (LPBYTE)pImageDosHeader + pImageSectionHeader->PointerToRawData,
            pImageSectionHeader->SizeOfRawData);

        pImageSectionHeader++;
    }
    // ********************************************************************

    // 映射到进程中的DOS头和PE头起始地址
    PIMAGE_DOS_HEADER pImageDosHeaderMap;     // 映射到进程中的DOS头起始地址
    PIMAGE_NT_HEADERS pImageNtHeaderMap;      // 映射到进程中的PE头起始地址
    pImageDosHeaderMap = (PIMAGE_DOS_HEADER)lpBaseAddress;
    pImageNtHeaderMap = (PIMAGE_NT_HEADERS)((LPBYTE)pImageDosHeaderMap + pImageDosHeaderMap->  
e_lfanew);

    // ********************************************************************
    // 修正映射到进程中的PE内存映像的重定位代码
    PIMAGE_BASE_RELOCATION pImageBaseRelocationMap; // 映射到进程中的重定位表的起始地址
    PWORD pRelocationItem;                  // 重定位项数组的起始地址
    DWORD dwRelocationItem;                 // 重定位项的个数
    PDWORD pdwRelocationAddress;           // PE重定位地址
    PULONGLONG pullRelocationAddress;     // PE32+重定位地址
    DWORD dwRelocationDelta;               // PE实际载入地址与建议装载地址的差值
    ULONGLONG ullRelocationDelta;         // PE32+实际载入地址与建议装载地址的差值

    // 获取重定位表的起始地址
    pImageBaseRelocationMap = (PIMAGE_BASE_RELOCATION)((LPBYTE)pImageDosHeaderMap +
        pImageNtHeaderMap->OptionalHeader.DataDirectory[5].VirtualAddress);

    // 这里不判断是否存在重定位表,因为通常情况下都存在

    // 遍历重定位表
    while (pImageBaseRelocationMap->VirtualAddress != 0)
    {
        // 重定位项数组的起始地址
        pRelocationItem = (PWORD)((LPBYTE)pImageBaseRelocationMap + sizeof(IMAGE_BASE_ RELOCATION));
        // 重定位项的个数
        dwRelocationItem = (pImageBaseRelocationMap->SizeOfBlock -
            sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD);

        for (DWORD i = 0; i < dwRelocationItem; i++)
        {
            // 区分PE和PE32+的重定位
            if (pRelocationItem[i] >> 12 == 3)
            {
                pdwRelocationAddress = (PDWORD)((LPBYTE)pImageDosHeaderMap +
                    pImageBaseRelocationMap->VirtualAddress + (pRelocationItem[i] & 0x0FFF));
                dwRelocationDelta = (DWORD)pImageDosHeaderMap -
                    pImageNtHeaderMap->OptionalHeader.ImageBase;
                *pdwRelocationAddress += dwRelocationDelta;
            }
            else if (pRelocationItem[i] >> 12 == 0xA)
            {
                pullRelocationAddress = (PULONGLONG)((LPBYTE)pImageDosHeaderMap +
                    pImageBaseRelocationMap->VirtualAddress + (pRelocationItem [i] & 0x0FFF));
                ullRelocationDelta = (ULONGLONG)pImageDosHeaderMap -
                    pImageNtHeaderMap->OptionalHeader.ImageBase;
                *pullRelocationAddress += ullRelocationDelta;
            }
        }

        // 指向下一个重定位块结构
        pImageBaseRelocationMap = (PIMAGE_BASE_RELOCATION)((LPBYTE)pImage  
BaseRelocationMap +
            pImageBaseRelocationMap->SizeOfBlock);
    }
// ************************************************************************

    // *********************************************************************
    // 修正映射到进程中的PE内存映像的导入函数地址表IAT
    PIMAGE_IMPORT_DESCRIPTOR pImageImportDescriptor;// 映射到进程中的导入表起始地址
    PIMAGE_THUNK_DATA pImageThunkData;                // IMAGE_THUNK_DATA数组起始地址
    PIMAGE_IMPORT_BY_NAME pImageImportByName;        // IMAGE_IMPORT_BY_NAME结构指针
    TCHAR szDllName[MAX_PATH] = { 0 };                // 动态链接库名称
    HMODULE hDll;                                         // DLL模块句柄
    DWORD dwFuncAddress;                                // 32位函数地址
    ULONGLONG ullFuncAddress;                          // 64位函数地址

    // 是否有导入表(当然,没有的可能性不大)
    if (pImageNtHeaderMap->OptionalHeader.DataDirectory[1].Size != 0)
    {
        // 导入表起始地址
        pImageImportDescriptor = (PIMAGE_IMPORT_DESCRIPTOR)((LPBYTE)pImageDosHeaderMap +
            pImageNtHeaderMap->OptionalHeader.DataDirectory[1].VirtualAddress);

        // 遍历导入表
        while (pImageImportDescriptor->OriginalFirstThunk || pImageImportDescriptor->  
               TimeDateStamp ||
            pImageImportDescriptor->ForwarderChain || pImageImportDescriptor->Name ||
            pImageImportDescriptor->FirstThunk)
        {
            // 在进程中加载DLL
            MultiByteToWideChar(CP_UTF8, 0,
                (LPSTR)((LPBYTE)pImageDosHeaderMap + pImageImportDescriptor->Name), -1,
                szDllName, _countof(szDllName));
            hDll = LoadLibrary(szDllName);

            // IMAGE_THUNK_DATA数组起始地址
            pImageThunkData = (PIMAGE_THUNK_DATA)((LPBYTE)pImageDosHeaderMap +
                pImageImportDescriptor->FirstThunk);
            while (pImageThunkData->u1.AddressOfData != 0)
            {
                // 区分PE和PE32+的IAT
                if (pImageNtHeaderMap->OptionalHeader.Magic == IMAGE_NT_OPTIONAL_HDR32_MAGIC)
                {
                    // 按序号导入还是按函数名称导入
                    if (pImageThunkData->u1.AddressOfData & IMAGE_ORDINAL_FLAG32)
                    {
                        // 获取加载的DLL中函数的地址
                        dwFuncAddress = (DWORD)GetProcAddress(hDll,
                            (LPSTR)(pImageThunkData->u1.AddressOfData & 0xFFFF));
                    }
                    else
                    {
                        // IMAGE_IMPORT_BY_NAME结构指针
                        pImageImportByName = (PIMAGE_IMPORT_BY_NAME)
                            ((LPBYTE)pImageDosHeaderMap + pImageThunkData->u1.AddressOfData);

                        // 获取加载的DLL中函数的地址
                        dwFuncAddress = (DWORD)GetProcAddress(hDll, (LPSTR)p  
                                           ImageImportByName->Name);
                    }
                    // 修复IAT项
                    pImageThunkData->u1.Function = dwFuncAddress;
                }
                else
                {
                    // 按序号导入还是按函数名称导入
                    if (pImageThunkData->u1.AddressOfData & IMAGE_ORDINAL_FLAG64)
                    {
                        // 获取加载的DLL中函数的地址
                        ullFuncAddress = (ULONGLONG)GetProcAddress(hDll,
                            (LPSTR)(pImageThunkData->u1.AddressOfData & 0xFFFF));
                    }
                    else
                    {
                        // IMAGE_IMPORT_BY_NAME结构指针
                        pImageImportByName = (PIMAGE_IMPORT_BY_NAME)
                            ((LPBYTE)pImageDosHeaderMap + pImageThunkData->u1.AddressOfData);

                        // 获取加载的DLL中函数的地址
                        ullFuncAddress = (ULONGLONG)GetProcAddress(hDll,
                            (LPSTR)pImageImportByName->Name);
                    }
                    // 修复IAT项
                    pImageThunkData->u1.Function = ullFuncAddress;
                }

                // 指向下一个IMAGE_THUNK_DATA结构
                pImageThunkData++;
            }

            // 指向下一个导入表描述符
            pImageImportDescriptor++;
        }
    }
    // *********************************************************************

    // *********************************************************************
    // 修改建议装载地址,并执行可执行文件
    LPVOID lpExeEntry;                          // 可执行文件入口点

    if (pImageNtHeaderMap->OptionalHeader.Magic == IMAGE_NT_OPTIONAL_HDR32_MAGIC)
    {
        ((PIMAGE_NT_HEADERS32)pImageNtHeaderMap)->OptionalHeader.ImageBase = (DWORD)lpBaseAddress;

        lpExeEntry = (LPVOID)((LPBYTE)pImageDosHeaderMap +
            ((PIMAGE_NT_HEADERS32)pImageNtHeaderMap)->OptionalHeader.Address OfEntryPoint);
    }
    else
    {
        ((PIMAGE_NT_HEADERS64)pImageNtHeaderMap)->OptionalHeader.ImageBase = (ULONGLONG)  
lpBaseAddress;

        lpExeEntry = (LPVOID)((LPBYTE)pImageDosHeaderMap +
            ((PIMAGE_NT_HEADERS64)pImageNtHeaderMap)->OptionalHeader.AddressOfEntryPoint);
    }

    // 如果本程序编译为64位,不支持内联汇编,则采取直接写入可执行机器码的方式执行可执行文件
#ifndef _WIN64
    // mov eax, 0x12345678
    // jmp eax
    BYTE bDataJmp[7] = { 0xB8, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xE0 };
    *(PINT_PTR)(bDataJmp + 1) = (INT_PTR)lpExeEntry;
    memcpy_s((LPBYTE)lpBaseAddress + nSizeOfImage, 7, bDataJmp, 7);
#else
    // mov rax, 0x1234567812345678
    // jmp rax
    BYTE bDataJmp[12] = { 0x48, 0xB8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xE0 };
    *(PINT_PTR)(bDataJmp + 2) = (INT_PTR)lpExeEntry;
    memcpy_s((LPBYTE)lpBaseAddress + nSizeOfImage, 12, bDataJmp, 12);
#endif

    // 可以根据每个节区的属性设置其对应内存页的内存保护属性,此处省略

    // 是可执行文件还是DLL,如果是可执行文件则执行上面的“jmp 入口地址”指令,否则执行DllMain
    if (pImageNtHeaderMap->FileHeader.Characteristics & IMAGE_FILE_DLL)
    {
        // 执行DllMain入口点函数
        typedef BOOL(APIENTRY* pfnDllMain)(HMODULE hModule, DWORD ulreason, LPVOID lpReserved);
        pfnDllMain fnDllMain = (pfnDllMain)(lpExeEntry);
        fnDllMain((HMODULE)lpBaseAddress, DLL_PROCESS_ATTACH, 0);

        // 尝试执行一个导出函数
        typedef VOID(*pfnShowMessage)();
        // 如果调用GetProcAddress函数获取ShowMessage函数的地址,会提示找不到指定的模块
        /*pfnShowMessage fnShowMessage = (pfnShowMessage)
            GetProcAddress((HMODULE)lpBaseAddress, "ShowMessage");*/

        // GetFuncRvaByName是自定义函数,用于获取指定函数的RVA
        pfnShowMessage fnShowMessage = (pfnShowMessage) ((LPBYTE)lpBaseAddress +
            GetFuncRvaByName((PIMAGE_DOS_HEADER)lpBaseAddress, TEXT("ShowMessage")));
        fnShowMessage();
    }
    else
    {
        // 跳转到exe入口点执行
        typedef VOID(WINAPI* pfnExe)();
        pfnExe fnExe = (pfnExe)((LPBYTE)lpBaseAddress + nSizeOfImage);
        fnExe();
    }
    // *********************************************************************

    return TRUE;
}

代码有些复杂,但是很容易理解,完整代码参见Chapter11\RunExecutableInMemory项目。

如果要调用加载的DLL中的导出函数,通过GetProcAddress函数获取ShowMessage函数的地址会提示找不到指定模块的错误提示,因此这里定义了如下两个自定义函数:

// 在DLL内存映像中根据函数序数获取函数地址(RVA值)
INT GetFuncRvaByOrdinal(PIMAGE_DOS_HEADER pImageDosHeader, DWORD dwOrdinal);
// 在DLL内存映像中根据函数名称获取函数地址(RVA值)
INT GetFuncRvaByName(PIMAGE_DOS_HEADER pImageDosHeader, LPCTSTR lpFuncName);

按函数序数dwOrdinal获取DLL导出表中的函数地址的步骤如下。

(1)获取导出表目录结构IMAGE_EXPORT_DIRECTORY的起始地址pImageExportDirectory。

(2)计算指定的函数在导出函数地址表(EAT)中的索引:dwIndexAddressOfFunctions = dwOrdinal - pImageExportDirectory->Base。

(3)获取导出函数地址表的起始地址pAddressOfFunctions。

(4)pAddressOfFunctions[dwIndexAddressOfFunctions]就是指定函数的内存地址(RVA值),该RVA值加上DLL模块基地址就是指定函数的真正入口地址。

按函数名称lpFuncName获取DLL导出表中的函数地址的步骤如下。

(1)获取导出表目录结构IMAGE_EXPORT_DIRECTORY的起始地址pImageExportDirectory。

(2)依次获取导出函数地址表的起始地址pAddressOfFunctions,函数序数表的起始地址pAddressOfNameOrdinals和函数名称地址表(ENT)的起始地址pAddressOfNames。

(3)以pImageExportDirectory->NumberOfNames字段的值作为循环次数,遍历函数名称地址表,如果指定的函数名称与函数名称地址表中的一项(ENT中的每一项是指向函数名称的RVA)相符合则记下指定函数lpFuncName在函数名称地址表中的索引。

(4)AddressOfNames和AddressOfNameOrdinals这两个字段是一一对应的关系,通过函数名称地址表(ENT)中的一个索引n,到函数序数表中查找索引n对应的WORD值[表示导出函数地址表的索引],pAddressOfFunctions[n]就是指定函数的内存地址,该RVA值加上DLL模块基地址就是指定函数的真正入口地址。

这两个自定义函数的实现代码参见Chapter11\RunExecutableInMemory项目。

这个RunExecutableInMemory程序说明:IAT并不是必须位于导入表中,而是可以位于PE内存映像中任何具有写权限的地方,只要PE加载器可以定位到所有IID项(导入表描述符结构IMAGE_IMPORT_DESCRIPTOR),然后根据函数名称获取到函数地址即可,大部分加壳程序会对导入表、IAT进行特别处理,以防止被脱壳。另外,一个DLL中的所有导入函数内存地址顺序排列在一起,形成一个导入函数内存地址数组,所有DLL的导入函数内存地址数组通常也会顺序排列在一起,形成导入函数地址表IAT,其实IAT完全可以不连续,只要通过IMAGE_IMPORT_DESCRIPTOR.FirstThunk字段可以定位到IMAGE_THUNK_DATA结构数组即可。

通过对IAT的了解,我们可以实现另一种Hook API的方式,那就是修改某API对应的IAT项的内存地址为我们自定义函数的内存地址(也称为API重定向),但是为了保持栈平衡,自定义函数的函数参数、函数调用约定、返回值类型等必须与目标函数完全一致。

也可以实现把一个PE文件加载到其他进程中执行,感兴趣的朋友可以自行研究,这里不再演示。

11.9 线程局部存储表

在编译链接生成可执行文件时,系统会把所有TLS变量放到一个名为.tls的节区中(如果编译为Release发行版本,该节区可能会被优化到名为.rdata的节区中),线程局部存储表用于静态TLS。

线程局部存储表是一个TLS目录结构IMAGE_TLS_DIRECTORY32,该结构的定义如下:

typedef struct _IMAGE_TLS_DIRECTORY32 {
     DWORD   StartAddressOfRawData;    // 指向TLS模板的起始地址(VA值)
     DWORD   EndAddressOfRawData;      // 指向TLS模板的结束地址(VA值)
     DWORD   AddressOfIndex;           // 指向TLS索引的DWORD数组(VA值)
     DWORD   AddressOfCallBacks;      // 指向TLS回调函数(PIMAGE_TLS_CALLBACK类型)指针的数组(VA值)
     DWORD   SizeOfZeroFill;           // TLS模板之后填充0的个数
     union {
          DWORD Characteristics;      // TLS标志
          struct {
               DWORD Reserved0 : 20;
               DWORD Alignment : 4;
               DWORD Reserved1 : 8;
          } DUMMYSTRUCTNAME;
     } DUMMYUNIONNAME;
} IMAGE_TLS_DIRECTORY32;
  • StartAddressOfRawData字段是指向TLS模板的起始地址(VA值),TLS模板是存放所有TLS变量初始化值的数据块,每当创建线程时系统都会复制这些数据块,因此这些数据一定不能出错。

  • EndAddressOfRawData字段是指向TLS模板的结束地址(VA值)。

  • AddressOfIndex字段是指向TLS索引的DWORD数组(VA值),索引的具体值由PE加载器确定。

  • AddressOfCallBacks字段是指向TLS回调函数(PIMAGE_TLS_CALLBACK类型)指针的数组(VA值),数组的最后是一个NULL指针(DWORD值为0x00000000),如果没有回调函数,该字段指向位置的值为0x00000000。

上述字段都是一个VA值(绝对地址),因此重定位表中应该有对应的重定位项以修正这些VA值。

  • SizeOfZeroFill字段表示TLS模板之后填充0的个数。

通过使用TLS回调函数,可以在程序运行(执行入口点)前执行一段自定义代码,基于这一点可以实现程序反调试。程序可以提供一个或多个TLS回调函数,以支持对TLS数据进行额外的初始化和清理操作,通常情况下回调函数不会超过一个,但还是将其作为一个数组来实现,以在需要时另外添加回调函数,如果回调函数超过一个,系统会按照它们在数组中出现的顺序调用每个回调函数。TLS回调函数的定义格式如下:

VOID NTAPI TlsCallback(PVOID DllHandle, DWORD Reason, PVOID Reserved);

可以看到TLS回调函数和DLL入口点函数DllMain的定义格式是类似的。

Reason参数表示回调函数被调用的原因,可以是表11.11所示的值之一。

表11.11

常量含义
DLL_PROCESS_ATTACH1启动了一个新进程(包括第一个线程)
DLL_PROCESS_DETACH0进程将要被终止(包括第一个线程)
DLL_THREAD_ATTACH2创建了一个新线程,创建所有线程时都会发送这个通知,除了第一个线程
DLL_THREAD_DETACH3线程将要被终止,终止所有线程时都会发送这个通知,除了第一个线程

PE32+的TLS目录结构是IMAGE_TLS_DIRECTORY64,条件编译定义如下:

#ifdef _WIN64
    typedef IMAGE_TLS_DIRECTORY64           IMAGE_TLS_DIRECTORY;
#else
    typedef IMAGE_TLS_DIRECTORY32           IMAGE_TLS_DIRECTORY;
#endif

IMAGE_TLS_DIRECTORY64结构的定义如下:

typedef struct _IMAGE_TLS_DIRECTORY64 {
     ULONGLONG StartAddressOfRawData;      // 该字段是一个ULONGLONG类型,而PE格式是DWORD类型
     ULONGLONG EndAddressOfRawData;        // 该字段是一个ULONGLONG类型,而PE格式是DWORD类型
     ULONGLONG AddressOfIndex;          // 该字段是一个ULONGLONG类型,而PE格式是DWORD类型
     ULONGLONG AddressOfCallBacks;         // 该字段是一个ULONGLONG类型,而PE格式是DWORD类型 
     DWORD SizeOfZeroFill;
     union {
          DWORD Characteristics;
          struct {
               DWORD Reserved0 : 20;
               DWORD Alignment : 4;
               DWORD Reserved1 : 8;
          } DUMMYSTRUCTNAME;
     } DUMMYUNIONNAME;
} IMAGE_TLS_DIRECTORY64;

可以发现,与IMAGE_TLS_DIRECTORY32结构不同的只是前4个字段。

接下来我们来改写Chapter6\TlsDemo_Static项目,为之添加TLS回调函数。TlsDemo.cpp源代码改写为如下形式:

#include <windows.h>
#include "resource.h"

// 宏定义
#define THREADCOUNT 5

// 全局变量
__declspec(thread) LPVOID gt_lpData = (LPVOID)0x12345678;// 赋初值是为了分析TLS表时方便查看
HWND g_hwndDlg;

// 函数声明
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);
// 线程函数
DWORD WINAPI ThreadProc(LPVOID lpParameter);

// TLS回调函数
VOID NTAPI TlsCallback(PVOID DllHandle, DWORD Reason, PVOID Reserved);
// 注册TLS回调函数
#pragma data_seg(".CRT$XLB")
     PIMAGE_TLS_CALLBACK pTlsCallback = TlsCallback;
#pragma data_seg()

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
     DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
     return 0;
}

INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
     HANDLE hThread[THREADCOUNT];

     switch (uMsg)
     {
     case WM_INITDIALOG:
          g_hwndDlg = hwndDlg;
          return TRUE;

     case WM_COMMAND:
          switch (LOWORD(wParam))
          {
          case IDC_BTN_OK:
               // 创建THREADCOUNT个线程
               SetDlgItemText(g_hwndDlg, IDC_EDIT_TLSSLOTS, TEXT(""));
               for (int i = 0; i < THREADCOUNT; i++)
               {
                    if ((hThread[i] = CreateThread(NULL, 0, ThreadProc, (LPVOID)i, 0, NULL)) != NULL)
                       CloseHandle(hThread[i]);
               }
               break;

          case IDCANCEL:
               EndDialog(hwndDlg, 0);
               break;
          }
          return TRUE;
     }

     return FALSE;
}

DWORD WINAPI ThreadProc(LPVOID lpParameter)
{
     TCHAR szBuf[64] = { 0 };

     gt_lpData = new BYTE[256];
     ZeroMemory(gt_lpData, 256);

     // 每个线程的静态TLS数据显示到编辑控件中
     wsprintf(szBuf, TEXT("线程%d的gt_lpData值:0x%p\r\n"), (INT)lpParameter, gt_lpData);
     SendMessage(GetDlgItem(g_hwndDlg, IDC_EDIT_TLSSLOTS), EM_SETSEL, -1, -1);
     SendMessage(GetDlgItem(g_hwndDlg, IDC_EDIT_TLSSLOTS), EM_REPLACESEL, TRUE, (LPARAM)szBuf);

     delete[]gt_lpData;
     return 0;
}

VOID NTAPI TlsCallback(PVOID DllHandle, DWORD Reason, PVOID Reserved)
{
     switch (Reason)
     {
     case DLL_PROCESS_ATTACH:
          // 启动了一个新进程(包括第一个线程)
          MessageBox(g_hwndDlg, TEXT("我是TLS回调函数"), TEXT("提示"), MB_OK);
          break;

     case DLL_PROCESS_DETACH:
          // 进程将要被终止(包括第一个线程)
     case DLL_THREAD_ATTACH:
          // 创建了一个新线程,创建所有线程时都会发送这个通知,除第一个线程外
     case DLL_THREAD_DETACH:
          // 线程将要被终止,终止所有线程时都会发送这个通知,除第一个线程外
          break;
     }
}

有改动的代码已经被标识出来。注册 TLS 回调函数的方式是添加一个新的节区.CRTXLB,CRT表示使用C运行时机制,XLB,CRT表示使用C运行时机制,XLBCRT表示使用C运行时机制,后面的XLB中,X表示随机标识,L表示TLS Callback Section,B可以是B~Y之间的任意一个字符(A和Z已经被占用)。

把程序编译为Debug x86,可以看到首先弹出“我是TLS回调函数”消息框,单击确定按钮后TlsDemo出现程序界面。但是如果把程序编译为Release x86,TLS回调函数消息框并不会弹出,即回调函数没有被调用,打开项目属性对话框→配置属性→C/C++ →优化→全程序优化,设置为否,单击确定按钮,再次编译运行程序,回归正常。

接下来重点研究AddressOfCallBacks字段,指向TLS回调函数(PIMAGE_TLS_CALLBACK类型)指针的数组(VA值)。使用PEInfo程序打开Chapter11\TlsDemo_Static\Debug\TlsDemo.exe,可以看到原书的图11.19所示的界面。

OD载入Chapter11\TlsDemo_Static\Debug\TlsDemo.exe,首先弹出“我是TLS回调函数”消息框,单击确定按钮后出现反汇编代码,通过内存窗口可以看到TlsDemo程序加载的基地址为0x00D70000,那么线程局部存储表TLS目录结构IMAGE_TLS_DIRECTORY32的内存地址是0x00D70000 + 0x00011DF8,等于0x00D81DF8,数据窗口中定位到0x00D81DF8(见原书的图11.20)。

用鼠标右键单击原书的图 11.20 中选中的 AddressOfCallBacks 字段的值,然后选择数据窗口中跟随DWORD(见原书的图11.21)。

选中部分就是TLS回调函数(PIMAGE_TLS_CALLBACK类型)指针的数组,可以看到只有一个TLS回调函数,内存地址为0x00D71000。

在反汇编窗口中定位到0x00D71000(见原书的图11.22)。

这正是我们编写的TLS回调函数TlsCallback。如果在第一行设置断点,OD重新载入程序,就会中断在TLS回调函数的起始地址处。

OD的StrongOD插件有一个“Break On Tls”选项,选中该项后,载入程序时会自动中断在TLS回调函数的起始地址处。另外,可以打开OD的选项菜单项→调试设置→事件选项卡,设置第一次暂停于系统断点,这样一来OD载入程序时就会中断在系统领空,此时还没有执行TLS回调函数。

11.10 加载配置信息表

加载配置信息表是一个加载配置目录结构IMAGE_LOAD_CONFIG_DIRECTORY32,加载配置信息表最初仅用于定义一些操作系统加载PE时用到的一些附加信息,这些信息之所以被单独定义,是因为信息量比较大、信息类型比较复杂,无法被标准PE头和扩展PE头的数据结构所容纳。加载配置信息表后被用作异常处理,其中存放了基于结构化异常处理SEH的各种异常句柄,当程序发生异常时,操作系统会根据异常类别对异常进行分发处理,并根据这些句柄实施程序流程的转向,从而保证系统能从程序异常中全身而退。

IMAGE_LOAD_CONFIG_DIRECTORY32结构的定义如下:

typedef struct _IMAGE_LOAD_CONFIG_DIRECTORY32 {    // 164(0xA4)字节
    DWORD   Size;                                    // 0x00,该结构的大小,0x000000A4
    DWORD   TimeDateStamp;                         // 0x04,
    WORD    MajorVersion;                            // 0x08,
    WORD    MinorVersion;                            // 0x0A,
    DWORD   GlobalFlagsClear;                       // 0x0C,
    DWORD   GlobalFlagsSet;                         // 0x10,
    DWORD   CriticalSectionDefaultTimeout;       // 0x14,
    DWORD   DeCommitFreeBlockThreshold;          // 0x18,
    DWORD   DeCommitTotalFreeThreshold;          // 0x1C,
    DWORD   LockPrefixTable;                       // 0x20,
    DWORD   MaximumAllocationSize;                // 0x24,
    DWORD   VirtualMemoryThreshold;               // 0x28,
    DWORD   ProcessHeapFlags;                      // 0x2C,
    DWORD   ProcessAffinityMask;                  // 0x30,
    WORD    CSDVersion;                             // 0x34,
    WORD    DependentLoadFlags;               // 0x36,
    DWORD   EditList;                           // 0x38
    DWORD   SecurityCookie;                    // 0x3C,
    DWORD   SEHandlerTable;                    // 0x40,指向SEH异常处理程序RVA数组,VA值
    DWORD   SEHandlerCount;                      // 0x44,SEH异常处理程序的个数
    DWORD   GuardCFCheckFunctionPointer;         // 0x48,
    DWORD   GuardCFDispatchFunctionPointer;      // 0x4C,
    DWORD   GuardCFFunctionTable;                  // 0x50,
    DWORD   GuardCFFunctionCount;                  // 0x54,
    DWORD   GuardFlags;                              // 0x58,
    IMAGE_LOAD_CONFIG_CODE_INTEGRITY CodeIntegrity;     // 0x5C,
    DWORD   GuardAddressTakenIatEntryTable;            // 0x68,
    DWORD   GuardAddressTakenIatEntryCount;              // 0x6C,
    DWORD   GuardLongJumpTargetTable;                     // 0x70,
    DWORD   GuardLongJumpTargetCount;                     // 0x74,
    DWORD   DynamicValueRelocTable;                       // 0x78,
    DWORD   CHPEMetadataPointer;                            // 0x7C,
    DWORD   GuardRFFailureRoutine;                          // 0x80,
    DWORD   GuardRFFailureRoutineFunctionPointer;        // 0x84,
    DWORD   DynamicValueRelocTableOffset;                 // 0x88,
    WORD    DynamicValueRelocTableSection;                // 0x8C,
    WORD    Reserved2;                                       // 0x8E,
    DWORD   GuardRFVerifyStackPointerFunctionPointer;    // 0x90,
    DWORD   HotPatchTableOffset;                           // 0x94,
    DWORD   Reserved3;                                       // 0x98,
    DWORD   EnclaveConfigurationPointer;                   // 0x9C,
    DWORD   VolatileMetadataPointer;                       // 0xA0,
} IMAGE_LOAD_CONFIG_DIRECTORY32, * PIMAGE_LOAD_CONFIG_DIRECTORY32;
  • SEHandlerTable字段是指向SEH异常处理程序RVA地址的DWORD数组(按RVA从小到大排序),该字段的值是一个VA值,仅适用于x86平台。

  • SEHandlerCount字段表示SEH异常处理程序的个数,仅适用于x86平台。

PE32+的加载配置目录结构是IMAGE_LOAD_CONFIG_DIRECTORY64,条件编译定义如下:

#ifdef _WIN64
     typedef IMAGE_LOAD_CONFIG_DIRECTORY64     IMAGE_LOAD_CONFIG_DIRECTORY;
#else
     typedef IMAGE_LOAD_CONFIG_DIRECTORY32     IMAGE_LOAD_CONFIG_DIRECTORY;
#endif

IMAGE_LOAD_CONFIG_DIRECTORY64结构中有25个字段的类型由DWORD变为ULONGLONG,因此该结构的大小为164 + 100等于264(0x108)字节,可以参见WinNt.h头文件中关于该结构的定义。

11.11 资源表

PE和PE32+的资源组织方式相同,都是按照类似于文件系统的目录组织方式。资源可以包括图标、光标、位图和菜单等十几种标准类型,还可以使用自定义类型,每种类型的资源中可能存在多个资源项。这些资源项使用不同的ID或名称来分辨。对于某个资源项,还可以同时存在不同代码页的版本(例如简体中文、繁体中文和英语等)。采取类似于文件系统的目录组织方式可以很好地对程序资源进行归类,比如创建一个第1层目录,其中有图标、光标、位图和菜单等子目录;假设有n个图标,则可以在图标子目录下再以图标ID为目录名称创建n个第2层子目录;同一ID的资源可能存在不同代码页的版本,这样可以在第2层子目录下再以代码页ID为目录名称创建第3层子目录,第3层子目录中的数据可以指向真正的资源数据。要查找某个资源,可以根据资源类型→资源ID→资源代码页这样的顺序逐层进入相应的子目录找到正确的资源。PE和PE32+的资源组织方式如原书的图11.23所示。

第1层目录按照资源类型进行划分,例如光标、图标和菜单等子目录;第2层目录按照资源ID进行划分,例如同样是第1层目录“图标”下面的子目录,可以有ID为103的图标、ID为104的图标等子目录;第3层目录按照代码页例如简体中文、繁体中文和英语等进行划分,例如同样是第2层目录“ID为101菜单”下面的子目录,可以有简体中文和英语子目录。注意,第1层到第3层目录的数据结构是相同的,都是由一个资源目录结构IMAGE_RESOURCE_DIRECTORY和紧跟其后的若干个资源目录入口结构IMAGE_RESOURCE_DIRECTORY_ENTRY组成,这一系列数据结构可以称为资源目录表,而每个资源目录入口结构IMAGE_RESOURCE_DIRECTORY_ENTRY可以称为资源目录项。

资源目录结构IMAGE_RESOURCE_DIRECTORY的定义如下:

typedef struct _IMAGE_RESOURCE_DIRECTORY {  // 16字节
    DWORD   Characteristics;         // 资源标志,通常为0x00000000
    DWORD   TimeDateStamp;           // 资源编译器创建资源的时间戳,通常为0x00000000
    WORD    MajorVersion;                // 主版本号,通常为0x0000
    WORD    MinorVersion;                // 次版本号,通常为0x0000
    WORD    NumberOfNamedEntries;      // 以名称命名的资源目录入口结构的个数
    WORD    NumberOfIdEntries;         // 以ID命名的资源目录入口结构的个数
    //  IMAGE_RESOURCE_DIRECTORY_ENTRY DirectoryEntries[];// 该结构后面紧跟着资源目录入口结构数组
} IMAGE_RESOURCE_DIRECTORY, * PIMAGE_RESOURCE_DIRECTORY;

(1)用于第1层目录,资源类型可以是标准资源类型或自定义资源类型,标准资源类型(例如ICON、CURSOR等)在编译资源脚本文件时会被解释为255以下的一个ID数字,而自定义资源类型可以是一个字符串或255~65535中的ID数字,因此IMAGE_RESOURCE_DIRECTORY结构使用NumberOfNamedEntries和NumberOfIdEntries两个字段分别表示以名称命名的资源目录入口结构的个数和以ID命名的资源目录入口结构的个数,即以名称命名的资源类型个数和以 ID 命名的资源类型个数这两个字段的值相加得到紧跟在本结构后面资源目录入口结构IMAGE_RESOURCE_DIRECTORY_ENTRY的总数。

(2)用于第2层目录,资源ID可以是一个字符串或1~65535之间的ID数字,因此IMAGE_RESOURCE_DIRECTORY结构使用NumberOfNamedEntries和NumberOfIdEntries两个字段分别表示以名称命名的资源目录入口结构的个数和以ID命名的资源目录入口结构的个数,即以名称命名的资源ID个数和以ID命名的资源ID 个数两个字段的值相加得到紧跟在本结构后面资源目录入口结构IMAGE_RESOURCE_DIRECTORY_ENTRY的总数。

(3)用于第3层目录,与标准资源类型相同,每种标准语言都有预定义的ID,但是也可能存在非标准语言。

资源目录入口结构IMAGE_RESOURCE_DIRECTORY_ENTRY的定义如下:

typedef struct _IMAGE_RESOURCE_DIRECTORY_ENTRY {     // 8字节
     union {
          struct {
               DWORD NameOffset : 31; 
               DWORD NameIsString : 1; 
          } DUMMYSTRUCTNAME;
          DWORD     Name;                                // 资源类型或资源ID或代码页ID
          WORD      Id;
     } DUMMYUNIONNAME;
     union {
          DWORD       OffsetToData;                     // 资源数据入口结构或指向下一个资源目录表
          struct {
               DWORD   OffsetToDirectory : 31;
               DWORD   DataIsDirectory : 1;
          } DUMMYSTRUCTNAME2;
     } DUMMYUNIONNAME2;
} IMAGE_RESOURCE_DIRECTORY_ENTRY, * PIMAGE_RESOURCE_DIRECTORY_ENTRY;

这个结构看上去很复杂,但是有用的只有Name和OffsetToData两个字段,该结构的大小为8字节。

  • Name字段:如果该字段DWORD值的最高位即位31是0,则该字段的低位字作为一个ID来使用;如果该字段DWORD值的最高位即位31是1,则该字段的值(& 0x7FFFFFFF)是一个指向 IMAGE_RESOURCE_DIR_STRING_U 结构的偏移量(相对于资源表),该结构包含Unicode字符串的长度和Unicode字符串两个字段,因为IMAGE_RESOURCE_DIR_STRING_U结构有一个Unicode字符串长度字段,所以Unicode字符串字段并不是以零结尾。

当IMAGE_RESOURCE_DIRECTORY_ENTRY.Name字段用于不同层次目录时,其含义不同。

❏ 用于第 1 层目录,该字段的值表示资源类型,例如标准资源类型ICON、CURSOR,自定义资源类型MYDATA。前面说过标准资源类型例如ICON、CURSOR等在编译资源脚本文件时会被解释为255以下的一个ID数字,因此对于标准资源类型使用该字段的低位字表示标准资源类型ID;对于自定义资源类型,如果该字段DWORD值的最高位(即位31)是1,该字段的值(& 0x7FFFFFFF)是一个指向IMAGE_RESOURCE_DIR_STRING_U结构的偏移量(相对于资源表),IMAGE_RESOURCE_DIR_STRING_U.NameString字段表示资源类型Unicode字符串,但是自定义资源类型也可以是255~65535中的一个ID数字,如果该字段的最高位(即位31)是0,那么低位字表示自定义资源类型ID。不管程序使用Unicode还是ANSI字符集程序资源中的字符串都是使用Unicode编码,稍后介绍标准资源类型例如ICON、CURSOR等对应的ID。

❏ 用于第2层目录,该字段的值表示资源ID或资源名称,资源ID可以是一个字符串或1~65535之间的ID数字。是ID的情况下该字段的低位字表示资源ID;是字符串的情况下该字段的值(& 0x7FFFFFFF)是一个指向IMAGE_RESOURCE_DIR_STRING_U结构的偏移量(相对于资源表)。

❏ 用于第3层目录,该字段的值表示代码页ID。稍后介绍常见语言的代码页ID。

  • OffsetToData字段:如果该字段DWORD值的最高位即位31是0,那么该字段的值是一个指向资源数据入口结构IMAGE_RESOURCE_DATA_ENTRY的偏移量(相对于资源表),这种情况通常出现在第3层目录中;如果该字段DWORD值的最高位即位31是1,那么该字段的值(& 0x7FFFFFFF)是一个指向资源目录表的偏移量(相对于资源表),也就是指向一个资源目录结构IMAGE_RESOURCE_DIRECTORY和紧跟其后的若干个资源目录入口结构IMAGE_RESOURCE_DIRECTORY_ENTRY,这种情况通常出现在第1层和第2层目录中。

IMAGE_RESOURCE_DIR_STRING_U结构的定义如下:

typedef struct _IMAGE_RESOURCE_DIR_STRING_U {
     WORD    Length;
     WCHAR   NameString[1];
} IMAGE_RESOURCE_DIR_STRING_U, * PIMAGE_RESOURCE_DIR_STRING_U;

最后就是第 3 层目录指向的资源数据入口结构IMAGE_RESOURCE_DATA_ENTRY:

typedef struct _IMAGE_RESOURCE_DATA_ENTRY { // 16字节
  DWORD   OffsetToData;// 资源数据块的RVA
  DWORD   Size;          // 资源数据块的大小(字节单位)
  DWORD   CodePage;     // 用于解码资源数据中码位值的代码页,通常是Unicode代码页,值通常为0x00000000
  DWORD   Reserved;     // 保留字段
} IMAGE_RESOURCE_DATA_ENTRY, * PIMAGE_RESOURCE_DATA_ENTRY;

预定义的资源类型如表11.12所示。

表11.12

常量含义
RT_CURSOR1光标
RT_BITMAP2位图
RT_ICON3图标
RT_MENU4菜单
RT_DIALOG5对话框
RT_STRING6字符串表
RT_FONTDIR7字体目录
RT_FONT8字体
RT_ACCELERATOR9加速键
RT_RCDATA10应用程序定义的资源
RT_MESSAGETABLE11消息表
RT_GROUP_CURSOR12光标组
RT_GROUP_ICON14图标组
RT_VERSION16程序版本
RT_DLGINCLUDE17提供符号名称的头文件
RT_PLUGPLAY19即插即用资源
RT_VXD20VXD
RT_ANICURSOR21动态光标
RT_ANIICON22动态图标
RT_HTML23HTML
RT_MANIFEST24清单文件

常见语言的代码页ID如表11.13所示。

表11.13

代码页ID中英文说明
0x0000中性语言Language Neutral
0x0400程序默认语言Process Default Language
0x0404中文(中国台湾)Chinese(Taiwan Region)
0x0804中文(中国)Chinese(PRC)
0x0C04中文(中国香港)Chinese(Hong Kong SAR, PRC)
0x1004中文(新加坡)Chinese(Singapore)
0x0409英语(美国)English(United States)
0x0809英语(英国)English(United Kingdom)
0x0411日语Japanese
0x0412韩语Korean
0x0419俄语Russian

下面实现一个遍历程序资源的GetPEResource程序,程序运行效果如原书的图11.24所示。左侧是一个树视图控件,右侧是用于显示选中资源项资源数据的多行编辑控件,但该功能暂未实现。注意,对于自定义资源类型(例如上图中的前3个),资源数据入口结构PIMAGE_RESOURCE_DATA_ENTRY.OffsetToData字段指向的就是原生资源数据的RVA,但是对于标准资源类型,指向的资源数据和资源文件原生数据可能不是完全相同,需要额外处理。例如我们的Chapter10\HelloWindows7\Debug\HelloWindows.exe程序资源脚本文件中只是添加了ID为103、104和105这3个图标,但是资源表中存在图标组(ID为103~105)和图标(ID为2~4)两个资源类型,ID为103的图标是一个羽毛图标,很明显图标类型下ID为2的资源数据入口结构指出的数据大小更接近Feather.ico的实际文件大小,而图标组下ID为103的资源数据入口结构指出的数据大小比较小,只是一些图标文件头数据,关于如何解析各种标准资源类型,限于篇幅关系,本书不做介绍。

实现遍历程序资源的核心是自定义函数GetResourceInfo:

// 全局变量
LPCTSTR arrResType[] = { TEXT("未知类型"), TEXT("光标"), TEXT("位图"), TEXT("图标"),
    TEXT("菜单"), TEXT("对话框"), TEXT("字符串表"), TEXT("字体目录"), TEXT("字体"),
    TEXT("加速键"), TEXT("程序自定义资源"), TEXT("消息表"), TEXT("光标组"), TEXT("未知类型"),
    TEXT("图标组"), TEXT("未知类型"), TEXT("程序版本"), TEXT("提供符号名称的头文件"),
    TEXT("未知类型"), TEXT("即插即用资源"), TEXT("VXD"), TEXT("动态光标"), TEXT("动态图标"),
TEXT("HTML"), TEXT("清单文件") };

/**************************************************************************
  * 函数功能:        获取资源信息
  * 输入参数的说明:
    1. pImageRes参数表示第1层目录中的资源目录结构起始地址,也就是资源表的起始地址,必须指定
    2. pImageResDir参数表示第1~3层目录中的资源目录结构起始地址,必须指定
    3. hTreeParent参数表示树视图控件中父节点的句柄,必须指定
    4. dwLevel参数指定为数值1~3,表示当前调用本函数是为了获取第几层目录的信息,必须指定
  * 该函数为递归函数
*************************************************************************/
BOOL GetResourceInfo(PIMAGE_RESOURCE_DIRECTORY pImageRes, PIMAGE_RESOURCE_DIRECTORY   
pImageResDir,
    HTREEITEM hTreeParent, DWORD dwLevel)
{
    PIMAGE_RESOURCE_DIRECTORY pImageResDirSub;              // 下一层资源目录结构起始地址
    PIMAGE_RESOURCE_DIRECTORY_ENTRY pImageResDirEntry;     // 资源目录入口结构数组起始地址
    WORD wNums;                                                 // 资源目录入口结构数组个数
    PIMAGE_RESOURCE_DATA_ENTRY pImageResDataEntry;           // 资源数据入口结构起始地址
    PIMAGE_RESOURCE_DIR_STRING_U pString;
    HTREEITEM hTree;
    TVINSERTSTRUCT tvi = { 0 };
    TCHAR szResType[128] = { 0 }, szResID[128] = { 0 }, szLanguageID[128] = { 0 };
    TCHAR szBuf[256] = { 0 };

    // 资源目录入口结构数组起始地址
    pImageResDirEntry = (PIMAGE_RESOURCE_DIRECTORY_ENTRY)((LPBYTE)pImageResDir +
        sizeof(IMAGE_RESOURCE_DIRECTORY));
    // 资源目录入口结构数组个数
    wNums = pImageResDir->NumberOfNamedEntries + pImageResDir->NumberOfIdEntries;

    tvi.item.mask = TVIF_TEXT;
    tvi.hInsertAfter = TVI_LAST;
    tvi.hParent = hTreeParent;

    if (dwLevel == 1)
    {
        // 遍历资源目录入口结构数组
        for (WORD i = 0; i < wNums; i++)
        {
            // 资源类型
            if (pImageResDirEntry[i].Name & 0x80000000)
            {
                pString = (PIMAGE_RESOURCE_DIR_STRING_U)
                    ((LPBYTE)pImageRes + (pImageResDirEntry[i].Name & 0x7FFFFFFF));
                StringCchCopy(szResType, pString->Length + 1, pString->NameString);
            }
            else
            {
                if (LOWORD(pImageResDirEntry[i].Name) <= 24)
                    wsprintf(szResType, TEXT("%s"), arrResType[LOWORD(pImageResDirEntry[i].Name)]);
                else
                    wsprintf(szResType, TEXT("%d(自定义ID)"), LOWORD(pImageResDirEntry[i].Name));
            }
            tvi.item.pszText = szResType;
            hTree = (HTREEITEM)SendMessage(g_hwndTree, TVM_INSERTITEM, 0, (LPARAM)&tvi);

            // 递归进入第2层
            pImageResDirSub = (PIMAGE_RESOURCE_DIRECTORY)
                ((LPBYTE)pImageRes + (pImageResDirEntry[i].OffsetToData & 0x7FFFFFFF));
            GetResourceInfo(pImageRes, pImageResDirSub, hTree, 2);
        }
    }
    else if (dwLevel == 2)
    {
        // 遍历资源目录入口结构数组
        for (WORD i = 0; i < wNums; i++)
        {
            // 资源ID
            if (pImageResDirEntry[i].Name & 0x80000000)
            {
                pString = (PIMAGE_RESOURCE_DIR_STRING_U)
                    ((LPBYTE)pImageRes + (pImageResDirEntry[i].Name & 0x7FFFFFFF));
                StringCchCopy(szResID, pString->Length + 1, pString->NameString);
            }
            else
            {
                wsprintf(szResID, TEXT("%d"), LOWORD(pImageResDirEntry[i].Name));
            }
            tvi.item.pszText = szResID;
            hTree = (HTREEITEM)SendMessage(g_hwndTree, TVM_INSERTITEM, 0, (LPARAM)&tvi);

            // 递归进入第3层
            pImageResDirSub = (PIMAGE_RESOURCE_DIRECTORY)
                ((LPBYTE)pImageRes + (pImageResDirEntry[i].OffsetToData & 0x7FFFFFFF));
            GetResourceInfo(pImageRes, pImageResDirSub, hTree, 3);
        }
    }
    else
    {
        // 遍历资源目录入口结构数组
        for (WORD i = 0; i < wNums; i++)
        {
            // 语言ID
            if (pImageResDirEntry[i].Name & 0x80000000)
            {
                pString = (PIMAGE_RESOURCE_DIR_STRING_U)
                    ((LPBYTE)pImageRes + (pImageResDirEntry[i].Name & 0x7FFFFFFF));
                StringCchCopy(szLanguageID, pString->Length + 1, pString-> NameString);
            }
            else
            {
                wsprintf(szLanguageID, TEXT("0x%04X"), LOWORD(pImageResDirEntry[i].Name));
            }

            // 资源数据入口结构起始地址
            pImageResDataEntry = (PIMAGE_RESOURCE_DATA_ENTRY)
                ((LPBYTE)pImageRes + (pImageResDirEntry[i].OffsetToData));
            wsprintf(szBuf, TEXT("%s  RVA:0x%08X 大小:0x%X"),
                szLanguageID, pImageResDataEntry->OffsetToData, pImageResDataEntry->Size);

            tvi.item.mask = TVIF_TEXT | TVIF_PARAM;
            tvi.item.pszText = szBuf;
            tvi.item.lParam = (LPARAM)pImageResDataEntry;// 保存资源数据入口结构起始地址到项目数据
            SendMessage(g_hwndTree, TVM_INSERTITEM, 0, (LPARAM)&tvi);

            // 递归出口
            return TRUE;
        }
    }

    return TRUE;
}

完整代码参见Chapter11\GetPEResource项目。

程序资源查看、编辑工具有eXeScope、PExplorer、Restorator和ResourceHacker等,后两者支持PE/PE32+,通过这些工具可以对未加壳的程序资源进行修改。

11.12 延迟加载导入表

延迟加载指的是通过隐式链接的DLL,可执行模块开始运行时并不加载延迟加载的DLL(也不会检查该DLL是否存在),只有当代码中调用延迟加载DLL中的函数时,系统才会实际载入该DLL。设置延迟加载DLL以后,编译器在编译程序时会在PE文件中创建一个延迟加载导入表,延迟加载导入表记录了可执行模块要导入的DLL以及相关函数的信息。

与导入表类似,延迟加载导入表是一个延迟加载描述结构IMAGE_DELAYLOAD_DESCRIPTOR数组,结构的个数取决于程序要延迟加载的DLL文件的数量,每个结构对应一个DLL文件,最后以一个内容全为0的IMAGE_DELAYLOAD_DESCRIPTOR结构作为结束。IMAGE_DELAYLOAD_DESCRIPTOR结构的定义如下:

typedef struct _IMAGE_DELAYLOAD_DESCRIPTOR {
     union {
          DWORD AllAttributes;             // 如果最高位为1说明是延迟加载版本2
          struct {
               DWORD RvaBased : 1;
               DWORD ReservedAttributes : 31;
          } DUMMYSTRUCTNAME;
     } Attributes;
     DWORD DllNameRVA;                     // 指向以零结尾的延迟加载DLL名称字符串,RVA,UTF-8字符串
     DWORD ModuleHandleRVA;                // 延迟加载DLL模块句柄的RVA
     DWORD ImportAddressTableRVA;         // 延迟加载DLL的IAT的起始地址,RVA
     DWORD ImportNameTableRVA;             // 延迟加载DLL的INT的起始地址,RVA
     DWORD BoundImportAddressTableRVA;    // 可选的延迟加载DLL的绑定IAT的起始地址,RVA
     DWORD UnloadInformationTableRVA;     // 可选的延迟加载DLL的卸载IAT的起始地址,RVA
     DWORD TimeDateStamp;                   // 如果未绑定则为0,否则为绑定的时间戳
} IMAGE_DELAYLOAD_DESCRIPTOR, * PIMAGE_DELAYLOAD_DESCRIPTOR;

重要的字段是DllNameRVA、ModuleHandleRVA、ImportAddressTableRVA和ImportNameTableRVA。与导入表相同,可以说ImportAddressTableRVA和ImportNameTableRVA字段指向的都是一个IMAGE_THUNK_DATA结构数组的RVA,每一个IMAGE_THUNK_DATA结构表示一个导入函数的信息,数组的最后以一个内容全为0的IMAGE_THUNK_DATA结构作为结束。

导入表的IAT是由PE加载器在加载可执行文件时进行修正,但是延迟加载导入表的IAT中的每一项在PE文件中都已经是一个函数地址VA。当然,该函数地址VA需要进行重定位,重定位表中会存在延迟加载导入表IAT每一项的RVA地址。

11.13 校验和与CRC

校验和是通过对一段数据按照一定算法进行计算以后生成的值,通常作为判断这段数据是否被非法修改的依据。IMAGE_NT_HEADERS.OptionalHeader.CheckSum字段是一个DWORD值,该值的计算步骤如下。

(1)将IMAGE_NT_HEADERS.OptionalHeader.CheckSum字段的值清0。

(2)以WORD为单位对数据块进行带进位的累加,大于WORD部分自动溢出。

(3)将累加和与文件的长度相加即可得到PE文件的校验和。

Windows系统目录下有一个动态链接库ImageHlp.dll,该DLL专门用来操作PE文件,其中CheckSumMappedFile和MapFileAndCheckSum这两个函数都可以用于计算文件校验和。

MapFileAndCheckSum函数用于计算指定文件的校验和:

DWORD MapFileAndCheckSum(
     _In_  PCTSTR Filename,        // 要为其计算校验和的文件的文件名
     _Out_ PDWORD HeaderSum,      // 接收原始校验和的变量的指针
     _Out_ PDWORD CheckSum);      // 接收计算的校验和的变量的指针

如果函数执行成功,则返回值为CHECKSUM_SUCCESS(0)。

CheckSumMappedFile函数用于计算指定PE文件的校验和:

PIMAGE_NT_HEADERS CheckSumMappedFile(
     _In_  PVOID  BaseAddress,    // PE内存映射文件的基地址
     _In_  DWORD  FileLength,     // 文件大小,以字节为单位
     _Out_ PDWORD HeaderSum,     // 接收原始校验和的变量的指针
     _Out_ PDWORD CheckSum);     // 接收计算的校验和的变量的指针

如果函数执行成功,则返回值是PE内存映射文件中IMAGE_NT_HEADERS结构的指针,调用者可以通过返回的指针修改IMAGE_NT_HEADERS.OptionalHeader.CheckSum字段的值;如果函数执行失败,则返回值为NULL。

这里有一个示例程序CheckSum,可以分别使用自定义算法和MapFileAnd CheckSum函数计算校验和,程序运行效果如原书的图11.25所示。

具体代码参见Chapter11\CheckSum项目。注意,自定义算法仅作为参考。

循环冗余校验(Cyclic Redundancy Check,CRC)是一种根据网络包或计算机文件等数据产生简短固定位数校验码的一种信道编码技术,主要用来检测、校验数据传输或保存后可能出现的错误,它是利用除法及余数的原理来进行错误侦测的。

在数据传输过程中,无论传输系统的设计多么完美,差错总会存在,这种差错可能会导致在链路上传输的一个或多个帧被破坏(出现比特差错,0变为1,或1变为0),从而导致接收方接收到错误的数据。为了尽量提高接收方接收数据的正确率,在接收方接收数据前需要对数据进行差错检测,当且仅当检测的结果为正确时接收方才真正接收数据。检测的方式有多种,常见的有奇偶校验、因特网校验和循环冗余校验等。循环冗余校验是一种用于校验通信链路上数字传输准确性的计算方法(通过某种数学运算来建立数据位和校验位的约定关系)。发送方使用某种公式计算出需要传送数据的一个校验值,并将此值附加在被传送数据的后面,接收方则对同一数据进行相同的计算,通常应该得到相同的结果,如果两个校验结果不一致,则说明发送过程中出现了差错,接收方可以要求发送方重新发送该数据。在计算机网络通信中使用CRC校验相对于其他校验方法有一定的优势,CRC可以高比例地纠正信息传输过程中的错误,可以在极短的时间内完成数据校验码的计算,并迅速完成纠错过程,通过包自动重发的方式使计算机的通信速度大幅提高,对通信效率和安全提供了保障。由于CRC算法检验的检错能力极强,且检测成本较低,因此在编码器和电路的检测中使用较为广泛,在检错的正确率与速度、成本等方面,都比奇偶校验等校验方式具有优势,因此CRC成为计算机信息通信领域最为普遍的校验方式。

常用的CRC版本有CRC-8、CRC-12、CRC-16、CRC-CCITT、CRC-32和CRC-32C等,每个版本的具体算法有所不同,WinRAR、NERO、ARJ、LHA等压缩软件采用的是CRC-32,磁盘驱动器的读写采用了CRC-16,通用图像存储格式例如GIF、TIFF等也都采用CRC作为检错手段。

要计算一个文件或者一段数据的CRC-32,可以按照规定算法实现一个自定义函数,或者使用NtDll.dll中的RtlComputeCrc32函数。下面实现一个计算CRC-32的自定义函数CRC32:

#define Poly 0xEDB88320                 // CRC-32标准

VOID GenerateCRC32Table(PUINT pCRC32Table)
{
     UINT nCrc;

     for (UINT i = 0; i < 256; i++)
     {
          nCrc = i;
          for (int j = 0; j < 8; j++)
          {
               if (nCrc & 0x00000001)
                    nCrc = (nCrc >> 1) ^ Poly;
               else
                    nCrc = nCrc >> 1;
          }

          pCRC32Table[i] = nCrc;
     }
}

UINT CRC32(LPBYTE lpData, UINT nSize)
{
     UINT CRC32Table[256] = { 0 };       // CRC-32查询表
     UINT nCrc = 0xFFFFFFFF;

     // 生成CRC-32查询表
     GenerateCRC32Table(CRC32Table);

     // 计算CRC-32
     for (UINT i = 0; i < nSize; i++)
          nCrc = CRC32Table[(nCrc ^ lpData[i]) & 0xFF] ^ (nCrc >> 8);

     return nCrc ^ 0xFFFFFFFF;           // 按位取反
}

也可以使用NtDll.dll中的RtlComputeCrc32函数,速度更快一些,例如:

// RtlComputeCrc32计算CRC-32
typedef UINT(WINAPI* pfnRtlComputeCrc32)(INT dwInitial, LPVOID lpData, INT nLen);
pfnRtlComputeCrc32 fnRtlComputeCrc32;

fnRtlComputeCrc32 = (pfnRtlComputeCrc32)
     GetProcAddress(GetModuleHandle(TEXT("NtDll.dll")), "RtlComputeCrc32");
nCRC = fnRtlComputeCrc32(0, lpMemory, liFileSize.LowPart);  // 第一个参数指定为0

具体代码参见Chapter11\CRC32项目。

一个程序的代码段(通常是.text或.code)在PE文件加载前后不会发生变化,因此有的加密程序会对代码段进行CRC-32检验,代码段的起始位置和大小可以通过节表获取,计算出代码段的CRC-32值(可以对这个值进行加密)后,可以存储在PE文件的某个地方,在程序运行过程中计算PE内存映像中的代码段的CRC-32值,并与先前保存的值进行比较,以此判断程序是否正在被调试(例如int3断点就是通过修改机器码为0xCC)或者被非法修改。

通过PEID的Krypto Analyzer插件可以查询一个程序用到了哪些知名加密算法。

11.14 64位程序中如何书写汇编代码(以获取CPUID为例)

64位程序中不支持内联汇编,不过,也有一些方法可以实现在程序中嵌入汇编代码。例如,微软提供了一系列intrinsic函数(定义在intrin.h等头文件中),intrinsic系列函数比较多,如果需要详细了解,那么读者可以自行参考MSDN,其中cpuid和cpuidex这两个函数是微软对汇编指令cpuid的封装,可以获取CPU的信息及其支持的功能:

void __cpuid(
     _Out_ int cpuInfo[4],        // 返回CPU信息及其支持的功能
     _In_  int function_id);      // 指定要获取的基本信息(功能号,通常可以是0~3)
void __cpuidex(
     _Out_ int cpuInfo[4],        // 返回CPU信息及其支持的功能
     _In_  int function_id,       // 指定要获取的基本信息(功能号,通常可以是0~3)
     _In_  int subfunction_id);  // 指定要获取的扩展信息

cpuid指令可以获取CPU的信息(例如CPU的型号和家族等)和CPU支持的功能(例如是否支持MMX、SSE和FPU指令等)。cpuid指令有两组功能,一组返回基本信息,另一组返回扩展信息。cpuid指令的用法如下:

mov eax, 功能号
cpuid

执行上述指令后,通过eax、ebx、ecx和edx返回所需的信息。如果需要获取基本信息,功能号的最高位即位31设置为0,例如1(0x00000001);如果需要获取扩展信息,功能号的最高位即位31设置为1,例如0x80000001。要详细了解各个功能号以及返回的信息的含义,需要阅读Intel指令手册,限于篇幅关系这里不再详细说明。intrinsic函数cpuid和cpuidex就是把返回的eax~edx的值分别放入cpuInfo[0]~cpuInfo[3]。

一些需要获取用户CPUID的绑定计算机的软件通常使用功能号1来获取CPU的信息,接下来我们实现一个获取CPUID的GetCPUID程序,该程序通过intrinsic函数__cpuid和自定义函数GetCPUID(汇编代码)两种方法来实现,程序运行效果如原书的图 11.26所示。

可以发现与 Chapter3\GetComputerPhysicalInfoByWMI\Debug\ GetComputerPhysicalInfoByWMI.exe程序获取到的CPUID信息相同。

创建一个对话框程序空项目,GetCPUID.cpp源文件的内容如下:

#include <windows.h>
#include <intrin.h>
#include "resource.h"

// 函数声明
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);
// 声明引用外部函数
EXTERN_C VOID GetCPUID(int cpuInfo[4], int function_id);

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR   
lpCmdLine, int nCmdShow)
{
    DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
    return 0;
}

INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
    int arrCpuInfo[4] = { 0 };
    TCHAR szBuf[32] = { 0 };

    switch (uMsg)
    {
    case WM_COMMAND:
        switch (LOWORD(wParam))
        {
        case IDC_BTN_GET:
            // intrinsic函数
            __cpuid(arrCpuInfo, 1);
            wsprintf(szBuf, TEXT("%08X%08X"), arrCpuInfo[3], arrCpuInfo[0]);
            SetDlgItemText(hwndDlg, IDC_EDIT_INTRINSIC, szBuf);

            // 自定义汇编函数
            ZeroMemory(arrCpuInfo, sizeof(arrCpuInfo));
            GetCPUID(arrCpuInfo, 1);
            wsprintf(szBuf, TEXT("%08X%08X"), arrCpuInfo[3], arrCpuInfo[0]);
            SetDlgItemText(hwndDlg, IDC_EDIT_ASM, szBuf);
            break;

        case IDCANCEL:
            EndDialog(hwndDlg, 0);
            break;
        }
        return TRUE;
    }

    return FALSE;
}

EXTERN_C VOID GetCPUID(int cpuInfo[4], int function_id);语句用于声明引用外部函数GetCPUID。

然后,我们再添加一个Test.asm汇编源文件,内容如下:

.code

GetCPUID proc 
     mov r8, rcx
     mov eax, edx
     cpuid
     mov dword ptr [r8], eax
     mov dword ptr [r8 + 0Ch], edx    
     ret
GetCPUID endp

end

虽然64位程序不支持内联汇编,但是VS是可以编译汇编源文件.asm。用鼠标右键单击解决方案资源管理器源文件下面的Test.asm,然后选择属性,打开Test.asm属性页对话框,配置属性→常规,项类型选择自定义生成工具,然后单击“应用”按钮,此时左侧的树视图控件中会多出一个自定义生成工具子项。

配置属性→自定义生成工具→常规,命令行一项输入:

ml64 /Fo $(IntDir)%(fileName).obj /c %(fileName).asm

输出一项输入:

$(IntDir)%(fileName).obj

单击“确定”按钮,配置完成。

$(IntDir)宏表示当前生成配置目录,例如Chapter11\GetCPUID\GetCPUID\x64\Debug\或Chapter11\ GetCPUID\GetCPUID\x64\Release\,上面命令行一项所输入内容的意思是使用ml64.exe编译xxx.asm为xxx.obj到生成配置目录下(/Fo用于指定输出的.obj文件名,/c表示仅进行编译不自动进行链接),输出一项所输入的内容表示告诉链接器到哪里查找xxx.obj文件以完成链接工作。配置情况截图参见Chapter11\GetCPUID\AsmConfig.png。

按Ctrl + F5组合键,程序成功编译运行,如果需要编译为Release x64,对于Test.asm还需要进行同样的设置。这种嵌入汇编代码的方法实际上是调用其他.obj文件中的函数。在链接阶段,链接器会把各个.obj目标文件与需要用到的库绑定链接到一块形成可执行文件。

实际应用中,对于简单的汇编代码,如果不喜欢使用asm文件,也可以通过嵌入ShellCode的方式达到同样的目的,具体参见GetCPUIDI项目,读者可以根据实际情况灵活使用。

11.15 Detours-master库

Detours-master库提供了拦截API函数的功能,实现原理是将目标函数的前几条指令替换为无条件跳转到用户提供的自定义函数的jmp指令。在自定义函数中可以执行一些拦截处理,当自定义函数完成拦截处理后,恢复执行目标函数被覆盖的前几条指令,并继续执行目标函数的后续部分,目标函数执行完后,自定义函数返回,整个流程和6.8.2节中的例子极其类似。

自定义函数在内部实现层次上被一分为二为两个函数。执行拦截处理的称为Detour函数(绕行函数),Detour函数的函数参数、函数调用约定和返回值类型等必须与目标函数完全一致;负责恢复执行目标函数被覆盖的前几条指令并跳转到目标函数后续部分的功能可以单独放到一个函数中,称为Trampoline函数(跳板函数),Trampoline函数在Detour函数中被调用。

Detours-master库支持ARM、ARM64、x86、x64和IA-64平台。除了可以对API函数进行拦截,通过Detours-master库还可以修改可执行文件的导入表,向可执行文件中添加数据,以及将DLL加载到目标进程中等。

Detours-master库的当前版本是4.0.1。例如,可以下载Detours-master库并解压到F:\Source\ Windows\Detours-master文件夹。Detours-master提供了Makefile文件,因此编译工作比较简单。开始→ Visual Studio 2019 → x86 Native Tools Command Prompt for VS 2019,输入:

F:
cd F:\Source\Windows\Detours-master
nmake

稍等一会儿,可以发现Detours-master文件夹中生成了include、lib.X86和bin.X86这3个子文件夹。bin.X86目录中的内容是一些示例程序,include和lib.X86目录中的文件是使用Detours-master库提供的API接口所需的头文件和.lib文件。注意,生成的.lib文件是静态链接库。

如果需要生成x64版本的.lib文件,可以打开x64 Native Tools Command Prompt for VS 2019工具并输入上述命令,x86和x64所需的头文件是相同的,也可以通过VS打开vc目录中的Detours.sln解决方案文件并选择所需的解决方案配置和平台来进行编译。

11.15.1 注入DLL的编写

现在,我们通过Detours-master库提供的相关API来实现6.8.2节的例子。新建一个DLL空项目InjectDll,InjectDll.cpp源文件的内容如下所示(无须头文件):

#include <windows.h>
#include <tchar.h>
#include "..\..\..\Detours-master\include\detours.h"

// 编译为x86时需要使用的.lib
#pragma comment(lib, "..\\..\\..\\Detours-master\\lib.X86\\detours.lib")
// 编译为x64时需要使用的.lib
//#pragma comment(lib, "..\\..\\..\\Detours-master\\lib.X64\\detours.lib")

// 目标函数指针(加static关键字说明仅用于本文件)
static BOOL(WINAPI* OriginalExtTextOutW)(HDC hdc, int x, int y, UINT options,
     const RECT* lprect, LPCWSTR lpString, UINT c, const INT* lpDx) = ExtTextOutW;

// 自定义函数
BOOL WINAPI DetourExtTextOutW(HDC hdc, int x, int y, UINT options,
     RECT* lprect, LPCWSTR lpString, UINT c, INT* lpDx)
{
     TCHAR szText1[] = TEXT("屏幕");
     TCHAR szText2[] = TEXT("用户名");
     TCHAR szText3[] = TEXT("购买者");
     TCHAR szTextReplace[] = TEXT("                                                ");
     LPCTSTR lpStr;

     if ((lpStr = _tcsstr(lpString, szText1)) ||
          (lpStr = _tcsstr(lpString, szText2)) ||
          (lpStr = _tcsstr(lpString, szText3)))
     {
         memcpy((LPVOID)lpStr, szTextReplace, _tcslen(lpStr) * sizeof(TCHAR));
     }

     OriginalExtTextOutW(hdc, x, y, options, lprect, lpString, c, lpDx);

     return TRUE;
}

BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
{
     // 如果当前进程是辅助进程,则不执行任何处理
     if (DetourIsHelperProcess())
         return TRUE;

     if (ul_reason_for_call == DLL_PROCESS_ATTACH)
     {
         // 恢复当前进程的导入表
         DetourRestoreAfterWith();

         // 开启(开始)事务
         DetourTransactionBegin();
         // 指定更新线程
         DetourUpdateThread(GetCurrentThread());
         // 执行Hook处理
         DetourAttach(&(PVOID&)OriginalExtTextOutW, DetourExtTextOutW);
         // 提交事务
         DetourTransactionCommit();
     }
     else if (ul_reason_for_call == DLL_PROCESS_DETACH)
     {
         DetourTransactionBegin();
         DetourUpdateThread(GetCurrentThread());
         // 执行Unhook处理
         DetourDetach(&(PVOID&)OriginalExtTextOutW, DetourExtTextOutW);
         DetourTransactionCommit();
     }

     return TRUE;
}

具体代码参见Chapter11\Detours\InjectDll项目。

执行DetourAttach函数后,自定义函数DetourExtTextOutW中的OriginalExtTextOutW被修改为指向Trampoline函数(负责恢复执行目标函数被覆盖的前几条指令并跳转到目标函数后续部分),这就是前面所说的“自定义函数在内部实现层次上被一分为二为两个函数”的含义。读者可以自行调试研究Detours-master对于ExtTextOutW函数的Hook实现方法。

接下来介绍InjectDll用到的几个API函数。可执行模块可以通过调用DetourCreateProcessWithDllEx或DetourCreateProcessWithDlls函数将InjectDll加载到目标进程中,内部实现方法是修改目标进程的导入表,将InjectDll对应的导入表描述符结构放在导入表的最前部,这样一来就可以在目标进程启动后但在执行任何代码前加载InjectDll,在InjectDll中通过调用DetourAttach函数执行Hook处理。

Detours-master支持从64位父进程创建32位目标进程或从32位父进程创建64位目标进程,即32位父进程可以创建32位或64位目标进程,64位父进程也可以创建32位或64位目标进程。从64位父进程创建32位目标进程或从32位父进程创建64位目标进程时,DetourCreateProcessWithDllEx或DetourCreateProcessWithDlls函数必须创建临时辅助进程,方法是将InjectDll加载到rundll32.exe进程并通过调用DetourFinishHelperProcess函数来创建一个辅助进程,辅助进程会加载InjectDll的副本,通过辅助进程可以确保使用正确的32位或64位代码来修改目标进程的导入表。这种情况下必须注意以下两点。

(1)InjectDll必须导出DetourFinishHelperProcess函数并将其导出序数(函数序数)设置为1,否则目标进程无法正常启动。

(2)InjectDll应该在其DllMain函数中调用DetourIsHelperProcess函数以检查InjectDll所属的当前进程是辅助进程还是目标进程,如果是辅助进程,那么不应该执行Hook处理,DllMain函数直接返回TRUE即可。

DetourIsHelperProcess函数用于检查InjectDll所属的当前进程是辅助进程还是目标进程:

BOOL DetourIsHelperProcess(VOID);

如果当前进程是辅助进程,则返回值为TRUE;如果当前进程是目标进程,则返回值为FALSE。

调用DetourCreateProcessWithDllEx或DetourCreateProcessWithDlls函数会修改目标进程的导入表。为了确保目标进程可以正常运行,创建目标进程并且InjectDll被加载后,应该恢复其导入表,通常应该在InjectDll的DllMain函数的DLL_PROCESS_ATTACH中调用DetourRestoreAfterWith函数来恢复目标进程的导入表。DetourRestoreAfterWith函数原型如下:

BOOL DetourRestoreAfterWith(VOID);

事务是一系列原子性、独占性的操作。要进行事务工作,首先需要开启(开始)事务,然后是一系列操作,最后提交事务,只有在提交事务后所做的操作才会生效。调用DetourAttach函数执行Hook处理或调用DetourDetach函数执行Unhook处理前需要先调用DetourTransactionBegin函数开启事务,之后则需要调用DetourTransactionCommit函数提交事务,DetourUpdateThread函数用于指定需要更新的线程(受事务影响的线程)。下面是这几个函数的原型。

(1)开启(开始)事务的函数如下:

LONG DetourTransactionBegin(VOID);

(2)指定更新线程的函数如下:

LONG DetourUpdateThread(_In_ HANDLE hThread);   // 线程句柄,由GetCurrentThread()返回

(3)执行Hook处理的函数如下:

LONG DetourAttach(
    _Inout_ PVOID* ppPointer,     // 目标函数指针的地址
    _In_    PVOID  pDetour);       // 自定义函数的地址

(4)执行Unhook处理的函数如下:

LONG DetourDetach(
    _Inout_ PVOID* ppPointer,
    _In_    PVOID  pDetour);

(5)提交事务的函数如下:

LONG DetourTransactionCommit(VOID);

11.15.2 将注入DLL加载到目标进程中

DetourCreateProcessWithDllEx函数用于创建一个新进程并将指定的DLL加载到该进程中,而DetourCreateProcessWithDlls函数用于创建一个新进程并将指定的一个或多个DLL加载到该进程中。这两个函数的原型如下:

BOOL DetourCreateProcessWithDllEx(
  _In_opt_     LPCTSTR                   lpApplicationName,
  _Inout_opt_ LPTSTR                    lpCommandLine,
  _In_opt_     LPSECURITY_ATTRIBUTES    lpProcessAttributes,
  _In_opt_     LPSECURITY_ATTRIBUTES    lpThreadAttributes,
  _In_          BOOL                      bInheritHandles,
  _In_          DWORD                     dwCreationFlags,
  _In_opt_     LPVOID                    lpEnvironment,
  _In_opt_     LPCTSTR                   lpCurrentDirectory,
  _In_          LPSTARTUPINFOW            lpStartupInfo,
  _Out_         LPPROCESS_INFORMATION    lpProcessInformation,
  _In_         LPCSTR                    lpDllName,                         // 要注入的DLL的名称,CHAR*
  _In_opt_    PDETOUR_CREATE_PROCESS_ROUTINEW pfCreateProcessW);// 替换CreateProcessW的新  
                                                                            // 函数指针 
BOOL DetourCreateProcessWithDlls(
  _In_opt_     LPCTSTR                   lpApplicationName,
  _Inout_opt_ LPTSTR                    lpCommandLine,
  _In_opt_     LPSECURITY_ATTRIBUTES    lpProcessAttributes,
  _In_opt_     LPSECURITY_ATTRIBUTES    lpThreadAttributes,
  _In_          BOOL                      bInheritHandles,
  _In_          DWORD                     dwCreationFlags,
  _In_opt_     LPVOID                    lpEnvironment,
  _In_opt_     LPCTSTR                   lpCurrentDirectory,
  _In_          LPSTARTUPINFOW          lpStartupInfo,
  _Out_         LPPROCESS_INFORMATION    lpProcessInformation,
  _In_          DWORD                     nDlls,                          // rlpDlls数组中的数组元素个数
  _In_          LPCSTR*                   rlpDlls,                        // 要注入的DLL的名称数组,CHAR*
  _In_opt_     PDETOUR_CREATE_PROCESS_ROUTINEW pfCreateProcessW);    // 替换CreateProcessW的新函  
                                                        // 数指针

pfCreateProcessW参数指定为替换标准CreateProcessW函数的新函数指针。如果使用标准CreateProcessW函数,则该参数可以设置为NULL。

DetourCreateProcessWithDllEx或DetourCreateProcessWithDlls函数在内部调用CreateProcessW函数时,会把dwCreationFlags参数设置为CREATE_SUSPENDED以挂起模式创建目标进程,这两个函数会修改目标进程的导入表,将指定的DLL对应的导入表描述符结构放置在导入表的最前部,然后恢复目标进程的执行,系统会最先加载指定的DLL。这两个函数会备份目标进程的导入表以便将来恢复。

现在,我们通过Detours-master库提供的相关API来实现6.8.2节中的可执行模块。

调用DetourCreateProcessWithDllEx或DetourCreateProcessWithDlls函数时,应该确保InjectDll中导出了函数序数为1的DetourFinishHelperProcess函数,否则目标进程无法正常启动。我们为InjectDll添加模块定义文件InjectDll.def:

EXPORTS
     DetourFinishHelperProcess @1

然后分别编译32位和64位版本,将它们重命名为InjectDll32.dll和InjectDll64.dll。

新建一个Win32项目CreateProcessWithDll,程序运行效果如原书的图11.27所示。

注入DLL组合框可以选择InjectDll32.dll或InjectDll64.dll,目标程序组合框可以选择FloatingWaterMark32.exe或FloatingWaterMark64.exe。为了避免引用错误,CreateProcessWithDll.exe、InjectDll32.dll或InjectDll64.dll、FloatingWaterMark32.exe或FloatingWaterMark64.exe应该放置在同一个文件夹中。DetourCreateProcessWithDllEx或DetourCreateProcessWithDlls函数有一定的纠错能力,假设CreateProcessWithDll.exe编译为32位,注入DLL选择InjectDll32.dll,目标程序选择FloatingWaterMark64.exe,这两个函数发现目标程序是64位,会自动加载InjectDll64.dll(当然目录中必须存在该DLL),不过我认为不应该依赖这个特性,注入DLL和目标程序的位数始终应该保持一致。不管CreateProcessWithDll.exe编译为32位还是64位,都可以正确加载32位或64位目标程序。

CreateProcessWithDll.cpp源文件的内容如下:

#include <windows.h>
#include <tchar.h>
#include <CommCtrl.h>
#include "..\..\..\Detours-master\include\detours.h"
#include "resource.h"

// 编译为x86时需要使用的.lib
#pragma comment(lib, "..\\..\\..\\Detours-master\\lib.X86\\detours.lib")
// 编译为x64时需要使用的.lib
//#pragma comment(lib, "..\\..\\..\\Detours-master\\lib.X64\\detours.lib")

#pragma comment(linker,"\"/manifestdependency:type='win32' \
     name='Microsoft.Windows.Common-Controls' version='6.0.0.0' \
     processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'\"")

// 函数声明
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
    DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
    return 0;
}

INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
    HWND hwndComboDllPath;
    HWND hwndComboTarget;
    CHAR szInjectDll[MAX_PATH] = { 0 };       // 注入DLL路径
    TCHAR szTargetProcess[MAX_PATH] = { 0 };  // 目标程序路径
    STARTUPINFO si = { sizeof(STARTUPINFO) };
    PROCESS_INFORMATION pi = { 0 };
    BOOL bRet = FALSE;

    switch (uMsg)
    {
    case WM_INITDIALOG:
        hwndComboDllPath = GetDlgItem(hwndDlg, IDC_COMBO_DLLPATH);
        hwndComboTarget = GetDlgItem(hwndDlg, IDC_COMBO_TARGET);

        // 注入DLL组合框添加一些列表项
        SendMessage(hwndComboDllPath, CB_ADDSTRING, 0, (LPARAM)TEXT("InjectDll32.dll"));
        SendMessage(hwndComboDllPath, CB_ADDSTRING, 0, (LPARAM)TEXT("InjectDll64.dll"));
        SendMessage(hwndComboDllPath, CB_SETCURSEL, 0, 0);

        // 目标程序组合框添加一些列表项
        SendMessage(hwndComboTarget, CB_ADDSTRING, 0, (LPARAM)TEXT("FloatingWaterMark32.exe"));
        SendMessage(hwndComboTarget, CB_ADDSTRING, 0, (LPARAM)TEXT("FloatingWaterMark64.exe"));
        SendMessage(hwndComboTarget, CB_SETCURSEL, 0, 0);
        return TRUE;

    case WM_COMMAND:
        switch (LOWORD(wParam))
        {
        case IDC_BTN_CREATE:
            GetDlgItemTextA(hwndDlg, IDC_COMBO_DLLPATH, szInjectDll, _countof(szInjectDll));
            GetDlgItemText(hwndDlg, IDC_COMBO_TARGET, szTargetProcess, _countof(szTargetProcess));

            GetStartupInfo(&si);
            bRet = DetourCreateProcessWithDllEx(NULL, szTargetProcess, NULL, NULL, FALSE, 0,
                NULL, NULL, &si, &pi, szInjectDll, NULL);
            if (!bRet)
                MessageBox(hwndDlg, TEXT("创建目标进程失败!"), TEXT("错误提示"), MB_OK);
            break;
        }
        return TRUE;

    case WM_CLOSE:
        EndDialog(hwndDlg, 0);
        return TRUE;
    }

    return FALSE;
}

再介绍几个相关的函数。相比DetourAttach,DetourAttachEx函数可以返回Detour函数、Trampoline函数和目标函数的地址:

LONG DetourAttachEx(
    _Inout_   PVOID*                 ppPointer,            // 目标函数指针的地址
    _In_       PVOID                  pDetour,              // 自定义函数的地址
    _Out_opt_ PDETOUR_TRAMPOLINE* ppRealTrampoline,     // 返回Trampoline函数的地址
    _Out_opt_ PVOID*                 ppRealTarget,         // 返回目标函数的地址
    _Out_opt_ PVOID*                 ppRealDetour);        // 返回Detour函数的地址

DetourFindFunction函数用于从指定的模块中查找指定函数的地址:

PVOID DetourFindFunction(
    _In_ LPCSTR pszModule,         // 模块名称,CHAR*
    _In_ LPCSTR pszFunction);      // 函数名称,CHAR*

如果函数执行成功,则返回指定函数的内存地址;如果函数执行失败,则返回值为NULL。

DetourCodeFromPointer函数用于获取指定函数的代码实现地址:

PVOID DetourCodeFromPointer(
    _In_      PVOID  pPointer,        // 目标函数指针
    _Out_opt_ PVOID* ppGlobals);    // 返回目标函数的全局(或静态)数据的地址,不需要可以设置为NULL

如果函数执行成功,则返回目标函数的代码实现地址。

解释一下DetourFindFunction和DetourCodeFromPointer函数的区别。有时候获取到的函数地址处可能是一个jmp指令,然后跳转到函数的代码实现处,调用DetourCodeFromPointer函数返回的是目标函数的代码实现地址。OD载入Chapter11\Detours\CreateProcessWithDll\Release\FloatingWaterMark32.exe,反汇编窗口中Ctrl + G,输入GetCurrentProcess,单击“OK”按钮,看到原书的图11.28所示的界面。

调用kernel32.GetCurrentProcess的结果实际上是jmp到KernelBase.GetCurrentProcess,后者的实现方法如原书的图11.29所示。

请看如下代码:

LPVOID lpGetCurrentProcess = NULL, lpRealGetCurrentProcess = NULL, lpGlobals = NULL;
TCHAR szBuf[512] = { 0 };

lpGetCurrentProcess = DetourFindFunction("kernel32", "GetCurrentProcess");
lpRealGetCurrentProcess = DetourCodeFromPointer(lpGetCurrentProcess, &lpGlobals);
wsprintf(szBuf, TEXT("函数指针:0x%p\n函数代码实现地址:0x%p\n全局(或静态)数据的地址:0x%p\n"), 
    lpGetCurrentProcess, lpRealGetCurrentProcess, lpGlobals);
MessageBox(NULL, szBuf, TEXT("提示"), MB_OK);

结果如原书的图11.30所示。

DetourEnumerateModules函数用于枚举进程中的模块:

HMODULE DetourEnumerateModules(_In_opt_ HMODULE hModuleLast);  // 模块句柄

第1次调用的时候,hModuleLast参数应该设置为NULL,函数返回下一个模块的句柄,以返回的模块句柄循环调用该函数,直到函数返回NULL。

枚举到一个模块后,可以通过调用DetourGetEntryPoint函数获取模块的入口点,通过调用DetourGetModuleSize函数获取模块的大小。这两个函数的原型如下:

PVOID DetourGetEntryPoint(
   _In_opt_ HMODULE hModule);     // 模块句柄,设置为NULL则返回调用进程的可执行模块的入口点
ULONG DetourGetModuleSize(
   _In_     HMODULE hModule);     // 模块句柄

DetourEnumerateExports函数用于枚举模块的导出函数:

BOOL DetourEnumerateExports(
   _In_     HMODULE hModule,                                    // 模块句柄
   _In_opt_ PVOID   pContext,                                   // 传递给回调函数的参数
   _In_     PF_DETOUR_ENUMERATE_EXPORT_CALLBACK pfExport);    // 回调函数

每枚举到一个导出函数,都会调用一次回调函数,回调函数的定义格式如下:

BOOL CALLBACK ExportFunc(
   _In_opt_ PVOID  pContext,      // DetourEnumerateExports函数传递过来的参数
   _In_     ULONG  nOrdinal,      // 函数序数
   _In_opt_ LPCSTR pszName,      // 函数名称
   _In_opt_ PVOID  pCode);        // 函数的代码实现地址

如果需要继续枚举,则回调函数返回值为TRUE;如果需要中止枚举,则返回值为FALSE。

DetourEnumerateImports函数用于枚举模块的导入表:

BOOL DetourEnumerateImports(
  _In_opt_ HMODULE hModule,        // 模块句柄
  _In_opt_ PVOID   pContext,      // 传递给pfImportFile和pfImportFunc的参数
  _In_opt_ PF_DETOUR_IMPORT_FILE_CALLBACK pfImportFile,      // 每枚举到一个DLL,都会调用一次
  _In_opt_ PF_DETOUR_IMPORT_FUNC_CALLBACK pfImportFunc);     // 每枚举到一个导入函数,都会调用一次

每枚举到一个DLL,都会调用一次回调函数pfImportFile;每枚举到一个导入函数,都会调用一次回调函数pfImportFunc。还有一个DetourEnumerateImportsEx函数,仅是回调函数pfImportFunc返回的信息稍有不同,有需要的读者可以自行查阅帮助文档。

11.15.3 编辑可执行文件

DetourBinaryOpen函数用于将可执行文件的内容读入内存:

PDETOUR_BINARY DetourBinaryOpen(_In_ HANDLE hFile);   // 文件句柄

如果函数执行成功,则返回值是指向detours二进制文件对象的指针(typedef VOID * PDETOUR_BINARY);如果函数执行失败,则返回值为NULL。

DetourBinarySetPayload函数用于向detours二进制文件对象中添加数据:

PVOID DetourBinarySetPayload(
    _In_ PDETOUR_BINARY pBinary,    // DetourBinaryOpen函数返回的detours二进制文件对象的指针
    _In_ REFGUID          rguid,      // 要添加的数据的GUID(开发人员自行设定)
    _In_ PVOID            pData,        // 要添加的数据的指针
    _In_ DWORD            cbData);      // 要添加的数据的大小(字节单位)

如果函数执行成功,则返回值是数据实际写入到的内存地址;如果函数执行失败,则返回值为NULL。

DetourBinaryFindPayload函数用于从detours二进制文件对象中查找指定GUID的数据:

PVOID DetourBinaryFindPayload(
    _In_  PDETOUR_BINARY pBinary,     // DetourBinaryOpen函数返回的detours二进制文件对象的指针
    _In_  REFGUID          rguid,       // 要查找的数据的GUID
    _Out_ DWORD *         pcbData);    // 返回查找到的数据的大小(字节单位)

如果函数执行成功,则返回值是指定数据的内存地址;如果函数执行失败,则返回值为NULL。

DetourBinaryDeletePayload函数用于从detours二进制文件对象中删除指定GUID的数据,DetourBinaryPurgePayloads函数用于从detours二进制文件对象中删除所有的数据。这两个函数的原型如下:

BOOL DetourBinaryDeletePayload(
    _In_ PDETOUR_BINARY pBinary,    // DetourBinaryOpen函数返回的detours二进制文件对象的指针
    _In_ REFGUID         rguid);      // 要删除的数据的GUID
BOOL DetourBinaryPurgePayloads(
    _In_ PDETOUR_BINARY pBinary);     // DetourBinaryOpen函数返回的detours二进制文件对象的指针

DetourBinaryEditImports函数用于修改detours二进制文件对象的导入表:

BOOL DetourBinaryEditImports(
   _In_     PDETOUR_BINARY pBinary,    // DetourBinaryOpen函数返回的detours二进制文件对象的指针
   _In_opt_ PVOID           pContext,   // 传递给各个回调函数的参数
   _In_opt_ PF_DETOUR_BINARY_BYWAY_CALLBACK  pfByway,
   _In_opt_ PF_DETOUR_BINARY_FILE_CALLBACK   pfFile,
   _In_opt_ PF_DETOUR_BINARY_SYMBOL_CALLBACK pfSymbol,
   _In_opt_ PF_DETOUR_BINARY_COMMIT_CALLBACK pfFinal);

DetourBinaryEditImports函数会遍历detours二进制文件对象的导入表,在不同的时候会调用不同的回调函数,程序可以在回调函数中执行所需的编辑操作,关于各个回调函数,有需要的读者可以自行查阅帮助文档。

DetourBinaryResetImports函数用于重置detours二进制文件对象的导入表:

BOOL DetourBinaryResetImports(
   _In_ PDETOUR_BINARY pBinary); // DetourBinaryOpen函数返回的detours二进制文件对象的指针

DetourBinaryWrite函数用于将更新写入文件,DetourBinaryClose函数用于关闭打开的detours二进制文件对象。这两个函数的原型如下:

BOOL DetourBinaryWrite(
   _In_ PDETOUR_BINARY pBinary,        // DetourBinaryOpen函数返回的detours二进制文件对象的指针
   _In_ HANDLE hFile);               // 文件句柄
BOOL DetourBinaryClose(
   _In_ PDETOUR_BINARY pBinary);     // DetourBinaryOpen函数返回的detours二进制文件对象的指针

11.16 通过修改模块导入表中的IAT项来Hook API

通过对IAT的了解,我们可以实现另一种Hook API的方式,那就是修改某API对应的IAT项的内存地址为我们自定义函数的内存地址,但是为了栈平衡,自定义函数的函数参数、函数调用约定、返回值类型等必须与目标函数完全一致。

我们通过修改IAT项的方式来实现6.8.2节的例子中的DLL。在注入DLL的DllMain函数的DLL_PROCESS_ATTACH中执行下列工作。

(1)修改进程可执行模块导入表中ExtTextOutW函数对应的IAT项的内存地址为我们自定义函数HookExtTextOutW的内存地址,但是进程中其他模块也可以调用ExtTextOutW函数,因此我们应该修改进程所有模块导入表中ExtTextOutW函数对应的IAT项;

(2)进程中所有模块都可以随时通过调用Kernel32.dll中的LoadLibraryA、LoadLibraryW、LoadLibraryExA、LoadLibraryExW函数来加载一个可以调用ExtTextOutW函数的模块,加载该模块可能会导致加载其他依赖模块,因此还应该Hook掉LoadLibrary*函数,修改进程所有模块导入表中LoadLibrary*函数对应的IAT项为我们自定义函数HookLoadLibrary*的内存地址,在HookLoadLibrary*函数中执行相关处理;

(3)进程中所有模块都可以随时通过调用Kernel32.dll中的GetProcAddress函数来动态获取ExtTextOutW函数的内存地址并调用,因此还应该Hook掉GetProcAddress函数,修改进程所有模块导入表中GetProcAddress函数对应的IAT项为我们自定义函数HookGetProcAddress的内存地址。

ImageDirectoryEntryToDataEx函数用于从普通PE文件或PE内存映像中查找指定的数据目录(例如导入表)的内存地址:

PVOID IMAGEAPI ImageDirectoryEntryToDataEx(
    _In_       PVOID                     pBase,              // PE或PE内存映像的基地址
    _In_       BOOLEAN                   bMappedAsImage,    // 是否是PE内存映像
    _In_       USHORT                    nDirectoryEntry,    // 数据目录索引
    _Out_      PULONG                    pSize,              // 返回数据目录的大小
    _Out_opt_ PIMAGE_SECTION_HEADER* pFoundHeader);     // 返回数据目录所在的节区的信息

nDirectoryEntry参数用于指定数据目录索引,例如IMAGE_DIRECTORY_ENTRY_EXPORT表示导出表,IMAGE_DIRECTORY_ENTRY_IMPORT表示导入表等。如果函数执行成功,则返回值是指定数据目录的内存地址;如果函数执行失败或不存在指定的数据目录,则返回值为NULL。

修改进程所有模块导入表中ExtTextOutW函数对应的IAT项为我们自定义函数HookExtTextOutW的内存地址。这里封装了ReplaceIATInOneMod和ReplaceIATInAllMod两个函数,前者用于替换进程的指定模块导入表中的一个IAT项,后者通过调用TlHelp32系列函数来枚举进程模块,为每个模块调用ReplaceIATInOneMod函数。这两个函数如下所示:

// 替换进程的指定模块导入表中的一个IAT项(导入函数地址)
BOOL ReplaceIATInOneMod(HMODULE hModule, LPCSTR pszDllName, PROC pfnTarget, PROC pfnNew)
{
   ULONG                        ulSize;                       // 导入表的大小
   PIMAGE_IMPORT_DESCRIPTOR pImageImportDesc = NULL;    // 导入表起始地址
   PIMAGE_THUNK_DATA          pImageThunkData = NULL;     // IMAGE_THUNK_DATA数组起始地址

   // 获取导入表起始地址
   pImageImportDesc = (PIMAGE_IMPORT_DESCRIPTOR)ImageDirectoryEntryToDataEx(hModule, TRUE,
      IMAGE_DIRECTORY_ENTRY_IMPORT, &ulSize, NULL);
   if (!pImageImportDesc)
      return FALSE;

   // 遍历导入表,查找目标函数
   while (pImageImportDesc->OriginalFirstThunk || pImageImportDesc->TimeDateStamp ||
      pImageImportDesc->ForwarderChain || pImageImportDesc->Name || pImageImportDesc->FirstThunk)
   {
      if (_stricmp(pszDllName, (LPSTR)((LPBYTE)hModule + pImageImportDesc->Name)) == 0)
      {
         pImageThunkData = (PIMAGE_THUNK_DATA)((LPBYTE)hModule + pImageImportDesc->FirstThunk);
         while (pImageThunkData->u1.AddressOfData != 0)
         {
            PROC* ppfn = (PROC*)&pImageThunkData->u1.Function;
            if (*ppfn == pfnTarget)
            {
               DWORD dwOldProtect;
               BOOL bRet = FALSE;

               // 替换目标IAT项的值为pfnNew
               VirtualProtect(ppfn, sizeof(pfnNew), PAGE_READWRITE, &dwOldProtect);
               bRet = WriteProcessMemory(GetCurrentProcess(), ppfn, &pfnNew, sizeof(pfnNew), NULL);
               VirtualProtect(ppfn, sizeof(pfnNew), dwOldProtect, &dwOldProtect);

               return bRet;
            }

            // 指向下一个IMAGE_THUNK_DATA结构
            pImageThunkData++;
         }
      }

      // 指向下一个导入表描述符
      pImageImportDesc++;
   }

   return FALSE;
}

// 替换进程的所有模块导入表中的一个IAT项(导入函数地址)
VOID ReplaceIATInAllMod(LPCSTR pszDllName, PROC pfnTarget, PROC pfnNew)
{
   MEMORY_BASIC_INFORMATION mbi = { 0 };
   HMODULE                      hModuleThis;     // 当前代码所处的模块
   HANDLE                       hSnapshot = INVALID_HANDLE_VALUE;
   MODULEENTRY32               me = { sizeof(MODULEENTRY32) };
   BOOL                         bRet = FALSE;

   VirtualQuery(ReplaceIATInAllMod, &mbi, sizeof(mbi));
   hModuleThis = (HMODULE)mbi.AllocationBase;

   hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, GetCurrentProcessId());
   if (hSnapshot == INVALID_HANDLE_VALUE)
      return;

   bRet = Module32First(hSnapshot, &me);
   while (bRet)
   {
      if (me.hModule != hModuleThis)         // 排除当前代码所处的模块
         ReplaceIATInOneMod(me.hModule, pszDllName, pfnTarget, pfnNew);

      bRet = Module32Next(hSnapshot, &me);
   }

   CloseHandle(hSnapshot);
}

调用ReplaceIATInAllMod修改进程所有模块导入表中LoadLibrary*函数对应的IAT项为我们自定义函数HookLoadLibrary*的内存地址。例如HookLoadLibraryA函数:

HMODULE WINAPI HookLoadLibraryA(LPCSTR lpLibFileName)
{
   // 调用原LoadLibraryA函数
   HMODULE hModule = OrigLoadLibraryA(lpLibFileName);

   // 原LoadLibraryA函数执行完毕,进程所有模块的导入表中的相关IAT项再替换一次
   if (hModule != NULL)
   {
      ReplaceIATInAllMod("gdi32.dll", (PROC)OrigExtTextOutW, (PROC)HookExtTextOutW);
      ReplaceIATInAllMod("kernel32.dll", (PROC)OrigLoadLibraryA, (PROC)HookLoadLibraryA);
      ReplaceIATInAllMod("kernel32.dll", (PROC)OrigLoadLibraryW, (PROC)HookLoadLibraryW);
      ReplaceIATInAllMod("kernel32.dll", (PROC)OrigLoadLibraryExA, (PROC)HookLoadLibraryExA);
      ReplaceIATInAllMod("kernel32.dll", (PROC)OrigLoadLibraryExW, (PROC)HookLoadLibraryExW);
      ReplaceIATInAllMod("kernel32.dll", (PROC)OrigGetProcAddress, (PROC)HookGetProcAddress);
   }

   return hModule;
}

调用ReplaceIATInAllMod修改进程所有模块导入表中GetProcAddress函数对应的IAT项为我们自定义函数HookGetProcAddress的内存地址。HookGetProcAddress函数如下:

FARPROC WINAPI HookGetProcAddress(HMODULE hModule, LPCSTR lpProcName)
{
   // 调用原GetProcAddress函数
   FARPROC pfn = OrigGetProcAddress(hModule, lpProcName);

   // 如果原GetProcAddress函数获取的是ExtTextOutW函数的地址,则替换
   if (pfn == (FARPROC)OrigExtTextOutW)
      pfn = (FARPROC)HookExtTextOutW;

   return pfn;
}

DllMain函数如下:

BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
{
   switch (ul_reason_for_call)
   {
   case DLL_PROCESS_ATTACH:
      ReplaceIATInAllMod("gdi32.dll", (PROC)OrigExtTextOutW, (PROC)HookExtTextOutW);
      ReplaceIATInAllMod("kernel32.dll", (PROC)OrigLoadLibraryA, (PROC)HookLoadLibraryA);
      ReplaceIATInAllMod("kernel32.dll", (PROC)OrigLoadLibraryW, (PROC)HookLoadLibraryW);
      ReplaceIATInAllMod("kernel32.dll", (PROC)OrigLoadLibraryExA, (PROC)HookLoadLibraryExA);
      ReplaceIATInAllMod("kernel32.dll", (PROC)OrigLoadLibraryExW, (PROC)HookLoadLibraryExW);
      ReplaceIATInAllMod("kernel32.dll", (PROC)OrigGetProcAddress, (PROC)HookGetProcAddress);

   case DLL_THREAD_ATTACH:
   case DLL_THREAD_DETACH:
   case DLL_PROCESS_DETACH:
      break;
   }

   return TRUE;
}

完整代码参见ReplaceIATEntry项目。

再讨论一下延迟加载DLL的情况,只有当代码中调用延迟加载DLL中的函数时,系统才会调用LoadLibrary*函数(通常是LoadLibraryExA)载入该DLL,并调用GetProcAddress获取函数地址。可以在自定义函数HookLoadLibrary*中进行处理,如果加载的是延迟加载DLL,则替换该模块导出表中的指定EAT项(导出函数地址)。这里封装了一个ReplaceEATInOneMod函数用于替换指定模块导出表中的一个EAT项:

BOOL ReplaceEATInOneMod(HMODULE hModule, LPCSTR pszFuncName, PROC pfnNew)
{
   ULONG                      ulSize;                            // 导出表的大小
   PIMAGE_EXPORT_DIRECTORY pImageExportDir = NULL;            // 导出表起始地址
   PDWORD                     pAddressOfFunctions = NULL;        // 导出函数地址表的起始地址
   PWORD                      pAddressOfNameOrdinals = NULL;    // 函数序数表的起始地址
   PDWORD                     pAddressOfNames = NULL;            // 函数名称地址表的起始地址

   // 获取导出表起始地址
   pImageExportDir = (PIMAGE_EXPORT_DIRECTORY)ImageDirectoryEntryToDataEx(hModule, TRUE,
       IMAGE_DIRECTORY_ENTRY_EXPORT, &ulSize, NULL);
   if (!pImageExportDir)
       return FALSE;

   // 导出函数地址表、函数序数表、函数名称地址表的起始地址
   pAddressOfFunctions = (PDWORD)((LPBYTE)hModule + pImageExportDir->AddressOfFunctions);
   pAddressOfNameOrdinals = (PWORD)((LPBYTE)hModule + pImageExportDir->AddressOfNameOrdinals);
   pAddressOfNames = (PDWORD)((LPBYTE)hModule + pImageExportDir->AddressOfNames);

   // 遍历函数名称地址表
   for (DWORD i = 0; i < pImageExportDir->NumberOfNames; i++)
   {
      if (_stricmp(pszFuncName, (LPSTR)((LPBYTE)hModule + pAddressOfNames[i])) != 0)
         continue;

      // 已经找到目标函数,获取导出函数地址
      PROC* ppfn = (PROC*)&pAddressOfFunctions[pAddressOfNameOrdinals[i]];
      pfnNew = (PROC)((LPBYTE)pfnNew - (LPBYTE)hModule);    // To RVA

      DWORD dwOldProtect;
      BOOL bRet = FALSE;

      // 替换目标EAT项的值为pfnNew
      VirtualProtect(ppfn, sizeof(pfnNew), PAGE_READWRITE, &dwOldProtect);
      bRet = WriteProcessMemory(GetCurrentProcess(), ppfn, &pfnNew, sizeof(pfnNew), NULL);
      VirtualProtect(ppfn, sizeof(pfnNew), dwOldProtect, &dwOldProtect);

      return bRet;
   }

   return FALSE;
}

GetMd5Test程序调用了延迟加载GetMd5.dll中的GetMd5函数,对于GetMd5函数的Hook,自定义函数HookLoadLibrary*的处理如下(以HookLoadLibraryA为例):

HMODULE WINAPI HookLoadLibraryA(LPCSTR lpLibFileName)
{
   // 调用原LoadLibraryA函数
   HMODULE hModule = OrigLoadLibraryA(lpLibFileName);

   // 原LoadLibraryA函数执行完毕,进程所有模块的导入表中的相关IAT项再替换一次
   if (hModule != NULL)
   {
      ReplaceIATInAllMod("kernel32.dll", (PROC)OrigLoadLibraryA, (PROC)HookLoadLibraryA);
      ReplaceIATInAllMod("kernel32.dll", (PROC)OrigLoadLibraryW, (PROC)HookLoadLibraryW);
      ReplaceIATInAllMod("kernel32.dll", (PROC)OrigLoadLibraryExA, (PROC)HookLoadLibraryExA);
      ReplaceIATInAllMod("kernel32.dll", (PROC)OrigLoadLibraryExW, (PROC)HookLoadLibraryExW);

      if (strstr(lpLibFileName, "GetMd5.dll"))
         ReplaceEATInOneMod(hModule, "GetMd5", (PROC)HookGetMd5);
   }

   return hModule;
}

自定义函数HookGetMd5:

BOOL HookGetMd5(LPCTSTR lpFileName, LPTSTR lpMd5)
{
   MessageBox(NULL, TEXT("延迟加载dll中的GetMd5函数已被Hook"), TEXT("提示"), MB_OK);

   return TRUE;
}

效果如原书的图11.31所示。

具体代码参见ReplaceIATEntry2项目。

既然延迟加载的原理是调用LoadLibrary*函数载入DLL并调用GetProcAddress获取函数地址,仅Hook掉GetProcAddress函数也是可以的,例如:

FARPROC WINAPI HookGetProcAddress(HMODULE hModule, LPCSTR lpProcName)
{
   // 调用原GetProcAddress函数
   FARPROC pfn = OrigGetProcAddress(hModule, lpProcName);

   // 如果原GetProcAddress函数获取的是GetMd5函数的地址,则替换
   if (_stricmp(lpProcName, "GetMd5") == 0)
      pfn = (FARPROC)HookGetMd5;

   return pfn;
}

具体代码参见Chapter6\ReplaceIATEntry3项目。

关于通过修改模块导入表中的IAT项来Hook API,本节介绍了常见的场景。对一些加密保护很厉害的软件来说,这些方法可能无法奏效,因为它们都会包含反调试、反跟踪和反Hook等功能。Hook技术建立在对目标软件充分了解的基础上,这是加密解密、逆向工程的讨论范围。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐