这篇文章学习的是通过注入方式进行API钩取,在前面的这篇文章中提到过,注入方式大致分为两类:DLL注入和代码注入。本次将学习的是通过DLL注入钩取目标进程的IAT表来完成API钩取的操作。
在PE文件的结构中的可选文件头内,有一个数据目录的结构体,即:IMAGE_DATA_DIRECTORY,存储了指向IAT结构的指针。那么如果该PE文件没有加壳,我们就可以通过解析该PE文件本身的二进制数据来找到其IAT结构的具体地址。
而我们知道,一个PE文件中所有引用的外部函数的真实地址都存储在IAT中,程序在运行时如果需要调用某个外部函数,就会在IAT表中查找这个函数的真实地址,从而转到相应的位置上进行操作。
例如如果程序中调用了一个外部函数A,它被加载入内存后的真实地址是:0x77C84590,而它的实际地址被存储在IAT中,其在内存中的位置是:0x01001110,那么该程序实际上的调用指令是:
CALL DWORD PTR[01001110]
结合汇编知识可以知道这条指令实际上CALL的是0x01001110这个地址上存储的数据,即:
CALL 77C84590
那么如果我们将IAT中存储的某个API函数的实际地址更改为我们自己编写的函数地址,那么当程序真正调用这个API函数时我们就可以将程序的执行流劫持到我们自己编写的函数流程中(如果对CTF pwn有所了解的话会发现这个方法与GOT表覆盖的利用手法非常相似)。
在劫持完成后还要根据操作目的对伪造函数的返回操作进行一定的设计。比如只是单纯的修改目标函数的参数的话,就要将程序的执行流返回正常的API函数再执行一次,而如果是想要直接截取这个函数的执行流程的话正常返回即可。
对于API钩取操作来说,重要的操作不只有如何钩取,钩取什么也是一个问题。Windows向用户提供了大量的API函数,经验不足的话是无法直接判断出需要钩取的API函数是什么,所以我们可以借助PE解析工具来查看目标程序的导入函数:
我们本次要完成的操作是将计算器的数字显示从阿拉伯数字更换为汉字,这个操作主要涉及的是界面的显示,所以API函数大概率是属于USER32.dll的,查找一下发现SetWindowTextW正好是完成这个功能的,即设置控件的标题文本。
所以我们本次操作的目标API函数即是SetWindowTextW。
本部分是需要注入到目标进程中的DLL文件,先给出总的源码及注释:
#include "windows.h" #include "tchar.h" #include "stdio.h" typedef BOOL(WINAPI* PFSETWINDOWTEXTW)(HWND hWnd, LPWSTR lpString); //定义SetWindowsTextW的函数指针 FARPROC g_orgFunc = NULL; BOOL WINAPI MySetWindowsTextW(HWND hWnd, LPWSTR lpString) //截取原始SetWindowsTextW的字符串并将其修改 { //wchar_t* cNum = (wchar_t*)L"零一二三四五六七八九"; wchar_t cNum[] = L"零一二三四五六七八九"; wchar_t temp[2] = { 0, }; //temp是一个数组的原因是由于Unicode编码下的一个中文字符使用两个字节存储 int i = 0, len = 0, index = 0; len = wcslen(lpString); for (i = 0; i < len; i++) { if (L'0' <= lpString[i] && lpString[i]<= L'9') { temp[0] = lpString[i]; index = _wtoi(temp); lpString[i] = cNum[index]; } } return ((PFSETWINDOWTEXTW)g_orgFunc)(hWnd, lpString); //将原输入内容修改后返回执行一次正常的WriteFile } BOOL IAT_Hook(LPCSTR DllName, PROC OrgFuncAddr, PROC NewFuncAddr) { HMODULE hModule = NULL; LPCSTR szLibName = NULL; //存储指向导入文件的名称的指针 PIMAGE_IMPORT_DESCRIPTOR pImportDesc; //指向IDT的结构体指针 PIMAGE_THUNK_DATA pThunk; //指向Thunk结构的结构体指针 DWORD dwOldProtect; //用于接受页面先前的保护值 DWORD dwRVA; PBYTE pAddr = NULL; hModule = GetModuleHandle(NULL); //获取加载该DLL的对应进程的句柄 pAddr = (PBYTE)hModule; pAddr += *((DWORD*)&pAddr[0x3C]); //找到IMAGE_NT_HEADER的位置 dwRVA = *((DWORD*)&pAddr[0x80]); //找到IMAGE_DATA_DIRECTORY Import的RVA pImportDesc = (PIMAGE_IMPORT_DESCRIPTOR)((DWORD)hModule + dwRVA); //获取该地址上的PIMAGE_IMPORT_DESCRIPTOR结构体(注意dwRVA是相对偏移,要加上基址) for (; pImportDesc->Name; pImportDesc++) //遍历IDT中各个导入文件 { szLibName = (LPCSTR)((DWORD)hModule + pImportDesc->Name); //获取导入文件(DLL)的名称 if (!_stricmp(DllName, szLibName)) //比较导入文件的名字与目标模块的名字是否一致 { pThunk = (PIMAGE_THUNK_DATA)((DWORD)hModule + pImportDesc->FirstThunk); //找到对应DLL的IAT的地址 for (; pThunk->u1.Function; pThunk++) //遍历IAT表找到目标API函数的地址 { if (pThunk->u1.Function == (DWORD)OrgFuncAddr) { VirtualProtect((LPVOID)&pThunk->u1.Function, 4, PAGE_EXECUTE_READWRITE, &dwOldProtect); //修改IAT相对应API函数地址出的内存为可对可写可执行的权限 pThunk->u1.Function = (DWORD)NewFuncAddr; //修改该位置上API函数的地址为我们自定义的新地址 VirtualProtect((LPVOID)&pThunk->u1.Function, 4, dwOldProtect, &dwOldProtect); //恢复该段内存的权限状态 return TRUE; } } } } return FALSE; } BOOL APIENTRY DllMain( HINSTANCE hModule, DWORD ul_reason_for_call, LPVOID lpReserved ) { switch (ul_reason_for_call) { case DLL_PROCESS_ATTACH: //hook过程 g_orgFunc = GetProcAddress(GetModuleHandle(L"user32.dll"), "SetWindowTextW"); //保存目标API函数的原始地址 IAT_Hook("user32.dll", g_orgFunc, (PROC)MySetWindowsTextW); //钩取对应的API函数并将其修改为MySetWindowsTextW的地址 break; case DLL_PROCESS_DETACH: //unhook过程 IAT_Hook("user32.dll", (PROC)MySetWindowsTextW, g_orgFunc); //卸载DLL后将IAT中的目标API函数地址改为其初始地址 break; } return TRUE; }
程序的大致流程如下:
下面将分别对函数流程中比较重要的部分进行分析
首先来看一下原始的SetWindowsTextW的函数结构:
BOOL SetWindowTextW(
[in] HWND hWnd,
[in, optional] LPCWSTR lpString
);
拥有两个参数:
本次需要完成的操作是将显示的阿拉伯数字更换为汉字,所以我们需要修改需要显示的字符串,也就是lpString这个参数。
下面来看一下实现更改的具体流程:
for (i = 0; i < len; i++) { if (L'0' <= lpString[i] && lpString[i]<= L'9') { temp[0] = lpString[i]; index = _wtoi(temp); lpString[i] = cNum[index]; } }
逻辑非常简单,查询字符具体是哪个阿拉伯数字,再将其作为索引找到前面汉字数组中对应的汉字后完成更改。
这里主要要提一下这个中间数组temp[2],为什么要设置为两个成员的数组呢?
这是由于Windows下的程序大多采用Unicode编码模式,即一个字符用两个字节来存储,所以需要一个两个字节大小的数据结构来进行存储。
这是本次操作中最为重要的一个部分,即完成IAT表钩取的具体操作。首先我们要明确如何在二进制数据的角度下来找到IAT表的具体位置。
首先我们要获取我们这个DLL的在内存中加载后的句柄:
hModule = GetModuleHandle(NULL); //GetModuleHandle的参数传入NULL即是获取当前模块的句柄
在Windows编程中,句柄类似于指针但又不完全与指针相同(指针能够完成的操作比句柄多且使用非常自由,而句柄只能用于完成某些特定的操作),而站在二进制数据的角度,我们需要的是这个模块的具体地址,也就是指向其开头位置的一个指针,所以我们要将这个模块句柄转化为一个具体的指针:
pAddr = (PBYTE)hModule
这个指针的指向的位置就是该模块在内存中开始的位置。
之后可以随便打开一个32位的PE文件观察一下:
可以发现DOS头中指向NT头的数据位置是在0x3C的位置上,那么通过前面找到的模块起始地址就可以找到NT头的地址:
pAddr += *((DWORD*)&pAddr[0x3C]);
注:这里是加上了0x3C上的数据
由于数据目录数组存在于NT头的尾部,而IMAGE_DATA_DIRCTORY Import处于该数组的第二个位置上(一个成员大小为8个字节),那么可以算出其与NT头的相对距离是:160h-80h = 80h
即:
dwRVA = *((DWORD*)&pAddr[0x80]); //找到IMAGE_DATA_DIRECTORY Import的RVA
再将这个dwRVA加上模块的基地址即是IMAGE_IMPORT_DESCRIPTOR结构体所在的具体位置:
pImportDesc = (PIMAGE_IMPORT_DESCRIPTOR)((DWORD)hModule + dwRVA);
这里提一下这个 PIMAGE_IMPORT_DESCRIPTOR,这是指向IMAGE_IMPORT_DESCRIPTOR结构体的指针,而这个结构体就是IDT,它在Windows编程中被解释为一个这样的结构体(代码来源:winnt.h):
typedef struct _IMAGE_IMPORT_DESCRIPTOR { union { DWORD Characteristics; // 0 for terminating null import descriptor DWORD OriginalFirstThunk; // RVA to original unbound IAT (PIMAGE_THUNK_DATA) } DUMMYUNIONNAME; DWORD TimeDateStamp; // 0 if not bound, // -1 if bound, and real date\time stamp // in IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT (new BIND) // O.W. date/time stamp of DLL bound to (Old BIND) DWORD ForwarderChain; // -1 if no forwarders DWORD Name; DWORD FirstThunk; // RVA to IAT (if bound this IAT has actual addresses) } IMAGE_IMPORT_DESCRIPTOR; typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED *PIMAGE_IMPORT_DESCRIPTOR;
其中存储了包括IAT以及模块名字等重要数据。
在找到IDT后,我们只需要遍历其Name成员是否与目标DLL(user32.dll)的Name相同即可找到目标DLL的IDT:
for (; pImportDesc->Name; pImportDesc++) //遍历IDT中各个导入文件 { szLibName = (LPCSTR)((DWORD)hModule + pImportDesc->Name); //获取导入文件(DLL)的名称 if (!_stricmp(DllName, szLibName)) //比较导入文件的名字与目标模块的名字是否一致 { // // // } }
再找目标DLL的IDT后,只需要读取其IMAGE_IMPORT_DESCRIPTOR中的FirstThunk成员即可找到其对应的IAT地址,之后只要遍历每一个Thunk中的数据即可找到目标API函数的真实函数地址,这里的Thunk即是IAT中记录每个导入函数的数据的结构体,它在为winnt.h中被解释为一个结构体:IMAGE_THUNK_DATA32,具体的成员如下:
typedef struct _IMAGE_THUNK_DATA32 { union { DWORD ForwarderString; // PBYTE DWORD Function; // PDWORD DWORD Ordinal; DWORD AddressOfData; // PIMAGE_IMPORT_BY_NAME } u1; } IMAGE_THUNK_DATA32; typedef IMAGE_THUNK_DATA32 * PIMAGE_THUNK_DATA32;
需要比较的成员是:Function,即是该API函数的真实地址数据,操作如下:
pThunk = (PIMAGE_THUNK_DATA)((DWORD)hModule + pImportDesc->FirstThunk); //找到对应DLL的IAT的地址 for (; pThunk->u1.Function; pThunk++) //遍历IAT表找到目标API函数的地址 { if (pThunk->u1.Function == (DWORD)OrgFuncAddr) { VirtualProtect((LPVOID)&pThunk->u1.Function, 4, PAGE_EXECUTE_READWRITE, &dwOldProtect); //修改IAT相对应API函数地址出的内存为可对可写可执行的权限 pThunk->u1.Function = (DWORD)NewFuncAddr; //修改该位置上API函数的地址为我们自定义的新地址 VirtualProtect((LPVOID)&pThunk->u1.Function, 4, dwOldProtect, &dwOldProtect); //恢复该段内存的权限状态 return TRUE; }
其中VirtualProtect函数是用于修改该段内存的读写保护权限,因为要更改其中的数据且要执行,所以必须保证该段内存具有E/R/W的权限,而又因为只需要更改Function这一个成员,所以只需要更改4个字节(DWORD)的内存保护即可。
注:在修改完数据后要将内存的保护权限更改为原来的值
这个函数就是关于DLL文件在载入和卸载过程中需要完成的操作,由于本次操作还要完成DLL卸载后操作,所以在DLL_PROCESS_DETACH事件发生时也会有对应的操作。
由于本次的API钩取只是修改数字的显示,而其他的控件文本要正常显示,所以还要将SetWindowTextW在IAT表中的地址更改回原始地址让其在其他控件的文本操作中能够正常执行,对应操作如下:
BOOL APIENTRY DllMain( HINSTANCE hModule, DWORD ul_reason_for_call, LPVOID lpReserved ) { switch (ul_reason_for_call) { case DLL_PROCESS_ATTACH: //hook过程 g_orgFunc = GetProcAddress(GetModuleHandle(L"user32.dll"), "SetWindowTextW"); //保存目标API函数的原始地址 IAT_Hook("user32.dll", g_orgFunc, (PROC)MySetWindowsTextW); //钩取对应的API函数并将其修改为MySetWindowsTextW的地址 break; case DLL_PROCESS_DETACH: //unhook过程 IAT_Hook("user32.dll", (PROC)MySetWindowsTextW, g_orgFunc); //卸载DLL后将IAT中的目标API函数地址改为其初始地址 break; } return TRUE; }
首先给出完整的源码及注释:
#include "windows.h" #include "tchar.h" #include "winbase.h" #include "stdio.h" #include "tlhelp32.h" BOOL EnableDebugPriv() //提权函数 { HANDLE hToken; LUID Luid; TOKEN_PRIVILEGES tkp; if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &hToken)) { printf("OpenProcessToken failed!\n"); return FALSE; } if (!LookupPrivilegeValue(NULL, SE_DEBUG_NAME, &Luid)) { CloseHandle(hToken); printf("LookupPrivilegeValue failed!\n"); return FALSE; } tkp.PrivilegeCount = 1; tkp.Privileges[0].Luid = Luid; tkp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED; if (!AdjustTokenPrivileges(hToken, FALSE, &tkp, sizeof tkp, NULL, NULL)) { printf("AdjustTokenPrivileges failed!"); CloseHandle(hToken); } else { printf("privilege get!\n"); return TRUE; } } BOOL Inject(DWORD dwPID, LPCTSTR szDllName) //注入DLL { HANDLE hProcess = NULL; HANDLE hThread = NULL; LPVOID pfRemoteBuf = NULL; DWORD dwBufSize = (DWORD)(_tcslen(szDllName) + 1) * sizeof(TCHAR); LPTHREAD_START_ROUTINE pThreadProc; if (!(hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwPID))) { printf("OpenProcess failed!\n"); return FALSE; } pfRemoteBuf = VirtualAllocEx(hProcess, NULL, dwBufSize, MEM_COMMIT, PAGE_READWRITE); //为ThreadProc线程函数的参数分配空间 WriteProcessMemory(hProcess, pfRemoteBuf, (LPVOID)szDllName, dwBufSize, NULL); pThreadProc = (LPTHREAD_START_ROUTINE)GetProcAddress(GetModuleHandle(L"kernel32.dll"), "LoadLibraryW"); //指定远程线程执行的函数操作 hThread = CreateRemoteThread(hProcess, NULL, 0, pThreadProc, pfRemoteBuf, 0, NULL); //创建远程线程并执行 if (hThread) { printf("inject successfully!"); } else { printf("inject failed!"); CloseHandle(hProcess); CloseHandle(hThread); return FALSE; } WaitForSingleObject(hThread, INFINITE); CloseHandle(hProcess); CloseHandle(hThread); return TRUE; } BOOL Eject(DWORD dwPID, LPCTSTR szDllName) { BOOL bMore = FALSE; BOOL bFound = FALSE; HANDLE hSnapshot = NULL; //指向进程映像结构体的指针 HANDLE hProcess = NULL; HANDLE hThread = NULL; MODULEENTRY32 me = { sizeof(MODULEENTRY32) }; //描述模块列表的相关结构体 LPTHREAD_START_ROUTINE pThreadProc; //对应的线程函数 if ((hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, dwPID)) == INVALID_HANDLE_VALUE) //获取进程映像结构体 { printf("Snapshot get failed!\n"); return FALSE; } bMore = Module32First(hSnapshot, &me); for (; bMore; bMore = Module32Next(hSnapshot, &me)) //遍历获取对应DLL模块的模块列表 { if (!_tcsicmp(me.szModule, szDllName) || !_tcsicmp(me.szExePath, szDllName)) { bFound = TRUE; break; } } if (!bFound) { printf("Module SnapShot not found!\n"); CloseHandle(hSnapshot); return FALSE; } if (!(hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwPID))) { printf("OpenProcess failed!\n"); CloseHandle(hSnapshot); return FALSE; } pThreadProc = (LPTHREAD_START_ROUTINE)GetProcAddress(GetModuleHandle(L"kernel32.dll"), "FreeLibrary"); hThread = CreateRemoteThread(hProcess, NULL, 0, pThreadProc, me.modBaseAddr, 0, NULL); //创建远程线程并执行 //在MODULEENTRY32当中,modBaseAddr成员即为该模块在程序中加载的地址,改地址作为FreeLibrary的参数 if (hThread) { printf("Eject successfully!"); } else { printf("Eject failed!"); CloseHandle(hProcess); CloseHandle(hThread); return FALSE; } WaitForSingleObject(hThread, INFINITE); CloseHandle(hSnapshot); CloseHandle(hProcess); CloseHandle(hThread); return TRUE; } int _tmain(int argc, _TCHAR* argv[]) { EnableDebugPriv(); if (!_tcsicmp(argv[1], L"i")) //当命令行输入i时执行注入DLL操作 { Inject((DWORD)_tstoi(argv[2]), argv[3]); } else if (!_tcsicmp(argv[1], L"e")) //当命令行输入e时执行卸载DLL操作 { Eject((DWORD)_tstoi(argv[2]), argv[3]); } return 0; }
这个程序主要执行DLL的注入与卸载操作,程序逻辑也比较简单:
由于提权函数和注入操作函数在以前的文章中已经详细分析过了,这里就不在做赘述,下面主要分析一下DLL卸载函数
由于卸载DLL是注入DLL的镜像操作,所以其实在原理上来说差异是不大的,注入操作通过一个API函数LoadLibrary来完成注入;相应的,卸载操作也是通过一个API函数:FreeLibrary来实现的。这里看一下MSDN关于FreeLibrary的说明:
BOOL FreeLibrary(
[in] HMODULE hLibModule
);
只有一个参数:hLibModule。这里要注意其与LoadLibrary的不同,LoadLibrary的参数一个指向需要加载的DLL的名字的字符串指针,而FreeLibrary的参数是一个指向目标DLL的模块句柄。
那么重点即是如何获取这个DLL的句柄。
这里就需要提一下Windows中的一个映像机制了。Windows编程中的进程可以使用CreateToolhelp32Snapshot函数来为其拍摄快照,以及其使用的线程,堆,模块等相关信息。CreateToolhelp32Snapshot函数在MSDN上查到的说明如下:
HANDLE CreateToolhelp32Snapshot(
[in] DWORD dwFlags,
[in] DWORD th32ProcessID
);
而这个快照信息可以用于遍历搜索该进程所加载使用的各个模块。而在Windows编程中,加载模块的具体数据列表被解释为一个结构体:MODULEENTRY32,它可以在TIHelp32.h中查到如下:
typedef struct tagMODULEENTRY32W { DWORD dwSize; DWORD th32ModuleID; // This module DWORD th32ProcessID; // owning process DWORD GlblcntUsage; // Global usage count on the module DWORD ProccntUsage; // Module usage count in th32ProcessID's context BYTE * modBaseAddr; // Base address of module in th32ProcessID's context DWORD modBaseSize; // Size in bytes of module starting at modBaseAddr HMODULE hModule; // The hModule of this module in th32ProcessID's context WCHAR szModule[MAX_MODULE_NAME32 + 1]; WCHAR szExePath[MAX_PATH]; } MODULEENTRY32W;
其中modBaseAddr即是指向该模块在对应进程虚拟内存中的加载的基地址,这个数据就可以作为FreeLibrary的参数用于从目标进程中卸载DLL了。
注:成员hModule也可以使用,但是直接使用地址指针比使用句柄要好一点(关于句柄与指针前面有提到过),可以避免一些难以解释的bug
所以DLL的卸载操作流程可以总结如下:
获取目标进程的快照:
if ((hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, dwPID)) == INVALID_HANDLE_VALUE) //获取进程映像结构体 { printf("Snapshot get failed!\n"); return FALSE; }
通过快照遍历模块列表找到对应模块的MODULEENTRY32结构体
bMore = Module32First(hSnapshot, &me); for (; bMore; bMore = Module32Next(hSnapshot, &me)) //遍历获取对应DLL模块的模块列表 { if (!_tcsicmp(me.szModule, szDllName) || !_tcsicmp(me.szExePath, szDllName)) { bFound = TRUE; break; } }
通过远程线程注入完成卸载操作
pThreadProc = (LPTHREAD_START_ROUTINE)GetProcAddress(GetModuleHandle(L"kernel32.dll"), "FreeLibrary"); hThread = CreateRemoteThread(hProcess, NULL, 0, pThreadProc, me.modBaseAddr, 0, NULL); //创建远程线程并执行 //在MODULEENTRY32当中,modBaseAddr成员即为该模块在程序中加载的地址,改地址作为FreeLibrary的参数
首先打开计算器程序:
现在是正常的显示为阿拉伯数字,然后在命令提示符中运行注入程序:
之后可以看到计算器中已经显示的是中文数字了:
对应的进程下也出现了相应的DLL文件:
之后再执行注入程序中的卸载操作:
这时候计算器的数值恢复为阿拉伯数字:
这里我们使用x32dbg来调试一下这个注入操作,首先用x32dbg打开计算器程序并设置中断于新模块的载入:
然后F9运行程序至出现计算器的操作窗口:
启动注入程序将DLL文件注入到进程当中,注入后调试器会停止在DLL文件的入口处(注意此处的注入程序要以管理员权限运行,否则会出现进程打开失败的错误):
调试会停止在此处:
这里就是DLL的入口点函数,之后我们需要找到执行IAT钩取的函数IAT_Hook,这里可以通过步进跟随程序流程(因为程序本身比较简单),也可以结合程序用到的字符串查找来找到函数的调用位置如下:
这里可以看见调用了GetModuleHandle和GetProcAddress等参数,符合程序中的流程,并且下面可以看到一个call IAT_Hook的函数调用标记,直接步进即可进入IAT_Hook函数:
这里就是IAT_Hook函数的实际执行部分,向下步进到这个位置:
这里有一组明显的比较以及条件跳转指令,其实就是程序中遍历寻找IAT表中对应API函数地址的操作部分,可以试着自己步进跟踪一下这个流程:
在右侧的寄存器界面也可以看到调试器标记出了一个uer32.dll库中API函数的位置。
接下来步进到这个位置:
发现一条赋值指令:
mov eax, dword ptr ss:[ebp+0x8]
结合其注释可以知道这里就是具体修改IAT表中SetWindowTextW的真实地址为伪造函数MySetWindowTextW的操作部分。
至此IAT_Hook的修改操作结束。