浅析MSIL中间语言——PE文件结构篇一、开篇
开篇我想讲一下于本文无关的话题,其实我很想美化一下自己博客园一直没时间弄,无意间找了博客园
李宝亨的博客园里面有一篇分享自己主题的文章,我就将这个模板暂时用作我的blog主题,我要讲述一个关于PE文件结构的文章,这篇文章动手能力比较强,希望大家能够动手进行操作,这边文章篇幅有可能会长一些,为了方便大家阅读我可以将其分为几个部分进行讲解,主要分为以下几个部分:
① PE文件头
② 导入表
③ 导出表
④ 资源表
下面我来讲解下为什么要学PE文件结构,因为了解PE文件结构就会了解到数据字典中第十五存放的就是元数据通过这个可以进一步研究元数据结构,至于.NET的 PE文件结构下一次进行分析
二、.NET的特殊之处
这里我们不讲普通程序的PE文件结构,我们只针对当前.NET程序进行分析,了解普通的PE文件结构后,我们会知道.NET的PE结构不同之处在于在PE头中的IMAGE_OPTIONAL_HEARDER这个结构中的数据目录DataDirectory这个包括了映像文件中的CLR头的RVA和大小。这就使我们能够很快的进行扩展.NET的PE文件结构,下面我们就对文件进行分析,随便找一个.NET的程序,我这里有一个程序,我们用16进制编辑器打开,找到数据目录的第十五个,这个对应的2个字节的CLR头RVA和2个字节的大小。
现在我们来记录下这个记录:
CLR头:RVA:0x2008 size:0x48
既然我们知道了CLR头的RVA和大小那么我们计算他在磁盘中的RVA也就是定位在磁盘中的位置,这里我们还需要其他几个区段的RVA和文件中的大小,这里我们就不在16进制编辑器中进行查找了我们直接打开,CFF Explorer将程序载入后我们查看区块表信息:
那么我们就开始进行定位,定位该区段在文件中的地址,这里我们来看这个CLR头的RVA落在了那个区段上,CLR的RVA为0x2008,我们首先看的是第一个区段.text,该区段装在在内存的地址是0x2000,而这个区段的大小事0x12A00,所以这个区段的范围是0x2000-0x14A00,刚好0x2008落在这个区段上,那么我们来算出他在文件中的偏移,2008-2000=8,200+8=208;也就是0x208的位置是CLR在文件的RVA。下面实例图将表述算出来的过程:
这张图已经很清楚的说明了这个RVA的换算公式,也是我们在这里标出来的?号处就是我们要的东西,这里内存中的CLR头是内存里面的地址0x2008而区段的开始RVA是0x2000这样就是S=0x2000,R=.0x2008那么差值=8,P=0x200这样的话?=0x208这样就算出了文件的RVA;
这样我们找到了CLR头的RVA 我们就来16进制编辑器中进行查看CLR头,下面是CLR头的结构:
typedef struct IMAGE_COR20_HEADER{ ULONG cb; USHORT MajorRuntimeVersion; USHORT MinorRuntimeVersion; //符号表和开始信息 IMAGE_DATA_DIRECTORY MetaData; ULONG Flags; union{ DWORD EntryPointToken; DWORD EntryPointRVA; }; //绑定信息 IMAGE_DATA_DIRECTORY Resource; IMAGE_DATA_DIRECTORY StrongNameSignature; //常规的定位和绑定信息 IMAGE_DATA_DIRECTORY CodeMagagerTable; IMAGE_DATA_DIRECTORY VTableFixups; IMAGE_DATA_DIRECTORY ExprotAddressTableJumps; IMAGE_DATA_DIRECTORY MagageNativeHeader;}IMAGE_COR20_HEADER
下面是对应字段的描述和对应的大小偏移量等等信息:
偏移量
大小
字段名
描述
0
4
Cb
头的字节大小。
4
2
MajorRuntimeVersion
CLR需要运行程序的最小版本的主版本号。
6
2
MinorRuntimeVersion
CLR需要运行程序的最小版本的次版本号。
8
8
MetaData
RVA和元数据的大小。
16
4
Flags
二进制标记,在接下来的章节讨论。在ILAsm中,你可以通过显示地使用指令.corflags <integer value>和/或命令行选项/FLAGS=<integer value>详细指明这个值。这个命令行选项优先于指令。
20
4
EntryPointToken/EntryPointRVA
这个映像文件的入口点的元数据识别符(符号);对于DLL映像而言可以是0。这个字段识别了属于这个模块的一个方法或包括这个入口点方法的一个模块。在2.0或更新的版本中,这个字段可能包括内嵌的本地入口点方法的RVA
24
8
Resources
RVA和托管资源的大小。
32
8
StrongNameSignature
RVA和用于这个PE文件的哈希数据的大小,由加载器在绑定和版本控制中使用。
40
8
CodeManagerTable
RVA和代码管理表的大小。在现有的CLR发布版本中,这个字段是保留的,并被设置为0。
48
8
VTableFixups
RVA和一个由虚拟表(v-表)修正组成的数组的字节大小。在当前托管的编译器中,只有VC++连接器和IL编译器能够生成这个数组。
56
8
ExportAddressTableJumps
RVA和由jump thunk的地址组成的数组的大小。在托管的编译器中,只有8.0之前版本的VC++能够生成这种表,这将允许导出内嵌在托管PE文件中的非托管本地方法。在CLR的2.0版本中,这个入口是废弃的并且必须被设置为0。
64
8
ManagedNativeHeader
为预编译映像而保留的,被设置为0。
既然我们已经知道了整个CLR头的结构,那么我们就来对.NET的这个文件进行十六进制查找下:CTRL+G查找0x208
对应这一块就是CLR头的数据,我们可以一步一步进行分析,比如cb占2个字节那么他就是00000048这个数据,以此进行分析可以将所有数据进行分析出来。注意是这里面是以小端的形式存放,也就是他要从后面的是高位,前面的是地位。
那么我可以注意到这个字段StrongNameSignature这个字段就是强命名的字段,如果程序加了强命名我们的一种手段就是将这个RVA和大小全部设置为0就去除了强命名。还有就是Flags标志位,标志里面去除COMIMAGE_FLAGEX_STRONGNAMESIGNED=0x00000008//此程序有强命名。
这里我们要强调的是根据表中最重要的MetaData项,来查看元数据在PE文件中的存储格式,我们可以在上图中寻找到:
其中元数据(MetaData)的RVA:0000B2D8,元数据的大小为:00009534,通过这个RVA我们可以将其换算成文件地址,那么这个RVA落在了第一个区段上也就是.text段上,这样的话我们就可以换算出文件中的RVA:0x94D8,那么我们就可以在16进制编辑器中查看元数据头的结构。首先我们先看一下整体结构是什么:
类型
字段
描述
DWORD
lSignature
424A5342h,就是4个固定的ASCII码,代表.NET四个创始人的首位字母缩写
WORD
iMajorVersion
元数据的主版本,一般为1
WORD
iMinorVersion
元数据的副版本,一般为1
DWORD
iExtraData
保留,为0
DWORD
iLength
接下来版本字符串的长度,包含尾部0,且按4字节对其
BYTE[?]
iVersionString
UTF8格式的编译环境版本号
BYTE
fFlags
保留为0
BYTE
[padding]
此字节无意义,对齐用
WORD
iStreams
NStream的个数(流的个数)
既然我们已经了解了元数据头的结构之后我们就对应的RVA看一下16进制编辑器里面的内容:
这里我们就不将所有的字段的值取出来我们直接用CFF来看一下我们查找的数据是不是正确的;
其实这里面最重要的就是我们要看一下流到底有多少个,这里面最后一个字段就是iStreams这里面显示的是5,那么就说明有5个流数据,接下来就开始分析几个流数据,紧接着元数据头便是几个流数据的头,流按存储结构的不同分为堆(heap)和表(Table),在元数据中堆是用来存储字符串和二进制对象。堆分为以下三种:
#Strings:UTF8格式的字符串堆,包含各种元数据的名称(比如类名,方法名,成员名,参数等),以0开始以0结尾。
#Blob:二进制数据堆,存储程序中非字符串信息,比如常量值,方法的signature、pubicKey等。每个数据的长度由该数据的前1-3为决定:0表示长度1字节,10表示长度2字节,110表示长度4字节。
#GUID:存储所有的全局唯一标识
#US:用户自定义字符串
#~:元数据表流,重要的流,几乎所有元数据的信息都以表的形式存在
上面我们已经提及到了,MetaData Root紧接着就是流数据,那么我们先看一下流数据的结构,方便我们对其进行分析:
大小
字段
描述
DWORD
iOffset
该流的存储位置相对于MetaData Root的偏移
DWORD
iSize
该流占多少字节
char[]
rcName
流的名称,与4字节对齐
既然我们看到流数据头的结构我们可以发现iOffset这个字段是关于流存储的位置,也就是流数据头里面存放的是真正流数据的位置,那么我们上面找到的元数据头的地址是RVA:0x94D8这样的话我们就可以找到真正的对应的流数据了!那么我们先看一下整体的流数据,我们已经知道一共有5个流数据。
其中的红色“|”标示着下一个流数据结构的开始,相应对应的结果我用CFF更直观的展现给大家看,这样我们就可以进行一个详细的对比;
经过我们上下数据的比较数据完全符合那么,就说明我们流数据头找的是正确的。
既然我们将流数据头找出来,我们就对这5个流数据进行分析,这里我们就单纯的讲一下#~流,因为这个是.NET都要存在的!上面我们可以看到#~流相对于MetaData的偏移量是0x6C,0x94D8+0x6C就是真正该流数据的存储位置:0x9544,好的,既然已经寻找到了这个地址那么先来了解下#~内部存储结构是什么样的?请看下表:
大小
字段
描述
4 bytes
Reserved
保留,为0
1 byte
Major
元数据表的主版本号,于.NET主版本号一致
1 byte
Minor
元数据的副版本号,一般为0
1 byte
Heaps
Heap中定义数据时的索引的大小,为0表示16位索引值,若堆中数据超出16位数据表示范围,则使用32位索引值。01代表strings堆,02h代表GUID堆04h代表blob堆
1 byte
Rid
所有元数据表中记录最大索引值,在运行时有.NET计算,文件中通常为1
8 bytes
MaskValid
8字节长度的掩码,每个为代表一个表,为1表示该表有效,为0表示该表无效
8 bytes
Sorted
8字节长度的掩码,每个为代表一个表,为1表示该表已排序,反之为0
下面我们来看一下该程序的#~元数据表流的存储内容,将程序载入到16进制编辑器中,CTRL+G进行搜索0x9544,这个地址就是元数据表流的开始位置:如下所示:
红色地方代表的是Vaild,其中的数据是0XF0929B69D57,那么将其换算成二进制,看一下哪一些表是有效的,二进制数据如下图所示:
其中红色部分表示表数据是有效的一共有24个表,元数据中所有的表:
00-Module
01-TypeRef
02-TypeDef
03-FiledPtr
04-Filed
05-MethodPtr
06-MethodDef
07-ParamPtr
08-Param
09-MethodImpl
10-MemberRef
11-Constant
12-CustomAttribute
13-FieldMarshal
14-DeclSecurity
15-ClassLayout
16-FieldLayout
17-StandAloneSig
18-EventMap
19-EventPtr
20-Event
21-PropertyMap
22-PropertyPtr
23-Property
24-MethodSemantics
25-MethodImpl
26-ModuleRef
27-TypeSpec
28-ImplMap
29-FiledRVA
30-ENCLog
31-ENCMap
32-AssemblyRef
33-AssemblyProcessor
34-AssemblyOS
35-Assembly
36- AssemblyRefProcessor
37- AssemblyRefOS
38- File
39-ExportedType
40-ManifestResource
41- NestedClass
42-GenericParam
43-MethodSpec
44-GenericParamConstraint
紧接着元数据表头的是一串4字节数组,每个双字节代表该表中有多少项纪录(record),本程序中存在24个表那么就是,24*4=144个字节。那么我们就从元数据头结尾处进行查找:
我们来验证一下正确性使用CFF来看一下:
经过我们的验证确实是Module里面只有一条纪录。点开就可以看到内部结构是什么!这里我们不去讲所有表的结构。
这样我们已经知道了元数据是描述数据的数据,那么这句话要怎么理解呢?那么就来用一个例子来解释下这个说明的含义:比如该程序我们将其反编译成IL代码,查看IL代码的元数据.
这里我要不去讲这个Token的由来,我只讲一下这个Token怎么去索引,前面比如这个02000002,前面的02代表在元数据表中的第二个表也就是TypeDef表,至于表内部的结构自己可以再进行研究。那么后面的02代表的是什么呢?代表的是表里面的第二条纪录。截图说明下:
和IL图中描述一致:
至于剩下的#Strings堆都是一些二进制形式存在的数据。为了节省篇幅就到此了!其他的自行分析!
三、结束语
有可能这分析当中会存在一些问题,希望各位能人指出,我将其该正。抽时间将这篇文章整理出来!