11.5 Defender中的保护技术
我们试着总结一下你刚在破解Defender中遭遇到的保护技术,并试着评价它们的效用。对那些没有耐心看50页的反汇编代码的人来说,也可以把这部分内容看作是一个很好的Defender“执行总结”。
首先,你要知道,与许多其他商业保护技术相比,Defender的保护机制已经算是非常强大的了,但毫无疑问还是可以改进的,这一点很重要。事实上,限于本书的篇幅,我有意限制了它的保护级别,以保证破解Defender的可操作性。如果不是出于这种限制的话,破解过程可能要长得多了。
11.5.1 局部化的函数级加密
像所有拷贝保护和可执行程序加壳技术一样,Defender将其大部分的关键代码都以加密的形式存放。这是一种非常好的设计,因为它至少可以防止破解者“大摇大摆地”在像IDA Pro这样的反汇编程序中加载程序,并轻松地分析整个程序。从现场调试(live-debugging)的角度来看,加密代码的好处在于这样可以阻止破解者在代码中设置断点(至少可以使之变得更加困难)。
当然,大多数保护方案只使用一个单独的密钥对整个程序进行加密,而且这个密钥在程序中的某个地方可以直接得到。这就使得破解者可以轻而易举地写出一个“脱壳”程序,用它来自动解密整个程序,然后给程序创建一个新的解了密的版本。
Defender的加密方法的巧妙之处就在于它的每一个加了密的代码块的解密密钥都是在运行时获得的,这使得破解者很难开发出自动脱壳程序。
相对强大的密码分组链接(CBC)加密算法
Defender使用一个相当稳健但又很简单的加密算法,叫做密码分组链接(Cipher Block Chaining,CBC,参见Bruce Schneier所著的“Applied Cryptography, Second Edition”[Schneier2])。该算法思想是简单地将每一个明文块(plaintext block,即未加密的数据块)
与前一个加了密的块做异或运算,然后再将结果与密钥进行异或。这个算法安全性非常高,不堪一击的异或算法根本就无法与之相比。在简单的异或算法中,一旦你确定了密钥的长度,你就能非常容易地找到这个密钥——你所要做的就是在加了密的数据块中确定出一段肯定被加了密的明文,并将它与对应的加了密的数据做一次异或,得到的结果就是密钥(假定你确定出的这段明文的字节数不少于密钥的长度)。
当然,如我所论证过的,CBC也易受到暴力破解攻击。但如果想要避开暴力破解,你只需要增加密钥的长度到64位或者更高就可以了。拷贝保护技术中真正的问题是,密钥最终还是要提供给程序使用的,如果没有专门的硬件,想要密钥避开破解者的火眼金睛是不可能的。
再加密
在每个函数返回其主调函数之前,Defender都要对这个函数再加密(Reencrypting)。这个操作给破解者造成了一定的不便(我承认影响不是很大),因为破解者永远不可能把整个程序解密后放在内存中(将整个解了密的程序转储到一个文件中,再在这个文件中方便地实施逆向,这对逆向者来说是再好不过的情况了)。
11.5.2 混淆应用程序与操作系统之间的接口
Defender中最重要的保护特征之一是有意混淆它与操作系统之间的接口,这种方法确实不常见。其思想是使破解者难于识别程序对操作系统功能的调用,也使得破解者几乎不可能在操作系统的API函数上设置断点。这大大地增加了破解工作的复杂性,因为大多数破解者需要依赖操作系统调用来找到目标程序中的关键代码区域(想想在我们在破解KeygenMe3时成功“抓获”的那个MessageBoxA调用)。
Defender的接口试着不使用任何一个直接的API调用来访问操作系统。Defender通过TEB找到了第一个系统组件(NTDLL.DLL),然后在其导出表中搜索API函数。
除了在初始化的时候做了一次API函数调用外,后面再没有通过用户模式组件调用过API函数。所有用户模式的操作系统组件都在程序开始的时候被拷贝到随机分配好的内存空间中了,因此,程序访问操作系统都是通过这份拷贝的代码,而没有使用模块本身。任何设置在用户模式API函数上的断点都永远不会起效。不用说,这对程序来说会消耗大量的内存,运行性能也会受到一定的影响(因为程序在每次启动时都要拷贝大量的代码)。
为了使破解者难以辨认程序正在调用的API函数,Defender用了由API的函数名计算出的校验和来搜索API函数,而不是存储它们真实的函数名。想要从校验和反推API函数名是不可能的。
这项技术也有几个缺点。首先,Defender在实现这一技术的时候保留了API函数在导出表中的次序,这使得辨认被调用API函数的工作大大简化了。在初始化的时候重新随机地组织这张表,就可以防止破解者使用这个方法找到所调用的API函数。还有,对一些API函数来说,可以用核调试器直接跟踪到内核中,这样就能确定调用的是哪个API了。似乎这个问题不可能那么简单就可以解决,但你要记住,对于NTDLL中的API函数来说确实如此,而对于Win32 API函数来说就不一定了。
还有一点我要说明——我们看到了Defender静态链接到KERNEL32.DLL上的方式,而且还看到它有一个IsDebuggerPresent导入项,你还记得吗?对IsDebuggerPresent函数的调用显然是没什么用的——它实际上是一个永远执行不到的代码。我为Defender加上这个调用的原因是:如果没有这个调用旧版本的Windows(Windows NT 4.0和Windows 2000)就不让我加载Defender。看来Windows希望所有的程序都要至少做一次系统调用。
11.5.3 处理器时间戳验证线程
在我看来,Defender采用一种固若金汤的保护机制,使得破解者要对受保护的应用程序实施在线调试变得非常困难。其思路是:创建一个专用的校验线程,不断地监视处理器的时间戳计数器,如果该进程有被停止执行的迹象(比如说在调试器中设置的断点耽搁了进程的执行)就“干掉”该进程。Defender用了诸如RDTSC这样的底层指令来直接访问计数器,而没有使用系统的API函数,这样破解者就不能在程序中放钩子(hook)或是替换掉获得计数器值的函数,这一点很重要。
加之每个关键函数都做了很好的加密,校验线程(verification thread)使得逆向工作的开展比原来更令人头痛。记住,如果没有加密,这项技术就不会这么有效了,因为破解者可以直接在反汇编器中加载程序,然后阅读反汇编代码。
在我们完成的破解任务中,为什么我们可以那么轻松地就避开了时间戳校验线程?我已经说过了,我有意地把Defender变得更容易破解一些,以确保它在本章的篇幅不会太长(本章已经是全书中页数最多的一章)。下面给出的几种改进会让时间戳校验线程变得更难对付(当然,再难也一定能解决,关键是看得花多少的时间):
, 在主线程中添加一个定期的校验和计算,用来检验校验线程。如果发现校验和不匹配,就说明有人对校验线程做了修补——立即终止进程。
, 必须把校验和存放在代码内,而不要集中放在某个地方。对实际的校验和验证代码也一样,必须把它们写成内联(inlined)函数,而不是用单个函数来实现。这样,消除检查或者修改校验和就变得非常困难了。
, 为校验线程存放一个全局句柄。用每一个校验和验证来确保线程仍在运行。如果线程不运行了,就立刻终止程序。
应该提到的一点是,目前我所实现的这个校验线程不是很靠得住。对于破解练习而言它已经足够可靠了,但对于其他要求更高的场合就不行了。相对较短的校验周期,再加上校验线程是以普通(normal)优先级运行的,这意味着即使在没有调试器的情况下它也可能会“不合理地”终止程序。
在商业产品环境中,时间戳计数器常数应该比我选得大很多,而且可能会要求在运行时根据计数器的更新速度而计算间隔的周期。此外,为了确保具有较高优先级的线程不会阻止它获取CPU时间而导致错误的判断,应该让校验线程运行在更高的优先级上。
11.5.4 在运行时生成解密密钥
在运行时生成解密密钥是很重要的,因为这就意味着破解者不能实现程序的自动脱壳。在运行时获取密钥的方法有很多,Defender采用了下面两种方法。
相互关联的密钥(Interdependent Keys)
Defender中的一些独立函数使用了相互关联的密钥进行加密,这些相互关联的密钥是从一些其他的程序数据在运行时计算出来的。对于Defender程序,我在再加密过程中计算了校验和,并将这个校验和用作为下一个函数的解密密钥。这也就是说,只要加了密的函数发生了变化(如修补或者断点),都会阻止下一个(指的是运行时的执行顺序)函数正确地解密。为此,我们可以使用密码学中的hash算法,就可以防止攻击者“作弊”——通过修改代码并添加几个字节来保持原校验和的值不变。这样的修改是逃不过密码学中的hash算法的——任何对代码的修改都将导致一个新的hash值。
基于用户输入的解密密钥
Defender中最重要的两个函数你是无法访问的,除非你输入了合法的序列号。这一点有点儿像加密狗(dongle)保护——在加密狗保护中,程序代码用一个只能从加密狗中读取到的密钥加密程序代码。这样,没有加密狗(对Defender来说是没有合法的序列号)的用户是不可能破解程序的。你之所以能够破解Defender,是因为我在编写Defender的时候有意在密码分组链接算法(Cipher Block Chaining,CBC)中使用了短小的32位密钥。如果我用长一些的密钥,如64位或128位的密钥,你在没有合法序列号的情况下是不可能破解Defender的。
不幸的是,你回想一下这个方案,其实它并没有多么高明。假定Defender是一个商业软件产品,第一个破解者要破解它确实需要花很长的时间,但是一旦找出了计算密钥的算法,那么只要有一个合法的序列号就可以算出用于加密关键代码块的密钥。接下来破解者只需要几个小时就能编写出一个针对Defender的密钥生成程序并放在网上供其他人下载。记住:秘密只是一个暂时的状态!
11.5.5 重度内联
最后,还有一项保护技术为Defender的汇编语言代码“低可读性”做出了卓越的贡献,那就是Defender在编译时采用了“重度内联(Heavy Inlining)”的方式。内联是指将函数代码直接插入调用它的函数体内的过程。这意味着我们要在每个主调函数中拷贝一份内联函数的代码(只要它调用了该内联函数),而不是让所有主调函数共用同一份内联函数的拷贝,而是在调用它的每个函数的内部都保留一份这个被调用函数的拷贝。这是一个标准C++的特性,在C++中你只需要在函数的原型中加上inline关键字,这个函数就变成内联函数了。
总的来说,内联会大大增加逆向工作、特别是破解的复杂度,因为你很难确定当前你在目标程序中的什么地方——而明确定义的函数调用可以大大简化逆向者的工作。从破解的角度看,对内联函数做修补难度更大,因为你必须找到代码的每一个实例(即每一份拷贝),每一个都要修补,而不能只修补一个并让所有调用都去调用修补过的版本。






