现在我们已经很清楚,托管PE文件可以轻而易举的被反编译,如果您想源代码不被使用者通过反编译的方式获得,该使用哪种保护手段呢?
借鉴传统Windows应用程序防止被反汇编的做法,我们也可以采用代码混淆和对应用程序集加壳的方法。关于程序集加壳的内容我会在下一篇文章中讨论。
代码混淆,简单的说就是使用名称替换、字符串加密等手段使得我们最初的代码面目全非,从而使破解者即使能能成功获得IL代码也很难得到想要的源代码。代码混淆常用的方式有名称混淆、流程混淆和语法混淆。
9.3.1 名称混淆
在讲解名称混淆的原理之前,我们先建一个用于测试的控制台程序,如代码清单9-8所示。
代码清单9-8 名称混淆测试代码
class Program
{
static void Main(string[] args)
{
string userID="asldjf3333djf";
string pwd = GetPassword(userID);
ChangeInfo(userID, pwd);
}
public static string GetPassword(string userID)
{
return "123456";
}
public static void ChangeInfo(string userID,string pwd)
{
}
}
代码清单9-8中的代码很简单,包含了三个函数Main函数、GetPassword函数和ChangeInfo函数。从函数的名称和参数中,我们很容易想到其含义。接下来,编译项目,然后使用Reflector打开生成的EXE文件,查看Program类的IL代码。如图9-14所示。
图9-14 Program类的IL代码
下面我们使用PE文件查看工具CFF载入可执行文件。定位到#Strings流,如图9-15所示。当前程序集的类型、引用类型、成员等的定义都在该字符串中。
图 9-15 Program.exe 的#Strings流
下面我们使用CFF来修改#Strings流的内容。查找到字符串“ChangeInfo”和字符串“GetPassword”,随意替换,然后运行程序验证是否有问题。
那么这样的修改有什么作用呢,我们保存修改后,使用Reflector重新打开exe文件,如图9-16所示。
图9-16 修改#Strings流后的IL代码
从9-16中,我们可以看到,先前的ChangeInfo和GetPassword方法名已经被替换成不可识别的乱码。
在完整的实践上面的演示之后,我想告诉你的是,你已经明白了名称混淆的原理。现在简单的总结一下,所谓.NET名称混淆就是修改#Strings流内特定字符串,使其不能 被轻易辨认。上面的例子只修改了两个方法的名称,当然我们可以修改包括在变量在内的所有名称来迷惑“对手”。如果您要问我,如果一个大型的软件项目,这样手动修改可行吗?当然不可行,但是原理知道了,编写这样一个工具并非难事。当然现在已经出现很多成熟的名称混淆工具,这里就不给您一一介绍了。
明白了原理之后,我们再看字符串替换的方式到底有哪些。
第一种替换方法为无意义替换。我们知道在实际的开发过程中,我们都必须遵守一定的命名规范来为类型、属性、字段命名。但是当我们对编译成功的代码进行名称混淆的时候就是要将这些规范的、规律的名称变得毫无意义,毫无规律可循。
第二种替换方法称为不可打印字符替换。在UNICODE字符集中,一些特殊字符,目前无法得到正确的显示,比如从0x01到0x20之间的字符。如果把名称替换成这些字符,显示出来的就是奇怪的乱码。
第三种替换方法为空字符替换。空字符替换就是把名称替换为空串。但是这种方法并不适合实际的应用,因为如果名称都为空,那么势必要产生二义性。
在实际环境中,我们常常要引用其他程序集。如果被引用的程序集的方法名称没有被混淆的话,那么在本程序集中混淆引用的方法名是无效的,会引发调用异常。这是名称混淆最大的局限性。
9.3.2 流程混淆
流程混淆是指打乱方法的流程,让反编译软件无法将代码正确的反编译为高级语言。流程混淆即可保护代码又可增加破解者分析代码的难度。
目前流程混淆基本上都是基于跳转实现的,我们在程序中使用的跳转有如下三种方式:
1) goto跳转;
2) if-else跳转
3) switch跳转。
.NET的流程混淆和传统windows应用程序的流程混淆本质上是有区别的。传统的流程混淆的目的是防止反汇编,而.NET流程混淆的目的是防止反编译。传统的流程混淆是基于汇编指令进行的,操作层次较低,可更改堆栈调用,可操作方式较多;.NET流程混淆是基于IL指令进行的,操作层次较高,不可触及堆栈调用,可操作方式较少。
下面我们针对代码清单9-9和代码清单9-10的示例程序,来实践流程混淆的不同方式。
代码清单9-9 测试流程混淆代码
class Program
{
static void Main(string[] args)
{
string h = "hello";
if (h == "hell0")
{
OutString();
}
else
{
Console.WriteLine("There is no hello!");
}
}
public static void OutString()
{
Console.WriteLine("hello");
}
}
现在编译项目,然后使用ILDasm导出改程序的IL代码(只截取Class IL代码部分),如代码清单9-10所示。
代码清单9-10 代码清单9-9的IL代码(Class IL代码部分)
// =============== CLASS MEMBERS DECLARATION ===================
.class private auto ansi beforefieldinit FlowObufscation.Program
extends [mscorlib]System.Object
{
.method private hidebysig static void Main(string[] args) cil managed
{
.entrypoint
// 代码大小 49 (0x31)
.maxstack 2
.locals init ([0] string h,
[1] bool CS$4$0000)
IL_0000: nop
IL_0001: ldstr "hello"
IL_0006: stloc.0
IL_0007: ldloc.0
IL_0008: ldstr "hell0"
IL_000d: call bool [mscorlib]System.String::op_Equality(string, string)
IL_0012: ldc.i4.0
IL_0013: ceq
IL_0015: stloc.1
IL_0016: ldloc.1
IL_0017: brtrue.s IL_0023
IL_0019: nop
IL_001a: call void FlowObufscation.Program::OutString()
IL_001f: nop
IL_0020: nop
IL_0021: br.s IL_0030
IL_0023: nop
IL_0024: ldstr "There is no hello!"
IL_0029: call void [mscorlib]System.Console::WriteLine(string)
IL_002e: nop
IL_002f: nop
IL_0030: ret
} // end of method Program::Main
.method public hidebysig static void OutString() cil managed
{
// 代码大小 13 (0xd)
.maxstack 8
IL_0000: nop
IL_0001: ldstr "hello"
IL_0006: call void [mscorlib]System.Console::WriteLine(string)
IL_000b: nop
IL_000c: ret
} // end of method Program::OutString
.method public hidebysig specialname rtspecialname
instance void .ctor() cil managed
{
// 代码大小 7 (0x7)
.maxstack 8
IL_0000: ldarg.0
IL_0001: call instance void [mscorlib]System.Object::.ctor()
IL_0006: ret
} // end of method Program::.ctor
} // end of class FlowObufscation.Program
// =============================================================
// *********** 反汇编完成 ***********************
// 警告: 创建了 Win32 资源文件 D:\FlowObufscation.res
代码清单9-10为代码清单9-9中整个Program类对应的IL代码。下面结合这两段代码进行流程混淆的实践。
q 代码块易位
Main方法内,代码从IL_0000一直到IL_0030,下面我们将这段代码分成三段,如代码清单9-11所示。
代码清单9-11 把Main方法分成三段
.method private hidebysig static void Main(string[] args) cil managed
{
//第一段开始
.entrypoint
// 代码大小 49 (0x31)
.maxstack 2
.locals init ([0] string h,
[1] bool CS$4$0000)
IL_0000: nop
IL_0001: ldstr "hello"
IL_0006: stloc.0
IL_0007: ldloc.0
IL_0008: ldstr "hell0"
IL_000d: call bool [mscorlib]System.String::op_Equality(string, string)
//第一段结束
//第二段开始
IL_0012: ldc.i4.0
IL_0013: ceq
IL_0015: stloc.1
IL_0016: ldloc.1
IL_0017: brtrue.s IL_0023
IL_0019: nop
IL_001a: call void FlowObufscation.Program::OutString()
IL_001f: nop
IL_0020: nop
IL_0021: br.s IL_0030
IL_0023: nop
IL_0024: ldstr "There is no hello!"
//第二段结束
//第三段开始
IL_0029: call void [mscorlib]System.Console::WriteLine(string)
IL_002e: nop
IL_002f: nop
IL_0030: ret
//第三段结束
} // end of method Program::Main
如代码清单9-11,我将Main方法的IL代码分成了三段,分段并没有什么依据,完全是随意的。下面我们将这三段代码重新组合,按照第一段、第三段、第二段的顺序排列,然后在第一段的结尾加上跳转语句“br IL_0012”,在第二段代码的后面加上跳转语句“br IL_0029”。修改之后的代码如代码清单9-12所示。
代码清单9-12重新排列的Main方法
.method private hidebysig static void Main(string[] args) cil managed
{
//第一段开始
.entrypoint
// 代码大小 49 (0x31)
.maxstack 2
.locals init ([0] string h,
[1] bool CS$4$0000)
IL_0000: nop
IL_0001: ldstr "hello"
IL_0006: stloc.0
IL_0007: ldloc.0
IL_0008: ldstr "hell0"
IL_000d: call bool [mscorlib]System.String::op_Equality(string, string)
br IL_0012
//第一段结束
//第三段开始
IL_0029: call void [mscorlib]System.Console::WriteLine(string)
IL_002e: nop
IL_002f: nop
IL_0030: ret
//第三段结束
//第二段开始
IL_0012: ldc.i4.0
IL_0013: ceq
IL_0015: stloc.1
IL_0016: ldloc.1
IL_0017: brtrue.s IL_0023
IL_0019: nop
IL_001a: call void FlowObufscation.Program::OutString()
IL_001f: nop
IL_0020: nop
IL_0021: br.s IL_0030
IL_0023: nop
IL_0024: ldstr "There is no hello!"
br IL_0029
//第二段结束
} // end of method Program::Main
现在我们使用ILAsm重新编译修改后的il代码,验证确认可以正常运行。如图9-17所示。
图9-17 重新编译修改过的IL
如图9-17,我重新编译修改过的IL文件,并在命令行下运行生成的exe文件,程序正常运行。但是这样的跳转对保护程序有什么意义吗?我们带着这个疑问使用Reflector打开生成的exe文件。当我们尝试将Main方法转成C#代码时,Reflector抛出了异常。如图9-18所示。
图9-18 Reflector打开代码段易位的程序集报错
图9-18中,我使用Reflector报的竟然是为将对象引用设置到对象实例的错误,看了是引发了Reflector的内部错误。
结果是这样的,但是原因呢,为什么IL运行运行的代码,Reflector却在反编译的时候出错呢?实际上这和我们设置的跳转点有关系。CLI规定程序走入任何分支时,要保持堆栈为空,如果我们在堆栈不为空的时候跳转,反编译器依照CLI进行代码反编译时就会出错。如果我们的跳转点设置在堆栈为空的地方,那么反编译是不会出错的。
q 连续跳转
基于上面提到的代码易位,很有效的达到了阻止反编译的目的,但是,在自动化程序中如何有效的去设置其跳转点呢?针对大块代码的跳转点设置,目前仍没有好的解决方案。于是有人提出了针对每一条IL指令(或者很少的几条)做跳转。这种方法被称作连续跳转。
很显然,连续跳转的保护强度要高于代码块易位的方法。想要手工修复连续跳转,对于大型软件来说几乎是不可能的。
连续跳转还有一种“变体”。我们事先设置好一连串的跳转指令,这样我们可以在需要跳转的地方每次自动跳转固定次数然后再转到目标点。
q 逻辑跳转
上面的各种跳转方法都是直接跳转,为了增加跳转的复杂度,我们可以对各个跳转增加逻辑判断。当然我们的逻辑判断只能是恒真或者恒假,虽然如此但是其保护强度却大大加强了。
现在我们对代码清单9-12作少量的修改,给两处跳转指令加上条件判断。修改后的代码如代码清单9-13所示。
代码清单9-13 逻辑跳转示例
.method private hidebysig static void Main(string[] args) cil managed
{
//第一段开始
.entrypoint
// 代码大小 49 (0x31)
.maxstack 2
.locals init ([0] string h,
[1] bool CS$4$0000)
IL_0000: nop
IL_0001: ldstr "hello"
IL_0006: stloc.0
IL_0007: ldloc.0
IL_0008: ldstr "hell0"
IL_000d: call bool [mscorlib]System.String::op_Equality(string, string)
Ldc.i4.1
Ldc.i4.0
Bgt IL_0012
//第一段结束
//第三段开始
IL_0029: call void [mscorlib]System.Console::WriteLine(string)
IL_002e: nop
IL_002f: nop
IL_0030: ret
//第三段结束
//第二段开始
IL_0012: ldc.i4.0
IL_0013: ceq
IL_0015: stloc.1
IL_0016: ldloc.1
IL_0017: brtrue.s IL_0023
IL_0019: nop
IL_001a: call void FlowObufscation.Program::OutString()
IL_001f: nop
IL_0020: nop
IL_0021: br.s IL_0030
IL_0023: nop
IL_0024: ldstr "There is no hello!"
Ldc.i4.1
Ldc.i4.0
sub
brtrue IL_0029
ret
//第二段结束
} // end of method Program::Main
在代码清单9-13中,我们对两处跳转指令做了修改,第一处修改为:
Ldc.i4.1
Ldc.i4.0
Bgt IL_0012
相当于C#中的c#中的:
if(1>0)
{goto IL_0012;}
第二处跳转被修改为:
Ldc.i4.1
Ldc.i4.0
sub
brtrue IL_0029
ret
相当于C#代码的:
if(1-0==true)
{
Goto IL_0029;
}
将直接跳转改为逻辑跳转,为反混淆增加了代码识别的难度,因为有时候很难判断代码if条件是源代码就有的还是后来为了混淆才添加上的。当然我们也可以仿造直接跳转的方式,增加多层条件判断来增强保护强度。
增强逻辑跳转的迷惑性的另一个方法是添加临时变量,并在条件判断中使用临时变量。
但是在IL中是允许的,那么如果我们在IL中加入这种判断,反编译时一定会报错的。
q Switch跳转
Switch跳转的基本基本原理和上面的提到的方法大同小异。基本方法为把程序中那个的每条指令都放在switch的判断中。这样就需要建立多个局部变量来进行判断。这种方法无疑会增加太多的垃圾代码。
Switch跳转的例子这里就不做演示了。如果您感兴趣可自行实践。
q 利用语言差异
我们还有一个更强的保护手段,就是利用IL语言和高级语言之间的差异性。我们知道,并不是IL语言所有的特性都会反映在高级语言中。高级语言只是IL的子集而已。
比如在c#中这样的代码是不允许的:
if(12.32)
{
//do something
}
关于代码混淆的技术不只这些,由于篇幅所限我们暂且讨论至此。在实际应用中我们也不可能手动的去做代码混淆,有很多成熟的工具可以供选择。