第30章 记事本WriteFile() API钩取
本文最后更新于:2022年5月19日 晚上
第30章记事本WriteFile() API钩取
本章将讲解前面介绍过的调试钩取技术。通过钩取记事本的kernel32!WriteFile() API,使其执行不同动作。
30.1 技术图表-调试技术
下面讲解调试方式的API钩取技术(请参考图30-1技术图表中有下划线的部分)。
由于该技术借助“调试”钩取,所以能够进行与用户更具交互性(interctive)的钩取操作。也就是说,这种技术会向用户提供简单的接口,使用户能够控制目标进程的运行,并且可以自由使用进程内存。使用调试钩取技术前,先要了解一下调试器的构造。
30.2 关于调试器的说明
30.2.1 术语
先简单整理一下常用术语。
调试器(Debugger):进行调试的程序
被调试者(Debuggee):被调试的程序
30.2.2 调试器功能
调试器用来确认被调试者是否正确运行,发现(未能预料到的)程序错误。调试器能够逐一执行被调试者的指令,拥有对寄存器与内存的所有访问权限。
30.2.3 调试器的工作原理
调试进程经过注册后,每当被调试者发生调试事件(DebugEvent)时,OS就会暂停其运行,并向调试器报告相应事件。调试器对相应事件做适当处理后,使被调试者继续运行。
□ 一般的异常(Exception)也属于调试事件。
□若相应进程处于非调试,调试事件会在其自身的异常处理或OS的异常处理机制中被处理掉。
□调试器无法处理或不关心的调试事件最终由OS处理。
图30-2形象描述了上述说明。
30.2.4 调试事件
各种调试事件整理如下:
□ EXCEPTION_DEBUG_EVENT
□ CREATE_THREAD_DEBUG_EVENT
□ CREATE PROCESS DEBUG EVENT
□ EXIT_THREAD_DEBUG_EVENT
□ EXIT_PROCESS_DEBUG_EVENT
□ LOAD_DLL_DEBUG_EVENT
□ UNLOAD_DLL_DEBUG_EVENT
□ OUTPUT_DEBUG_STRING_EVENT
□ RIP_EVENT
上面列出的调试事件中,与调试相关的事件为EXCEPTION_DEBUG_EVENT,下面是与其相关的异常列表。
□ EXCEPTION_ACCESS_VIOLATION
□ EXCEPTION_ARRAY_BOUNDS_EXCEEDED
□ EXCEPTION_BREAKPOINT
□ EXCEPTION_DATATYPE_MISALIGNMENT
□ EXCEPTION_FLTDENORMAL_OPERAND
□ EXCEPTION_FLT_DIVIDE_BY_ZERO
□ EXCEPTION_FLT_INEXACT_RESULT
□ EXCEPTION_FLT_INVALID_OPERATION
□ EXCEPTION_FLT_OVERFLOW
□ EXCEPTION_FLT_STACK_CHECK
□ EXCEPTION_FLT_UNDERFLOW
□ EXCEPTION_ILLEGAL_INSTRUCTION
□ EXCEPTION_IN_PAGE_ERROR
□ EXCEPTION_INT_DIVIDE_BY_ZERO
□ EXCEPTIONJNT_OVERFLOW
□ EXCEPTION_INVALID_DISPOSITION
□ EXCEPTION_NONCONTINUABLE_EXCEPTION
□ EXCEPTION_PRIV_INSTRUCTION
□ EXCEPTION_SINGLE_STEP
□ EXCEPTION_STACK_OVERFLOW
上面各种异常中,调试器必须处理的是EXCEPTION_BREAKPOINT异常。断点对应的汇编指令为INT3, IA-32指令为0xCC。代码调试遇到INT3指令即中断运行,EXCEPTION_BREAKPOINT异常事件被传送到调试器,此时调试器可做多种处理。
调试器实现断点的方法非常简单,找到要设置断点的代码在内存中的起始地址,只要把1个 字节修改为0xCC就可以了。想继续调试时,再将它恢复原值即可。通过调试钩取API的技术就是利用了断点的这种特性。
30.3 调试技术流程
下面详细讲解借助调试技术钩取API的方法。基本思路是,在“调试器-被调试者”的状态下,将被调试者的API起始部分修改为0xCC,控制权转移到调试器后执行指定操作,最后使被调试者重新进入运行状态。
具体的调试流程如下:
□对想钩取的进程进行附加操作,使之成为被调试者;
□ “钩子”:将API起始地址的第一个字节修改为0xCC;
□调用相应API时,控制权转移到调试器;
□执行需要的操作(操作参数、返回值等);
□脱钩:将0xCC恢复原值(为了正常运行API);
□运行相应API (无0xCC的正常状态);
□ “钩子”:再次修改为0xCC (为了继续钩取);
□控制权返还被调试者。
以上介绍的是最简单的情形,在此基础上可以有多种变化。既可以不调用原始API,也可以调用用户提供的客户API; 可以只钩取一次,也可以钩取多次。实际应用时,根据需要适当调整即可。
30.4 练习
结合上面学习的内容,我们通过一个示例来练习。该示例钩取Notepad.exe的WriteFile()API,保存文件时操作输入参数,将小写字母全部转换为大写字母。也就是说,在Notepad中保存文件内容时,其中输入的所有小写字母都会先被转换为大写字母,然后再保存。
本示例在Windows XP 32位系统环境下通过测试。
首先运行Notepad.exe,获取其PID,如图30-3所示。
运行示例源文件中的钩取程序(hookdbg.exe)。hookdbg.exe是基于控制台的程序,其运行参数为目标进程的PID,如图所示。
如图30-4所示,运行hookdbg.exe程序后,就开始了对notepad进程(PID为3284)的WriteFile()API的钩取。然后在notepad中随意输入一些英文小写字母,如图30-5所示。
完成输入后,保存输入的文本内容,如图30-6所示。
保存文件后,notepad界面中不会有任何变化(请注意前面只是钩取了WriteFile()API)。关闭notepad,查看hookdbg程序的控制台窗口,如图30-7所示。
从图30-7中可以看到,“original string”中的部分是原来输入的小写字母,“converted string”中的字符是WriteFile() APl钩取之后经过变换得到的字符串(小写字母→大写字母),这是为了显示hookdbg.exe程序内部的钩取过程而输出的字符串。打开保存的test.txt文件,查看实际文本是否以大写字母形式保存,如图30-8所示。
从文本内容可知,原来的小写字母全部被转换为大写字母并保存。虽然这个示例功能非常简单,但它能够很好地说明通过调试进行API钩取的技术。
30.5 工作原理
为帮助大家理解示例,先讲解其工作原理。假设notepad要保存文件中的某些内容时会调用kernel32!WriteFile() API (先确定一下假设是否正确)。
30.5.1 栈
WriteFile()定义(出处:MSDN)如下:
1 |
|
第二个参数(lpBuffer)为数据缓冲区指针,第三个参数(nNumberOfBytesToWrite)为要写的字节数。顺便提醒一下:函数参数被以逆序形式存储到栈。使用OllyDbg工具调试正常运行的notepad,并查看程序栈。
示例中使用的是Windows XP SP3 (32位)中的notepad.exe记事本程序。
如图30-9所示,使用OllyDbg打开notepad后,在Kernel32!WriteFile() API处设置断点,按(F9)键运行程序。在记事本中输入文本后,以合适的文件名保存,如图30-10所示。
在OllyDbg代码窗口中可以看到,调试器在kernel32!WriteFile()处(设有断点)暂停,然后查看进程栈,如图30-11所示。
当前栈(ESP:7FA7C)中存在1个返回值(01004C30),ESP+8 (7FA84)中存在数据缓冲区的地址(0E7310)(参考图30-11中的栈窗口)。直接转到数据缓冲区地址处,可以看到要保存到notepad的字符串(“ReverseCore”)(参考上图中的内存窗口)。钩取WriteFile() API后,用指定字符串覆盖数据缓冲区中的字符串即可达成所愿。
30.5.2 执行流
我们现在已经知道应该修改被调试进程内存的哪一部分了。接下来,只要正常运行WriteFile(),将修改后的字符串保存到文件就可以了。
下面我们使用调试方法来钩取API。利用前面介绍的hookdbg.exe,在WriteFile() API起始地址处设置断点(INT3)后,向被调试进程(notepad.exe)保存文件时,EXCEPTION_BREAKPOINT事件就会传给调试器(hookdbg.exe)。那么,此时被调试者(notepad.exe)的EIP值是多少呢?
乍一想很容易认为是WriteFile() API的起始地址(7C7E0E27)。但其实EIP的值应该为WriteFile()API的起始地址(7C7E0E27)+ 1 = 7C7E0E28。
原因在于,我们在WriteFile() API的起始地址处设置了断点,被调试者(notepad.exe)内部调 用WriteFile()时,会在起始地址7C7E0E27处遇到INT3 (0xCC)指令。执行该指令(Breakpoint - INT3)时,EIP的值会增加1个字节(INT3指令的长度)。然后控制权会转移给调试器(hookdbg.exe) (因为在“调试器-被调试者”关系中,被调试者中发生的EXCEPTION_BREAKPINT异常需要由调试器处理)。修改覆写了数据缓冲区的内容后,EIP值被重新更改为WriteFile()API的起始地址,继续运行。
30.5.3 “脱钩” & “钩子”
另一个问题是,若只将执行流返回到WriteFile()起始地址,再遇到相同的INT3指令时,就会陷入无限循环(发生EXCEPTION_BREAKPOINT)。为了不致于陷入这种境地,应该去除设置在WriteFile() API起始地址处的断点,即将0xCC更改为original byte(0x6A) (original byte在钩取API前已保存)。这一操作称为“脱钩”,就是取消对API的钩取。
覆写好数据缓冲区并正常返回WriteFile() API代码后,EIP值恢复为WriteFile()API的地址,修改后的字符串最终保存到文件。这就是hookdbg.cpp的工作原理。
若只需要钩取1次,那到这儿就结束了。但如果需要不断钩取,就要再次设置断点。只靠说明是理解不了的,下面结合源代码(hookdbg.cpp)详细讲解。
像OllyDbg这类应用范围很广的调试器,EIP值与设置断点的地址是相同的,并不显示INT3 (0xCC)指令,如图30-11所示。这是OllyDbg为了向用户展示更方便的界面而提供的功能。也就是说,覆写了 INT3(0xCC)之后,若执行该命令,则EIP值增1。此时OllyDbg会将0xCC恢复为原来的字节,并调整EIP (最终的实现算法如上所述)。
30.6 源代码分析
本节分析hookdbg.cpp源代码。
30.6.1 main()
1 |
|
main()函数的代码非常简单,以程序运行参数的形式接收要钩取API的进程的PID。然后通过DebugActiveProcess() API(出处:MSDN)将调试器附加到该运行的进程上,开始调试(上面输入的PID作为参数传入函数)。
1 |
|
然后进人DebugLoop()函数,处理来自被调试者的调试事件。
另一种启动调试的方法是使用CreateProcess() API,从一开始就直接以调试模式运行相关进程。更详细的说明请参考MSDN。
30.6.2 DebugLoop()
1 |
|
DebugLoop()函数的工作原理类似于窗口过程函数(WndProc),它从被调试者处接收事件并处理,然后使被调试者继续运行。DebugLoop()函数代码比较简单,结合代码中的注释就能理解。
下面看看其中比较重要的2个API。
顾名思义,WaitForDebugEvent() API (出处: MSDN)是一个等待被调试者发生调试事件的函数(行为动作类似于WaitForSingleObject() API)。
DebugLoop()函数代码中,若发生调试事件,WaitForDebugEvdnt() API就会将相关事件信息设置到其第一个参数的变量(DEBUG_EVENT结构体对象),然后立刻返回。DEBUG_EVENT结构体定义(出处:MSDN)如下所示:
1 |
|
前面的讲解中已经提到过,共有9种调试事件。DEBUG_EVENT.dwDebugEventCode成员会被设置为9种事件中的一种,根据相关事件的种类,也会设置适当的DEBUG_EVENT.u (union)成员(DEBUG_EVENT.u共用体成员内部也由9个结构体组成,它们对应于事件种类的个数)。
例如,发生异常事件时,dwDebugEventCode成员会被设置为EXCEPTION_DEBUG_EVENT, u.Exception结构体也会得到设置。
ContinueDebugEvent() API (出处:MSDN )是一个使被调试者继续运行的函数。
1 |
|
ContinueDebugEvent() API 的最后一个参数 dwContinueStatus 的值为 DBG_CONTINUE或DBG_EXCEPTION_NOTHANDLED。
若处理正常,则其值设置为DBG_CONTINUE;若无法处理,或希望在应用程序的SEH中处理,则其值设置为DBG_EXCEPTION_NOT_HANDLED。
SEH是Windows提供的异常处理机制。关于这种异常处理及反调试技术将在第49章中详细讲解。
代码30-2的DebugLoop()函数处理3种调试事件,如下所示。
□ EXIT_PROCESS_DEBUG_EVENT
□ CREATE_PROCESS_DEBUG_EVENT
□ EXCEPTION_DEBUG_EVENT
下面分别看看这3个事件
30.6.3 EXIT_PROCESS_DEBUG_EVENT
被调试进程终止时会触发该事件。本章的示例代码中发生该事件时,调试器与被调试者将一起终止。
30.6.4 CREATE_PROCESS_DEBUG_EVENT-OnCreateProcessDebugEvent()
OnCreateProcessDebugEvent()是CREATE_PROCESS_DEBUG_EVENT事件句柄,被调试进程启动(或者附加)时即调用执行该函数。
1 |
|
首先获取WriteFile() API的起始地址,需要注意,它获取的不是被调试进程的内存地址,而是调试进程的内存地址。对于Windows OS的系统DLL而言,它们在所有进程中都会加载到相同地址(虚拟内存),所以上面这样做是没有任何问题的。
g_cpdi是CREATE_PROCESS_DEBUG_INFO结构体(出处:MSDN)变量。
1 |
|
通过CREATE_PROCESS_DEBUG_INFO结构体的hProcess成员(被调试进程的句柄),可以钩取WriteFile() API (不使用调试方法时,可以使用OpenProcess() API获取相应进程的句柄)。调试方法中,钩取的方法非常简单。
只要在API的起始位置设置好断点即可。由于调试器拥有被调试进程的句柄(带有调试权限),所以可以使用ReadProcessMemory()、WriteProcessMemory() API对被调试进程的内存空间自由进行读写操作。用上面的函数可以向被调试者设置断点(INT3 0xCC)0通过ReadProcessMemory()读取WriteFile() API的第一个字节,并将其存储到g_chOrgByte变量。如图30-12所示,WriteFile()API的第一个字节为0x6A (Windows XP操作系统)。
g_chOrgByte变量中存储的是WriteFile() API的第一个字节,后面“脱钩”时会用到。然后使用WriteProcessMemory() API将WriteFile() API的第一个字节更改为0xCC (参考图30-13)。
0xCC是IA-32指令,对应于INT3指令,也就是断点。CPU遇到INT3指令时会暂停执行程序,并触发异常。若相应程序正处于调试中,则将控制权转移到调试器,由调试器处理。这也是一般调试器设置断点的基本原理。
这样一来,被调试进程调用WriteFile()API时,控制权都会转移给调试器。
30.6.5 EXCEPTION_DEBUG_EVENT-OnExceptionDebugEvent()
OnExceptionDebugEvent()是EXCEPTION_DEBUG_EVENT事件句柄,它处理被调试者的INT3指令。代码30-4是OnExceptionDebugEvent()函数代码,下面看一下它的核心部分。
1 |
|
OnExceptionDebugEvent()函数代码有些多,接下来分析核心部分。首先,if语句用于检测异常是否为EXCEPTION_BREAKPOINT异常(除此之外,还有大约19种异常,请参考前几节内容)。然后,用if语句检测发生断点的地址是否与kernel32!WriteFile()的起始地址一致(OnCreateProcessDebugEvent()已经事先获取了WriteFile()的起始地址)。若满足条件,则继续执行以下代码。
#1. “脱钩”(删除API钩子)
1 |
|
首先需要“脱钩”(删除API “钩子”),因为在将小写字母转换为大写字母后需要正常调用WriteFile()函数(请参考前面“‘脱钩’ & ‘钩子’”部分中的相关说明)。类似“钩子”、“脱钩”的方法也非常简单,只要将0xCC恢复原值(g_chOrgByte)即可。
可以根据实际需要取消对相关API的调用,也可以调用用户自定义的MyWriteFile()函数,所以“脱钩”过程不是必须的。使用时,要根据具体情况灵活选择处理方法。
#2.获取线程上下文(Thread Context)
这是第一次提到“线程上下文”,所有程序在内存中都以进程为单位运行,而进程的实际指令代码以线程为单位运行。Windows OS是一个多线程(multi-thread)操作系统,同一进程中可以同时运行多个线程。多任务(multi-tasking)是将CPU资源划分为多个时间片(time-slice),然后平等地逐一运行所有线程(考虑线程优先级)。CPU运行完一个线程的时间片而切换到其他线程时间片时,它必须将先前线程处理的内容准确备份下来,这样再次运行它时才能正常无误。
再次运行先前线程时,必须有运行所需信息,这些重要信息指的就是CPU中各寄存器的值。通过这些值,才能保证CPU能够再次准确运行它(内存信息栈&堆存在于相应进程的虚拟空间,不需要另外保护)。负责保存线程CPU寄存器信息的就是CONTEXT结构体(每个线程都对应一个CONTEXT结构体),它的定义如下(出处:MS VC++: winnt.h):
1 |
|
下面是获取线程上下文的代码。
1 |
|
像这样调用GetThreadContext() API (出处:MSDN),即可将指定线程(g_cpdi.hThread)的CONTEXT存储到ctx结构体变量(g_cpdi.hThread是被调试者的主线程句柄)。
1 |
|
#3.获取WriteFile()的param 2、3值
调用WriteFile()函数时,我们要在传递过来的参数中知道param2(数据缓冲区地址)与param3 (缓冲区大小)这2个参数。函数参数存储在栈中,通过#2中获取的CONTEXT.Esp成员可以分别获得它们的值。
1 |
|
存储在dwAddrOfBuffer中的数据缓冲区地址是被调试者(notepad.exe)虚拟内
存空间中的地址。param2与param3分别为ESP+0x8、ESP+0xC,原因请参考第7章。
#4.~#8. 把小写字母转换为大写字母后覆写WriteFile()缓冲区
获取数据缓冲区的地址与大小后,将其内容读到调试器的内存空间,把小写字母转换为大写字母。然后将修改后的大写字母覆写到原位置(被调试者的虚拟内存)。整个代码不难,结合代码中的注释就能轻松理解。
1 |
|
#9.把线程上下文的EIP修改为WriteFile()起始地址
下面将#2中获取的CONTEXT结构体的Eip成员修改为WriteFile()的起始地址。EIP的当前地址为WriteFile()+1 (参考前面“#执行流”中的说明)。
修改好CONTEXT.Eip成员后,调用SetThreadContext() API。
1 |
|
下面是SetThreadContext()API (出处:MSDN):
1 |
|
#10.运行调试进程
一切准备就绪后,接下来就要正常调用WriteFile() API了。调用ContinueDebugEvent() API可以重启被调试进程,使之继续运行。由于在#9已经将CONTEXT.Eip修改为WriteFile()的起始地址,所以会调用执行WriteFile()。
1 |
|
Sleep(0)有什么用?
首先用源代码运行测试,然后对Sleep(0)语句进行注释处理,再次运行测试(请在notepad中输入文本后,快速反复保存)。比较2种测试结果,并思考有什么不同,以及产生不同的原因。(答案见最后)
#11.设置API “钩子”
最后设置API “钩子”,方便下次钩取操作(若略去该操作,由于#1中已经“脱钩”,WriteFile()API钩取将完全处于“脱钩”状态)。
1 |
|
DebugLoop()函数的讲解到此结束。建议大家在实际的代码调试过程中分别查看各结构体的值,经过几次调试后,相信大家都能掌握程序的执行流程。
从Windows XP开始就可以调用DebugSetProcessKillOnExit()函数了,我们可以不销毁被调试进程就退出(detach)调试器。需要注意的是,必须在调试器终止前“脱钩”。否则,调用API时就会因为其起始部分仍为0xCC而导致EXCEPTION BREAKPOINT异常。由于此时不存在调试器,所以终止被调试进程。
关于调试器工作原理与异常的内容请参考第48章
Q.在OnExceptionDebugEvent()函数中调用了ContinueDebugEvent()函数后,为什么还要调用Sleep(0)函数?
A.调用Sleep(0)函数可以释放当前线程的剩余时间片,即放弃当前线程执行的CPU时间片也就是说,调用Sleep(0)函数后,CPU会立即执行其他线程。被调试进程(Notepad.exe)的主线程处于运行状态时,会正常调用WriteFile() API。然后经过一定时间,控制权再次转移给HookDbg.exe,Sleep(0)后面的“钩子”代码(WriteProcessMemory() API)会被调用执行。若没有Sleep(0)语句,Notepad.exe调用 WriteFile() API的过程中,HookDbg.exe会尝试将WriteFile()API的首字节修改为0xCC。若运气不佳,这可能会导致内存访问异常。
参考
《逆向工程核心原理》 第30章