查看原文
其他

API 钩取:逆向分析之“花”

Tray_PG 看雪学苑 2022-07-01

本文为看雪论坛优秀文章
看雪论坛作者ID:Tray_PG


跟着李承远的逆向工程核心原理边学边做的,发这个贴子的目的是为了鼓励自己坚持下去,毕竟才刚刚起步。希望大家共同进步。


1


钩取


代码逆向分析中,钩取(Hooking)是一种截取信息、更改程序执行流向、添加新功能的技术。钩取的整个流程如下:
  • 使用反汇编器/调试器把握程序的结构与工作原理。
  • 开发需要的“钩子”代码,用于修改 bug 、改善程序功能。
  • 灵活操作可执行文件与进程内存,设置“钩子”代码。

上述这一系列的工作就是代码逆行分析工程的核心(Core)内容,所以“钩取”被称为“逆向分析之花”。
 
钩取多种多样,其中钩取 Win32 API 的技术被称为 API 钩取。它与消息钩取共同广泛应用于用户模式。API 钩取是一种应用范围非常广泛的技术。


2


API


API(Application Programming Interface,应用程序编程接口)。

Windows OS 中,用户程序要使用系统资源(内存、文件、网络、视频、 音频等)时无法直接访问。这些资源都是由windows OS 直接管理的,出于多种考虑(稳定性、安全、效率等),Windows OS 禁止用户程序直接访问他们。用户需要使用这些资源时,必须向系统内核(Kernel)申请,申请方法就是使用微软提供的 Win32 API(或是其他OS开发公司提供的API)。

也就是说,若没有API函数,则不能创建出任何有意义的应用程序(因为它不能访问进程、线程、内存、文件、网络、注册表、图片、音频以及其他系统资源)。
 
为了运行实际的应用程序代码,需要加载许多系统库(DLL)。所有进程都会默认加载 kernel32.dll库,kernel32.dll又会加载 ntdll.dll库。
 
注:某些特定的系统进程(如:smss.exe)不会加载 kernel32.dll库。此外, GUI 应用程序中,user32.dll 与 gdi32.dll 是必须库。
 
 
假设 notepad.exe 要打开 c:\abc.txt 文件,首先在程序代码中调用 msvcrt!fopen() API ,然后引发一系列的 API 调用,如下:
- msvcrt ! fopen() kernel32 ! CreateFileW() ntdll ! ZwCreateFile() ntdll ! KiFastSystemCall() SYSENTRY // IA-32 Instruction ——> 进入内核模式

如上所示,使用常规系统资源的 API 会经由 kernel32.dll 与 ntdll.dll 不断向下调用,通过 SYSRNTRY 命令进入内核模式。


3


API 钩取


通过 API 钩取技术可以实现对某些 Win32 API 调用过程的拦截,并获得相应的控制权限。使用 API 钩取技术的优势如下:
  • 在 API 调用前/后运行用户的“钩子”代码。
  • 查看或操作传递给 API 的参数或 API 函数的返回时。
  • 取消对 API 的调用,或者更改执行流程,运行用户代码。


正常调用 API


下图描述了正常 API 调用的情形,首先在应用程序代码区域中调用 CreateFile() API ,由于 CreateFile() API 是 kernel32.dll 的导出函数,所以,kernel32.dll 区域中的 CreateFile() API 会被调用执行并正常返回。
 

钩取API调用


下图描述的是 kernel32!CreateFile()调用情形。用户先使用 DLL 注入技术将 hook.dll 注入目标进程的内存空间,然后用 hook!MyCreateFile() 钩取对 kernel32.dll!CreateFile()的调用(有多种方法可以设置钩取函数)。这样,每当目标进程要调用kernel32!CreateFile() API 时都会先调用 hook!MyCreateFile()。
 
钩取某函数的目的有很多,如调用它之前或之后运行用户代码,或者干脆阻止它调用执行等。实际操作中只要根据自身需要灵活运用该技术即可。这也是 API 钩取的基本理念。
 
实现 API 钩取的方法多种多样,但钩取的基本概念是不变的。只要掌握了上面的概念,就能很容易得理解后面得具体实现方法。


4


技术图标


下图是一张技术图标 (Tech Map),涵盖了 API 钩取得所有技术内容。
 
借助这张图表,就能(从技术层面)轻松理解前面学过的有关 API 钩取的内容。钩取 API 时,只要根据具体情况从图标中选择合适的技术即可。
 

对象


首先时关于 API 钩取方法(Method)的分类,根据针对的对象(Object)不同,API 钩取方法大致可以分为静态方法与动态方法。
 
静态方法针对的时“文件”,而动态方法针对的是进程内存,一般 API 钩取技术指动态方法,当然在某些非常特殊的情况下也可以使用静态方法。如下
 
 
注:静态方法在 API 勾取中并不常用。

位置


技术图表中这一栏用来指出实施 API 钩取时应该操作哪部分(通常有三个部分)。
 
IAT
 
IAT 将其内部的 API 地址更改为钩取函数的地址。该方法的优点是实现起来非常简单,缺点是无法勾取不在 IAT 而在程序用使用的 API (如:动态加载并使用 DLL 时)。
 
代码
 
系统库(.dll)映射到进程内存时,从中查找 API 的实际地址,并直接修改代码。该方法应用非常广泛,具体实现中有如下几种选择:
  • 使用 JMP 指令修改起始代码;
  • 覆写函数内部;
  • 仅修改必需部分的局部

EAT
 
将记录在 DLL 的 EAT 中的 API 的起始地址更改为钩取函数地址,也可以实现 API 钩取。这种方法从概念上看非常简单,但在具体实现上不如前面的 Code 方法简单、强大,所以修改 EAT 的这种方法并不常用。


技术


技术图表中的这一栏是向目标进程内存设置钩取函数的具体技术,大致分为调试法与注入法两类:注入法又细分为代码注入与DLL注入两种。
 
调试
 
调试法通过调试目标进程钩取 API 。调试器拥有被挑事者(被调试进程)的所有权限(执行控制、内存访问等),所以可以向被调试进程的内存任意设置钩取函数。
 
这里说的调试器并不是 Olludbg、WinDbg、IDAPro等,而是用户直接编写的、用来钩取的程序。也就是说,在用户编写的程序中使用调试 API 附加到目标进程,然后(执行处于暂停状态)设置钩取函数。这样,重启运行是就能完全实现 API 钩取了。
 
注入
 
注入技术是一种向目标进程内存区域进行渗透的技术,根据注入对象的不同,可以细分为 DLL 注入与代码注入两种,其中 DLL 注入技术应用最为广泛。

DLL 注入
使用 DLL 注入技术可以驱使目标进程强制加载用户指定的 DLL 文件。使用该技术时,先在要注入的 DLL 中创建钩取代码与设置代码,然后在 DllMain()中调用设置代码,注入的同时即可完成 API 钩取。

代码注入

代码注入技术比 DLL 注入技术更发达(更复杂),广泛应用于恶意代码(病毒、Shellcode等)


5


记事本WriteFile() API 钩取


通过钩取记事本的 kernel32.dll!WriteFile() API,使其执行不同动作。

技术图表 - 调试技术


下面是调试方式的 API 钩取技术:
 
 
由于该技术借助“调试”钩取,所以能够进行与用户更具交互性(interactive)的钩取才做。也就是说,这种技术会向用户提供简单的接口,使用户能够控制目标进程的运行,并且可以自由使用金正内存。使用调试钩取技术前,我们先来了解一下调试器的构造。

关于调试器


术语

调试器(Debugger):进行调试的程序被调试器(Debuggee):被调试的程序


调试器功能


调试器用来确认被调试者是否正确运行,发现(未能预料到的)程序错误。调试器能够逐一执行被调试者的命令,拥有对寄存器与内存的所有访问权限。


调试器的工作原理


调试进程经过注册后,每当被挑事者发生调试事件(Debug Event)时,OS 就会暂停其运行,并向调试器报告相应事件。调试器对相应事件做适当处理后,时被调试者继续运行。
  • 一般的异常(Exception)也属于调试事件。
  • 若相应进程处于非调试,调试事件会在其自身的异常处理或 OS 的异常处理机制中被处理掉。
  • 调试器无法处理或不关心的调试事件最终由 OS 处理

下图用来说明调试器工作原理

 

调试事件


各种调试事件整理如下:
EXCEPTION_DEBUG_EVENTCREATE_THREAD_DEBUG_EVENTCREATE_PROCESSDEBUG_EVENTEXIT_THREAD_DEBUG_EVENTEXIT_PROCESS_DEBUG_EVENTLOAD_DLL_DEBUG_EVENTUNLOAD_DLL_DEBUG_EVENTOUTPUT_DEBUG_STRING_EVENTRIP_EVENT

上面列出的调试事件中,与调试相关的时间为EXCEPTION_DEBUG_EVENT,下面是与相关的对应异常列表:
EXCEPTION_ACCESS_VIOLATIONEXCEPTION_ARRAY_BOUNDS_EXCEEDEDEXCEPTION_BREAKPOINTEXCEPTION_DATATYPE_MISALIGNMENTEXCEPTION_FLT_DENORMAL_OPERANDEXCEPTION_ELT_DIVIDE_BY_ZEROEXCEPTION_ELT_INEXACT_RESULTEXCEPTION_ELT_INVALID_OPERATIONEXCEPTION_ELT_OVERFLOWEXCEPTION_ELT_STACK_CHECKEXCEPTION_ELT_UNDERFLOWEXCEPTION_ILLEGAL_INSTRUCTIONEXCEPTION_IN_PAGE_ERROREXCEPTION_INT_DIVIDE_BY_ZEROEXCEPTION_INT_OVERFLOWEXCEPTION_INVALID_DISPOSITIONEXCEPTION_NONCONTINUABLE_EXCEPTIONEXCEPTION_PRIV_INSTRUCTIONEXCEPTION_SINGLE_STEPEXCEPTION_STACK_OOVERFLOW

上面的各种异常中,调试器必须处理的是EXCEPTION_BREAKPOINTER异常。断点对应的汇编指令为 INT3,IA-32 指令为 0xCC 。代码调试遇到 INT3 指令即中断运行,EXCEPTION_BREAKPOINTER异常事件被传送到调试器,测试调试器可做多种处理。
 
调试器实现断点的方法很简单,找到要设置断点的代码,在内存中的起始地址,只要把一个字节修改为 0xCC 就可以了。想继续调试时,再将它恢复原值即可。通过调试钩取 API 的技术就是利用了断点的这种特性。

调试技术的流程


借助调试技术钩取 API 的方法。基本思路时,在“调试器--被调试者”的状态下,将被调试者的 API 起始部分修改为 0xCC,控制权转移到调试器后执行指定操作,最后是被调试者重新进入运行状态。
 
具体流程如下:
  1. 对想钩取的进程进行附加操作,使之成为被调试者;
  2. “钩子”:将 API 起始地址的第一个字节修改为 0xCC;
  3. 调用相应的 API 时,控制权转移到调试器;
  4. 执行需要的操作(操作参数、返回值等);
  5. 脱钩:将 0xCC 恢复原值(为了正常运行 API);
  6. 运行相应的API(无0xCC的正常状态);
  7. “钩子”:再次修改为 0xCC (为了继续钩取);
  8. 控制权返还被调试者。

以上介绍的就是最简单的情形,在此基础上可以有多种变化。即可以不调用原始的 API ,也可以调用用户提供的客户 API ;可以只钩取一次,也可以钩取多次。实际应用时,根据需要适当调整即可。


6


练习


实验目标


钩取 notepad.exe 的 WriteFile() API ,保存文件是操作输入参数,将小写字谜全部转换为大写字母。也就是说,在 Notepad 中保存文件内存时,其中输入的所有小写字母都会先被转换为大写字母,然后再保存。

工作原理


介绍下原理,方便实验的进行。



WriteFile()定义如下:
BOOL WriteFile( HANDLE hFile, LPCVOID lpBuffer, DWORD nNumberofBytesToWrite, LPDWORD lpNumberofBytesWritten, LPOVERLAPPED lpOverlapped );

  • 第一个参数(hFile)

文件或者I/O设备的句柄。

  • 第二个参数(lpBuffer)

为数据缓冲指针,指向包含要写入文件或设备的数据缓冲区的指针。

  • 第三个参数(nNumberOfBytesToWrite)

要写入文件或设备的字节数。

  • 第四个参数(lpNumberofBytesWritten)

一个指向接收使用同步hFile参数时写入的字节数的变量的指针。

  • 第五个参数(lpOverlapped)

如果hFile参数是用FILE_FLAG_OVERLAPPED打开的, 则需要指向OVERLAPPED结构的指针,否则该参数可以为 NULL。
 
顺便提醒一下:函数参数被以逆向形式存储到栈。
 
如下图所示,使用OllyDbg打开 notepad 后,在 Kernel32!WriteFile() API 处设置断点,如下所示:
 
 
按 F9 键运行程序。在记事本中输入文本后,以合适的文件名保存,如下所示:
 
 
在OllyDbg代码窗口可以看见,调式器在 kernel32!WriteFile() 处(设有断点)暂停,然后查看进程栈,如下所示:
 
 
当前栈(ESP:DF97C)中存在一个返回值(01004C30),ESP + 8 (DF984)中存在数据缓冲区的地址(0070DFC8)(如上图),直接跳转到数据缓冲区,可以看到要保存的 notepad 的字符串(“This is a test”),钩取 WriteFile() API 后,用指定的字符串覆盖数据缓冲区中的字符串即可达成所愿。

执行流


在确定应该修改被调试进程内存的位置之后,接下来,只需要正常运行 WritieFile(),将修改后的字符串保存到文件就可以了。
 
下面我们使用调试方法来钩取 API。利用 hook.exe 在 WriteFile() API 起始位置处设置断点(INT3)后,向被调试进程(notepad.exe)保存文件时,EXCEPTION_BREAKPOINR 事件就会传给调试器(hook.exe)。那么此时被调试者(notepa.exe)的 EIP 值是多少呢?
 
乍一看很容易认为是 WriteFile() API 的起始地址(752335B0)。但起始 EIP 的值应该为 WriteFile() API的起始地址(752335B0)+ 1 = 752335B1;
 
原因在于,我们在 WriteFile() API 的起始地址处设置了断点,被调试者(notepad.exe) 内部调用 WriteFile() 时,会在起始地址 752335B0 处遇到 INT3(0xCC)指令。

执行该指令(BreakPoint-INT3)时,EIP的值会增加1个字节(INT3指令的长度)。然后控制权会转移给调式器(hook.exe)(因为在“调式器-被调试器者”关系中,被调试者中发生的 EXCEPTION_BREAKPOINT异常需要由调式器处理。)修改覆写了数据缓冲区的内容后,EIP的值被重新更改为WriteFile() API 的起始地址,继续运行。

“脱钩”&“钩子”


另一个问题是,若只将执行流程返回到 WriteFile() API 起始位置,在遇到的 INT3 指令时,就会陷入无限循环(发生 EXCEPTION_BREAKPOINT)。为了不致于陷入这种境地,应该去除设置在 WriteFile() API 起始地址处的断点.即,将 0xCC 更改为 original byte(0x6A)(original byte 在钩取 API 前已保存)。这一操作称为“脱钩”,就是取消对API的钩取。
 
覆写好数据缓冲区并正常返回 WriteFile() API 代码后,EIP值恢复为 WriteFile() API 的地址,修改后的字符串最终保存到文件。这就是 hook.exe 的工作原理。
 
若只需要钩取一次,到这儿就结束了。但是需要不断钩取,就要再次设置断点。

源代码分析


这一节分析 hook.exe 的源代码。
#include "windows.h"#include "stdio.h" LPVOID g_pfWriteFile = NULL;CREATE_PROCESS_DEBUG_INFO g_cpdi;BYTE g_chINT3 = 0xCC, g_chOrgByte = 0; BOOL OnCreateProcessDebugEvent(LPDEBUG_EVENT pde){ // 获取WriteFile() API 地址 g_pfWriteFile = GetProcAddress(GetModuleHandleA("kernel32.dll"), "WriteFile"); // API Hook - WriteFile() // 更改第一个字节为 0xCC(INT3) // originalbyte 是 g_ch0rgByte 备份 memcpy(&g_cpdi, &pde->u.CreateProcessInfo, sizeof(CREATE_PROCESS_DEBUG_INFO)); ReadProcessMemory(g_cpdi.hProcess, g_pfWriteFile, &g_chOrgByte, sizeof(BYTE), NULL); WriteProcessMemory(g_cpdi.hProcess, g_pfWriteFile, &g_chINT3, sizeof(BYTE), NULL); return TRUE;} BOOL OnExceptionDebugEvent(LPDEBUG_EVENT pde){ CONTEXT ctx; PBYTE lpBuffer = NULL; DWORD dwNumOfBytesToWrite, dwAddrOfBuffer, i; PEXCEPTION_RECORD per = &pde->u.Exception.ExceptionRecord; // 是断点异常(INT3)时 if (EXCEPTION_BREAKPOINT == per->ExceptionCode) { // 断点地址为 WriteFile() API 地址时 if (g_pfWriteFile == per->ExceptionAddress) { // #1. Unhook // 将0xCC 恢复为 original byte WriteProcessMemory(g_cpdi.hProcess, g_pfWriteFile, &g_chOrgByte, sizeof(BYTE), NULL); // #2. 获取线程上下文 ctx.ContextFlags = CONTEXT_CONTROL; GetThreadContext(g_cpdi.hThread, &ctx); // #3. 获取WriteFil() 的 param 2、3 值 // 函数参数存在于相应进程的栈 // param 2 : ESP + 0x8 // param 3 : ESP + 0xC ReadProcessMemory(g_cpdi.hProcess, (LPVOID)(ctx.Esp + 0x8), &dwAddrOfBuffer, sizeof(DWORD), NULL); ReadProcessMemory(g_cpdi.hProcess, (LPVOID)(ctx.Esp + 0xC), &dwNumOfBytesToWrite, sizeof(DWORD), NULL); // #4. 分配领事缓冲区 lpBuffer = (PBYTE)malloc(dwNumOfBytesToWrite + 1); memset(lpBuffer, 0, dwNumOfBytesToWrite + 1); // #5. 复制 WriteFile() 缓冲区到临时缓冲区 ReadProcessMemory(g_cpdi.hProcess, (LPVOID)dwAddrOfBuffer, lpBuffer, dwNumOfBytesToWrite, NULL); printf("\n### original string ###\n%s\n", lpBuffer); // #6. 将小写字母转换为大写字母 for (i = 0; i < dwNumOfBytesToWrite; i++) { if (0x61 <= lpBuffer[i] && lpBuffer[i] <= 0x7A) lpBuffer[i] -= 0x20; } printf("\n### converted string ###\n%s\n", lpBuffer); // #7. 将变换后的缓冲区复制到WriteFile()缓冲区 WriteProcessMemory(g_cpdi.hProcess, (LPVOID)dwAddrOfBuffer, lpBuffer, dwNumOfBytesToWrite, NULL); // #8.释放临时缓冲区 free(lpBuffer); // #9. 将线程上下文的 EIP 更改为 WriteFile()首地址 // 当前为 WriteFile()+1 位置,INT3命令之后 ctx.Eip = (DWORD)g_pfWriteFile; SetThreadContext(g_cpdi.hThread, &ctx); // #10. 运行被调试进程 ContinueDebugEvent(pde->dwProcessId, pde->dwThreadId, DBG_CONTINUE); Sleep(0); // #11. API Hook WriteProcessMemory(g_cpdi.hProcess, g_pfWriteFile, &g_chINT3, sizeof(BYTE), NULL); return TRUE; } } return FALSE;} void DebugLoop(){ DEBUG_EVENT de; //描述调试事件 DWORD dwContinueStatus; // 等待被调试者发生事件 while (WaitForDebugEvent(&de, INFINITE))//等待正在调试的进程中发生调试事件。 { dwContinueStatus = DBG_CONTINUE; // 被调试进程生成或者附加事件 if (CREATE_PROCESS_DEBUG_EVENT == de.dwDebugEventCode) { OnCreateProcessDebugEvent(&de); } // 异常事件 else if (EXCEPTION_DEBUG_EVENT == de.dwDebugEventCode) { if (OnExceptionDebugEvent(&de)) continue; } // 被调试进程终止事件 else if (EXIT_PROCESS_DEBUG_EVENT == de.dwDebugEventCode) { // 被调试者终止---调试器终止 break; } // 再次运行被调试者 ContinueDebugEvent(de.dwProcessId, de.dwThreadId, dwContinueStatus); }} int main(int argc, char* argv[]){ DWORD dwPID; if (argc != 2) { printf("\nUSAGE : hookdbg.exe <pid>\n"); return 1; } // Attach Process dwPID = atoi(argv[1]); if (!DebugActiveProcess(dwPID)) { printf("DebugActiveProcess(%d) failed!!!\n" "Error Code = %d\n", dwPID, GetLastError()); return 1; } // 调试器循环 DebugLoop(); return 0;}


main()

#include "windows.h"#include "stdio.h" LPVOID g_pfWriteFile = NULL;CREATE_PROCESS_DEBUG_INFO g_cpdi;BYTE g_chINT3 = 0xCC, g_ch0rgByte = 0; int main(int agc, char* argv[]){ DWORD dwPID; if( argc !=2 ) { printf("\n USEAGE : %s <PID>\n",argv[0],argv[1]); return 1; } //Attach Process dwPID = atoi(argv[1]); if( !DebugActiveProcess(dwPID)) { printf("DebugActiveProcess(%d) failed !!!\n""Error Code = %d\n",dwPID,GetLastError()); return 1; } //调试器 DebugLoop(); return 0; }

BOOL DebugActiveProcess( DWORD dwProcessId);//使调试器能够附加到活动进程并对其进行调试。 Parameters dwProcessId //要调试的进程的标识符。 //调试器被授予对进程的调试访问权限, //就像它使用DEBUG_ONLY_THIS_PROCESS标志创建进程一样。

main() 函数的代码非常简单,以程序运行参数的形式接受要钩取 API 的进程 PID。然后通过 DebugActiveProcess() API 将调试器附加到该运行的进程上,开始调试(上面输入的 PID 作为参数传入函数)。
 
然后进入DebugLoop()函数,处理来自被调试者的调试信息。
void DebugLoop(){ DEBUG_EVENT de; //描述调试事件 DWORD dwContinueStatus; // 等待被调试者发生事件 while (WaitForDebugEvent(&de, INFINITE))//等待正在调试的进程中发生调试事件。 { dwContinueStatus = DBG_CONTINUE; // 被调试进程生成或者附加事件 if (CREATE_PROCESS_DEBUG_EVENT == de.dwDebugEventCode) { OnCreateProcessDebugEvent(&de); } // 异常事件 else if (EXCEPTION_DEBUG_EVENT == de.dwDebugEventCode) { if (OnExceptionDebugEvent(&de)) continue; } // 被调试进程终止事件 else if (EXIT_PROCESS_DEBUG_EVENT == de.dwDebugEventCode) { // 被调试者终止---调试器终止 break; } // 再次运行被调试者 ContinueDebugEvent(de.dwProcessId, de.dwThreadId, dwContinueStatus); }}
BOOL WaitForDebugEvent( LPDEBUG_EVENT lpDebugEvent, DWORD dwMilliseconds);//等待正在调试的进程中发生调试事件。 Parameters lpDebugEvent 指向接收调试事件信息的DEBUG_EVENT结构的指针 。 dwMilliseconds 等待调试事件的毫秒数。如果此参数为零,则该函数测试调试事件并立即返回。如果参数为 INFINITE,则函数在调试事件发生之前不会返回。
DesbugLoop()函数的工作原理类似窗口过程函数(WndProc),它从被调试者处接收事件并处理,然后使被调试者继续运行。DebugLoop()函数代码比较简单,结合代码中的注释就能理解。
 
DEBUG_EVENT 结构体定义
typedef struct _DEBUG_EVENT{ DWORD dwDebugEventCode; DWORD dwProcessId; DWORD dwThreadId; union{ EXCEPTION_DEBUG_INFO Exception; CREATE_THREAD_DEBUG_INFO CreateThread; CREATE_PROCESS_DEBUG_INFO CreateProcess; EXIT_THREAD_DEBUG_INGO ExitThread; EXIT_PROCESS_DEBUG_INFO ExitProcess; LOAD_DLL_DDEBUG_INFO LoadDll; UNLOAD_DLL_DEBUG_INFO UnloadDll; OUTPUT_DEBUG_STRING_INFO DebugString; RIP_INFO Ripinfo } u;} DEBUG_EVENT,*LPDEBUG_EVENT;

前面提到了共有9种调试事件。DEBUG_EVENT.dwDebugEventCode成员会被设置为九种事件中的一种,根据相关事件的种类,也会设置适当的`DEBUG_EVENT.u(union)成员(DEBUG_EVENT.u共用体成员内部也有九个结构体组成,它们对应事件种类的个数)。
 
提示
 
例如:如果发生异常事件时,dwDebugEventCode 成员会被设置为 EXCEPTION_DEBUG_EVENT , u.Exception 结构体也会得到设置。
 
ContinueDebugEvent() API 是一个被调试者继续运行的函数。
BOOL WINAPI ContinueDebugEvent( DWORD dwProcessId, DWORD dwThreadId, DWORD dwContinueStatus);

ContinueDebugEvent() API 的最后一个参数 dwContinueStatus 的值为 DGBG_CONTINUE 或 DBG_EXCEPTION_NOT_HANDLED 。
 
若处理正常,则其值设置为 DBG_CONTINUE ;若无法处理,或希望在应用程序的 SEH 中 处理,则其值为 DBG_EXCEPTION_NOT_HANDLED 。
 
提示
 
SEH 时 Windows提供的异常处理机制。
 
DebugLoop()函数中处理3中调试事件,如下所示:
  • EXIT_PROCESS_DEBUG_EVENT
  • CREATE_PROCESS_DEBUG_EVENT
  • EXCEPTION_DEBUG_EVENT


EXIT_PPPROCESS_DEBUG_EVENT


被调试进程终止时会触发该事件,本节实例代码中发生该事件时,调试器与被调试器者将一起终止。

CREATE_PROCESS_DEBUG_EVENT-OnCreateProcessDebugEvent()


OnCreateProcessDebugEvent()是 CREATE_PROCESS_DEBUG_EVENT 事件句柄,被调试进程启动(或者附加)时即调用执行该函数。
BOOL OnCreateProcessDebugEvent(LPDEBUG_EVENT pde){ // 获取WriteFile() API 地址 g_pfWriteFile = GetProcAddress(GetModuleHandleA("kernel32.dll"), "WriteFile"); // API Hook - WriteFile() // 更改第一个字节为 0xCC(INT3) // originalbyte 是 g_ch0rgByte 备份 memcpy(&g_cpdi, &pde->u.CreateProcessInfo, sizeof(CREATE_PROCESS_DEBUG_INFO)); ReadProcessMemory(g_cpdi.hProcess, g_pfWriteFile, &g_chOrgByte, sizeof(BYTE), NULL); WriteProcessMemory(g_cpdi.hProcess, g_pfWriteFile, &g_chINT3, sizeof(BYTE), NULL); return TRUE;}

首先获取 WriteFile() API 的起始地址,需要注意,它获取的并不是被调试进程的内存地址,而是调试进程的内存地址。对于 windows OS 的系统 DLL 而言,它们在所有进程中都会加载到相同地址(虚拟内存),所以上面这样做是没有任何问题的。
 
g_cpdi 是 CREATE_PROCESS_DEBUG_INFO 结构体变量。
typedef struct _CREATE_PROCESS_DEBUG_INFO{ HANDLE hFile; HANDLE hProcess; HANDLE hThread; LPVOID lpBaseOfImage; DWORD dwDebugInfoFileOffset; DWORD nDebugInfoSize; LPVOID lpThreadLocalBase; LPTHREAD_START_ROUTINE lpStartAddress; LPVOID lpImageName; WORD fUnicode;} CREATE_PROCESS_DEBUG_INFO, *LPCREATE_PROCESS_DEBUG_INFO;

通过 CREATE_PROCESS_DEBUG_INFO 结构体的 hProcess 成员(被调试进程的句柄),可以钩取 WriteFile() API (不适用调试方法时,可以使用 OpenProcess() API 获取相应进程的句柄)。调试方法中,钩取的方法非常简单。
 
只要在 API 的起始位置好断点即可。由于调试器拥有被调试进程的句柄(带有调式权限),所以可以使用 ReadProcessMemory()、WriteProcessMemry() API 对调试进程的内存空间自由进行读写操作。用上面的函数可以向被调试者设置断点(INT3 0xCC)。通过 ReadProcessMemory() 读取 WriteFile() API 的第一个字节,并将其存储到 g_chOrgByte 变量。
 
g_chOrgByte 变量中存储的是 WriteFile() API 的第一个字节,后面“脱钩”时会用到。然后使用 WriteProcessMemory() API 将 WritFile() API 的第一个字节更改为 0xCC。
 
0xCC 时 IA-32 指令,对应于 INT3 指令,也就是断点。CPU 遇到 INT3 指令时会暂停执行程序,并触发异常 。若相应程序正处于调试中,则控制权转移到调试器,由调试器处理。这也是一般调试器设置断点的原理。
 
这样一来,被调试进程调用 WriteFile() API 时,控制权都会转移给调试器。

EXCEPTION_DEBUG_EVENT-OnExceptionDebugEvent()


OnExceptionDebugEvent()时EXCEPTION_DEBUG_EVENT事件句柄,它处理被调试者的 INT3 指令。
BOOL OnExceptionDebugEvent(LPDEBUG_EVENT pde){ CONTEXT ctx; PBYTE lpBuffer = NULL; DWORD dwNumOfBytesToWrite, dwAddrOfBuffer, i; PEXCEPTION_RECORD per = &pde->u.Exception.ExceptionRecord; // 是断点异常(INT3)时 if (EXCEPTION_BREAKPOINT == per->ExceptionCode) { // 断点地址为 WriteFile() API 地址时 if (g_pfWriteFile == per->ExceptionAddress) { // #1. Unhook // 将0xCC 恢复为 original byte WriteProcessMemory(g_cpdi.hProcess, g_pfWriteFile, &g_chOrgByte, sizeof(BYTE), NULL); // #2. 获取线程上下文 ctx.ContextFlags = CONTEXT_CONTROL; GetThreadContext(g_cpdi.hThread, &ctx); // #3. 获取WriteFil() 的 param 2、3 值 // 函数参数存在于相应进程的栈 // param 2 : ESP + 0x8 // param 3 : ESP + 0xC ReadProcessMemory(g_cpdi.hProcess, (LPVOID)(ctx.Esp + 0x8), &dwAddrOfBuffer, sizeof(DWORD), NULL); ReadProcessMemory(g_cpdi.hProcess, (LPVOID)(ctx.Esp + 0xC), &dwNumOfBytesToWrite, sizeof(DWORD), NULL); // #4. 分配领事缓冲区 lpBuffer = (PBYTE)malloc(dwNumOfBytesToWrite + 1); memset(lpBuffer, 0, dwNumOfBytesToWrite + 1); // #5. 复制 WriteFile() 缓冲区到临时缓冲区 ReadProcessMemory(g_cpdi.hProcess, (LPVOID)dwAddrOfBuffer, lpBuffer, dwNumOfBytesToWrite, NULL); printf("\n### original string ###\n%s\n", lpBuffer); // #6. 将小写字母转换为大写字母 for (i = 0; i < dwNumOfBytesToWrite; i++) { if (0x61 <= lpBuffer[i] && lpBuffer[i] <= 0x7A) lpBuffer[i] -= 0x20; } printf("\n### converted string ###\n%s\n", lpBuffer); // #7. 将变换后的缓冲区复制到WriteFile()缓冲区 WriteProcessMemory(g_cpdi.hProcess, (LPVOID)dwAddrOfBuffer, lpBuffer, dwNumOfBytesToWrite, NULL); // #8.释放临时缓冲区 free(lpBuffer); // #9. 将线程上下文的 EIP 更改为 WriteFile()首地址 // 当前为 WriteFile()+1 位置,INT3命令之后 ctx.Eip = (DWORD)g_pfWriteFile; SetThreadContext(g_cpdi.hThread, &ctx); // #10. 运行被调试进程 ContinueDebugEvent(pde->dwProcessId, pde->dwThreadId, DBG_CONTINUE); Sleep(0); // #11. API Hook WriteProcessMemory(g_cpdi.hProcess, g_pfWriteFile, &g_chINT3, sizeof(BYTE), NULL); return TRUE; } } return FALSE;}

首先,if 语句用于检测异常是否为 EXCEPTION_BREAKPOINT 异常(除此之外,还有大约19中异常)。然后用 if 语句检测发生断点的地址是否与 kernel32.dll!WriteFile() 的起始地址一致(OnCreateProcessDebugEvent()已经实现获取了 WriteFile()的起始地址)。若满足条件,则继续执行以下代码。

“脱钩”(删除 API 钩子)
//将 0xCC 恢复为original byteWriteProcessMemory( g_cpdi, hProcess, g_pfWriteFile,&g_chOrgByte, sizeof(BYTE), NULL)

首先需要“脱钩”(删除 API 钩子),因为在将小写字母转换为大写字母后需要正常调用 WriteFile() 函数。类似“钩子”、“脱钩”的方法也非常简单,只要将0xCC 恢复原值(g_chOrgByte)即可。
 
提示
 
可以根据实际需要取消对相关 API 的调用,也可以调用用户自定义的 MyWriteFile() 函数,所以“脱钩”不是必须的,要根据具体情况灵活选择处理方法。

获取上下文(Thread Context)

这是一次提到“线程上下文”,所有程序在内存中都以进程为单位运行,而进程的实际指令代码以线程为单位运行。Windows OS 是一个多线程(multi-thread)操作系统,统一进程中可以同时运行多个线程。

多任务(multi-tasking)是将 CPU 资源划分为多个时间片(time-slice),然后平等地逐一运行所有线程(考虑线程优先级)。CPU 运行完一个线程的时间片而切换到其他线程时间片时,它必须将先前线程处理的内容准确备份下来,这样再次运行它时才能正常无误。
 
再次运行先前线程时,必须有运行所需信息,这些重要信息指的就是 CPU 中各寄存器的值。通过这些值,才能保证 CPU 能够再次准确运行它(内存信息栈&堆存在于相应进程的虚拟空间,不需要另外保护)。负责保存线程 CPU 寄存器信息的就是 CONTEXT 结构体(每个线程都对应一个 CONTEXT结构体),它的定义如下:
typedef struct _CONTEXT{ DWORD ContextFlags; DWORD Dr0; DWORD Dr1; DWORD Dr2; DWORD Dr3; DWORD Dr6; DWORD Dr7; FLOADTING_SAVE_AREA FloatSave; DWORD SegGs; DWORD SegFs; DWORD SegEs; DWORD SegDs; DWORD Edi; DWORD Esi; DWORD Ebx; DWORD Edx; DWORD Ecx; DWORD Eax; DWORD Ebp; DWORD Eip; DWORD SegCs; DWORD EFlags; DWORD Esp; DWORD SegSs; byte ExtendedRegisters[MAXIMUM_SUPPORTED_EXTENSION];} CONTEXT;

下面是获取线程上下文的代码:
//获取线程上下文ctx.ContextFlags = CONTEXT_CONTROL;GetThreadContext( g_cpdi,hThread, &ctx);

像这样调用 GetThreadContext() API ,即可将指定现线程(g_cpdi.hThread)的 CONTEXT 存储到 ctx 结构体变量(g_cpdi.hThread 是被调试者的注线程句柄):
BOOL WINAPI GetThreadContext( HANDLE hTread, LPCONTEXT lpContext);

获取 WriteFile() 的 param 2、3 值


调用 WriteFile() 函数时,我们要在传递过来的参数中知道 param2(数据缓冲区地址)与 param3(缓冲区大小)这2个参数。函数参数存储在栈中,通过线程上下文获取的 CONTEXT.Esp 成员可以分别获得它们的值。
//函数参数存在与相应进程栈//param 2 : ESP + 0x8//param 3 : ESP + 0xC ReadProcessMemory(g_cpdi.Process,(LPVOID)(ctx.Esp + 0x8), &dwAddrOfBuffer, sizeof(DWORD), NULL); RradProcessMemory(g_cpdi.hProcess,(LPVOID)(ctx.Esp + 0xC), &dwNumOfBytesToWrite, sizeof(DWORD), NULL);

提示
  • 存储在 dwAddrOfBuffer 中的数据缓冲区地址是被调试者(notepad.exe)虚拟内存空间中的地址。
  • param 2 与param 3 分别为 ESP + 0x8、ESP + 0xC。

把小写字母转换为大写字母后覆写 WriteFile() 缓冲区

获取数据缓冲区的地址与大小后,将其内容读到调试器的内存空间,把小写字母转换为大写字母。然后将修改后的大写字母覆写到原来的位置(被调试者的虚拟内存)。代码如下:
//分配临时缓冲区lpBuffer = (PBYTE)malloc(dwNumOfBytestoWrite + 1);memset(lpBuffer, 0, dwNumOfBytesToWrite +1 ); //复制 WriteFile() 缓冲区到临时缓冲区ReadProcessMemory( g_cpdi.hProcess, (LPVOID)dwAddrOfBuffer, lpBuffer. dwNumberOfBytesToWrite, NULL);printf("\n### oriiginal string : %s\n", lpBuffer); //将小写字母转换为大写字母for(i = 0; i < dwNumberOfBytesToWrite; i++){ if( 0x61 <= lpBuffer[i] && lpBuffer[i] <= 0x7A) lpBuffer[i] -= 0x20;}printf("\n### converted string : %s\n", lpBuffer); //将变换后的缓冲区复制到 WriteFile()缓冲区WriteProcessMemory(g_cpdi.hProcess, (LPVOID)dwAddrOfBuffer, lpBuffer, dwNumberOfBytesToWrite, NULL); //释放缓冲区free(lpBuffer);

把线程上下文的 EIP 修改为 WriteFile() 起始地址

将获取的 CONTEXT 结构体的 Eip 成员修改为 WriteFile() 的起始地址。EIP 的当前地址为 WriteFile()+1。
 
修改好 CONTEXT.Eip成员之后,调用 SetThreadContext() API
//当前 WriteFie() + 1 位置,INT3命令之后。ctx.Eip = (DWORD)g_pfWriteFile;SetThreadContext(g_cpdi.hThread, &ctx);

SetThreadContext() API
SetThreadContext( HANDLE hThread, const CONTEXT *lpContext);

运行调试进程

调用 ContinueDebugEvent() API 可以重启被调试的进程,使之继续运行。由于之前已经将CONTEXT.Eip 修改为 WriteFile() 的起始地址,所以会调用执行 WriteFile()。
ContinueDebugEvent(pde -> dwProcessId, pde->dwThreadId, DBG_CONTINUE);sleep(0);

设置 API “钩子”

最后设置 API “钩子”,方便下次钩取操作(若略去该操作,WritteFile() API 钩取将完全处于“脱钩”状态)。
WriteProcessMemory(g_cpdi.hProcess, g_pfWriteFile, &g_chINT3, sizeof(BYTE), NULL);

建议
 
建议在实际的代码调试过程中分别查看各种结构体的值,经过几次调试之后,相信大家都能掌握程序的执行流程。

效果图


启动调试程序并打开记事本输入相关内容:


保存文件,并查看新文件。




 


看雪ID:Tray_PG

https://bbs.pediy.com/user-home-879928.htm

*本文由看雪论坛 Tray_PG 原创,转载请注明来自看雪社区






# 往期推荐

1. 极为详细:双重释放漏洞调试分析

2. 新人PWN入坑总结

3.OD插件 - 支持chm帮助文档

4. Galgame汉化中的逆向:ArmArm64_ELF中汉化字符串超长修改方法

5. FartExt之优化更深主动调用的FART10

6. V8利用初探 2019 StarCTF oob 复现分析



公众号ID:ikanxue
官方微博:看雪安全
商务合作:wsc@kanxue.com



球分享

球点赞

球在看



点击“阅读原文”,了解更多!

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存