前段时间需要实现对 Windows PE 文件版本信息的提取,如文件说明、文件版本、产品名称、版权、原始文件名等信息。获取这些信息在 Windows 下当然有一系列的 API 函数供调用,简单方便。但是当需要在 Linux 操作系统平台下提取 PE 文件的版本信息数据时,就需要自己对 PE 文件的结构进行手动解析。

当时在网上没有找到成体系的并 100% 正确的获取实现方法,所以只能根据零散的资料和信息,自己实现了。在最终实现后,便整理了一下思路,在这里把实现思路分享一下,虽然技术含量不高,但总会有人需要的嘛。

0x0 定位资源目录

首先当然是将 PE 文件读入内存缓冲区,获得缓冲区首地址,按照 PE 文件格式解析 IMAGE_DOS_HEADER 和 NT IMAGE_NT_HEADERS 获取偏移并根据 PE 文件缓冲区基地址计算其真实指针地址。这部分技术实现在互联网上有足够多的信息,所以在这里就不额外地赘述了。在获得 NT Headers 指针之后,需要判断 IMAGE_NT_HEADERS + 0x18 位置的 WORD 长度的值。

IMAGE_NT_HEADERS + 0x18 位置其实是 IMAGE_OPTIONAL_HEADER32 或 IMAGE_OPTIONAL_HEADER64 域结构。IMAGE_OPTIONAL_HEADERXX 结构中第一个成员是 WORD Magic 域。Magic 域是一个标记字,Magic 域不同的值决定 Optional Header 以及后续的一些数据结构采用 32 位 PE 文件格式还是 64 位格式进行解析,所以非常必要进行判断。在这里根据该域判断这个 PE 文件是 PE32 还是 PE32+ 格式:当 Magic 值为 0x10b 时该 PE 文件是 PE32 结构格式;当置为 0x20b 时该 PE 文件是 PE32+ 结构格式。

#define IMAGE_NT_OPTIONAL_HDR32_MAGIC      0x10b
#define IMAGE_NT_OPTIONAL_HDR64_MAGIC      0x20b
#define IMAGE_ROM_OPTIONAL_HDR_MAGIC       0x107

如果该 PE 文件是 PE32 格式则 Optional Header 应使用 IMAGE_OPTIONAL_HEADER32 数据类型解析,否则是 PE32+ 格式时则使用 IMAGE_OPTIONAL_HEADER64 类型。根据应使用的数据类型定位到:

IMAGE_NT_HEADERS -> OptionalHeader . DataDirectory[2]

位置。

DataDirectory 是 Optional Header 结构中最后一个域:数据目录表,IMAGE_DATA_DIRECTORY 数据类型的数组,数组长度为 16。

typedef struct _IMAGE_DATA_DIRECTORY {
    DWORD   VirtualAddress; // 数据块的起始RVA
    DWORD   Size;           // 数据块的长度
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;

数据目录表数组中资源目录的 IMAGE_DATA_DIRECTORY 结构对象是数组的第三个元素,其索引为 2。根据前面的定位,获取到资源表数据块的 RVA,并根据 RVA 和 NT Header 的地址以及文件缓冲区基地址计算资源表的数据块的实际内存地址。

0x1 计算资源表数据块实际内存地址

由于现在内存缓冲区中的 PE 文件内容并不是通过 Windows PE 文件装载器加载进系统的,所以在计算数据块实际内存地址的时候,不能直接使用 RVA 进行计算。这时候需要借助到区块表。

在 PE 文件中紧跟着 IMAGE_NT_HEADERS 后的是区块表。区块表是一个 IMAGE_SECTION_HEADER 结构数组。每个 IMAGE_SECTION_HEADER 结构包含了它所关联区块的信息,如位置、长度、属性等;该数组的数目由 IMAGE_NT_HEADERS -> FileHeader . NumberOfSections 指出。

IMAGE_SECTION_HEADER 数据类型的定义:

typedef struct _IMAGE_SECTION_HEADER {
    BYTE   Name[8];
    union {
        DWORD   PhysicalAddress;
        DWORD   VirtualSize;
    } Misc;
    DWORD   VirtualAddress;
    DWORD   SizeOfRawData;
    DWORD   PointerToRawData;
    DWORD   PointerToRelocations;
    DWORD   PointerToLinenumbers;
    WORD    NumberOfRelocations;
    WORD    NumberOfLinenumbers;
    DWORD   Characteristics;
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

其中 VirtualAddress 域是该区块的相对虚拟地址。在 PE 装载器装载 PE 文件的的时候,将该区块映射至内存时会读取这个值。前面说过,当前我们并非通过 PE 装载器来装载 PE 文件,所以该值不使用。

PointerToRawData 域是该区块基于文件的偏移量,根据该域的值找到该区块数据在文件中的位置。在某些不同的编译器或操作系统环境中编译生成 PE 文件时,VirtualAddress 的值和 PointerToRawData 的值很可能是不一样的。获取这方面更详细的介绍,可访问链接:http://stackoverflow.com/questions/9614041/difference-between-offset-and-an-rva

接下来需要定位到 IMAGE_SECTION_HEADER 数组中所属资源数据块的数组元素。根据 Nt Headers 的相对偏移加上 IMAGE_NT_HEADERS 的大小获得区块表的相对偏移,并计算得到实际内存地址。遍历区块表中每一个元素,根据 IMAGE_SECTION_HEADER 中的 VirtualAddress 域和 Misc.VirtualSize 子域,判断前面 0x0 节最后获得的资源表数据块的 RVA 是否在当前遍历到的区块表数组元素所属的区块范围内。

如果命中,则计算该数组元素中的 VirtualAddress 域和 PointerToRawData 域的差值,然后将前面 0x0 节最后面获得的资源表数据块的 RVA 与该差值相减。最后根据获得的偏移值,加上文件缓冲区基地址,即获得资源数据块的实际内存地址了。

0x2 解析资源数据块

资源数据是 PE 文件的重要组成部分,包括位图、光标、对话框、图标、菜单、字符串表、工具栏、版本信息等。在 PE 文件所有结构中,资源部分是最复杂的。资源数据通过类似于磁盘目录结构的方式保存。目录通常包含 3 层,最上面的目录类似于一个文件系统的根目录。每一个在根目录下的目录条目总是在它管辖范围下的一个子目录。每一个二级目录对应于前面所述的资源类型之一。在每一个二级资源类型目录下,是第三级目录。

资源数据块的目录结构示意图如下所示:

资源数据块起始地址是一个 IMAGE_RESOURCE_DIRECTORY 数据类型:资源目录。

typedef struct _IMAGE_RESOURCE_DIRECTORY {
    DWORD   Characteristics;
    DWORD   TimeDateStamp;
    WORD    MajorVersion;
    WORD    MinorVersion;
    WORD    NumberOfNamedEntries;
    WORD    NumberOfIdEntries;
//  IMAGE_RESOURCE_DIRECTORY_ENTRY DirectoryEntries[];
} IMAGE_RESOURCE_DIRECTORY, *PIMAGE_RESOURCE_DIRECTORY;

资源目录数据结构中的每一个节点都是由 IMAGE_RESOURCE_DIRECTORY 结构和紧随其后的若干个 IMAGE_RESOURCE_DIRECTORY_ENTRY 结构组成,共同构成目录块。在 IMAGE_RESOURCE_DIRECTORY 结构中最后两个域 NumberOfNamedEntries 和 NumberOfIdEntries 是两个 WORD 数据类型的域。NumberOfNamedEntries 是使用名字的资源条目个数,而 NumberOfIdEntries 是使用ID数字的资源条目个数。两者之和是当前目录的目录项的总和,即为 IMAGE_RESOURCE_DIRECTORY_ENTRY 数组的元素个数。

typedef struct _IMAGE_RESOURCE_DIRECTORY_ENTRY {
    union {
        struct {
            DWORD NameOffset : 31;
            DWORD NameIsString : 1;
        };
        DWORD   Name;
        WORD    Id;
    };
    union {
        DWORD   OffsetToData;
        struct {
            DWORD   OffsetToDirectory : 31;
            DWORD   DataIsDirectory : 1;
        };
    };
} IMAGE_RESOURCE_DIRECTORY_ENTRY, *PIMAGE_RESOURCE_DIRECTORY_ENTRY;

根据定义可以看出,简化联合体之后该数据结构由两个 DWORD 数据类型的域组成:Name (或Id) 和 OffsetToDirectory 域。

关于 Name/Id 域:定义目录项的名称或 ID 数字。当结构用于第一层目录时,定义的是资源类型;当结构用于第二层目录时,定义的是资源的名称;当结构用于第三层目录时,定义的是代码页的编号,其具体含义在此按下不表。

关于 OffsetToData 域:相对资源数据块基地址的偏移量。当最高位为 1 时,低 31 位数据指示下一层目录块的相对偏移;当最高位为 0 时,表示目录块已经到达最下层,则其低 31 位数据指示当前类型资源的 IMAGE_RESOURCE_DATA_ENTRY 结构的相对偏移。在这里需要注意,这两处偏移都是相对于资源数据块基地址的偏移量,而不是相对于文件缓冲区基地址的偏移。

0x3 定位版本信息数据块

起始位置的 IMAGE_RESOURCE_DIRECTORY 数据结构和紧随其后的 IMAGE_RESOURCE_DIRECTORY_ENTRY 结构数组,共同构成了根目录的目录块。定位到根目录块的第一个 IMAGE_RESOURCE_DIRECTORY_ENTRY 结构,根据 NumberOfNamedEntries 和 NumberOfIdEntries 两个域的数值,开始遍历其所有的目录项。目前是在三层目录结构的第一层目录,所以 Name/Id 域的含义是资源类型。

在这里需要注意的是,Name 域最高位(第31位)是用来判断是否为字符串的 NameIsString 标志位,如果为 0 的话,那么 Name/Id 域将是 WORD 数据类型的 Id 域,此时需要跳过这个元素的判断并遍历至下一个数组元素;如果为 1 的话,才是 Name 域。根据 IMAGE_RESOURCE_DIRECTORY_ENTRY 的 Name 域匹配是否为 RT_VERSION 值。RT_VERSION 是一个长度为 WORD 的数,值为 0x10,表示版本信息资源类型。定义如下:

#define MAKEINTRESOURCEA(i) ((LPSTR)((ULONG_PTR)((WORD)(i))))
#define MAKEINTRESOURCEW(i) ((LPWSTR)((ULONG_PTR)((WORD)(i))))
#ifdef UNICODE
#define MAKEINTRESOURCE  MAKEINTRESOURCEW
#else
#define MAKEINTRESOURCE  MAKEINTRESOURCEA
#endif // !UNICODE
#define RT_VERSION      MAKEINTRESOURCE(16)

更多资源类型可在 MSDN 查看:https://msdn.microsoft.com/en-us/library/windows/desktop/ms648009(v=vs.85).aspx

定位匹配到的 IMAGE_RESOURCE_DIRECTORY_ENTRY 结构的 OffsetToData 域,判断其值最高位:如果为 1 则 OffsetToData 域低 31 位指向下一级的目录块偏移,那么根据 OffsetToData 低 31 位和资源数据块基地址计算下一级的目录块的实际内存地址,然后越过目录块的 IMAGE_RESOURCE_DIRECTORY 结构,继续解析 IMAGE_RESOURCE_DIRECTORY_ENTRY 结构,判断其 OffsetToData 域的最高位的值,并按照本段的方式继续处理。

直到 OffsetToData 最高位为 0 时, OffsetToData 低 31 位的值指向当前资源类型的资源数据入口的相对偏移。

资源数据入口是 IMAGE_RESOURCE_DATA_ENTRY 类型的结构,描述资源目录树中当前所属资源类型的资源数据块入口信息。根据该结构可以定位到版本信息数据块的位置。资源数据入口结构中的 OffsetToData 域,表示相对于资源数据块起始位置到该资源数据块位置的相对偏移量;其中的 Size 域,表示该资源数据块的字节数。另外两个成员在这里不多做叙述。

typedef struct _IMAGE_RESOURCE_DATA_ENTRY {
    DWORD   OffsetToData;
    DWORD   Size;
    DWORD   CodePage;
    DWORD   Reserved;
} IMAGE_RESOURCE_DATA_ENTRY, *PIMAGE_RESOURCE_DATA_ENTRY;

由于每个资源类型自身数据块和资源根节点数据块处于不同的区块,所以这里根据 OffsetToData 域获得的相对偏移应是通过 PE 装载器映射该区块到内存的相对虚拟地址偏移量,所以不应直接被用来计算版本信息数据块的实际内存地址,应通过前面 0x1 节最后所述的方法遍历区块表,并根据 PointerToRawData 域计算获得指向版本信息数据块在内存中实际所处的位置的地址。

0x4 解析版本信息数据块

获得版本信息数据块的起始地址,就到了最关键的部分了。位于版本信息数据块起始位置的是一个 VS_VERSIONINFO 类型的结构,其定义如下:

typedef struct tag_VS_VERSIONINFO
{
    WORD             wLength;        // 00 length of entire version resource
    WORD             wValueLength;   // 02 length of fixed file info, if any
    WORD             wType;          // 04 type of resource (1 = text, 0 = binary)
    WCHAR            szKey[16];      // 06 key -- VS_VERSION_INFO
    WORD             Padding1;       // 26 padding byte 1
    VS_FIXEDFILEINFO Value;          // 28 fixed information about this file (13 dwords)
} VS_VERSIONINFO, *PVS_VERSIONINFO;   // 5C

其中,第一个成员 wLength 是一个 WORD 类型的数,表示整个版本信息资源数据块的字节数;第二个成员 wValueLength 指示成员 VS_FIXEDFILEINFO Value 的字节数;如果当前版本信息结构体未指定未指定 Value 成员的话,则改域的值为 0;成员 wType 表示资源类型,值为 1 时表示文本,为 2 时表示二进制数据;域 szKey 是一个 WCHAR 类型的元素个数为 16 的字符数组,其值始终为 L"VS_VERSION_INFO" 并且最后一个元素为 0 值。

成员 Value 是一个 VS_FIXEDFILEINFO 类型的结构,存储由当前版本信息结构指定的任意数据,其值目前可无需过多关注,除了其中的 DWORD dwSignature 成员,其值恒为 0xFEEF04BD。根据结构体对齐原则,在 szKey 和 Value 成员之间须有一个 2 字节的间隔,成员 Padding1 放置在这里仅起到占位说明的作用。

typedef struct tagVS_FIXEDFILEINFO
{
    DWORD   dwSignature;            /* e.g. 0xfeef04bd */
    DWORD   dwStrucVersion;         /* e.g. 0x00000042 = "0.42" */
    DWORD   dwFileVersionMS;        /* e.g. 0x00030075 = "3.75" */
    DWORD   dwFileVersionLS;        /* e.g. 0x00000031 = "0.31" */
    DWORD   dwProductVersionMS;     /* e.g. 0x00030010 = "3.10" */
    DWORD   dwProductVersionLS;     /* e.g. 0x00000031 = "0.31" */
    DWORD   dwFileFlagsMask;        /* = 0x3F for version "0.42" */
    DWORD   dwFileFlags;            /* e.g. VFF_DEBUG | VFF_PRERELEASE */
    DWORD   dwFileOS;               /* e.g. VOS_DOS_WINDOWS16 */
    DWORD   dwFileType;             /* e.g. VFT_DRIVER */
    DWORD   dwFileSubtype;          /* e.g. VFT2_DRV_KEYBOARD */
    DWORD   dwFileDateMS;           /* e.g. 0 */
    DWORD   dwFileDateLS;           /* e.g. 0 */
} VS_FIXEDFILEINFO;

在 VS_VERSIONINFO 结构的 Value 成员后面,是当前 VS_VERSIONINFO 结构的子节点。它的子节点是由 StringFileInfo 结构体和 VarFileInfo 结构体组成的数组。这两个数组都可能为 0,并且其先后顺序没有固定。我们需要获得的是 StringFileInfo 结构体,所以接下来需要进行一系列的判断。

(VS_VERSIONINFO)该结构体不是一个真正意义上的C语言结构体,因为它包含变长成员。该结构体只用来描述在版本信息资源中的数据,并不出现在附带于 SDK 中的任何头文件中。
获取该结构体更多信息请访问文后 0x5 节中的超链接。本文中根据实际实现,部分数据成员定义跟 MSDN 定义可能有所不同,读者根据实际需要各取所需。

由于 VarFileInfo 或 StringFileInfo 结构体属于 VS_VERSIONINFO 结构的子节点,所以其仍受结构体对齐规则的约束。为确保万无一失,可通过下面定义的宏 DWORD_ALIGN(offset, base) 对当前指针进行结构体对齐计算,并获得相对文件缓冲区基地址的偏移以计算其实际内存地址。

// offset: 当前成员对于文件缓冲区基地址的偏移
// base: 当前结构体起始地址对于文件缓冲区基地址的偏移
#define DWORD_ALIGN(offset, base) \
    (((offset + base + 3) & 0xfffffffcL) - (base & 0xfffffffcL))

首先判断是不是 VarFileInfo 结构体,该结构体定义如下:

typedef struct {
    WORD   wLength;
    WORD   wValueLength;
    WORD   wType;
    WCHAR  szKey[12]; // WCHAR L"VarFileInfo"
    // WORD Padding;
    Var    Children[1];
} VarFileInfo;

在这里仅需关注其 szKey 成员和 wLength 成员。VarFileInfo 结构体的 szKey 成员是值恒为 L"VarFileInfo" 字符串的 WCHAR 字符数组,所以只需判断数组的内容是否为 L"VarFileInfo" 字符串。如果命中,则说明该域为 VarFileInfo 结构,而 StringFileInfo 结构数据应该位于其后的内存位置。由于 VarFileInfo 仍含有变长的成员,所以不能直接计算其长度,则通过其成员 wLength 的值,绕过该长度的内存地址即可。

根据目前指针定位的位置,判断是否为 StringFileInfo 类型的结构体。与 VarFileInfo 结构类似,StringFileInfo 结构的 szKey 成员是一个元素个数为 15 且内容恒为 L"StringFileInfo" 的字符数组,所以同样地,通过判断其内容是否为 L"StringFileInfo" 字符串可验证当前位置是否为 StringFileInfo 结构。

typedef struct {
    WORD   wLength;
    WORD   wValueLength;
    WORD   wType;
    WCHAR  szKey[15]; // WCHAR L"StringFileInfo"
    // WORD Padding;
    StringTable Children[1];
} StringFileInfo;

StringFileInfo 结构体的成员与前面的结构体类似。成员 wLength 表示整个 StringFileInfo 数据块的长度;成员 wValueLength 恒为 0 值。成员 wType 表示资源类型,值为 1 时表示文本,为 2 时表示二进制数据。

最后的成员 StringTable Children[] 是一个 StringTable 类型的变长数组,根据定义其中至少包含一个 StringTable 元素,具体的版本信息就包含在 StringTable 数据块中。

typedef struct {
    WORD   wLength;
    WORD   wValueLength;
    WORD   wType;
    WCHAR  szKey[9]; // WCHAR L"88888888"
    // WORD Padding;
    String Children[1];
} StringTable;

数组中每个不同的 StringTable 元素表示各个不同语言的版本信息,StringTable 结构的 szKey 成员表示该 StringTable 数据块中展示文本的语言编码和代码页。现在按通常情况定位到数组的第 0 个元素。如果有解析多语言版本 PE 文件的特殊需求,可针对不同语言的版本信息,对数组中每个 StringTable 元素单独解析。

定位到当前 StringTable 结构的 Children[] 成员。该成员是一个 String 类型的变长数组。需要注意的是,这里的 String 类型并非 C++ 中定义的 std::string 数据类型,而只是 PE 文件结构定义中的一种结构体类型。

typedef struct {
    WORD   wLength;
    WORD   wValueLength;
    WORD   wType;
    WCHAR  szKey[1]; // WCHAR L"String"
    // WORD Padding;
    // WCHAR Value[1];
} String;

与前面的各个结构体定义不同的是,String 结构中的 szKey 是个不定长的 WCHAR 字符数组,其内容表示当前版本信息类型的名称。其可取的内容如下:

L"Comments"          // 为诊断信息展示的附加信息
L"CompanyName"       // 公司名称
L"FileDescription"   // 文件描述
L"FileVersion"       // 文件版本
L"InternalName"      // 内部名称
L"LegalCopyright"    // 版权信息
L"LegalTrademarks"   // 应用于该文件的商标或注册商标
L"OriginalFilename"  // 原始文件名
L"PrivateBuild"      // PrivateBuild *
L"ProductName"       // 产品名称
L"ProductVersion"    // 产品版本
L"SpecialBuild"      // SpecialBuild *

需要注意的是无论该 szKey 成员取以上的任何内容,该 szKey 都是以 L'0' 结尾。通过判断 szKey 的内容来识别当前 String 数据块表示何种版本信息类型。识别后,绕过该字符数组长度的内存地址,定位到 String 结构的 WORD Value 成员位置。成员 Value 由于它前面的 szKey 成员导致是一个不固定位置的成员。所以只能根据 szKey 的长度来辅助定位。定位时需要通过前面描述过的宏 DWORD_ALIGN(offset, base) 来进行结构体对齐计算。

Value 成员是一个以 0 结尾的 WCHAR 字符数组。其内容则是 当前版本信息类型的值,长度通过 wValueLength 成员指示。

下一个 String 元素紧随当前 Value 成员的结尾之后,通过宏 DWORD_ALIGN(offset, base) 获取其地址偏移之后,计算其实际内存地址,并根据与前面同样的获取方法,获取下一个版本信息类型的内容。如何判定已获取完所有的版本信息类型了?可根据当前 StringTable 结构的 wLength 域作为限定范围。

至此,对于 PE 文件的版本信息资源的获取就完成了。

0x5 备注

关于 VS_VERSIONINFO 结构的更多说明:https://msdn.microsoft.com/en-us/library/windows/desktop/ms647001%28v=vs.85%29.aspx?f=255&MSPPError=-2147217396

关于结构体内存对齐的介绍可访问文章:http://www.cnblogs.com/motadou/archive/2009/01/17/1558438.html

- THE END -

文章链接: https://xiaodaozhi.com/develop/25.html