聊聊反编译器ILSpy

Posted 说给开发游戏的你

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了聊聊反编译器ILSpy相关的知识,希望对你有一定的参考价值。

内容接。


进入IL到Lua的翻译器正题之前,小说君这篇文章打算先写写ILSpy。

由于内容比较独立,标题就单独起了。




把IL翻译到Lua,比较核心的部分其实是把集合形式的IL Instructions转换为结构化的语法树。


ILSpy虽然看起来用起来都只是一个用来反编译IL Assembly的GUI工具,实际上代码结构非常清晰,拿掉GUI的部分,就是一个完整的、易于定制的IL反编译库。

我们可以在这个库的基础上做任何想做的事情。


小说君在一开始构思IL转Lua的翻译器的时候,首先想到的就是基于ILSpy扩展。其他语言的一些IL翻译器也是这么做的,比如有一个JSIL,就是一个基于ILSpy实现的JS翻译器。




回顾上篇文章,我们看看目前已经做到的:

我们借助Mono.Cecil从IL Assembly中拿到了所有类型定义,每个类型的字段、属性、方法等的定义,以及各元素的元信息(比如名称,类型,Modifier,Attribute等等)。

其中,只有属性和方法的具体实现是体现为一组IL Instructions。除了这部分之外的信息,都不涉及到反编译,只需要“提取”出来。


需要反编译的就是一个具体方法块的IL Instructions。


IL是一种基于操作栈的虚拟机语言。指令间借助栈传递数据。


还是这个add指令的例子,从栈上pop两个值,加完以后把结果push回栈。


举个稍微复杂的例子:

Console.WriteLine(condition ? "true" : "false");


这样一段代码,生成的IL是这样:

 1IL_0001: ldarg.1
2IL_0002: brtrue.s IL_000b
3
4IL_0004: ldstr "false"
5IL_0009: br.s IL_0010
6
7IL_000b: ldstr "true"
8
9IL_0010: call void [mscorlib]System.Console::WriteLine(string)
10IL_0016: ret


简单解释下代码中涉及的几条IL指令的含义:

IL 解释
ldarg.1 把第一个参数push到操作栈
brtrue.s IL_000b 有条件跳转,从栈上pop一个值,true/非零/非空的话跳转
ldstr 把一个字符串的引用push到操作栈
br.s IL_0010 无条件跳转
call 方法调用,会从栈上pop所需参数
ret return,并且从callee的栈上pop一个值,push到caller的栈上


很容易理解,而且实现一个stack-based machine也很容易。

我们的目标是反编译,而在一般的高级语言中,没有操作栈这样的概念。

C#和Lua中,我们都用变量和环境来表达数据之间的传输。


不过IL本身既有操作栈,也有局部变量。一组普通的IL Instructions经过变换,就能完全去掉操作栈的概念。


我们可以把一个连续的操作栈看作离散的一个个变量S0,S1,S2等等。

  • 某个指令push,就相当于给Si赋值。

  • 某个指令pop,就相当于从Si取值。


因此,ILSpy首先对IL指令的一步处理就是做了这样的转换:

 1IL_0001: stloc S_0(condition)
2IL_0002: if (ldloc S_0) br IL_000b
3
4IL_0004: stloc S_1(ldstr "false")
5IL_0009: br IL_0010
6
7IL_000b: stloc S_1(ldstr "true")
8
9IL_0010: call WriteLine(ldloc S_1)
10IL_0016: ret


其中,stloc指令表示的是从操作栈上pop一个值,赋给某个local变量;ldloc指令表示的是把某个local变量push到操作栈。


IL指令本身是带有类型信息的。

理论上,不管经过任何路径,执行到某一条指令前,栈上的元素数量和各自的类型都是固定的。

这也是ILSpy做数据流分析的基础。


例如:

在IL_0004语句执行前,操作栈一定是空的;执行后,操作栈上一定只有一个string类型的值。


ILSpy在具体实现上,就是过一遍语句,为每条语句维护一个当前栈状态。

根据遇到的指令大体上有三类操作:

  • 遇到push变量到操作栈的指令,比如IL_0001。

    处理逻辑是构造一个stloc和一个IL variable,包一下这条指令。同时把这个IL variable push到代码模拟的一个栈里。

  • 遇到从操作栈pop变量的指令,比如IL_0010。

    处理逻辑是构造一个ldloc,同时从代码模拟的栈里pop一个IL variable,作为这条指令的子指令。

  • 遇到跳转指令。

    首先会做指令自身对栈的操作(比如brtrue会执行一次pop,br不会)。然后更新下当前语句和跳转目标语句的栈状态。例如IL_0009,此时栈状态是一个string,同时会把当前栈状态copy一份,设置到跳转目标IL_0010的栈状态。


这样处理了之后,还要确定pop的栈变量与push的栈变量的对应关系。


三种情况:

1. 最简单的,先stloc,再ldloc。转换完的语句类似于s0 = xx,yy = s0。

2. 然后是一处stloc,根据不同分支多处ldloc。转换完的语句类似于s0 = xx,(分支1)yy1 = s0, (分支2)yy2 = s0。


这两种情况比较简单,ldloc的时候可以从代码中模拟的栈直接拿到同一份实例。


3. 最后一种情况是不同分支中的多处stloc,最后一处ldloc。


就像代码中的IL_0004 / IL_000b / IL_0010。

如果还像前两种一样处理,IL_0004和IL_000b两条指令store的就是不同的IL variable实例,无法确定两条指令实际push的都是S1。


解决方案还是栈分析。

  • IL_0009跳到IL_0010的时候,栈状态是s0。

  • IL_000b跳到IL_0010的时候,栈状态是s1。

  • s0有一个string variable。

  • s1有一个string variable。

  • 在转语句IL_0010之前,做一次栈合并。


ILSpy的合并逻辑,用了并查集实现。


这样,转换完,过一遍所有栈模拟语句中的variable的时候,所有的stloc会查并查集,找root节点。IL_0009和IL_000b两句的stloc就查到了同一个variable。




接下来,ILSpy对IL Instructions以跳转指令为界限,划分了基本的block。block间构成树形结构。

比如之前的例子,中间没有空行相隔的就同属一个block,大概像这样:

 1BlockContainer {
2    Block IL_0000 (incoming: 1) {
3        nop
4        stloc S_0(ldloc condition)
5        if (ldloc S_0) br IL_000b
6        br IL_0004
7    }
8
9    Block IL_0004 (incoming: 1)
{
10        stloc S_1(ldstr "false")
11        br IL_0010
12    }
13
14    Block IL_000b (incoming: 1)
{
15        stloc S_1(ldstr "true")
16        br IL_0010
17    }
18
19    Block IL_0010 (incoming: 2)
{
20        call WriteLine(ldloc S_1)
21        nop
22        leave IL_0000 (nop)
23    }
24}


一连串相关的IL Instructions组成一个block。


这样就拿到了结构化的IL block,可以看做是IL的AST。

画个树形图:

聊聊反编译器ILSpy


之后ILSpy会基于这个IL AST做各种变换。

每次变换其实就是构建个visitor,遍历IL AST,操作、修改,再进行下一次变换。


ILSpy在实现中,变换有很多,简单列出几种:

new ControlFlowSimplification(),
new SplitVariables(),
new ILInlining(),
new YieldReturnDecompiler(),
new AsyncAwaitDecompiler(),  
new DetectCatchWhenConditionBlocks(),
new DetectExitPoints(canIntroduceExitForReturn: false),
new RemoveDeadVariableInit(),
new BlockILTransform { // per-block transforms
   PostOrderTransforms = {
       new LoopDetection()
   }
},
new BlockILTransform { // per-block transforms
   PostOrderTransforms = {
       new ConditionDetection(),
       new CopyPropagation(),
   }
},
new ProxyCallReplacer(),
new DelegateConstruction(),
new HighLevelLoopTransform(),
new AssignVariableNames(),


命名上都比较自描述,小说君接下来就挑几个感觉比较关键的介绍下。




在之前的流程中,ILSpy不仅加进了一些dummy变量来模拟栈,还加了不少跳转指令来描述结构化的block。


因此,变换的一开始先是简化掉无意义的跳转(ControlFlowSimplification)。做的事情主要有:

  • 删掉nop指令。

  • 删掉入度为零的block。

  • 简化掉a->b->c,但是b只有一条跳转指令的跳转。


之后是inlining优化(ILInlining)。用来干掉不必要的局部变量。

干掉局部变量的流程很直接。

像消除操作栈时引入的变量这样,如果仅声明了一次仅使用了一次,并且唯一的一次使用紧跟着声明的话,就可以直接inline掉。

ILInlining,实现逻辑比较简单,就是遍历整棵树,找stloc,检查下相关IL variable只赋值一次读一次的话,如果下一个节点的ldloc语句关联的variable也是这个,就做一次变换。

举例,IL_0001和IL_0002开始是这样:

stloc S_0(ldloc condition)
if (ldloc S_0) br IL_000b


inline完之后是这样:

if (ldloc condition) br IL_000b




然后比较重要的是控制流分析(LoopDetection和ConditionDetection)。


换一下示例代码,加进去条件分支和循环结构。

 1private void Test11(bool condition)
2
{
3    int a = 10;
4
5    while (condition)
6    {
7        a += 10;
8
9        if (a > 100)
10            condition = false;
11    }
12}


我们直接看化简后的IL AST:

 1BlockContainer {
2    Block IL_0000 (incoming: 1) {
3        stloc a(ldc.i4 10)
4        br IL_0019
5    }
6
7    Block IL_0019 (incoming: 3)
{
8        if (ldloc condition) br IL_0006
9        leave IL_0000 (nop)
10    }
11
12    Block IL_0006 (incoming: 1)
{
13        stloc a(binary.add.i4(ldloc a, ldc.i4 10))
14        if (logic.not(comp.signed(ldloc a > ldc.i4 100))) br IL_0019
15        br IL_0015
16    }
17
18    Block IL_0015 (incoming: 1)
{
19        stloc condition(ldc.i4 0)
20        br IL_0019
21    }
22}


画个简单的跳转关系图:

聊聊反编译器ILSpy



控制流分析的核心是两个变换,LoopDetection和ConditionDetection。


这部分代码比较复杂,不过相关理论比较成熟,主要是控制流图Control flow graph相关内容。

这篇文章不会涉及太多控制流图相关的知识,涉及多了小说君也不懂。所以这里就可以简单理解为block之间的跳转关系图。


LoopDetection和ConditionDetection都是BlockTransform。

BlockTransform会先根据各block之间的跳转关系构建Control flow graph,做了这样几件事:

  • 指一下节点间的前后继关系。

  • 标记下entry point和exit point。

  • 计算下每个节点的dominators和immediate dominator。


备注一下:

从entry point到节点n的所有路径如果都要经过节点d,那d就是n的dominator(简记dom)。

节点n的dominator中,如果某个节点d不是n的其他dominator的dominator,那d就是n的immediate dominator(简记idom)。


比如前面的跳转图中,Block IL_0000是其他block的dom。Block IL_0019的idom是Block IL_0000。

这两个概念可以用来找循环结构的入口block。


接下来BlockTransform会对各block做后根遍历,这样可以处理嵌套的循环结构,先找出子节点的循环或条件分支结构,再找父节点的。


然后就是针对每个block的具体Transform。篇幅所限,这里就只简单介绍下LoopDetection的流程。


由于在做具体的LoopDetection前,已经构建好了节点间的dominator关系,所以只需要遍历block,查到如果某个block节点dominate某个先继,这个block节点就是loop head。然后根据先继关系,找出这整个loop涉及的节点。


最后根据这些节点构建出来对应的结构。


变换完之后,代码结构就变成了这样,构建了一个注记为while的BlockContainer,子节点是之前平铺的几个block。

 1BlockContainer {
2    Block IL_0000 (incoming: 1) {
3        stloc a(ldc.i4 10)
4        br IL_0019
5    }
6    Block IL_0019 (incoming: 1)
{
7        BlockContainer (while-true) {
8            Block IL_0019 (incoming: 3) {
9                if (ldloc condition) br IL_0006
10                leave IL_0000 (nop)
11            }
12            Block IL_0006 (incoming: 1)
{
13                stloc a(binary.add.i4(ldloc a, ldc.i4 10))
14                if (logic.not(comp.signed(ldloc a > ldc.i4 100))) br IL_0019
15                br IL_0015
16            }
17            Block IL_0015 (incoming: 1)
{
18                stloc condition(ldc.i4 0)
19                br IL_0019
20            }
21        }
22    }
23}


对应的跳转关系图:


全部变换做完之后,结构变成了这样:

 1ILFunction Test11 {
2    local a : System.Int32(Index=0, LoadCount=2, AddressCount=0, StoreCount=2)
3    param condition : System.Boolean(Index=0, LoadCount=1, AddressCount=0, StoreCount=2)
4
5    BlockContainer {
6        Block IL_0000 (incoming: 1) {
7            stloc a(ldc.i4 10)
8            BlockContainer (while)
{
9                Block IL_0019 (incoming: 2) {
10                    if (ldloc condition) br IL_0006 else leave IL_0019 (nop)
11                }
12                Block IL_0006 (incoming: 1)
{
13                    stloc a(binary.add.i4(ldloc a, ldc.i4 10))
14                    if (comp.signed(ldloc a > ldc.i4 100)) Block IL_0015
{
15                        stloc condition(ldc.i4 0)
16                    }
17                    br IL_0019
18                }
19            }
20            leave IL_0000 (nop)
21        }
22    }
23}


表达形式已经比较高级。




ILSpy处理得到这样一颗AST之后,对各节点逐个直译,就转换为了基本C#表达的语法树。


得到基本语法树之后,ILSpy还会做各种基于C# AST的Transform,表达更高阶的C#语法节点。


不过这部分不是重点,因为C#的高阶语法,有很多是Lua表达不了的。我们做IL到Lua的翻译工具时也不需要。


后面的实现中,就是选择性的打开一些Transform,根据最后拿到的语法树,再做次处理,就得到了Lua的语法树。

当然,只是理论上是这样,实际实现起来就直接在访问C#语法树的时候做代码生成了。


下篇文章就重点讲讲导出Lua代码的一些具体case,以及相应的运行模型。




个人订阅号:gamedev101「说给开发游戏的你」,聊聊服务端,聊聊游戏开发。

以上是关于聊聊反编译器ILSpy的主要内容,如果未能解决你的问题,请参考以下文章

求c# 通过Debug 文件反编译出其中的代码,能看见编译的代码就可以了,求这样的软件工具???

如何利用ILSPY反编译工具重建C#NETWeb源码解决方案

ILSpy反编译工具的使用

ILSpy反编译工具之C#反汇编

ILSpy反编译工具的使用

[转]dll反编译工具(ILSpy)的使用