9.2.3 HOOK技术

9.2.3 HOOK技术

“HOOK”在英文中是钩子的意思,在软件破解技术中是一种非常重要的流程控制手段。HOOK技术的一般目标就是通过接管原始程序的某段流程达到一些原始程序设计之外的目的。HOOK技术的一般原理如图9.33所示。

图9.33 HOOK技术的原理

假设有一个从A到B,再到C的简单流程。HOOK技术的一般原理就是:通过在原始流程外添加一个额外的流程(这里为流程D),然后用某种流程控制手段(如JMP指令)在原始流程的某个点上修改原始流程到新添加的流程,以达到在新的流程中接管流程执行的目的。从图9.33中可以看到。当我们接管了原始的流程以后,可以选择是否继续执行流程B,或者直接跳过流程B转而执行流程C,甚至阻止流程的继续执行。这个过程就称为HOOK技术。

在软件破解技术中,要想破解原始程序,大多数情况下必然需要修改原始程序的执行流程,因此,HOOK技术非常重要。下面详细介绍三种HOOK技术,分别是代码HOOK、函数HOOK和模块HOOK技术。

1.代码HOOk

这里要介绍的代码HOOK技术特指x86指令序列中的代码HOOK,结合前面介绍的HOOK技术的一般原理,可以将代码HOOK具体化。x86指令集中的代码HOOK一般是指使用一条或者多条流程控制指令(如利用jmp跳转指令或者push指令与ret指令的组合等)来实现对程序执行流程的控制,进而实现修改代码的原始执行流程到我们自定义的处理代码中的目的。

为了让读者理解代码HOOK的原理和代码HOOK时所面临的问题,这里选择程序内的自我HOOK方式来引导读者,并附带介绍如何编写一个代码HOOK函数,这种函数在自动化破解技术中是必不可少的。

首先,我们构建如下程序代码:

这是一个相当简单的小程序。我们定义一个函数func1(),并直接在程序启动时调用该函数,使用MessageBox A()函数显示一个消息。接下来,编译该程序,并查看其汇编代码,见表9.5。

表9.5 func1()函数汇编代码

表9.5是func1()函数编译后的汇编代码。这里根据实现的难易程度设定目标:我们希望通过代码HOOK技术实现一种效果,使这段代码执行时先弹出另外一个提示消息,再弹出这个消息;我们希望通过代码HOOK技术实现当程序执行时直接弹出另外一个消息,并且不弹出程序原本设定的消息窗口;我们希望在HOOK程序中可以有选择地决定是否弹出原来的提示消息。

根据前面所了解的HOOK原理我们应当明白,若要实现这样的功能,就必须在程序调用MessageBox A()函数之前(假设将MessageBox A()函数看成单一指令)更改程序代码的流程,因此,01071031处以前的任何一条指令都可以成为我们的选择。那么,在哪条指令上入手最为合适呢?这个问题是在许多HOOK情景中都会遇到的问题。其答案是:任何一条指令都可以,只要根据我们要实现的目的来选择即可。但是,如果我们针对的是一个函数的功能,那么在函数的第一条指令(也就是入口)处HOOK是比较恰当的。因此,这里将func1()函数的第一条汇编指令作为代码HOOK的开始地址。

解决了目标和HOOK点的问题后,我们将前面的代码修改为如下代码:

将代码修改为以上内容后,实际上我们就实现了—个非常简单的代码HOOK。如果取消其中的“__asm int 3”语句,这个示例还是能够正常运行的,而且恰好实现了我们认为难度比较高的第二种效果。但这只是一个巧合,实际情况是:上面的HOOK代码是非常不完整的,只不过刚刚实现了原始代码流程的中转而已。下面详细讲解其中的原理。

通过上面的例子不难看出,其核心是hookprocl()函数,在这个函数里我们实现了代码流程中转,这一点通过func1()函数入口汇编代码的变化能够直观体会,如图9.34所示。

图9.34 func1的汇编代码

在hookproc1()函数执行前后,从func1()函数入口处代码指令的变化中可以直观地看出hookproc1()函数的功能。原本跳转到func1()函数主体的指令被修改成了跳转到013E1005处的指令序列,且013E1005处正好是hookedproc()函数的入口。这样,我们就简单实现了流程中转。细心一点的读者可能会问:原来的“jmp func1”指令去哪儿了呢?没错,这就是代码HOOK中遇到的第一个问题——破坏原始代码。

由于我们是通过修改原始代码指令的方式来修改原始代码的执行流程的,因此必然会破坏原始代码的指令序列。尽管看起来我们只是破坏了一点点,但是对程序来说,每条指令都可能是必不可少的。为了保证在HOOK代码以后还能够等价还原或者得到与原始指令代码序列等价的结果,需要保存原始指令修改的相关信息,使我们在必要时可以恢复原始的代码指令,或者至少能够计算出等价的执行结果。为了实现这个目的,一般在HOOK代码之前将被HOOK指令处的一小段代码(代码长度一般取决于HOOK代码影响原始指令的范围)复制到其他地方并保存,然后在需要取消HOOK时将代码恢复到原始状态。因此,可以修改HOOK函数如下:

上面的代码已经使我们能够改变原始指令的流程转而执行hookedproc()函数了。但仔细思考一下,如果我们需要在hookedproc()函数执行后继续执行原有的代码指令,也就是实现后两个目标,还有一定难度。尽管在hookedproc()函数中利用unhookproc1()函数取消了对代码的HOOK,并直接用jmp指令等跳转指令转到了原来的HOOK点上,这从逻辑上看是正确的,但是在实现过程中会遇到以下问题。

首先,如果在hookedproc()函数中取消对代码的HOOK,那么我们将失去代码的后续HOOK权,也就是说,一旦恢复对原始代码的HOOK,就需要重新寻找时机对代码进行HOOK才能再次接管代码的执行流程,这一方面带来了寻找再次HOOK代码时机的问题,另外一方面也带来了遗漏HOOK的问题。因为程序可以是多线程的,那么也许在我们刚刚取消代码HOOK的时候,代码就会被其他线程执行,这样就不可避免地漏掉了某些流程。其次,就是现场破坏问题。从上面的代码中不难看出,在指令流程被中转到hookedproc()函数的过程中,我们没有进行任何保护现场的操作就开始在hookedproc()函数里执行额外的代码,这无疑会破坏原有代码的线程环境。因此,在需要继续执行原始代码指令的情况下,保护代码执行的现场是非常有必要的。

那么,如何保护现场呢?在髙级语言中,要保护线程的上下文现场是很困难的,所以,最简便的方法是嵌入汇编指令。我们可以用如下代码来保护代码现场。

用一个新的函数充当HOOK程序的入口,当原始指令的执行流程转入这个函数以后,我们要做的第一件事就是保护现场,然后调用hookedproc()函数执行额外的工作。当这些工作做完以后,再恢复原本的现场。这样就解决了现场保护的问题。这里用_declspec(naked)属性来修饰该函数,指示编译器在编译该函数的时候去掉函数框架(因为函数框架也会破坏执行环境)。在函数恢复线程的popfd指令后有一个jmp指令,这个指令没有给出目标地址——这就是我们遇到的下一个问题,即HOOK程序的出口问题。因为我们的函数是没有框架的,所以当popfd()函数执行完毕,编译器不会管我们的代码何去何从,我们必须用指令告诉函数,执行完毕后该做些什么。

在代码HOOK中,我们需要小心控制HOOK函数的出口。由于此时的线程上下文是我们HOOK函数入口时的上下文,如果要保证原来的指令继续正确地执行,一般没有其他选择,只能跳转到被HOOK指令的原始指令处。因此,也许可以将指令的目标地址指示为“jmp func1”,让CPU继续执行func1()函数处的代码。但是,新的问题出现了。如果这样做,程序并不会按照我们想象的那样执行,因为func1()函数处的入口代码已经被我们HOOK掉了,如果跳转到func1()函数处,CPU又会中转到HOOK程序中,最后形成一个死循环(即代码重入问题)。我们无法恢复原来的代码指令,这样做将出现前面提到的失去后续控制权的问题。所以,这里将介绍代码HOOK技术的核心——代码移位。

将原始指令序列中被HOOK掉这一小段指令代码搬到内存中的另一个位置,然后从HOOK程序出口处转到这个位置,执行这段代码,从而达到使结果等价的目的,最终执行流程如图9.35所示。

图9.35 HOOK执行流程

也就是说,将代码HOOK点处的一小段代码搬到其他位置就可以避免重新转回代码HOOK点,从而避免代码重入的问题。为了实现这个目的,我们可以构建如下代码:

在上面的代码中遗留了一个代码迁移的问题,该问题涉及代码迁移技术。代码迁移的关键在于一些执行结果与指令自身的内存位置有关的指令(如jmp、jmc系列、call、loop等),因此需要特殊处理,而定位指令的长度又要用到代码的反汇编引擎。上面给出了一段简单的指令迁移代码,适用于大多数指令代码的HOOK。

到此我们就实现了代码HOOK的基本功能。在实际操作过程中,代码HOOK函数还需要处理很多问题,如多线程问题、HOOK多个代码指令的问题,这些问题留给有兴趣的读者自行研究。为了加深印象,这里给出一组HOOKMessageBox A()函数入口代码的实际效果对比图。未HOOK时,MessageBox A()函数的实际代码如图9.36所示。

图9.36 未HOOK的MessageBox A()函数的实际代码

HOOK后,MessageBox A()函数的实际代码如图9.37所示。

图9.37 HOOK后MessageBox A()函数的实际代码

程序流程被中转到了00150000处,如图9.38所示。

图9.38 00150000处实际代码

00150000处是一个跳转代码。这里使用的是笔者私用的HOOK库,所以和上面的代码有一些出入,但是原理大体相同。

接着我们看013D1B2C处的代码,如图9.39所示。

图9.39 013D1B2C处实际代码

013D1B2C处明显转向了函数入口,这里直接给出函数出口代码,如图9.40所示。

图9.40 013D1B2C处实际代码

可以看出,MessageBox A()函数开始处的一小段指令被移植到了00150024处充当入口,这一小段代码执行完毕后直接返回7723FD25处,即MessageBox A()函数的主体代码处,如图9.41所示。

图9.41 7723FD25处实际代码

这样,代码HOOK既可以保证原始代码的正常运行,也可以运行额外的代码,且无须担心代码重入的问题。

尽管到这里我们已经能够轻松实现第一个目标了,但是还不能实现第二个和第三个目标,准确地说,仅使用代码HOOK实现第二个和第三个目标将非常困难,所以,下面介绍如何通过函数HOOK来实现这些目标。

2.函数HOOK

函数HOOK是代码HOOK中的一种特例。由于函数可以被看成一段特殊的代码,其某些特征可以被我们事先了解,所以在HOOK的时候,可以省去代码HOOK那样复杂的保存现场等操作,其原理和实现与代码HOOK并无太大区别,但在使用和效果上会有不同。函数HOOK的核心是充分利用我们事先能够知道函数的结构定义和一般的函数都是一个完整的子过程的假定,因此一般只能在函数的开头HOOK,而且在HOOK的函数中可以调用该函数自身。分析如下代码:

在以上代码中,假定代码HOOK函数为HookCode(),其实现的是函数的HOOK过程,以及我们在代码HOOK当中所设定的第二个和第三个目标。具体过程读者可以自行体会,这里不再阐述。

3.模块HOOK

在介绍模块HOOK之前,我们来简单回顾一下进程模块的概念。

进程模块的含义类似于程序中的库,是指将一组函数或者功能封装到一个单独的程序文件中,并通过某种方式导出这些函数的信息供其他程序调用。在Windows中,一个模块仍旧是一个PE程序文件,只不过在PE头中的相应标志被设置为“DLL”,所以无法单独以进程的方式启动,只能作为进程的一个模块载入。

一般情况下,一个模块可以由程序设计者设计代码手动载入,也可以由编译器设定相关信息通过系统自动载入。无论哪种载入方式,最终都会调用Ldr Load Dll()函数载入模块。

可以看出,每个模块都有一个对应的载入地址,这个载入地址称为模块基址,在进程中类似模块的句柄,对这个模块的操作一般以这个基址作为参数来实现,如获得模块的路径、导出函数信息等。因此,在使用这个基址查找对应模块文件位置的过程中,模块HOOK的意思就是:通过HOOK技术改变这些过程,从而达到我们的目的。例如,可以将Getmodule Handle A(“kernel32.dll”)函数的地址变成我们能够控制的值,而不是模块的真实地址,有时这对分析和破解代码非常有帮助。

因此,模块HOOK是一种完全不同于代码HOOK的技术,其目的不是要改变一段指令序列的执行过程,而是要改变系统和程序在进程模块判定上的过程,从而改变程序对进程模块的判定结果。例如,在一般的程序中,使用Getmodule HandleA(“kernel32”)函数都会得到一个高地址的系统模块基址,如果希望控制这个值,可以通过模块HOOK来实现。

为了理解这个过程,我们从GetmoduleHandleA()函数的原理说起。GetmoduleHandleA()函数是一个取模块基址函数,其原型如下。

HMODULE WINAPI Get Module HandleA(__in_opt LPCSTR Ip ModuleName);

通过传递一个模块名称的参数给函数以获得模块对应的基址,如果进程中没有该函数就返回零。那么,Getmodule HandleA()函数是如何根据模块名称找到模块基址的呢?

原来,进程每次载入或者卸载一个模块的时候,系统就会自动维护一张模块的线性列表,列表里的每一个成员都存放了许多关于这个模块的信息,如模块的基址、入口、大小、文件位置等。这个列表的存放地址可以在进程的PEB(Process Environment Block)数据块中的LoaderData+C处找到,如图9.42所示。

图9.42 PEB数据块示意图

此处的链表006A3648入口就是模块链表的开始,其结构大致如下:

其中,hash Table List表就是系统使用Get Module Handle()函数时查找的表。

我们的目标是通过模块HOOK实现使用Get Module Handle(“kernel32”)函数返回的地址并非kernel32模块的真实地址,而是我们指定的一个值。为了达到此目的,我们设计了如图9.43所示的kernel32 HOOK方法。

图9.43 设计kernel32 HOOK方法

每一个方框代表一个模块的模块信息。由于这是一个链表,因此系统一般都是通过顺序或者倒序遍历此表来查找模块信息的,在Windows中是顺序遍历。所以,我们可以通过在实际需要HOOK模块的前面插入仿制的模块信息,使得系统在查找模块信息的时候首先匹配我们提供的模块信息,这样就实现了我们的目的。但是,由于模块列表对于一个进程的正常运行是非常重要的,而且模块列表在进程加载的早期就已经初始化,因此,我们不能仅仅简单地修改原始模块的信息,这样会导致程序修改前所引用的模块信息失效,进而引发进程崩溃。

例如,一个模块在实行模块HOOK前已经取得kernel32模块的基址10000000,并保存了这个信息,且需要在以后的某个时刻通过这个基址获取模块的相关信息,当这个模块获取信息后,我们修改kernel32模块信息中的基址为其他值(如20000000),此时,对这个模块来说,之前取得的10000000就失效了。所以,通过仿制模块信息的方式就可以使这里的10000000保持有效,但是新的获取模块信息的过程总是会返回我们仿制的模块信息。

为了实现上述目的,我们首先要了解系统维护这个链表的过程。由于篇幅问题,这里直接给出实现代码和系统维护此链表的关键代码区域,有兴趣的读者可以自行研究。系统维护链表的部分关键代码如图9.44所示。

图9.44 系统维护的部分关键代码

如图9.44所示的过程是系统正在初始化模块链表,其中777D020C就是我们前面提及的LoaderData+C处,这是一个很明显的标志。读者可以通过调试器的代码查找功能查找ntdll模块里相似的代码段进行分析,该段代码在Ldr Load Dll中,示例如下:

在上面的代码中,fhGen Mod Name Hash()函数用于生成模块名称的hash值。此函数在不同的平台上功能不同,在NT6以下是一个很草率的版本。该函数代码如下:

通过上面的分析,相信读者可以自己动手实现模块HOOK了。

4.导出表HOOK

因为一个模块是对一组函数或代码的封装,所以它必定会通过某种信息记录该模块中代码或者函数的封装信息,如函数的入口、函数的定义等。在软件破解技术中,我们最关心的是模块中函数的入口地址,因为一旦获得函数的入口地址,就可以通过分析函数代码获取其他关于函数的信息。在PE程序文件头中,有一个专用的结构记录导出函数的相关信息,这个结构就是导出表(Export Table)。读者在PE文件格式文档中会了解该结构详细定义。

导出表HOOK的意思就是,通过某种技术手段实现模仿或者篡改PE程序原始导出表的方式使其他通过该模块导出表获取模块函数信息的代码获取的关于函数信息的数据是受我们控制的。简单地说,就是我们可以通过这些技术手段伪造模块导出函数的入口地址。这种技术的效果就类似于函数HOOK,但是在大量HOOK函数时会出现许多问题,尤其是在保护系统中,一般都会特别小心地检测函数的HOOK,因此导出表HOOK在软件破解技术中是一种非常有用的技术。

导出表HOOK的方式有很多种,其中有最简单的修改PE程序导出表数据的方式,也有虚拟模块等复杂的方式。它们各有优缺点,但是修改PE程序导出表数据的方式最简单且最有效,一般情况下也非常稳定。