.NET 代码保护方案评测
本文讨论了 .NET 代码保护方法。
引言
正如你们许多人可能已经知道的那样,.NET 应用程序不是以二进制代码的形式分发的,而是使用一种称为“MSIL”(Microsoft Intermediate Language 的缩写)的特殊格式。MSIL 格式背后的理念是提供一种与机器无关的分发格式,从而促进 .NET 应用程序的跨平台运行能力。这由 CLR(.NET 运行时引擎)完成,特别是通过“即时”编译器(也称为 Jitter)。CLR 将 MSIL 代码交给 Jitter,Jitter 再将其编译成本地格式。然后,本地格式的代码被交给 CPU 执行。不幸的是,这个重要的特点使得 .NET 应用程序容易受到恶意逆向工程攻击。事实上,它非常容易被篡改,以至于考虑到代码暴露给潜在黑客的风险,人们可能会考虑使用该框架是否值得。为了演示篡改代码有多么容易,我们将看一个例子。
假设您正在开发一个名为“MyPreciousApp”的 WinForms 应用程序。在投入数月开发后,您现在准备部署它。您知道软件盗版者正盯着您,因此您决定使用您最喜欢的许可管理组件来保护您的应用程序。您的代码可能如下所示:
读者可能会惊讶地发现,要解除这种保护措施,黑客只需要花费几分钟时间摆弄您的应用程序。事实上,您不需要成为黑客也能做到这一点。您只需要两个微软提供的简单实用工具来完成这项工作。首先,您需要使用 ILDasm 工具,这是微软的 IL 反汇编器。该工具会暴露您程序使用的 IL 代码指令。
现在,要解除代码保护,您需要通过简单地删除对 'IsRegisteredUser
' 方法的调用来编辑 IL 代码。这可以通过删除由圆角矩形标记的代码来完成。
要完成这个过程,您需要再次使用微软提供的 ILASM 工具,该工具允许您从 MSIL 代码组装 .NET 应用程序。
最终,您的应用程序已被篡改!
打破对强名称程序集(Strong Name Assemblies)的误解
鉴于上面展示的例子,我想讨论我遇到的三种试图解决这个问题的手段,我称之为“.NET 应用程序篡改的不可忍受的轻盈”。
首先,我想讨论“强名称程序集”。似乎围绕这个问题存在很多混淆,我想在此澄清。这个功能允许您通过为其指定一个强名称来唯一标识一个程序集。名称实际上包含用于识别它的信息,包括程序集的文本名称、四部分版本号、区域性信息(如果提供)、公钥以及存储在程序集清单中的数字签名。数字签名在编译期间创建,方法是将程序集的内容通过某种哈希算法进行处理;结果使用分发者的私钥进行编码,并存储在程序集清单中。
一旦程序集准备好加载到内存中,CLR 就会启动一个验证过程。CLR 获取程序集,并使用与编译时相同的哈希算法进行处理。然后将结果与存储在其中的原始签名进行比较,这是在编译阶段写入的相同签名。为了解密签名,CLR 使用程序集的公钥。
“强名称程序集”的引入确保了您的程序在编译时所依赖的同一程序集将在运行时加载到内存中。这解决了称为“DLL Hell”的问题,当一个组件被更新时,可能会破坏依赖于它的其他应用程序,从而导致该问题。此外,强名称程序集构成了发布者的身份,用户可以利用它来定义发布者的代码访问权限。例如,这意味着用户可以根据其身份,禁止某个发布者访问其硬盘上的文件。
一些开发人员可能会认为,由于强名称程序集可以唯一标识一个程序集,因此它们可以用作代码保护工具。他们只是相信,如果程序集被篡改,CLR 运行时引擎将无法加载它。这一切都是真实的,除了他们没有意识到强名称程序集并非设计为防篡改功能。因此,不足为奇的是,一个程序集被标记为强名称的事实可以通过删除几个 MSIL 指令来改变,就像在前一节中所说明的那样。
回到“MyPreciousApp”的例子,我们可以看到它已经被赋予了一个强名称。这从下图所示的 .publickey
属性中可以明显看出。从 MSIL 代码中删除此属性并使用 ILASM 工具重建程序集,会破坏程序集的强名称,实际上将其指定为私有程序集。以与上述相同的方式破坏应用程序的所有程序集,会导致程序完全容易受到逆向工程攻击。
混淆(Obfuscation)及其缺点
行业中一种流行的代码保护技术是代码混淆。代码混淆器将应用程序转换成功能相同但更难理解的形式。这是通过将有意义的符号名称(如变量、字段和方法名称)重命名为无意义的名称来实现的。代码块被重新排列,从而使得推断程序逻辑变得更加困难。
虽然代码混淆器提高了理解程序逻辑的门槛,但它们也有一些主要的缺点。首先,它们不会重命名公共方法名称,因为其他引用混淆程序集的程序集可以调用这些公共方法。这意味着当查看混淆程序集的 MSIL 代码时,程序集间的调用很容易被追踪,因为原始方法名称正在被使用。以下示例精确地说明了这一点:
混淆后的代码清楚地显示,通过 main
方法发起了对 System.Windows.Forms 程序集的外部调用。由于许多程序依赖于外部库组件来管理授权和身份验证,因此这个缺陷非常严重。通过简单地查看 MSIL 代码,就可以拦截并删除这些调用。
第二个缺点是混淆工具在使用反射(Reflection)时引入的问题。通过使用反射执行的方法调用,在应用程序被混淆后很可能会失败。发生这种情况是因为方法已经被混淆器重命名,但调用站点仍然以其原始名称引用该方法。
当然,许多混淆器允许用户定义不应被重命名的那些方法,但这增加了研发团队和 QA 团队的开销,他们现在必须应对由于代码混淆而引入的 bug。
第三个缺点是当从现场报告 bug 时,追踪 bug 的能力。通过使用 System.Exception.StackTrace
方法恢复堆栈转储信息的能力,对于在应用程序部署后追踪 bug 的根源至关重要。想象一下,一个用户给您发送了一个 bug 报告,说他在使用您的应用程序时遇到问题。堆栈转储信息如下所示:
System.NullReferenceException: 未将对象引用设置到对象的实例。在 a.a() 在 a.b() 在 a.c() 在 a.d() 在 a.a(Object A_0, EventArgs A_1) 在 System.Windows.Forms.Control.OnClick(EventArgs e)
这关于问题的根源几乎没有提供信息,并且在这种情况下可能会导致研发响应迟缓。
基于代码加密的解决方案
为了限制 MSIL 代码被窥探,代码加密技术被用来防止潜在的黑客进行代码逆向工程。代码加密使用标准的加密算法来对 MSIL 代码进行加密,从而使其对人类或反汇编器完全不可读。在这方面,混淆技术与代码加密技术相比有所不足。
ILDASM 或其他反汇编器无法转储程序集的内容,因为它们不再包含 MSIL 指令。您现在可能在想:“既然程序集不包含任何 MSIL 指令,CLR 如何读取程序集内容并将其编译成本地程序集指令?”
答案很简单,因为 CLR 引擎无法处理编码后的代码版本,它必须在使用前被解密。这引出了一个重要问题,许多我曾接触过的基于加密的代码保护工具未能认识到这一点。一旦代码被解密,它在内存中就完全暴露在潜在黑客的窥探之下,因为整个程序集以其 MSIL 形式加载到内存中。这构成了一个安全威胁,因为一旦程序集加载到内存中,就可以使用标准的内存转储工具将其转储到文件中。
实际上,您可以使用几个 Win32 API 函数来构建自己的内存转储工具。首先,您需要获取一个进程句柄,这可以通过 `OpenProcess` API 函数完成。
HANDLE ph = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, processId);
'OpenProcess' 接受进程访问权限和进程 ID,返回值为进程句柄。现在,您需要访问要将其内容转储到文件的特定程序集。由于一个进程可能加载多个程序集,我们使用 'EnumProcessModules
' API 函数来检索进程加载的所有映像的模块句柄。
HMODULE modules[1000];DWORD nModules;
EnumProcessModules(procHndl, (HMODULE*)&modules, 1000*sizeof(HMODULE), &nModules);
然后,您需要确定您感兴趣的特定程序集文件。可以使用 'GetModuleFileNameEx
' 和 'GetModuleInformation
' 来标识该文件并检索其内存位置。
GetModuleFileNameEx(ph, modules[0], (LPTSTR)&fileName, MAXFILENAME);
要完成此过程,只需读取程序集的内容。要做到这一点,我们使用 'ReadProcessMemory
'。
ReadProcessMemory(ph, lpAssemblyBaseAddress, destBuffer, dwBytesToRead, &dwBytesRead);
ReadProcessMemory
接受使用 'GetModuleInformation
' 检索到的程序集基址和一个用于写入内存的缓冲区。
所描述的过程表明,始终保持 MSIL 代码的编码形式是维护可靠的防篡改解决方案的关键。这限制了潜在黑客使用我上面说明的技术转储程序集内容。始终保持 MSIL 代码的保护是 SecureTeam 的人长期以来一直在致力于的事情。