8.1.6 破解技术

8.1.6 破解技术

下面让我们暂时客串一把破解者的角色!本节的目的并不是要把你训练成一个破解高手,而只想使大家对破解技术有一个大致印象。假设破解者拿到的只是一个运行在Linux操作系统下,针对x86处理器编译的二进制可执行文件,而且这个可执行文件还是不带调试符号的。我们最主要的破解工具是gdb调试器。

虽然攻击的具体细节依据所在的操作系统,破解者所使用的工具以及被破解软件中使用的保护技术的不同而不同,但所有破解技术的本质却是相通的。另外,也有一些描述如何在Windows系统、Android系统中进行破解的书籍已经出版。

1.从可执行文件中获取信息

在正式开始破解之前,你必须对被破解的可执行文件本身进行一次分析,搞清它是静态链接,还是动态链接的,是否带调试符号,程序中各个段的起始和结束位置等信息。在每个操作系统中都会有一些工具能帮助你获取这些信息,执行命令和结果如下所示:

现在你已经知道了不少关于这个可执行文件的有用信息了。首先,它是一个动态链接的程序,所以在可执行文件中就一定带有一些符号信息。此外你还知道了这个程序中.text段(代码段)和.rodata段(数据段)的起始和结束位置。如果要在可执行文件中搜索某个字符串或者某个指令序列的话,这些信息就会非常有用。最后(当然不是说你能得到的信息就到此为止了),你还知道这个程序应该从0x4006a0这个地址开始执行。

2.在库函数上设置断点

在破解之初,你把程序当成一个黑盒子。给它输入一定的数据,然后观察它的运行结果。你立刻就发现:程序将只输出一句“expired!”(过期!),而不是播放音乐给你听。

所以你首先要破解的就是这个讨厌的软件使用期限限制!

你已经知道这个可执行程序是不带调试符号和动态链接的。也就是说,你能通过函数名找到不少库函数。由于程序一般都会通过调用标准库函数time()来获取当前时间,并把结果和指定的软件过期时间相比较来实现限制软件使用日期的功能。所以你现在的任务就是从程序中找出那个与语句if(time(0)somevalue)等价的汇编指令序列。

我们现在的想法是,在time()函数上设置一个断点,然后运行程序,直到命中这个断点。这时,我们就可以去检查函数调用栈,看看是哪条指令调用了time()函数。而在这条指令附近就很可能是我们要找的东西了。找到它之后,我们就可以将跳转条件置反,把语句改成if(time(0)=somevalue)...

一切都按计划顺利执行!我们发现位于0x4008bc的这条指令就是我们要修改的跳转指令。

现在我们只要把操作码jle改成jg(x86的操作码是0x7f)就可以了。我们用gdb调试器中的set指令来完成这一工作:

在这个案例中,我们的运气不错,由于这个可执行文件是动态链接的,因而有一部分符号信息被保留了下来。如果这个程序是静态链接且不带调试符号的,我们就没这么容易在time()函数上设置断点了。不过这也不难!可以用模式匹配的方法,根据time()函数的特征从可执行文件中找出它。另外,由于time()函数最终是要调用gettimeofday()这个系统函数,才能从操作系统那里获取系统当前时间,我们也可以通过在gettimeofday()函数上设置断点的方法,达到与在time()函数上设置断点类似的效果。

3.静态模式匹配

现在播放器不再输出“expired!”,我们可以继续去对付其他的保护措施了!我们发现如果现在我们输入的不是正确的激活码“42”的话,程序将给出一个“wrong code”的消息,然后崩溃掉。

这次,我们准备使用另一个常用的破解方法,在可执行文件中搜索指定的字符串。估计我们这次要找的汇编代码大致应该是下面这个样子。

我们首先要在数据段里搜索字符串"wrong code"所在的位置addr1,然后把代码段中所有引用这个字符串的指令都找出来。

第一次搜索就找到了addr1的地址,第二次搜索又找到了addr2的地址。这次把je指令改成jmp指令,就能绕过printf()语句。顺便说一下,在x86体系结构的处理器中,jmp指令的操作码是0xeb。

我们的运气实在是不错,因为在这个例子里,addr2上的那条指令在引用字符串“wrong code”时是直接使用它的地址addr1的。所以我们才能用直接搜索addr1的方法找到addr2。而在其他许多处理器中(如x86的16位处理器),指令在引用数据段中的数据时是使用:

偏移地址+段基址寄存器上存放的段基地址=被引用数据地址

这个方法的。这样的话,搜索工作的难度无疑就要大一些。

4.内存断点

现在这个播放器再也不会去检查软件使用期限和激活码了,但是它还是会因为一个段违规而崩溃。

我们可以比较有把握地猜想这是因为我们之前对程序进行的修改,使得程序中防篡改代码被触发执行而导致的。在UNIX系统中,段违规一般是由于程序试图访问某个非法的内存地址,比如程序中使用了一个空指针(NULL pointer),而引发的。

这次我们采用的方法是:在调试器中运行这个程序,直到它崩溃棹。然后检查是哪条指令试图访问非法地址及出错的原因。接着再次在调试器中运行程序,这次我们将在被改为错误值的那个内存地址上设置一个内存断点(watchpoint),看它是在何时被写入一个错误值的。

显然,这个段违规是由于地址0x40087b上的这条指令试图往0x601240上存放的指针所指向的地址写入一些数据时引发的。所以我们就要在0x601240这个地址上设置一个内存断点,然后重新运行一下程序看看在这个内存地址上都发生了些什么。

原来是0x400806上的这条指令捣的鬼啊!这条指令把0x601240上的值改成了零!这条指令在源码中对应的就是die()函数中“key=NULL”这一语句。

要废掉这个语句,我们可以把这条指令改成一串NOP指令(在x86机器中,NOP指令的操作码是0x90)。

我们再来反汇编这一段代码,看看“key=NULL”是否已经被我们干掉了?

5.获取程序内部使用的数据

前面说过,在程序内部被解密出来的数字音频信号远比程序输出的模拟音频信号有价值。所以作为一个破解者,你肯定想要修改播放器的代码,把解密出来的数字音频信号的明文搞到手。但是要往二进制可执行代码中插入新的代码可不是件容易的事,因为这样做的话,将会使很多跳转指令的跳转地址发生变化,你将不得不逐一对它们进行修正!所以一个简单点的办法就是让调试器输出你想要的东西。

假定对程序分析了一段时间之后,你确定在程序运行到0x4008a6这个位置的时候,局部变量-0x8(%rbp)里存放的就是数字音频信号的明文:

上面代码对应的就是源码中这条语句:

现在你所要做的就是在0x4008a6这个位置上设置一个断点,并且要求调试器在命中这个断点时,自动把那个局部变量的值打印出来。

现在你已经学会如何让gdb在命中断点时,执行任何你指定的指令了。在这个例子里,我们让gdb在命中断点时,以十六进制的形式输出局部变量的值,然后继续执行。

6.修改环境变量

即使你能修改二进制可执行文件中的代码,做到这一点也是很麻烦的,有时甚至还要解除程序中的防篡改保护措施,所以我们还要另辟蹊径——通过修改程序所在系统的环境变量来达到同样的效果。比如,同样是要解除程序使用期限的限制,我们只要挂钩(hook)系统的时钟就可以了。

这一招有很多变化,如果程序是动态链接的,你就可以用修改后的库函数把原来的库函数替换掉(比如在你的库函数中time()函数永远都只能返回0),或者也可以略微修改一下库搜索路径的顺序,让程序使用你提供的库函数而不是系统中的库函数。

7.动态模式匹配

下面介绍一种强大的模式匹配技术:动态模式匹配。尽管在这个案例中你暂时还用不着它。在动态模式匹配中我们搜索的不是静态的代码或者数据,而是程序的动态行为。例如,如果代码中的“解密函数”不是一个简单的异或操作,而是一个类似Needham或者TEA(Tiny Encryption Algorithm,由Wheeler提出)之类的标准加密算法。

上面给出的就是TEA的实现代码,TEA是微软XBOX中使用的加密算法之一,而XBOX又是破解者们重点目标之一。如果出于种种原因,你无法使用静态分析的方法找到解密代码,你也可以在trace的结果中搜索所有的“移位/异或/与”等在加解密中常用的指令。下面给出的就是对TEA的一次内部for循环的trace结果,其中有关加载/存储数据,以及对数据进行算术操作的指令都已经被去掉了。

这些信息够不够你编写一个能从二进制代码中找出加密算法,甚至能把TEA与其他加密算法区分开来的动态特征呢?trace结果中如果有对某个数据连续进行几个移位操作,而且每次移动的位数都是常量的代码,那么这些代码就应该是你分析的起点了。

8.比对攻击

现在假设你手头上有这个播放器程序的两个不同版本,每个程序都有一个互不相同的32位数作为用户指纹。那么,在这两个版本之间唯一的区别如图8.6所示。

图8.6 播放器程序两个版本的区别

比对攻击不只可以用来对付用户指纹,它还可以用来寻找同一个程序的不同版本之间的差异,例如在程序的一个版本中含有一个已经修补掉的安全漏洞,而另一个版本中这个漏洞还没有被修补,或者在程序的一个版本中某个功能已经被去掉了,而在另一个版本中这个功能还在,你可以使用不同的方法发现程序中你感兴趣的部分。

VBinDiff之类的工具只能比较两个可执行文件的静态代码,只有当被比较的两个程序的结构类似,差别不大时这一招才管用。如果你要分析的两个程序差别很大的话,你也可以同时运行这两个程序,给它们输入同样的数据,并且检查两个程序执行路径之间的区别。这就是所谓的平行调试(relative debugging)。

9.从反编译结果中恢复软件使用的算法

你的终极任务是搞清楚DRM播放器程序中使用的算法,然后将其用于你自己的播放器程序中。作为一个破解高手,你应该能够直接阅读汇编代码。但是为了简化工作,你更乐于把大量尚不熟悉的代码从汇编语言形式转换成高级语言的形式。因而你应该把反编译器也放进工具箱里,下面我们讨论REC 2.1。

为了把事情变得更有趣些,现在假设这个程序是静态链接且不带调试符号的,在可执行文件里什么符号信息也没有。现在我们来看代码清单8-1和代码清单8-2中给出的反编译结果。注意我们已经对反编译的结果进行了一些编辑和剪裁,这既使你能够读起来更轻松些,又使我们能简短地在书中表示出来。完整的程序是由104 341行汇编代码,或者90 563行不带注释,也没有空格的C语言代码组成的。由于程序是静态链接的,程序中需要调用的所有库函数也全都已经被复制到了程序中,并在反编译时被一起反编译了,所以这些代码也会出现在反编译的结果中了。

代码清单8-1如下,DRM播放器程序中play()函数的反编译结果:

代码清单8-2如下,DRM播放器程序中player_main()函数的反编译结果:

我们只给出了与播放器程序的具体实现相关的代码。事实上,由于我们已经知道一些关于播放器程序的内部实现细节,所以在反编译结果中把这些代码给找出来是很容易的。具体操作是这样的,我们知道在hash()和play()函数中使用了C语言中的异或(xor)(而在绝大多数代码中异或操作是很少见的),所以只要在文本编辑器中直接搜索“^”就能很快把我们要找的东西查到。

在反编译结果中,控制流部分出乎意料地清晰,但是代码中关于数据类型的信息却一点都没有,这也使得我们无法把反编译的结果当成源码,重新对它进行编译。此外反编译器也被源码中插入的汇编代码(代码清单中用点状虚线框标出的部分)搞晕了,这一部分代码在反编译结果中根本就找不到。请注意,由于在编译DRM播放器程序时使用了一些编译优化技术,所以hash()和die()函数都已经变成内联(inline)函数。

一般而言,你能从本小节中得出的结论是一个模型——威胁糢型。这个模型是用来描述破解者能做什么,不能做什么,他会有哪些行为,不会有哪些行为,他会用哪些工具,不会有哪些工具,他想干什么,不想干什么,他觉得做什么容易,做什么很难等。你可以利用这个模型来设计防御方案,针对模型中给出的威胁,针锋相对地提出反制措施。

那么一次典型的破解应该是什么样的呢?它可能用到的技术如下:

①对代码进行静态的模式匹配(用以发现有关字符串或者库函数的位置);

②对程序的执行模式进行动态模式匹配(用来发现已知算法,比如加密函数,在程序中的位置);

③把程序使用系统库或者其他库中提供的函数的情况与程序内部的代码联系起来(用以找到保护代码在程序中的位置);

④对二进制机器码进行反汇编;

⑤对二进制代码进行反编译,尽管结果是不完整的,有时甚至是错误的;

⑥在得不到源码的情况下,对二进制代码进行调试;

⑦(静态或者动态地)比较同一个程序的两个不同版本(用以发现用户指纹所在的位置);

⑧修改程序执行时的环境变量(让程序使用破解者的动态库,修改动态库的搜索路径,对操作系统进行修改等);

⑨修改二进制可执行文件(去掉程序中破解者不希望有的行为,或者给程序加上新的功能)。

当然,并不是在每个破解案例中都会使用上述的所有技术,各个技术使用的难易程度也不一样。破解者通常都会根据软件所使用的保护技术,选择一些破解技术,由易到难地逐一进行尝试,直到成功或者技穷为止。