基于Cecil源码的IL练级攻略(1)PE文件结构

简介

上一篇文章中,我们提到了加载器(loader)负责.NET应用程序中元数据(metadata)的解析,那么加载器(loader)如何知道元数据在文件中哪个位置呢?

这就要从PE文件的结构开始说起了。 这里强烈推荐《A dive into the PE file format》,由浅入深地解释了PE的文件结构。

首先,我们定义托管编译器(managed compiler)为面向公共语言运行时并产生托管PE文件的编译器。托管编译器生成的.NET应用程序或者DLL,又被称为托管可执行文件(managed executable file)。托管可执行文件是标准的PE/COFF文件格式的一种扩展。任何托管可执行文件都属于PE/COFF文件。

PE文件格式实际上又是COFF文件格式的一种扩展。

这里托管可执行文件,我感觉叫做托管镜像文件(managed image file)可能会更好。镜像文件(Image File)来源于它们可以被认为是内存镜像。

因此托管可执行文件在操作系统看来和其他的PE文件没啥区别。用IDA打开.NET应用程序,可以发现程序运行后会调用jmp _CorExeMain ;Imports from mscoree.dll将控制权转交给运行时。

在具体展开讨论文件结构之前,我们了解一下几个名词的定义:

  • 文件指针(File Pointer):磁盘上文件内的偏移。
  • 相对虚拟地址(Relative Virtual Address, RVA):内存中相对镜像文件基地址(base address)的偏移。一块数据RVA的值大概率和文件指针的值不同。
  • 段(Section):PE文件中代码和数据的基础单位。一个段内所有的原始数据必须整块被加载,不可分割。

并且从本篇文章开始,我们会开始学习并改写Mono.Cecil的源码, 这里我们使用的是0.11.5的版本。

PE文件结构总览

我们用PE-bear打开Exe文件,我们能看到下面这样的结构。

Dos Header

每个PE文件的前64字节被称为Dos Header,这部分数据让PE文件成为MS-DOS可执行文件。

DOS Stub

Dos Header之后的数据是Dos stub,这部分其实是兼容MS-DOS2.0的可执行文件,它会在DOS模式下打印一个错误提示 “This program cannot be run in DOS mode”

NT Headers

NT Headers 包含3个组成部分:

  • Signature: 一个4字节的签名,用于标识当前文件为PE文件。
  • File Header: 一个标准的COFF文件头。它包含PE文件相关的信息。
  • Optional Header: NT Headers里面最重要的部分。虽然它的名字是Optional Header,这是因为一些文件比如obj文件不包含它,但对于image镜像文件(比如exe文件)来说是必须的。该部分为操作系统加载器(OS loader)提供了重要的信息。

Section Headers

Optional Header之后就是section header table,这是一组Image Section Header。每个section在PE文件中都有一个对应的section header。每个section header都包含对应的section的信息。

Sections

Sections是文件中实际内容存储的地方,这些section会包含程序使用的数据或者资源,以及代码。有很多类型的section,并且它们的作用各不相同。

Mono.Cecil中对应的代码位于Mono.Cecil.PE目录下的ImageReader。向ImageReader传入文件的数据流,然后调用ReadImage即可加载对应的PE信息。

DOS Header/Stub

ReadImage首先读取的是IMAGE_DOS_HEADER(点我),这是一个64字节长的数据结构,DOS Header在现代Windows操作系统上没有太多作用,它还存在只是起到前向兼容的作用,当应用程序在DOS系统上执行时,DOS Stub部分的代码会得到执行。

其中我们感兴趣的有两个字段, e_magic用于检测是否是合法的EXE程序, e_lfanew用于定位NT Headers的位置。

if (ReadUInt16 () != 0x5a4d) //检查dos的 e_magic字段
    throw new BadImageFormatException ();
//定位到e_lfanew字段
Advance (58);
// 读取并定位到e_lfanew
MoveTo (ReadUInt32 ());

NT Headers

ReadImage函数通过e_lfanew定位到IMAGE_NT_HEADERS (点我)

Signature

接着就会读取IMAGE_NT_HEADERS里面的 PE Signature,这个字段的值总是0x50450000 ,用ASCII码表示就是 PE\0\0 .

File Header

PE Signature之后就是File Header,又被称为 "The COFF File Header "。这个Header存储了obj或者镜像文件的基础信息。具体可以参考 IMAGE_FILE_HEADER 。在Cecil代码中,首先读取了文件目标的CPU架构,接着是段的个数,然后是当前文件创建时的时间戳,接着跳过了10字节,(对应着符号表的指针,符号的个数,以及OptionalHeader的字节大小)。最后读取了文件的特征Characteristics。

if (ReadUInt32 () != 0x00004550) //读取PE Signature
    throw new BadImageFormatException ();

// - PE FileHeader
image.Architecture = ReadArchitecture ();//目标CPU架构
ushort sections = ReadUInt16 (); //section段的个数
image.Timestamp = ReadUInt32 (); //文件创建时的时间戳
// PointerToSymbolTable	4    	托管文件为0
// NumberOfSymbols		4	 	托管文件为0
// OptionalHeaderSize	2
Advance (10);

ushort characteristics = ReadUInt16 (); //镜像文件的Flag

这里需要注意的有两个字段,一个是目标架构,另一个是文件的特征值Flag

In version 4.0 or later of the CLR, the Characteristics value is, as a rule, 0x0022 (EXE image) or 0x2022 (DLL image). EXECUTABLE_IMAG=0x0002 , _LARGE_ADDRESS_AWARE=0x0020 , DLL=0x2000

Optional Header

每个镜像文件都有一个Optional Header,用来给加载器提供必要的信息。这个header说是可选的(optional)是因为obj文件不需要。对于镜像文件(exe或者dll)来说,这个header是必须的。

如果 IMAGE_FILE_HEADEROptionalHeaderSize大于0,说明存在Optional Header。但是由于Cecil只处理DLL或者EXE文件,所以默认有Optional HeaderCecil通过调用ReadOptionalHeaders来读取。

Optional Header(点我)前8个字段是COFF的标准实现。

这里Cecil只关注Magic以及LinkVersion两个信息。此处的EntryPointRVA指向文中一开始提及的jmp _CorExeMain ,用于将控制权交给CLR运行时。

//   - StandardFieldsHeader

// Magic				2
bool pe64 = ReadUInt16 () == 0x20b;
image.LinkerVersion = ReadUInt16 ();
// CodeSize				4
// InitializedDataSize	4
// UninitializedDataSize4
// EntryPointRVA		4
// BaseOfCode			4
// BaseOfData			4 || 0

后面的字段是 Microsoft 修改的扩展部分。Cecil读取了部分数值,用于生成Dll时写回。其中最后两个字段分别是

  • NumberOfRvaAndSizes :DataDirectory的个数。
  • DataDirectory :DataDirectory的数组。

DataDirectory的定义如下:

typedef struct _IMAGE_DATA_DIRECTORY {
    DWORD VirtualAddress;
    DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;

本质上,DataDirectory就是一块数据,记录了数据的相对虚拟地址(RVA)和大小。但我们读取时需要的是数据的文件偏移(File Pointer),这里Cecil会通过Image.ResolveVirtualAddress将RVA转化为File Pointer。因为文件中的数据是按段加载的,Cecil先找到RVA所在的段,再将段的FilePointer加上数据相对段的偏移就得到RVA对应在文件中的偏移。

//   - DataDirectoriesHeader
image.Win32Resources = ReadDataDirectory ();
// ...
Advance (24);
// Debug				8
image.Debug = ReadDataDirectory ();
//...
Advance (56);
// CLIHeader			8
cli = ReadDataDirectory ();
if (cli.IsZero)
    throw new BadImageFormatException ();
Advance (8);

可以看到Cecil存储了三个DataDirectory的信息,也就是三块特定数据的位置和大小。其中cli = ReadDataDirectory ();读取了 CLR Runtime Header 所在的位置,这个header会告诉我们CLR运行时相关的信息。 读者如果想要了解更详细的信息,可以查询微软对应文档

Section Headers

读完Optional Header就是Section table ,这两部分信息由编译保证是相邻的,Section table存储了一组Section Header

对于Section HeaderCecil只关注:

  • Name: UTF-8 编码的8字节字符串。

  • VirtualAddress: 被加载到内存时,当前段相对于镜像文件基地址的偏移

  • SizeOfRawData: 当前段在磁盘上的大小。

  • PointerToRawData: 指向该段第一个页(page)的文件指针(file pointer)。

section.Name = ReadZeroTerminatedString (8);
// VirtualSize		4
Advance (4);
section.VirtualAddress = ReadUInt32 ();
section.SizeOfRawData = ReadUInt32 ();
section.PointerToRawData = ReadUInt32 ();
Advance (16);

CLI Header (common language infrastructure header)

上文Optional Header中的Data Directory用于存储各类数据的地址和大小。其中有一个Data Directory我们可以拿到CLR Runtime Header的位置信息。

这个Header由于一开始没有明确定义,因此有多种叫法,CLI header , CLR Header 或者 .NET Header 都是这一个Header。

Cecil代码的ReadCLIHeader函数会处理这部分的信息。它跳过了前几个字段,读取了MetadataDirectory, CorFlags, EntryPointToken以及ResourcesDirectoryStrongNameSignatureDirectory

其中Metadata Directory的数据类型是Data Directory,后面我们会使用这个字段来访问元数据相关的信息。EntryPointToken是对应.NET程序入口函数的在元数据表中的行ID(Row ID)。

void ReadCLIHeader ()
{
    MoveTo (cli);
    // - CLIHeader

    // Cb						4
    // MajorRuntimeVersion		2
    // MinorRuntimeVersion		2
    Advance (8);

    // Metadata Directory		8
    metadata = ReadDataDirectory ();
    // Flags					4
    image.Attributes = (ModuleAttributes) ReadUInt32 ();
    // EntryPointToken			4
    image.EntryPointToken = ReadUInt32 ();
    // Resources				8
    image.Resources = ReadDataDirectory ();
    // StrongNameSignature		8
    image.StrongName = ReadDataDirectory ();
    // CodeManagerTable			8
    // VTableFixups				8
    // ExportAddressTableJumps	8
    // ManagedNativeHeader		8
}

StrongNameSignature

CLR Header中的StrongNameSignature字段指向一个数据块,该数据块存储了整块镜像文件的hash值的强名称签名。 .NET Framework可以使用强名称签名来识别对应的Assembly,避免程序被中间人篡改。

Cecil中对应签名的代码位于Mono.Security.Cryptography目录,其中CryptoService类的StrongName用于编辑后的再次签名。

参考文献

A dive into the PE file format

PE Format

《.NET IL Assembler》

Cecil

posted @ 2024-12-22 21:44  dewxin  阅读(14)  评论(0编辑  收藏  举报