前言
在日常分析使用某个软件的过程中,如果我们想要去挖掘软件的漏洞、或者是通过打补丁的方式给软件增添一些新的功能,抑或是为了记录下软件运行过程中被调用的函数及其参数,有时候我们需要劫持对某些DLL库的调用过程。在一般情况下,如果我们是软件的开发者或者该软件提供源码下载,那么刚才提到的问题只要对源码进行一定的修改就可以了,简直是小菜一碟。但是在更多情况下,我们无从获取软件或是库的源码,因为他们根本没有采用源码发行的方式。那这样我们是否就一筹莫展了呢?通过阅读这篇文章,我会告诉你最流行的“API钩子”方法是什么,并且会以略微不同的方式展现给大家。
API钩子
正如上文我们已经提到的,劫持DLL最流行的方法被称作“API钩子”——一种将库函数调用重定向到你的代码的技术。最为流行的API钩子库非微软的
Microsoft Detours (常用于游戏破解)莫属,并且这个商业库被打上的价值标签已经高达9999.95美元(约68999元人民币)。再举一个例子,在Dephi语言中有一个库叫做
madCodeHook,他的商业价值约为349欧元(约2564元人民币)。
下面就让我们来看一看API钩子的具体实现原理。
对于已经加载的DLL库及对应函数,通过在想要钩取的函数头部首字节打上一个补丁(也叫重写,个人认为叫覆盖最为贴切),补丁内容为一个JMP指令,像是
JMP NEAR 这样的形式,转换成16进制就是 E9 xx xx xx xx。如下图所示:
图1:被钩取的函数前后内容示意 当控制权被传递到我们钩取过的函数后,通常这时就可以执行我们自己想要执行的代码了,执行完毕后又会接着运行原函数然后返回到之前从DLL库中调用该函数的代码位置。
API钩子其实会导致一些问题,而问题的来源就在于编译过的软件结构和它本身的代码结构。当我们想要通过钩子本身来调用原函数的时候(通常不加处理情况下会导致一个死循环),我们必须要创建一个特殊的代码区块来调用原函数代码,这个代码区块有个别称叫做“蹦床”(个人觉得在国内更常被称为跳板),这样的话就不用管钩子本身是否在要调用的函数体内了
另外需要说明的是,API钩子技术不是万能的,在受保护的DLL库中几乎不可能实现。说得详细一点就是,比如存在CRC校验保护的时候,无论是从硬盘上还是内存中对库DLL库代码的修改都是不可行的。
还有一点就是,经典的API钩子也不适用于DLL库导出的“伪函数”,这里的伪函数是指导出的变量、类指针等等。因为在这种类型的“函数”条件下我们根本不可能在原函数和我们的代码之间建立一个经典的代码钩子(事实上根本就没有函数可钩取)。那是不是就无可奈何了呢?上面我们提到的方法是改写原函数代码,而下面要介绍的第二种常见方法就是修改PE导出表。只不过这种方法的局限性很大,远不如前一种流行,而且只有很少的一部分钩子库支持它。
DLL转发
一种更加有创意但是也更为麻烦的API钩取方式叫做“DLL”转发,它通过Windows的内部机制来实现,基本原理就是转发DLL调用至其他模块。
DLL转发技术基于“替换表“来实现,所以也被称为“DLL代理”,它可以导出所有的原始库函数,也可以传递所有对库函数的调用——除了我们想要钩取的那部分函数。而函数调用是被通过一些鲜为人知的Windows机制传递给原函数库的,这样我们就可以借此来调用其他库函数,装作他们本来就是存储在我们使用的API钩子库里一样,但事实上这些代码被存储在其他的库中。弄明白以上这些过程,我们也就不难得知为什么要叫做“DLL转发”了。
函数调用惯例 函数调用惯例是一个低等级的用于传递函数参数和处理函数调用返回前的堆栈的方式。很大一部分情况下它取决于编译时的设置,并且在大多数高级编程语言中可以任意选择函数调用的方式,所以两者任取其一均可。为了让我们的API钩子库正常运行,它的钩取函数也必须使用和已经被钩取的函数相同的调用惯例。他们只有在二进制情况下相互兼容才不会引发像堆栈破坏之类的异常。
调用惯例高度依赖于编译器的默认设置,比如Delphi默认采用register调用惯例,C语言默认采用cdecl调用惯例。
WinAPI函数(Windows系统函数)默认使用stdcall调用惯例,所以在调用之前,函数的参数都使用push指令存储在栈中,然后call指令被执行,执行完毕后并没有必要去修正栈指针ESP,因为在stdcall调用惯例中,栈在函数返回前是自动修正的。这里值得一提的是,一个很有趣的现象是WinAPI中的有些函数并不使用stdcall而是C语言的cdecl,cdecl并不将参数存储于栈,但栈的修正会在调用完成后根据函数参数的数量被编译器修正。举一个例子,user32.dll中的一个函数wsprintfA()(它在C函数库中的对应是sprintf())就采用cdecl惯例,这种调用方式是备受推崇的,因为这样除了编译器之外没有人知道究竟传递了多少个参数。
API钩子实例
作为一个例子,我想让它尽量简单易懂一点,只会用到一个测试库BlackBox.dll,它只导出两个函数Sum()和Divide(),想必你已经猜到了,第一个函数的作用是两个数的求和,第二个函数是两个数的除法。让我们假设我们拥有一个完整的库文档,并且清楚地知道这两个函数使用的调用惯例(假设我们有这个库的头文件),而且我们还知道它们各自都使用哪些参数。在其他情况下我们需要使用逆向工程来获得这些底层信息
代码清单1:
6// 该函数将两个数相加并将结果储存于Result变量中 // 成功返回TRUE,失败返回ERROR BOOL __stdcall Sum(int Number1, int Number2, int * Result); // 该函数将两个数相除并将结果储存于Result变量中 // 成功返回TRUE,失败返回ERROR BOOL __stdcall Divide(int Number1, int Number2, int * Result); |
在我们的样例库中,Divide()函数是有bug的,因为如果除0就会导致程序崩溃(假设我们的程序并没有做异常处理),现在我们的目标就是来修补这个漏洞。
代理DLL
为了修补BlackBox.dll中的漏洞,我们接下来需要创建一个中间库,能够使Divide()函数得以有效应用而不出现除0异常。该应用采用FASM编译器(波兰的mr
Tomasza Grysztar 创建)的32位汇编器。在下面你会看到带有精确注释的样例库模板。
代码清单2:样例库的开头 ;
------------------------------------------------- ; DLL 输出文件格式 ;------------------------------------------------- format PE GUI 4.0 DLL ; DLL 入口点函数名 entry DllEntryPoint ; 导入的Windows函数和常数 include '%fasm%\include\win32a.inc' |
注意源代码的开头,你可以在找到输出文件的类型声明,并且在头文件、DLL库的函数入口点也可以放置这些代码
代码清单3:未初始化的数据段 ;
------------------------------------------------- ; 未初始化的数据段 ;------------------------------------------------- section '.bss' readable writeable ; uchwyt HMODULE oryginalnej biblioteki hLibOrgdd ? |
可执行文件和DLL库被分割为一个个独立的部分,他们其中之一是未初始化的数据段,这部分并不占用硬盘的空间,仅仅拥作于记录程序所使用的未初始化变量的整体大小信息。可执行文件的段名称并不重要(它被限制为最多只有8个字符),通常它会被赋以公司合同的名称。在这个段的声明中还会定义访问权限(如读、写、执行),但是在FASM编译器下.bss段的声明还会为变量创建一个未初始化的段。
代码清单4:数据段 ;
------------------------------------------------- ; 初始化的数据段 ;------------------------------------------------- section '.data' data readable writeable ; 原始库的名称 szDllOrgdb 'BlackBox_org.dll',0 |
因为原始库已经有了名称了,所以这里我们重命名一个BlackBox_org.dll(它以ASCII形式存储于源代码中,以null结束),这个库会在后面用到。
代码清单5:带有DLL入口点的代码段 ;
;------------------------------------------------- ; 库的代码段 ;------------------------------------------------- section '.text' code readable executable ;------------------------------------------------- ; DLL库入口点 (DllMain) ;------------------------------------------------- proc DllEntryPoint hinstDLL, fdwReason, lpvReserved moveax,[fdwReason] ; DLL library 加载完毕后立即传递事件 cmpeax,DLL_PROCESS_ATTACH je_dll_attach jmp_dll_exit ; 库已经加载 _dll_attach: ; 获得原始 DLL 库的句柄 ; 如果想要调用原始函数就会使用 pushszDllOrg call[GetModuleHandleA] mov[hLibOrg],eax ; 返回 1 说明库初始化成功 moveax,1 _dll_exit: ret |
代码段包含所有库函数和DLL入口点函数。这是一个特殊的函数,它在库加载以后被Windows系统函数调用。代码段需要被标记上可执行的标记,以此来告诉操作系统这段内存区域包含可以执行的代码段。如果没有这样标记,那么任何想从这块内存区域执行代码的行为都会以触发CPU处理器的DEP(Data
Execution Prevention)内存保护机制而告终。在初始化函数内部(DllMain),接收到
DLL_PROCESS_ATTACH 事件后我们将使用原始DLL库名称来获得他的句柄,也就是 HMODULE
(这样之后就可以被调用了)。
代码清单6:过度优化保护 ;
调用任何原始库 ; BlackBox_org.dll 中的函数, 没有它FASM编译器就会 ; 移除对库的引用并且不会被自动加载 calldummy |
我们自定义的库会调用到原始库,但是如果我们一点引用也不放在源代码中,FASM编译器会移除所有对它的引用(优化)而且原始库并不会被自动加载,这就是为什么在ret指令后直接放了一个伪调用的缘故(这样在任何时候都不会执行)
代码清单7:有效的Divide()函数代码 ;
------------------------------------------------ ; 我们修改后能够处理除0错误的Divide() 函数 ;------------------------------------------------- proc Divide Number1, Number2, Result ; 检查除数是否为0 ; 如果是的话返回ERROR代码 movecx,[Number2] testecx,ecx jeDivisionError ; 将第一个数字载入 EAX 处理器 moveax,[Number1] ;扩展 EDX 寄存器来处理有符号数 cdq ; 现在 EDX:EAX 寄存器对可以处理64位数据了 ; EDX:EAX / ECX 除法的实现, 除法在EDX:EAX寄存器对 ; 上实现,就像对待64位数据一样, 除法的结果保存在EAX ; 寄存器中, 余数保存在EDX 寄存器中 idiv ecx ; 检查有效的指向结果的指针 ; 如果没有检测到则返回error 代码 movedx,[Result] testedx,edx jeDivisionError ; 在受保护的地址存储除法的结果 mov[edx],eax ; 以 exit code TRUE (1) 返回 moveax,1 jmpDivisionExit ; 除法错误,返回FALSE (0) DivisionError: sub eax,eax DivisionExit: ; 从除法函数中返回 ; 布尔型的exit 代码被设置在 EAX 寄存器中 ret endp |
修改后的Divide()函数的实现增添了对除0错误的校验,函数遇到错误会返回错误代码FALSE,另外还额外做了对指向结果变量result的指针非空检查,如果指针指向null也会报错。另外请注意,修改后的函数的调用惯例与原函数是完全一致的,并且在我们的这个例子中使用的是stdcall惯例,所以函数参数被传递到栈中,函数返回值储存于EAX寄存器,栈指针也被FASM编译器自动修复,方法是根据源代码中的ret声明生成ret
(number_of_parameters * 4)指令
代码清单8:库的导入表 ;
------------------------------------------------ ; 我们的库使用的函数段 ;------------------------------------------------- section '.idata' import data readable writeable ; 在代码中用到的库的列表 library kernel,'KERNEL32.DLL',\ blackbox, 'BlackBox_org.dll' ; KERNEL32.dll库的函数列表 importkernel,\ GetModuleHandleA, 'GetModuleHandleA' ; 声明了原始库的用途 ; DLL 库会被自动加载 importblackbox,\ dummy, 'Divide' |
FASM编译器允许我们手动地定义我们自己的库调用到的库和函数,除了标准系统库,我们需要在这里添加一个对
BlackBox.dll 的引用。多亏于此,当Windows加载我们的钩子库的同时也会根据地址空间加载原始库,从而无需再手动调用
LoadLibraryA() 函数来加载它。 在某些情况下想要使用导入表来加载库甚至是强制性要求使用 LoadLibraryA()
的,它需要使用多线程应用程序中TLS(Thread Local Storage)机制的动态链接库来支持。
代码清单9:函数导出表 ;
------------------------------------------------ ; 导出表段包含我们的库中导出的函数 ; 这里我们也许要声明原始库中声明的函数 ;------------------------------------------------- section '.edata' export data readable ; 导出函数列表及其指针 export'BlackBox.dll',\ Sum, 'Sum',\ Divide, 'Divide' ; 转发表名称, 首先目的库被存储 (无需.DLL扩展) ; 然后最终的函数名称被存储 Sum db 'BlackBox_org.Sum',0 |
在这个段中我们必须声明原始库中的所有函数,而且我们想要钩取的函数必须在代码中得以应用,想要传递给原始库的函数存储在一个特殊的文本格式中:
DestinationDllLibrary.FunctionName
或
DestinationDllLibrar y.#1
以此来顺序导入函数而非按照名称的顺序。该机制的所有内部工作均交由Windows系统自身处理。
以上为DLL转发。
代码清单10:重定位部分 ;
------------------------------------------------- ; 重定位部分 ;------------------------------------------------- section '.reloc' fixups data discardable |
我们的库中最后一个段是重定位段,它保证了我们的库能够正常运行。这是因为动态链接库被加载的基地址是非常多变的,而引起这个多变性的原因在于指针使用的绝对地址和汇编器的指令使用的绝对地址必须根据当前内存中的基地址做出更新,而这个基地址的信息正是由编译器在重定位段中生成的。
总结
这篇API钩子介绍的方法可以被成功应用于各种使用动态链接库的场合,较传统的经典API钩子方法而言各有利弊,但是在我看来本文的方法为实践打开了更大的拓展空间,并提供了一种更加简单的改变软件完整功能性的方法。该方法同样可以在高级语言中以适当的导出函数定义文件(DEF)的方式实现。 |