基于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_HEADER
中OptionalHeaderSize
大于0,说明存在Optional Header
。但是由于Cecil
只处理DLL或者EXE文件,所以默认有Optional Header
, Cecil
通过调用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 Header
,Cecil
只关注:
-
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
以及ResourcesDirectory
和StrongNameSignatureDirectory
。
其中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
用于编辑后的再次签名。