第六章 将元数据表关联起来

在前面介绍的的所有章节中,通过不同的程序,我们阐明了所有的概念和表。在本章,我们竭力来描述一个单独的程序,它包括了所有的表并显示有意义的输出。在显示了指向不同表的引用的地方,例如typeDef[1]、MethodRef[2]等等。我们将显示存在于这些位置上的实际值。从而,我们尝试着解决所有的交叉引用并以一种全面的方式显示元数据信息。

但是,在开始解释这个巨大的程序之前——该程序对所有的表进行了交叉引用——我们希望在某些关键的方面使其清楚明白地显示出来。

元数据世界中的每个表都由字段组成。进一步而言,即使是一个结构,本质上也是一个由字段组成的集合。从而,在C#程序中,我们在一个结构中表示元数据表。

例如,我们创建ExportedTypeTable为一个结构标记,它包括了多个字段。结构,从本质上,不是实际存在的。因此,我们通过创建一个变量——它具有名为ExportedTypeTableStruct的结构标志——来生成一个实际的结构。

变量ExportedTypeTableStruct被声明为一个由结构组成的数组。

以下省略一些代码

程序开始于为元数据中的每个表创建一个结构标记。我们为你提供了全部的程序,尽管知道它将耗费好几张纸的事实。在你进入到主程序之前,编译b.cs到c:\mdata子目录中,并生成文件b.exe。这个可执行体的元数据信息将会被显示在输出中。

b.cs

a.txt

以下省略一些代码

>resgen a.txt

>csc b.cs /res:a.resources /unsafe

a.cs

代码太长,在这里下载

Output

输出太长,在这里下载

类zzz中的Main函数调用了函数abc,它立刻执行了显示元数据表中信息的任务——通过调用其它函数。

以下省略一些代码

第1个被调用的函数是InitializeObject。

以下省略一些代码

这个函数会验证是否使用了命令行参数来调用可执行体。如果使用了一个单独的参数——如>a mscorlib.dll——来调用可执行体,那么第0个成员就会变成第1个参数,即mscorlib.dll和成员的长度变成非0的数字。在没有提供命令行参数的情形中,会默认查看名为b.exe的文件。我们已经使用由.NET Framework所提供的mscorlib.dll和各种其它的dll来尝试并测试了这个程序。你可能会反汇编主程序来验证输出是否与程序文件有关。只是创建了a.exe的一份复制b.exe,随后,运行该程序,如下:

>a bb.exe

每个元数据表都是由名称和id标识的。第1个表的id为0,名称为module。类似地,第2个表的id为1,名称为TypeDef。一共有43个这般定义的表。然而,在IL反汇编模式中,名称并不是存储在任何位置的,因为表是由它的id表示的。因此,为了显示一个可读的输出,我们把这些表的名称存储在一个数组中,其中数组中的偏移量可以被认为id。

我们从下一个名为ReadPEStructures的函数中开始解释文件的内容。PE文件头,位于Image Optional Header之后,会被首先读取。虚地址、rawdata的大小,以及指向rawdata的指针,都存储在单独的数组中。

DisplayPEStructures函数显示了由Microsoft ILDASM程序工具流出的输出。ImportAddressTable和DisplayFromFile函数还会优化输出的显示,和使用工具来描述是一样的。

数据目录生成了关于目录开始的位置,即它的RVA和大小。RVA是一个内存位置。然而,由于文件是从磁盘上读取的,而不会被加载到内存中,这些目录的磁盘位置需要被确认。DisplayDataDirectory函数以一种恰当的格式为每个目录指出了这些细节。

倒数第2个数据目录成员,即CLR头,采取了一种特殊的处理方式,因为它形成了元数据信息的绝对基础。CLRHeader函数详细讲述了这个头的每个方面。

PE文件格式具有一个节表,它是一系列包含各种关于数据代码的信息的结构。它还存储了这些节的开始位置,包括内存上和磁盘上,以及每个节的大小。ConvertRVA函数决定了节的实际磁盘位置,节的内存位置事先已经提供的。

元数据头被读取到不同的变量中,随后,使用WriteLine函数来显示这些详细信息。

在abc函数中调用的下一个函数是ReadStreamsData()。它与我们在前面章节中观察到的非常类似。唯一的不同是,变量startofmetadata使用ConvertRVA函数来进行初始化。随后使用流中的Position属性在startofmetadata位置定位内存中的filepointer。需要考虑的重要一点是——.NET世界中的任何事物都在4字节上对齐。

在.NET世界中,元数据表以String流、GUID流或Blob流的形式被存储为偏移量或索引。突然出现的问题是,这些索引应该占据2个还是4个字节?答案完全依赖于流的大小。

如果流的大小不到64K,那么索引字段占据2个字节。然而,如果它超过64K,那么索引就占据4个字节。元数据的设计者可能迫切地将其固定在4字节,但是他们可能很快理解到这个方法可能会导致空间的过多浪费。因此,相当明显的是,元数据概念主要是为了效率而设计的。

我们为这5个流创建了5个数组。然后,包含在流中的数据被存储在数组中。之后,这些流的细节,不包括#~流,会被打印出来。

元数据头开始位置的第6个字节,是一个名为heapsize的字节。在这8个位之外,要检查3个位来确定索引的大小。如果第1位为on,那么字符串大小就大于64K,而此后,变量offsetstring会被设置为4。

以下省略一些代码

默认下,offsetstring、offsetguid和offsetblob这3个变量会被初始化为3。此后,为了避免更进一步的编译,ReadStringIndex、ReadGuidIndex和ReadBlobIndex函数会分别决定偏移量变量offsetstring的值。无论何时读取流,都会是这样。如果这个值被设置为2,就会使用Int16转换函数,然而如果它是4,则会使用Int32转换函数。

本书前面的所有程序都使用了小程序片断。结果,这个值用于不会超过64K。因此,我们假设这个值是2字节,并在这个假设下工作。

表中的索引还可能是2字节或4字节。然而,我们已经将这个值设置为2,假设了tablesizes,即表中的行,将不会超过64K。

以下省略一些代码

这里相反的方法是确定rows数组中的行是否超过64K,并且基于这个值,将会返回2或4。

正如在前面程序中所认识到的,在ReadStreamsData函数终止前,会检查valid字段中的表,而同时,它所包括的行的数量位于rows数组中。

一旦rows的大小数组被填充了,就会检查每个表的大小来填充size数组。这里最重要的是GetCodedIndexSize函数。让我们考虑这样一种特定情形——确定TypeRef表的tablesize。调用函数GetCodedIndexSize("ResolutionScope")来确定字段的大小是2字节还是4字节。

因此,GetCodedIndexSize函数会被调用,并且检查ReolutionScope 的if语句会返回true。然后,下面的语句会被执行:

以下省略一些代码

对于那些的内存,resolutionscope是4个表中的一个表的索引,即Module、ModuleRef、AssemblyRef或TypeRef,分别具有id为0x00、0x1a、0x23和0x01。在前面的某一章中,我们观察到,如果3个位具有值0,它就与Module表有关。值1用于ModuleRef,3用于AssemblyRef,4用于TypeRef。在GetCodedIndexSize函数中,会核实这3个表中的行以确认他们是否超过8192,即2^13,由于2字节的剩余3位是由表的引用占据的。如果它们是这样做的,那么resolutionscope就会占据4个字节,否则,它就会占据2个字节。

简而言之,GetCodedIndexSize获取一个字符串参数,它是编码索引的名称。他使用了rows数组来确定这个表的这些行是否超过了2字节的限制。这是通过减去为该表而使用的位来完成的。如果就是这样的,那么就会为resolutionscope提供4个字节。

这些表的大小不是常量。如果这些行扩展了大小,那么提供这些行所使用得到的字节的数量,也要进行扩展。对于MemberRef表,来自3个字段,第1个是编码索引,第2个是字符串索引,第3个是Blob中的索引。在表很小的情形中。每个字段都假设具有2个字节,从而一共是6个字节。然而,这种假设和技术上相差甚远。因此,GetCodedIndexSize动态地为这些表中的编码索引字段分配了索引。

计算这些表的大小并存储在size数组中。因此。为了检索MemberRef表的大小,你可以只使用size[0x0a]。

函数tablepresent计算了每个表的偏移量,并将其存储到tableoffset变量中。如果表存在于元数据流中,它就会返回一个布尔值True;然而,如果它不存在,它就会返回值False。

这块信息会在下一个名为ReadTablesIntoStructures的函数中使用到。如果这个表存在,那么就会创建一个由结构组成的数组,它是相当大的,可以存储表中所有的行。我们附加了一个额外的结构到现有的数量上,因为我们将忽略数组中的第0个成员并从1开始数组的索引。结构中的每个成员随后会填充整个数组。这会在全部的表中进行重复。

一旦这些结构被填充了,DisplayTableForDebugging()函数会被调用,它显示了存储在文件的每个表中的信息。

我们已经在前面极其详细地说明了每一个表。这里唯一的分歧是,对这些表的交叉引用是在values中指定的,伴随着其它相关的信息。例如,DisplayTypeDefTable函数生成了大量的信息——比我们之前遇到的还要多。

在这个函数中调用的DisplayTable函数,返回智能的信息。它获取2个参数,即tablename和index。让我们考虑类zzz的例子,它是TypeDef表中的第2行。DisplayTable函数被调用时带有的参数是TypeRef的表名称以及索引1。由于这个表是TypeRef,所以会调用带有index参数的GetTypeRefTable。

在GetTypeRefTable中,在Name字段中的值是从TypeRef结构中提取的,同时变量index将其自身输出为行的编号。更进一步,Namespace也会被检索到。如果Namespace不为null,那么就会在Namespace的名称后指定一个句点。

最后,使用GetString函数,就会获得实际的名称,随后它会被指定为返回值。因此,我们看到的值是System.Object而不是TypeRef[1]。

除此之外,使用DisplayAllMethods函数,所有包括在这个类中的方法也都会被显示。这个函数传递rowindex成员ii作为参数。结构的成员mindex 提供了这个类型所拥有的开始的方法索引。因此,我们将其存储在变量start中。然而,探察由这个类型拥有的最后一个方法并不是一个技术问题。再次,为了易于理解,也要以牺牲性能为代价。

为了获得最后一个方法,要检查下一个TypeDef成员。由这个TypeDef指向的函数,标注了一组新的由下一个类型拥有的函数。因此,可以安全地判断出——在这组新函数之前的所有函数,都是由当前类型所拥有的。然后,通过使用for循环,所有的位于范围内的函数都属于当前的类型。

如果该类型恰好是TypeDef表中的最后一个类型,就会产生一个小问题

在这样的例子的,变量startofnext被初始化为methodstruct-1的长度。因此,当TypeDef表中的索引对应到表中的最后一项时,在Method表中的的所有方法从头到尾,都被假设为当前的TypeDef没有在其中外包任何方法。

相同的规则适用于显示所有的字段、事件、参数和属性。

表6-1

表6-2

表6-3

我们选择放弃说明更多的代码,因为已经在前面的章节中的小零碎中使用了大量的代码。然而,还剩下一些内容没有完成,正如复合类型一样,它们没有被涉及到;而签名,也没有全部被解码。

我们把这些问题留下来给读者。因为这些在前面的章节中已经被详细地阐明了。每个问题都可以由所有这些知识合成,并就该主题毫不费力地编写一篇论文。然而,我们会节省你的努力,因为我们的下一本书将使用C#语言来提供所有这些关于Microsoft IL反汇编工具的复制品的要求。它还将合并所有格式的发布。再会!

原文地址:https://www.cnblogs.com/Jax/p/1598568.html