文/玄魂
在.NET框架中,公共语言基础结构使用CLS来绑定不同的语言。通过要求不同的语言至少要实现CTS包含在CLS中的部分,公共语言基础结构允许不同的语言使用.NET框架。因此,在.NET框架中,所有的语言(C#、VB.NET、Effil.NET等)最后都被转换为了一种通用语言:微软中间语言(Microsoft Intermediate Language,MSIL,以下简称IL)。
IL是一种介于高级语言和基于Intel的汇编语言的中间语言,是.NET平台的汇编语言。当用户编译一个.NET程序时,编译器将源代码翻译成一组可以有效地转换为本机代码且独立于CPU的指令。当执行这些指令时,实时编译器将它们转化为CPU特定的代码。由于CLR支持多种实时编译器,因此同一段IL代码可以被不同的编译器实时编译并运行在不同的结构上。
IL包括用于加载、存储和初始化对象以及对对象调用方法的指令,还包括用于算术和逻辑运算、控制流、直接内存访问、异常处理和其他操作的指令。要使代码可运行,必须先将 IL 转换为特定于 CPU 的代码,这通常是通过实时(JIT) 编译器来完成的。由于CLR为它支持的每种计算机结构都提供了一种或多种JIT编译器,因此同一组IL可以在所支持的任何结构上JIT编译和运行。
当编译器产生IL时,它也产生元数据。元数据描述代码中的类型,包括每种类型的定义、每种类型的成员的签名、代码引用的成员和运行库在执行时使用的其他数据。IL和元数据包含在一个可移植可执行(PE)文件中,下面重点介绍托管PE文件,以及元数据的相关知识。
PE(Portable Execute,可移植执行体)是微软Windows操作系统上的程序文件,常见的如EXE、DLL、OCX、SYS、COM。图1-3展示了标准的PE/COFF文件头部格式。
图1-3 标准的PE/COFF文件头部格式
MS DOS头是DOS系统的遗传内容,表示一个应用程序可以在DOS环境下运行。MS DOS根(stub)是一段代码,如果Windows程序在DOS环境下运行,会给出“该程序不能在DOS环境下运行”(This program cannot be run in DOS mode)的提示。在偏移量0x3c处,MS DOS头指向了PE标识(PE Signature)的地址。
PE标识表示该文件是一个PE文件。其值始终为00004550h,45h代表字符E,50h代表字符P。
COFF头(COFF Header)提供了COFF或者可执行文件的最一般的信息。
PE头(PE Header)提供了操作系统加载文件所需的信息。这对于PE文件是最重要的地方,其间包含了数据索引表和节。
关于标准PE文件的详细内容请读者阅读相关资料,本节内容只关注托管PE文件的特殊信息。CLR对传统的PE文件进行了扩展,如图1-4所示是托管PE文件的格式。
图1-4 托管PE文件的格式
标准的Windows PE文件头和COFF(通用对象文件格式)头类似,分为PE32和PE32+两种。如果文件头采用PE32格式,则该文件可运行在32位或64位操作系统上。如果文件头采用PE32+格式,则该文件只能在64位的操作系统上运行。PE32 或者 PE32+ 头也包含文件类型信息:GUI、CUI或者DLL。如果包含本地CPU代码的模块,则PE32或者PE32+ 头将包含有关本地CPU代码的相关信息。
CLR头包含使这个模块被托管的相关信息。这些信息包括CLR需要的版本信息、一些标识、入口方法的元数据信息、模块的元数据位置和大小信息、资源信息、强名称和一些其他信息。
每一个托管模块都包含元数据表。元数据表有两种,一种是描述源代码中的类型描述和成员描述的元数据表,另一种是包含源代码引用的类型描述和成员描述的元数据表。
IL代码是编译器编译产生的中间代码,程序运行时CLR负责将中间代码编译成本地代码执行。
CLR头定义在.NET Framework的CorHdr.h中,代码如代码清单1-4所示。
代码清单1-4 CLR 头定义
1 typedef struct IMAGE_COR20_HEADER 2 3 { 4 5 ULONG cb; 6 7 USHORT MajorRuntimeVersion; 8 9 USHORT MinorRuntimeVersion; 10 11 // Symbol table and startup information 12 13 IMAGE_DATA_DIRECTORY MetaData; 14 15 ULONG Flags; 16 17 union { 18 19 DWORD EntryPointToken; 20 21 DWORD EntryPointRVA; 22 23 }; 24 25 // Binding information 26 27 IMAGE_DATA_DIRECTORY Resources; 28 29 IMAGE_DATA_DIRECTORY StrongNameSignature; 30 31 // Regular fixup and binding information 32 33 IMAGE_DATA_DIRECTORY CodeManagerTable; 34 35 IMAGE_DATA_DIRECTORY VTableFixups; 36 37 IMAGE_DATA_DIRECTORY ExportAddressTableJumps; 38 39 IMAGE_DATA_DIRECTORY ManagedNativeHeader; 40 41 } IMAGE_COR20_HEADER;
关于CLR头中的各个字段的解释见表1-1,后文会对PE文件中的节信息做简要介绍,关于PE文件的详细信息参看书后附录中的参考书籍。
表1-1 CLR头字段说明
偏移(offset) |
大小(size) |
字段(field) |
描述(description) |
0 |
4 |
Cb |
头的长度(bytes) |
4 |
2 |
MajorRuntimeVersion |
CLR运行程序所必须的最小版本(Minimum Version)信息的主码(Major Number) |
6 |
2 |
MinorRuntimeVersion |
CLR运行程序所需要的版本信息中的次要编码(Minor Number) |
8 |
8 |
MetaData |
相对虚拟地址(RAV)和元数据的大小 |
16 |
4 |
Flags |
二进制标志位组合,包含系统相关,程序调用等相关信息 |
20 |
4 |
EntryPointToken/EntryPointRVA |
文件的入口点元数据标识符,对于DLL文件可以被设置为0 |
24 |
8 |
Resources |
托管资源的大小和相对虚拟地址 |
32 |
8 |
StrongNameSignature |
当前PE问件的hash数据的大小和相对偏移地址,被加载器用来绑定和版本验证 |
40 |
8 |
CodeManagerTable |
Code Manager table的大小和相对偏移地址。目前为保留字段被设置为0 |
48 |
8 |
VTableFixups |
一组V-Table的大小和相对虚拟地址信息 |
56 |
8 |
ExportAddressTableJumps |
用于C++的输出跳转地址表的RVA和size,大多数情况为0 |
64 |
8 |
ManagedNativeHeader |
为本地映像的保留字段,设置为0 |
下面通过ILDasm查看HelloWorld.exe的文件头信息。单击菜单“view-headers”,如图1-5所示。
图1-5 查看文件头信息
头信息的主要代码如代码清单1-5所示。
代码清单1-5 HelloWorld.exe 头信息
----- DOS Header: Magic: 0x5a4d Bytes on last page: 0x0090 ......(省略) File addr. of COFF header: 0x0080 ----- COFF/PE Headers: Signature: 0x00004550 ----- COFF Header: Machine: 0x014c Number of sections: 0x0003 Time-date stamp: 0x4b1b1d3a Ptr to symbol table: 0x00000000 Number of symbols: 0x00000000 Size of optional header: 0x00e0 Characteristics: 0x0102 ----- PE Optional Header (32 bit): Magic: 0x010b ......(省略) Directory: ......(省略) Table: 0x00000000 [0x00000000] address [size] of Delay Load IAT: 0x00002008 [0x00000048] address [size] of CLR Header: ......(节信息,略) Base Relocation Table 0x00002000 Page RVA 0x0000000c Block Size 0x00000002 Number of Entries Entry 1: Type 0x3 Offset 0x000007a0 Entry 2: Type 0x0 Offset 0x00000000 Import Address Table DLL : mscoree.dll ......(省略) Delay Load Import Address Table // No data. Entry point code: FF 25 00 20 40 00 ----- CLR Header: Header size: 0x00000048 Major runtime version: 0x0002 Minor runtime version: 0x0005 ......(省略) Metadata Header Storage Signature: ......(省略) Storage Header: 0x00 Flags 0x0005 Number of Streams Stream 1: 0x0000006c Offset 0x000001e8 Size '#~' Name ......(省略) Stream 5: 0x00000510 Offset 0x00000130 Size '#Blob' Name Metadata Stream Header: 0x00000000 Reserved 0x02 Major 0x00 Minor 0x00 Heaps 0x01 Rid 0x0000000900001547 MaskValid 0x000016003325fa00 Sorted Code Manager Table: default Export Address Table Jumps: // No data.
上面代码中涉及很多节信息,下面做简要论述。
映像文件的.reloc节包括了Fixup表,它包含了映像文件中的所有定位项。.reloc节的RVA和大小都由PE头的基地址重定位(Base Relocation)表目录定义。Fixup表由定位块组成,每个块都包括了一个4KB页的定位。这些块都是4字节对齐的。
每个定位都描述了映像文件中特定地址的位置,以及操作系统加载程序在将映像文件载入内存的时候,应该如何修改这个位置上的地址。
每个定位块都开始于两个4字节无符号整数:页面的RVA,这个页面包含了需要定位的地址、块的大小。紧随其后的是页面的定位项每个项都是16位宽的,其中的4个最高权重位包括了所需要的重定位类型,剩下的12位包括了页面中重定位地址的偏移量。
为了对地址进行重定位,操作系统加载程序会计算出首选的基地址(PE头的ImageBase字段)和实际加载映像文件的基地址之间的差异(delta)。接着根据重定位的类型,将这个delta应用到地址上。如果在首选位置加载映像文件,就不需要进行定位。
说明 Windows XP或者更新的版本都是支持CLR的操作系统,既不需要CLR的启动Stub,也不需要IAT来调用CLR。因此,如果CLR头标志指出映像文件是纯IL(COMIMAGE_FLAGS_ ILONLY),那么操作系统就会完全地忽略.reloc节。
PE文件的.text节是只读节。在托管PE文件中,它包括了元数据表、IL代码、导入表、CLR头、CLR非托管启动Stub。在由IL汇编器生成的映像文件中,这个节还包括了托管资源、强签名的散列值、调试数据以及非托管导出Stub。所以.text节是托管PE文件对传统PE文件改变最多的地方。
图1-6总结了由IL汇编器生成的映像文件的.text节的通用结构。
图1-6 .text节的通用结构
由IL汇编器生成的映像文件的数据节(.sdata)是可读写的节,它包括了数据常量、V表、非托管导出表以及TLS的目录结构。声明为特定于线程的数据位于一个不同的节,也就是.tls节。
数据常量代表了静态字段的映射,通常包括映射字段的初始化数据。
字段映射是一种使用ANSI字符串、Blob或结构来初始化静态字段的方法。另一种初始化静态字段的方法(对于CLR来说更正式的方法)是通过在类的构造函数中显式地进行初始化。
一方面,映射到数据节的字段就像类型控制和垃圾收集那样,是CLR控制机制所触及不到的;另一方面,它是完全开放的,可以不受限制地访问和修改。这将导致加载程序阻止特定的字段类型被映射。映射字段的类型不能包括对象引用、向量、数组或任何非公共的子结构。如果为静态字段初始化使用类的构造函数,就不会出现这样的问题。
在纯粹的托管代码模块中,V表用于将托管方法公开给非托管代码来调用。V表由一些项组成,每个项又由一个或多个槽组成。V表的这些项和槽都定义在V表定位中。每个定位指定了每个项中槽的数量和宽度(4字节或8字节)。V表的每个槽都包含各个方法的元数据标记,这些元数据标记在运行期间将会替换成方法本身的地址或者封送thunk,用于提供方法的非托管入口。因为这些定位是在运行期间执行的,所以托管PE文件的V表必须驻留于可读写的节中。IL汇编器将这个V表放在了.sdata节中,不像VTFixup表是驻留于.text节中的。
非托管映像文件的V表完全在链接期间定义,并只需操作系统加载程序执行的基地址重定位。因为在执行期间不需要改变V表(例如把方法标记替换成托管映像中的地址),所以非托管映像文件可以把它们的V表放在只读节中。
在非托管映像文件中的非托管导出表占据一个单独的节——.edata。在IL汇编器生成的映像文件中,非托管导出表和它引用的V表都驻留于.sdata节中。
ILAsm和VC++允许用户定义属于TLS的数据常量,并将静态字段映射到这些数据常量上。TLS是一种特殊的存储类,类中的数据对象不是栈变量而是各个独立线程的局部变量。因此,每个线程都可以为这样的变量维护不同的值。
TLS数据在TLS目录中描述,IL汇编器将其放置于.sdata节中。32位映像文件的TLS目录结构定义在Winnt.h中,如代码清单1-6所示。
代码清单1-6 32位映像文件的TLS目录结构
typedef struct _IMAGE_TLS_DIRECTORY32 { ULONG StartAddressOfRawData; ULONG EndAddressOfRawData; ULONG AddressOfIndex; ULONG AddressOfCallBacks; ULONG SizeOfZeroFill; ULONG Characteristics; } IMAGE_TLS_DIRECTORY32;
64位映像(IMAGE_TLS_DIRECTORY64)的TLS目录结构是类似的,除了开头的4个字段是8字节无符号整数(ULONGLONG),而不是4字节无符号整数(ULONG)。
TLS目录结构的RVA和大小存储在PE头的第10个数据目录(TLS)中。构成了TLS模板的TLS数据常量,驻留于映像文件的.tls节中。
在托管PE文件中可以嵌入两种不同的资源:特定于平台的非托管资源和特定于CLR的托管资源。它们驻留于托管映像文件的不同节,并通过不同的API进行访问。
非托管资源在PE文件的.rsrc节中。嵌入的非托管资源的起始RVA和大小都在PE头的资源数据目录中表示。
非托管资源由类型、名称和语言进行索引,并根据这三个特征的顺序进行二进制排序。
IL汇编器创建.rsrc节,并且会嵌入命令行选项指定的.res文件中的非托管资源。编译器只能为每个模块嵌入一个非托管资源文件。
当IL反汇编器分析托管PE文件并找到.rsrc节的时候,它会从这个节中读取数据和结构,并流并释放出包括在PE文件中所有非托管资源的.res文件。
CLR头的Resource字段包括了内嵌在PE文件中的托管资源的RVA和大小。它与PE头的Resource目录无关,后者指定了特定于平台的非托管资源的RVA和大小。
在IL汇编器生成的PE文件中,非托管资源驻留于映像文件的.rsrc节中,而托管资源和元数据、IL代码等都位于.text节中。托管资源在.text节中连续地存放。元数据携带着ManifestResource记录,每一笔记录对应着一个托管资源,包括了托管资源的名称以及从CLR头的Resources字段中指定的起始RVA算起的资源开始处的偏移量。在这个偏移位置上,会使用4字节无符号整数指出资源的字节长度。紧跟其后的是资源本身。
当IL反汇编器处理托管映像文件并找到嵌入的托管资源时,它会将每个资源各自写到根据资源名称命名的单独文件中。
当IL汇编器创建PE文件时,它会根据资源名称读取在源代码中定义为嵌入资源的所有托管资源,将它们写到.text节中,并在每个资源的前面放置该资源的指定长度。
-----------------------注:本文改编自《.net 安全揭秘》1.3节