逆向工程核心原理-第30章-记事本WriteFile()API钩取
30.1 技术图表(见第29章)-调试技术
由于该技术借助”调试”钩取, 所以能够进行与用户更具交互性(interctive)的钩取操作. 也就是说, 这种技术会像用户提供简单的接口, 使用户能够控制目标进程的运行, 并且可以自由使用进程内存. 使用调试钩取技术前, 先要了解一下调试器的构造.
30.2 关于调试器的说明
30.2.1 术语
- 调试器(Debugger): 进行调试的程序
- 被调试者(Debuggee): 被调试的程序
30.2.2 调试器功能
调试器用来确定被调试者是否真唱运行, 发现程序错误. 调试器能够
30.2.3 调试器工作原理
调试进程经过注册后, 每当被调试者发生调试事件时, OS就会暂停其运行, 并向调试器报告相应事件. 调试器对相应事件做适当处理后, 使被调试者继续运行.
- 一般的异常(Exception)也属于调试事件.
- 若相应进程处于非调试, 调试事件会在其自身的异常处理或OS的异常处理机制中被处理掉.
- 调试器无法处理或不关心的调试事件最终由OS处理.
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_DEBUGSTRING_EVENT
- RIP_EVENT
- 还有是一些其他一场相关的事件, 下面只列举最终的断点异常
- EXCEPTION_BREAKPOINT
上面的异常中, 调试器必须要处理的是EXCEPTION_BREAKPOINT(断点异常).
断点对应的汇编指令是INT3, 指令码为0xCC. 想继续调试时再将它恢复原值即可.
通过调试钩取API的技术就是利用了断点的这种特性
30.3 调试技术流程
调试钩取API的基本思路:
- 建立”调试器——被调试者”的关系
- 将被调试者的API起始部分修改为0xCC
- 控制权转移到调试器后执行指定操作
- 最后使被调试者重新进入运行状态
而具体的调试流程(实现)如下:
- 对想钩取的进程进行附加操作, 使之称为被调试者
- “钩子”: 将API起始地址的第一个字节修改为0xCC
- 调用相应API时, 控制权转移到调试器
- 执行需要的操作(操作参数, 返回值等)
- 脱钩: 将0xCC恢复原值(为了正常运行API)
- 运行相应API(无0xCC的正常状态)
- “钩子”: 再次修改为0xCC(为了继续钩取)
- 控制权返还给被调试者
30.4 练习
下面是示例, 该示例钩取Notepad.exe的WriteFile()API, 保存文件时操作输入参数, 将小写字母全部转换为大写字母.
运行notepad, 获取PID
运行示例文件钩取程序hookdbg.exe, 需要输入两个参数, 第二个参数目标钩取进程的PID
我们再notepad中输入一些文字
然后点击保存文件
我们回到终端观察情况
可以看到小写的输入被转换成了大写的字母, 我们再打开保存文件检验
30.5 工作原理
我们已经知道了要保存文件, 所调用的函数为WriteFile()
接下来使用OD打开notepad.exe, 并再WriteFile()API地址处设下断点
我这里有一个好用的插件就直接用了
30.5.1 栈
WriteFile()定义如下:
1 2 3 4 5 6 7
| BOOL WriteFile( HANDLE hFile, LPCVOID lpBuffer, DWORD nNumberOfBytesToWrite, LPDWORD lpNumberOfBytesWritten, LPOVERLAPPED lpOverlapped );
|
通过调试可以发现我们的lpBuffer所指向的缓冲区中的内容就是我们的输入, 所以我们在调用WriteFile之前修改lpBuffer所指向的内容(将小写字母改成大写字母)即可达成我们的目标
30.5.2 执行流
我们已经知道了需要修改的部分了(lpBuffer所指向的缓冲区内容), 接下来只需要正常运行WriteFile(), 将修改后的字符串保存在文件中就行了.
我们使用的调试法钩取API, 就是通过在WriteFile()API起始地址处设置断点后, 转至调试器进程处理该异常,
需要注意的是: 在调试器进程设置了断点并执行断点后, 此时的EIP为(该段断点的地址 + 1). 因为断点为一个INT3指令, 只要这是指令, 执行后EIP都会增加该指令对应的长度.
在执行断点INT3指令, 会触发断点异常, 控制权会交给调试器, 修改完缓冲区内容后, EIP重新改为WriteFile()API的起始地址, 继续运行.
30.5.3 “脱钩” & “钩子”
如果我们没有脱钩的话, 在我们重新会到WriteFile()API的起始地址时, 又会遇见没有修改的INT3断点指令, 并重复运行, 那程序就永远都在断点停止.
所以我们调试器进程执行完相应的语句后会进行脱钩操作, 即将断点INT3指令(0xCC)的字节恢复成原来的字节值(0x6A).
一个有意思的事: 我们平时使用的调试器OD和IDA设置断点的原理是跟这个一样的, 但是我们每次F9在断点停下的时候IP却仍然在断点的起始位置并没有+1, 这只是为了呈现效果, 实际上EIP是加上了1的.
30.6 源代码分析
首先是Hookdbg.exe的main源码分析, 以下分析我们会持续监视开头定义的三个全局变量(我在看源码的时候老是因为忘记当前全局变量的值是什么而头疼)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| #define _CRT_SECURE_NO_WARNINGS #include "windows.h" #include "stdio.h"
LPVOID g_pfWriteFile = NULL; CREATE_PROCESS_DEBUG_INFO g_cpdi; BYTE g_chINT3 = 0xCC, g_chOrgByte = 0;
int main(int argc, char* argv[]) { DWORD dwPID;
if (argc != 2) { printf("\nUSAGE : hookdbg.exe pid\n"); return 1; }
dwPID = atoi(argv[1]); if (!DebugActiveProcess(dwPID)) { printf("DebugActiveProcss(%d) failed!!!\n" "Error Code = %d\n", dwPID, GetLastError); return 1; }
DebugLoop();
return 0; }
|
main函数的具体流程:
- 检查输入参数个数, 总共有两个一个是程序名, 一个是我们要附加的进程PID
- 通过我们输入的PID使调试器进程附加到目标进程
- 进入DebugLoop()处理调试事件
30.6.2 DebugLoop()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| 此时: g_pfWriteFile = NULL, g_cpdi未定义, g_chINT3 = 0xCC, g_chOrgByte = 0
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); } }
|
大致的作用是while等待调试事件的发生, 如果发生了调试事件, 则通过记录调试事件的结构体de来识别不同的调试事件类型, 并执行相应的语句.
这里对比一下CREATE_PROCESS_DEBUG_INFO结构体跟DEBUG_EVENT结构体的区别
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| 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;
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;
|
我们可以看到DebugLoop()有三种调试事件(三个if)
- EXIT_PROCESS_DEBUG_EVENT(退出进程)
- CREATE_PROCESS_DEBUG_EVENT(创建进程)
- EXCEPTION_DEBUG_EVENT(异常事件)
我们分别讨论
30.6.3 EXIT_PROCESS_DEBUG_EVENT
被调试进程终止时会触发该事件, 执行break, 退出了DebugLoop. 发生该事件时, 调试器与被调试者一起终止.
30.6.4 CREATE_PROCESS_DEBUG_EVENT
在DebugLoop()中可以看到, 触发了该事件就会执行OnCreateProcessDebugEvent()函数.(这种关系成为事件句柄). 当建立起调试器—被调试者关系时就是触发该事件.
我们分析一下 OnCreateProcessDebugEvent()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
|
BOOL OnCreateProcessDebugEvent(LPDEBUG_EVENT pde) { g_pfWriteFile = GetProcAddress(GetModuleHandleA("kernel32.dll"), "WriteFile");
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; }
|
从上面我们知道了在建立了调试器—被调试者关系后, 立即执行了挂钩的操作(即设置断点)
中间我们能对被调试者进程进行内存修改是因为我们拥有其进程句柄(控制权), 而进程句柄又是在de中获取的. 为什么要de中已经有了联合体u我们还需要全局变量g_cpdi. 这是因为其中包含的进程句柄是后面一切操作的基础, 但是de中的u只是一个局部变量, 而且后面还需要接收其他的调试事件这些事件会覆盖掉之前的重要信息, 所以我们需要一个全局变量来保存这些重要的信息.
30.6.5 EXCEPTION_DEBUG_EVENT
OnExceptionDebugEvent()是EXCEPTION_DEBUG_EVENT事件句柄, 它处理的是被调试者的INT3指令.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76
|
BOOL OnExceptionDebugEvent(LPDEBUG_EVENT pde) { CONTEXT ctx; PBYTE lpBuffer = NULL; DWORD dwNumOfBytesToWrite, dwAddrOfBuffer, i; PEXCEPTION_RECORD per = &pde->u.Exception.ExceptionRecord;
if (EXCEPTION_BREAKPOINT == per->ExceptionCode) { if (g_pfWriteFile == per->ExceptionAddress) { WriteProcessMemory(g_cpdi.hProcess, g_pfWriteFile, &g_chOrgByte, 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), &dwNumOfBytesToWrite, sizeof(DWORD), NULL);
lpBuffer = (PBYTE)malloc(dwNumOfBytesToWrite + 1); memset(lpBuffer, 0, dwNumOfBytesToWrite + 1);
ReadProcessMemory(g_cpdi.hProcess, (LPVOID)dwAddrOfBuffer, lpBuffer, dwNumOfBytesToWrite, NULL); printf("\n### original string : %s\n", lpBuffer);
for (i = 0; i < dwNumOfBytesToWrite; i++) { if (0x61 <= lpBuffer[i] && lpBuffer[i] <= 0x7A) lpBuffer[i] -= 0x20; }
printf("\n### converted string : %s\n", lpBuffer); WriteProcessMemory(g_cpdi.hProcess, (LPVOID)dwAddrOfBuffer, lpBuffer, dwNumOfBytesToWrite, NULL); free(lpBuffer);
ctx.Eip = (DWORD)g_pfWriteFile; SetThreadContext(g_cpdi.hThread, &ctx);
ContinueDebugEvent(pde->dwProcessId, pde->dwThreadId, DBG_CONTINUE);
Sleep(0);
WriteProcessMemory(g_cpdi.hProcess, g_pfWriteFile, &g_chINT3, sizeof(BYTE), NULL);
return TRUE; } } }
|
需要反复记忆的重要内容
调试钩取API的基本思路:
- 建立”调试器——被调试者”的关系
- 将被调试者的API起始部分修改为0xCC
- 控制权转移到调试器后执行指定操作
- 最后使被调试者重新进入运行状态
而具体的调试流程(实现)如下:
- 对想钩取的进程进行附加操作, 使之称为被调试者
- “钩子”: 将API起始地址的第一个字节修改为0xCC
- 调用相应API时, 控制权转移到调试器
- 执行需要的操作(操作参数, 返回值等)
- 脱钩: 将0xCC恢复原值(为了正常运行API), 并将PC - 1
- 运行相应API(无0xCC的正常状态)
- “钩子”: 再次修改为0xCC(为了继续钩取)
- 控制权返还给被调试者