前面的几篇文章对进程注入的几种基本操作进行了学习,接下来会分享的是有关API钩取技术的学习。
钩取,也就是常说的Hooking,是一种截取信息,更改程序执行流向,添加新功能的技术。那么API钩取顾名思义就是一种针对API函数进行的钩取操作。我们知道,在Windows环境下开发的各种引用都大量的使用了Windows系统提供的API(应用程序编程接口),而如果我们可以通过钩取技术截取某些重要API的执行流程,并将其进行修改,就可以完成许多操作。
与以前的那篇文章中提到的消息钩子有所不同,API钩取是在程序的执行流上进行操作,下面简单介绍一下API钩取中的几种常用技术。
API钩取的技术大致分为两类:
调试:通过对目标进程进行调试来钩取API,这里所说的调试并不等同于我们使用OD,x64等调试软件对程序进行调试,而是运行我们自己写的调试程序来进行相应操作
注入:注入法可以细分为两种:DLL注入与代码注入(注入是API钩取的最常用的方法,会在以后的文章中进行学习),这两种方法的具体原理可以参考我的前两篇文章。
本篇文章学习的是使用调试方法进行API钩取,但是这里的调试器与之前所说的调试软件是两种不同的概念。我们使用的OD,x64等调试软件是由别人编写后封装的,可以直接使用的软件,而当我们要使用调试法进行API钩取时,使用的调试器其实是自己编写的程序,在这个程序中,我们会将程序以调试权限附加到目标进程上;由于调试者拥有被调试者所有的权限,包括内存读写甚至是相应寄存器读写等权限。
所以,进行调试的程序可以通过修改内存和寄存器等方式修改被调试程序调用API时使用的参数或者直接截取程序的执行流以完成API钩取的操作。
在开始对源码进行分析之前,先简单了解一下调试器的工作原理:
调试进程经过注册后,每当被挑事者发生调试事件(Debug Event)时,OS 就会暂停其运行,并向调试器报告相应事件。调试器对相应事件做适当处理后,时被调试者继续运行。
在各种异常事件中,断点异常ECXEPTION_BREAKPOINT异常是调试器必须要处理的。那么,如果我们将目标API函数的起始地址更改为断点指令:INT3(对应机器码为0xCC),那么我们就可以将程序的执行流在此处中断,而此时,栈中正好保存了这个API函数所需要的参数等,那么我们结合线程中的上下文结构(context)就可以完成修改API中相应的参数等操作
本次练习操作的对象是钩取notepad中调用的WriteFile函数,然后将notepad保存文件内容中所有的小写字母更改为大写字母,不是一个很复杂的操作,但是可以比较清楚的了解调试钩取的主要流程。
下面先给出总的源代码:
#include "windows.h" #include "stdio.h" LPVOID g_pWriteFile = NULL; CREATE_PROCESS_DEBUG_INFO g_cpdi; BYTE g_chINT3 = 0xCC; BYTE g_orgByte = 0; BOOL CreateProcessDebugEvent(LPDEBUG_EVENT pde) { g_pWriteFile = GetProcAddress(GetModuleHandleA("kernel32.dll"), "WriteFile"); memcpy(&g_cpdi, &pde->u.CreateProcessInfo, sizeof(CREATE_PROCESS_DEBUG_INFO)); ReadProcessMemory(g_cpdi.hProcess, g_pWriteFile, &g_orgByte, sizeof(BYTE), NULL); WriteProcessMemory(g_cpdi.hProcess, g_pWriteFile, &g_chINT3, sizeof(BYTE), NULL); return TRUE; } BOOL ExceprtionDebugEvent(LPDEBUG_EVENT pde) { CONTEXT ctx; PBYTE lpBuffer = NULL; DWORD dwNumofBytestTowrite = 0; DWORD dwAddrOfBuffer = 0; DWORD i = 0; PEXCEPTION_RECORD per = &pde->u.Exception.ExceptionRecord; if (per->ExceptionCode == EXCEPTION_BREAKPOINT) { if (g_pWriteFile == per->ExceptionAddress) { WriteProcessMemory(g_cpdi.hProcess, g_pWriteFile, &g_orgByte, sizeof(BYTE), NULL); ctx.ContextFlags = CONTEXT_CONTROL; GetThreadContext(g_cpdi.hThread, &ctx); ReadProcessMemory(g_cpdi.hProcess, (LPVOID)(ctx.Esp + 0x8), &dwAddrOfBuffer, sizeof(DWORD), NULL); ReadProcessMemory(g_cpdi.hProcess, (LPVOID)(ctx.Esp + 0xC), &dwNumofBytestTowrite, sizeof(DWORD), NULL); lpBuffer = (PBYTE)malloc(dwNumofBytestTowrite + 1); memset(lpBuffer, 0, dwNumofBytestTowrite + 1); ReadProcessMemory(g_cpdi.hProcess, (LPVOID)dwAddrOfBuffer, lpBuffer, dwNumofBytestTowrite, NULL); printf("orignal string: %s\n", lpBuffer); for (i = 0; i < dwNumofBytestTowrite; i++) { if (lpBuffer[i] >= 0x61 && lpBuffer[i] <= 0x7A) { lpBuffer[i] -= 0x20; } } printf("string after changing:%s\n", lpBuffer); WriteProcessMemory(g_cpdi.hProcess, (LPVOID)dwAddrOfBuffer, lpBuffer, dwNumofBytestTowrite, NULL); free(lpBuffer); ctx.Eip = (DWORD)g_pWriteFile; SetThreadContext(g_cpdi.hThread, &ctx); ContinueDebugEvent(pde->dwProcessId, pde->dwThreadId, DBG_CONTINUE); Sleep(0); WriteProcessMemory(g_cpdi.hProcess, g_pWriteFile, &g_chINT3, sizeof(BYTE), NULL); return TRUE; } return FALSE; } } void DebugLoop() { DEBUG_EVENT de; DWORD dwContinueStatus; while (WaitForDebugEvent(&de, INFINITE)) { dwContinueStatus = DBG_CONTINUE; if (de.dwDebugEventCode == CREATE_PROCESS_DEBUG_EVENT) { CreateProcessDebugEvent(&de); } else if (de.dwDebugEventCode == EXCEPTION_DEBUG_EVENT) { if (ExceprtionDebugEvent(&de)) continue; } else if (de.dwDebugEventCode == EXIT_PROCESS_DEBUG_EVENT) { break; } ContinueDebugEvent(de.dwProcessId, de.dwThreadId, dwContinueStatus); } } int main(int argc, char* argv[]) { DWORD dwPID = 0; dwPID = atoi(argv[1]); if (!DebugActiveProcess(dwPID)) { printf("DebugActiveProcess(%d) failed!!!\n""Error Code = %d\n", dwPID, GetLastError()); return -1; } DebugLoop(); return 0; }
程序的流程大概如下:
下面将具体分析比较重要的几个步骤的对应函数
主函数的流程和数据结构都比较简单,这里不在过多赘述,直接看一下代码即可:
#include "windows.h" #include "stdio.h" LPVOID g_pWriteFile = NULL; //记录WriteFile起始地址的指针 CREATE_PROCESS_DEBUG_INFO g_cpdi; //有关进程创建信息数据结构的全局变量 BYTE g_chINT3 = 0xCC; //INT3指令对应的机器码即为0xCC BYTE g_orgByte = 0; int main(int argc, char* argv[]) { DWORD dwPID = 0; dwPID = atoi(argv[1]); //将命令行参数转化为整型 if (!DebugActiveProcess(dwPID)) { printf("DebugActiveProcess(%d) failed!!!\n""Error Code = %d\n", dwPID, GetLastError()); return -1; } DebugLoop(); return 0; }
这个函数完成的操作是当程序进程发生进程创建异常时,将目标API函数的起始地址中的内存数据更改为INT3指令对应的机器码(0xCC),使程序能够在目标API处中断下来。
程序的流程比较简单:
这里提一下CREATE_PROCESS_DEBUG_INFO这个数据结构:
它是可由调试器使用的进程创建信息,它可以在MSDN上查到如下:
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;
其中比较重要的成员就是hProcess(进程句柄),hThread(线程句柄)。在后面的代码中会多次用到这个结构中的成员。
这个部分对应的代码和注释如下:
BOOL CreateProcessDebugEvent(LPDEBUG_EVENT pde) //创建进程的调试事件 { g_pWriteFile = GetProcAddress(GetModuleHandleA("kernel32.dll"), "WriteFile"); //获取WriteFile的真实函数地址 memcpy(&g_cpdi, &pde->u.CreateProcessInfo, sizeof(CREATE_PROCESS_DEBUG_INFO)); //将进程创建的相关信息拷贝全局变量g_cpdi中 ReadProcessMemory(g_cpdi.hProcess, g_pWriteFile, &g_orgByte, sizeof(BYTE), NULL); //将原始的WriteFile函数开始的一个字节记录存储下来,后面复原函数时会用到 WriteProcessMemory(g_cpdi.hProcess, g_pWriteFile, &g_chINT3, sizeof(BYTE), NULL); //将WriteFile函数的最开始一个字节改变为中断指令:INT3 return TRUE; }
这个函数是当程序遇到断点异常(也就是遇到INT3指令)时被中断后进行的操作。
它的主要流程如下:
这个函数的流程比较复杂但在逻辑上是很通顺的,下面简单说一下我在编写这个函数时遇到的一些问题
由于我们进行的是API钩取操作,所以一定会有挂钩与脱钩这一对操作,但是这里有点特殊,是先脱钩在挂钩。这是因为在前面的CreateProcessDebugEvent函数中已经完成了一次挂钩操作,而后面的函数流程中会重新运行目标API函数,如果不先脱钩的话,程序就会一直在API函数的开头被中断下来
在第二步操作中我们需要获取对应线程的上下文结构:CONTEXT,这其实是程序运行时各个寄存器在Windows编程中的数据结构表示,可以在MSDN上查到,这里只看一下比较重要的部分:
DWORD Edi;
DWORD Esi;
DWORD Ebx;
DWORD Edx;
DWORD Ecx;
DWORD Eax;
//
// This section is specified/returned if the
// ContextFlags word contians the flag CONTEXT_CONTROL.
//
DWORD Ebp;
DWORD Eip;
DWORD SegCs; // MUST BE SANITIZED
DWORD EFlags; // MUST BE SANITIZED
DWORD Esp;
DWORD SegSs;
这里可以看见其中包含了EIP,ESP等重要的寄存器值,而这个结构体中的成员数值在经过修改后会作为我们设置线程上下文的参数。
注:这个结构体在64位和32位下的程序差异很大,具体请在VS的WinNT.h中查看
在完成WriteFile写入内容的修改后,需要使这个API函数从开头再执行一次,为了完成这个操作,就需要用到前面说到的上下文结构(CONTEXT)。
我们知道,程序的执行流是由EIP这个寄存器中指向的地址来决定的,那么我们可以将这个地址指向WriteFile函数的起始地址,这样就完成了这个有点像“时空回溯”的线程重启操作:
ctx.Eip = (DWORD)g_pWriteFile;
SetThreadContext(g_cpdi.hThread, &ctx); //SetThreadContext指定CONTEXT存储到指定线程
这里可能还有一个疑问就是:我们设置INT3指令中断的位置不就是WriteFile的起始地址吗,为什么还要修改EIP?
这是由于程序的执行流在遇到INT3指令时是以一个执行的状态来处理的,所以在CPU的角度上等于EIP还是自加了1(INT3指令为1个字节),所以我们要将EIP重新设置
还有就是当我们继续被调试进程时会使用到一个sleep(0)的操作,这个sleep函数表示将线程挂起,括号中的是挂起时间,这里乍一看挂起0秒好像没有什么意义。但是站在操作系统的角度,这个挂起操作等于当前线程主动放弃自己的时间片,那么其他的线程就可以执行了。在这个程序中,dbgHook.exe主动挂起,那么对应的notepad就可以继续执行其WriteFile函数(也就是让其在文件中写入我们修改后的内容),执行完后就会将控制权再次转移给dbgHook.exe,这样后面的代码才可以正常执行。如果没有这个sleep(0),那么dbgHook.exe就会继续执行,而在逻辑上,后面的挂钩操作是要等WriteFile函数执行完在进行的,所以就有可能出现内存写入错误的bug。
Windows编程对于用户自定义的调试器中有一个关于异常记录的数据结构:EXCEPTION_RECORD,在MSDN中可以查到如下:
typedef struct _EXCEPTION_RECORD {
DWORD ExceptionCode;
DWORD ExceptionFlags;
struct _EXCEPTION_RECORD *ExceptionRecord;
PVOID ExceptionAddress;
DWORD NumberParameters;
ULONG_PTR ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS];
} EXCEPTION_RECORD;
其中比较重要的成员就是ExceptionCode,即调试信息中运行至此处对应的异常码。
这个部分的代码即注释如下:
BOOL ExceprtionDebugEvent(LPDEBUG_EVENT pde) //异常触发时的处理函数 { CONTEXT ctx; //用于记录上下文的结构,上下文中包含各种寄存器、程序状态字以及段寄存器的值等 PBYTE lpBuffer = NULL; //用于执行后面的临时缓冲区 DWORD dwNumofBytestTowrite = 0; //用于存储原WriteFile中的NumofBytestTowrite参数 DWORD dwAddrOfBuffer = 0; //用于存储原WriteFile中的lpBuffer参数 DWORD i = 0; PEXCEPTION_RECORD per = &pde->u.Exception.ExceptionRecord; //关于异常记录的结构体,用于记录程序运行中发生的异常 if (per->ExceptionCode == EXCEPTION_BREAKPOINT) //当放生断点异常时(也就是发生INT3中断时) { if (g_pWriteFile == per->ExceptionAddress) //检查发生异常的地址是否与WriteFile的地址一致 { //1.将WriteFile函数结构复原(即复原第一个字节) WriteProcessMemory(g_cpdi.hProcess, g_pWriteFile, &g_orgByte, sizeof(BYTE), NULL); //2.获取线程对应的上下文 ctx.ContextFlags = CONTEXT_CONTROL; GetThreadContext(g_cpdi.hThread, &ctx); //3.获取原始WriteFile函数的第二和第三个参数(也就是:lpBuffer、nNumberOfBytesToWrite) ReadProcessMemory(g_cpdi.hProcess, (LPVOID)(ctx.Esp + 0x8), &dwAddrOfBuffer, sizeof(DWORD), NULL); //第二个参数在ESP+8的位置上 ReadProcessMemory(g_cpdi.hProcess, (LPVOID)(ctx.Esp + 0xC), &dwNumofBytestTowrite, sizeof(DWORD), NULL); //第三个参数在ESP+C的位置上 //4.分配临时缓冲区 lpBuffer = (PBYTE)malloc(dwNumofBytestTowrite + 1); memset(lpBuffer, 0, dwNumofBytestTowrite + 1); //将临时缓冲区用0填充 //5.将WriteFile参数中lpBuffer的内容拷贝进临时缓冲区中 ReadProcessMemory(g_cpdi.hProcess, (LPVOID)dwAddrOfBuffer, lpBuffer, dwNumofBytestTowrite, NULL); printf("orignal string: %s\n", lpBuffer); //6.将小写字母转化为大写字母 for (i = 0; i < dwNumofBytestTowrite; i++) { if (lpBuffer[i] >= 0x61 && lpBuffer[i] <= 0x7A) //用于检查对应字符是否为英文字母 { lpBuffer[i] -= 0x20; //ASCII码中小写字母减去0x20即为对应的大写字母 } } printf("string after changing:%s\n", lpBuffer); //7.将修改后的字符串再复制到WriteFile的读取缓冲区中 WriteProcessMemory(g_cpdi.hProcess, (LPVOID)dwAddrOfBuffer, lpBuffer, dwNumofBytestTowrite, NULL); //8.将临时缓冲区释放掉 free(lpBuffer); //9.将线程的上下文结构中的EIP的数据改为WriteFile函数开始的地址 //当前地址为原WriteFile地址+1的地方,因为执行了INT3的命令,EIP移动了一个字节 ctx.Eip = (DWORD)g_pWriteFile; SetThreadContext(g_cpdi.hThread, &ctx); //设置目标线程的上下文结构 //10.从EIP处开始运行被调试进程 ContinueDebugEvent(pde->dwProcessId, pde->dwThreadId, DBG_CONTINUE); Sleep(0); //这个操作是为了让前面新开始的那个线程优先执行 //11.再次在WriteFile函数中设置INT3中断指令 WriteProcessMemory(g_cpdi.hProcess, g_pWriteFile, &g_chINT3, sizeof(BYTE), NULL); return TRUE; } return FALSE; } }
这个部分即使监视程序运行过程中是否出现对应调试事件并对其进行相应处理的函数
其函数结构主体是一个while循环,即不断等待调试事件的发生,函数流程大致如下:
这个部分的流程比较简单,着重讲一下一个在调试法中比较重要的数据结构:DEBUG_EVENT,这是Windows编程中有关调试信息的数据结构,它被解释为:
typedef struct _DEBUG_EVENT {
DWORD dwDebugEventCode;
DWORD dwProcessId;
DWORD dwThreadId;
union {
EXCEPTION_DEBUG_INFO Exception;
CREATE_THREAD_DEBUG_INFO CreateThread;
CREATE_PROCESS_DEBUG_INFO CreateProcessInfo;
EXIT_THREAD_DEBUG_INFO ExitThread;
EXIT_PROCESS_DEBUG_INFO ExitProcess;
LOAD_DLL_DEBUG_INFO LoadDll;
UNLOAD_DLL_DEBUG_INFO UnloadDll;
OUTPUT_DEBUG_STRING_INFO DebugString;
RIP_INFO RipInfo;
} u;
} DEBUG_EVENT, *LPDEBUG_EVENT;
这些成员都比较重要:
这一部分的代码及注释如下:
void DebugLoop() { DEBUG_EVENT de; //用于接受调试信息的结构体,后面将作为参数传递给调试函数使用 DWORD dwContinueStatus; //等待被调试者发生事件 while (WaitForDebugEvent(&de, INFINITE)) //当事件发生时将调试信息输入到de中 { dwContinueStatus = DBG_CONTINUE; //CREATE_PROCESS_DEBUG_EVENT(也就是进程创建事件会固定出现) if (de.dwDebugEventCode == CREATE_PROCESS_DEBUG_EVENT) //当发生异常时de.dwDebugEventCode会被设置为相应的异常码 { CreateProcessDebugEvent(&de); //执行当进程被创建时对应的函数 } else if (de.dwDebugEventCode == EXCEPTION_DEBUG_EVENT) { if (ExceprtionDebugEvent(&de)) //执行对应异常发生时的函数 continue; } else if (de.dwDebugEventCode == EXIT_PROCESS_DEBUG_EVENT) //当被调试进程终止时跳出循环,结束调试 { break; } ContinueDebugEvent(de.dwProcessId, de.dwThreadId, dwContinueStatus); //在程序主进程退出前继续运行被调试进程 } }
本次测试在XP下进行(win10环境下存在权限问题)。
首先如下图所示输入对应参数:
然后运行API钩取程序,之后在记事本中随意输入一些小写的英文字母:
之后保存这个文件,此时记事本中的内容还不会有变化,但是命令提示符窗口中可以看见程序抓取到了一些信息:
之后再次打开前面保存的文件就会发现字母全部变成大写了:
结束程序后可能会发现程序又抓取到了一些看起来很奇怪的信息,这个是由于Windows下的可执行程序在被编译时,有些段的空白部分会被编译为一串INT3指令,在调试的过程会经常看见。
《逆向工程核心原理》[韩] 李承远