可执行文件结构与导入地址表

heacsing 发布于

参考:《逆向工程核心原理》,Microsoft_ImageHlp

可执行文件结构与导入地址表

可执行文件结构

PE/COFF与ELF

  • Portable Executable 是Windows系统使用的可执行文件格式,其前身是UNIX平台的COFF(Common Object File Format),一般有PE32和PE32+(64位)两种格式。Windows下.exe, .scr, .sys, .vxd, .dll, .ocx, .cpl, .drv, .obj扩展名均为PE文件
  • Executable and Links Format 是linux系统下的可执行文件格式

PE基本结构

PEstructure

  • 从DOS Header到Section Table称为PE头部分,余下节区合称PE体。文件中使用偏移(offset),内存中使用虚拟地址(VA, Virtual Address)来表示位置。PE头尾部与各节区之间有NULL填充(NULL padding)
  • 以Win32为例,每个程序都被分配了4GB的虚拟内存(0x00000000~0xFFFFFFFF),VA指的是在此内存上的绝对地址,而为了实现重定位,PE头内广泛使用相对地址(RVA),其基准称为ImageBase

    PE file header


DOS header

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//未注释掉的为重要成员
struct _IMAGE_DOS_HEADER{
0x00 WORD e_magic; //※Magic DOS signature MZ(4Dh 5Ah):MZ标记:用于标记是否是可执行文件
//0x02 WORD e_cblp; //Bytes on last page of file
//0x04 WORD e_cp; //Pages in file
//0x06 WORD e_crlc; //Relocations
//0x08 WORD e_cparhdr; //Size of header in paragraphs
//0x0A WORD e_minalloc; //Minimun extra paragraphs needs
//0x0C WORD e_maxalloc; //Maximun extra paragraphs needs
//0x0E WORD e_ss; //intial(relative)SS value
//0x10 WORD e_sp; //intial SP value
//0x12 WORD e_csum; //Checksum
//0x14 WORD e_ip; //intial IP value
//0x16 WORD e_cs; //intial(relative)CS value
//0x18 WORD e_lfarlc; //File Address of relocation table
//0x1A WORD e_ovno; //Overlay number
//0x1C WORD e_res[4]; //Reserved words
//0x24 WORD e_oemid; //OEM identifier(for e_oeminfo)
//0x26 WORD e_oeminfo; //OEM information;e_oemid specific
//0x28 WORD e_res2[10]; //Reserved words
0x3C DWORD e_lfanew; //※Offset to start of PE header:定位PE文件,PE头相对于文件的偏移量
};

e_magic:DOS签名(魔数),PE文件为0x4D5A(ASCII:MZ)
e_lfanew:指示NT头的offset,根据PE规范应为0x000000E0

DOS stub

  • DOS存根(stub)在DOS Header之后,是个可选项且大小不固定:

    The MS-DOS stub is a valid application that runs under MS-DOS. It is placed at the front of the EXE image. The linker places a default stub here, which prints out the message “This program cannot be run in DOS mode” when the image is run in MS-DOS. The user can specify a different stub by using the /STUB linker option.

    NT Header

  • NT头包含一个4字节的签名,一个文件头结构头和一个可选头结构体
    1
    2
    3
    4
    5
    typedef struct _IMAGE_NT_HEADER {
    DWORD Signature;//PE
    IMAGE_FILE_HEADER FileHeader;
    IMAGE_OPTIONAL_HEADER32 OptionalHeader;
    }
  • 其中Signature处于最前部,其文件偏移正是_IMAGE_DOS_HEADER.e_lfanew所指示的offset

PE Header(COFF File Header)

  • 标准COFF文件头,即_IMAGE_FILE_HEADER,其结构为:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    //未注释掉的为重要成员
    typedef struct _IMAGE_FILE_HEADER {
    WORD Machine;
    WORD NumberOfSections;
    //DWORD TimeDateStamp;
    //DWORD PointerToSymbolTable;
    //DWORD NumberOfSymbols;
    WORD SizeOfOptionalHeader;
    WORD Characteristics;
    }

    Machine

    每个CPU都有唯一的Machine码,兼容32位Intel x86芯片的Machine码为0x014C。具体定义可见Microsoft debug help library

    NumberOfSections

    PE文件把代码、数据、资源等依据属性分类到各节区(Sections)中存储。该值指出文件中存在节区的数量。一定要大于0,且当与实际数量不符时,文件会运行错误。

    SizeOfOptionalHeader

    指出_IMAGE_OPTIONAL_HEADER结构体的长度,optional的意识是对于obj文件来说,这个结构体不是必须的,此时该长度值可为0

    Characteristics

    该字段用于标示文件属性,文件是否是可运行的状态,是否为DLL文件等信息,以bit_OR形式组合起来,即同EFLAGS相同以某一位的0/1指示属性,具体定义可见Microsoft debug help library

Optional Header

可选头是PE头结构体中最大的。完整定义参考Microsoft debug help library,其中的重要成员有:

Magic

0x010B表示这是32位可选头结构体;为0x020B表示这是64位可选头结构体

AddressOfEntryPoint

持有EP的RVA值,指出最先执行的代码起始地址

ImageBase

加载到虚拟内存时的基准;一般来说,.exe, .dll文件将被优先装载到0x00000000->0x7FFFFFFF,.sys文件将被装载到0x80000000->0xFFFFFFFF;执行PE文件时即先创建进程,再将文件载入内存,然后把EIP寄存器值设为ImageBase+AddressOfEntryPoint

SectionAlignment FileAlignment

分别指定了在内存中节区的最小单位和在磁盘文件中的最小单位。磁盘文件和内存的节区大小必定为改值的整数倍

SizeOfImage

加载PE文件到内存时,指定了镜像在虚拟内存中所占空间的大小,一般而言,文件的大小与加载到内存的大小是不同的

SizeOfHeader

指出整个PE FileHeader的大小,也必须是FileAlignment的整数倍。第一节区所在位置,与SizeOfHeader距文件开始偏移的量相同

Subsystem

用来区分系统驱动文件与一般可执行文件,值为1时表示系统驱动文件,2表示GUI文件,3表示CUI文件

NumberOfRvaAndSizes

指定DataDirectory数组中的成员个数

DataDirectory

理解成一个结构体数组即可,结构体内只有两个值分别是一个RVA和一个Size;数组内的关键成员有:

1
2
3
4
DataDirectory[0] = EXPORT Directory
DataDirectory[1] = IMPORT Directory
DataDirectory[2] = RESOURCE Directory
DataDirectory[9] = TLS Directory

Section Table(SectionHeaders)

  • 将代码、数据、资源分区存储的结构,可以保证程序的安全性。
  • 一个_IMAGE_SECTION_HEADER结构体构成的数组,其中每个结构体对应一个节区
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    typedef struct _IMAGE_SECTION_HEADER {
    BYTE Name[8];//该名称仅供参考用,无意义
    union {
    DWORD PhysicalAddress;
    DWORD VirtualSize;//内存中节区所占大小
    } Misc;
    DWORD SizeOfRawData;//磁盘文件中节区所占大小
    DWORD VirtualAddress;//不带任何值,内存中节区起始地址
    DWORD PointerToRawData;//不带任何值,磁盘文件中节区起始位置
    //分别由SectionAlignment和FileAlignment确定
    DWORD PointerToRelocations;
    DWORD PointerToLinenumbers;
    WORD NumberOfRelocations;
    WORD NumberOfLinenumbers;
    DWORD Characteristics;
    }

内存映射

文件偏移 RawDataOffset 等于 虚拟地址RVA 减去 节区起始地址 _IMAGE_SECTION_HEADER.VirtualAddress 加上 磁盘起始位置 _IMAGE_SECTION_HEADER.PointerToRawData


导入地址表

动态链接库

  • Dynamic Linked Library是支撑整个Windows OS的基石
  • 在16位DOS时代并不存在DLL这一概念,代码连接将所需要的“库函数”直接插入到程序中,这在32位Windows下会极高浪费内存与磁盘空间,于是便有了DLL
    1. 将“库”单独组成DLL文件,程序需要时即可调用
    2. 内存映射技术使加载后的DLL代码、资源在多个进程中共享
    3. 更新库时也只需更新DLL文件即可
  • DLL可以显式加载——调用时再加载,和隐式加载——加载程序时即加载;函数调用时先获取IAT地址处的值,这个值再指向内存中DLL函数的地址

    那么,为什么不直接调用内存中DLL函数的地址呢?

  • 即某一DLL函数在不同系统不同语言不同版本中的加载地址都不相同,且发生DLL重定位时,其ImageBase会发生改变。这样编译器记录了该函数IAT地址并预留了位置,文件执行时即发生DLL函数地址写到IAT预留位置的过程

    实际操作中无法保证DLL一定会被加载到PE头指定位置,但总能保证EXE文件被加载到ImageBase,因为后者拥有自己的虚拟内存空间

    _IMAGE_IMPORT_DESCRIPTOR

  • 该结构体记录了PE文件导入哪个库文件,导入多个库即有多个结构体并组成数组,末位有NULL结构体标记;结构体具体定义如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    typedef struct _IMAGE_IMPORT_DESCRIPTOR {
    union {
    DWORD Characteristics;
    DWORD OriginalFirstThunk; //INT(Import Name Table)的地址
    };
    DWORD TimeDateStamp;
    DWORD ForwarderChain;
    DWORD NAME; //库名称字符串的地址
    DWORD FirstThunk; //IAT的地址
    }

    typedef struct _IMAGE_IMPORT_BY_NAME {
    WORD Hint;
    BYTE Name[1];
    }
  • INT与IAT均为整型数组(4 bytes),以NULL结尾,两数组大小应相同;INT中各元素值为IMAGE_IMPORT_BY_NAME结构体指针;

    IAT importing

下面即可总结出PE装载器将一个DLL各个函数地址导入IAT的过程

  1. 读取一个IMAGE_IMPORT_DESCRIPTOR的Name成员,获取DLL库名称
  2. 装载对应库
  3. 读取IID的OriginalFirstThunk成员,获取INT地址
  4. 读取INT中的一个值,获取指向的IMAGE_IMPORT_BY_NAME结构体地址
  5. 依据该结构体获取对应函数的起始地址
  6. 读取IID的FirstThunk成员,获取IAT地址
  7. 将5获得的地址写入IAT对应地址上
  8. 重复4~7直到INT读到NULL

    分析练习

  • 以Windows的notepad.exe为例
    1. 打开确认几个签名,MZ@0x00000000占据两个字节, PE@0x000000E0占据四个字节
    2. PE后即为IMAGE_FILE_HEADER ,如图机器标识码为0x014C,节区个数有0x0003个,正下方即是可选头大小0x00E0与特征值0x010F
    3. 随即即可确认可选头区域 :开头字0x010B标示这是32位可选头,正下方双字即为EP地址0x0000739D,EP地址左下方双字即为基址地址0x01000000,基址地址右侧两个双字即分别是节区校准值0x00001000与文件校准值0x00000200(**alignment);两个双字下一行为不重要数据,再下行开始:第一个双字为内存镜像大小0x00014000,第二个双字为整个Headers大小0x00000400,右侧第三个字为子系统描述符0x0002表示是GUI程序;下一行为不重要数据,再一行开始第二个双字为目录(Directory)数组元素个数0x00000010,之后每个四倍字即描述一个目录,之后先看节区表(Section Table)
    4. 此时我们可以发现右栏ASCII转义区显示了有意义的节区名,以此可以快速分析,节区名所处四倍字为命名预留区域,下一个双字标示内存中节区所占大小,下一个双字标示内存中节区的起始地址,下一个双字标示磁盘文件中节区所占大小,下一个双字标示磁盘文件中节区的起始地址,再下下个双字为特征值;这样一个节区头结构体占36个字节
    5. 回到IAT导入过程,在第三步找到的目录数组中的第二个元素即为导入目录,四倍字的前双字标示导入目录在内存中的起始地址,通过内存映射换算:确定所处节区,减去内存中节区起始地址加上磁盘文件中节区所处地址,即找到导入描述结构体数组,每个元素由五个双字构成
    6. 第一个双字即为INT的RVA,第四个双字为命名字符串的RVA,第五个双子即为预留IAT的RVA,要在wWinHex上找到这些值均需要经过内存映射换算。