第30章 记事本WriteFile() API钩取

本文最后更新于:2022年5月19日 晚上

第30章记事本WriteFile() API钩取
本章将讲解前面介绍过的调试钩取技术。通过钩取记事本的kernel32!WriteFile() API,使其执行不同动作。

30.1 技术图表-调试技术

下面讲解调试方式的API钩取技术(请参考图30-1技术图表中有下划线的部分)。

图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 调试器工作原理

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所示。

图30-3 Process Explorer

运行示例源文件中的钩取程序(hookdbg.exe)。hookdbg.exe是基于控制台的程序,其运行参数为目标进程的PID,如图所示。

如图30-4所示,运行hookdbg.exe程序后,就开始了对notepad进程(PID为3284)的WriteFile()API的钩取。然后在notepad中随意输入一些英文小写字母,如图30-5所示。

图30-4 运行hookdbg.exe

图30-5 在notepad中输入小写字母

完成输入后,保存输入的文本内容,如图30-6所示。

图30-6 保存文件

保存文件后,notepad界面中不会有任何变化(请注意前面只是钩取了WriteFile()API)。关闭notepad,查看hookdbg程序的控制台窗口,如图30-7所示。

图30-7 hookdbg.exe运行结果

从图30-7中可以看到,“original string”中的部分是原来输入的小写字母,“converted string”中的字符是WriteFile() APl钩取之后经过变换得到的字符串(小写字母→大写字母),这是为了显示hookdbg.exe程序内部的钩取过程而输出的字符串。打开保存的test.txt文件,查看实际文本是否以大写字母形式保存,如图30-8所示。

图30-8 test.txt文件内容

从文本内容可知,原来的小写字母全部被转换为大写字母并保存。虽然这个示例功能非常简单,但它能够很好地说明通过调试进行API钩取的技术。

30.5 工作原理

为帮助大家理解示例,先讲解其工作原理。假设notepad要保存文件中的某些内容时会调用kernel32!WriteFile() API (先确定一下假设是否正确)。

30.5.1 栈

WriteFile()定义(出处:MSDN)如下:

1
2
3
4
5
6
7
BOOL WriteFile(
[in] HANDLE hFile,
[in] LPCVOID lpBuffer,
[in] DWORD nNumberOfBytesToWrite,
[out, optional] LPDWORD lpNumberOfBytesWritten,
[in, out, optional] LPOVERLAPPED lpOverlapped
);

第二个参数(lpBuffer)为数据缓冲区指针,第三个参数(nNumberOfBytesToWrite)为要写的字节数。顺便提醒一下:函数参数被以逆序形式存储到栈。使用OllyDbg工具调试正常运行的notepad,并查看程序栈。

示例中使用的是Windows XP SP3 (32位)中的notepad.exe记事本程序。

如图30-9所示,使用OllyDbg打开notepad后,在Kernel32!WriteFile() API处设置断点,按(F9)键运行程序。在记事本中输入文本后,以合适的文件名保存,如图30-10所示。

图30-9 kernel32.WriteFile()

图30-10 在notepad中输入文本

在OllyDbg代码窗口中可以看到,调试器在kernel32!WriteFile()处(设有断点)暂停,然后查看进程栈,如图30-11所示。

图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
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
#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;
}

// 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()函数的代码非常简单,以程序运行参数的形式接收要钩取API的进程的PID。然后通过DebugActiveProcess() API(出处:MSDN)将调试器附加到该运行的进程上,开始调试(上面输入的PID作为参数传入函数)。

1
2
3
4
BOOL DebugActiveProcess(
DWORD dwProcessId
// process to be debugged
);

然后进人DebugLoop()函数,处理来自被调试者的调试事件。

另一种启动调试的方法是使用CreateProcess() API,从一开始就直接以调试模式运行相关进程。更详细的说明请参考MSDN。

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
30
31
32
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);
}
}

DebugLoop()函数的工作原理类似于窗口过程函数(WndProc),它从被调试者处接收事件并处理,然后使被调试者继续运行。DebugLoop()函数代码比较简单,结合代码中的注释就能理解。

下面看看其中比较重要的2个API。

顾名思义,WaitForDebugEvent() API (出处: MSDN)是一个等待被调试者发生调试事件的函数(行为动作类似于WaitForSingleObject() API)。

DebugLoop()函数代码中,若发生调试事件,WaitForDebugEvdnt() API就会将相关事件信息设置到其第一个参数的变量(DEBUG_EVENT结构体对象),然后立刻返回。DEBUG_EVENT结构体定义(出处:MSDN)如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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;

前面的讲解中已经提到过,共有9种调试事件。DEBUG_EVENT.dwDebugEventCode成员会被设置为9种事件中的一种,根据相关事件的种类,也会设置适当的DEBUG_EVENT.u (union)成员(DEBUG_EVENT.u共用体成员内部也由9个结构体组成,它们对应于事件种类的个数)。

例如,发生异常事件时,dwDebugEventCode成员会被设置为EXCEPTION_DEBUG_EVENT, u.Exception结构体也会得到设置。

ContinueDebugEvent() API (出处:MSDN )是一个使被调试者继续运行的函数。

1
2
3
4
5
6
7
8
BOOL ContinueDebugEvent(
DWORD dwProcessId,
// process to continue
DWORD dwThreadId,
// thread to continue
DWORD dwContinueStatus
// continuation status
);

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
BOOL OnCreateProcessDebugEvent(LPDEBUG_EVENT pde)
{
// WriteFile() API 地址
g_pfWriteFile = GetProcAddress(GetModuleHandleA("kernel32.dll"), "WriteFile");

// API “钩子” - WriteFile()
// 更改第一个字节为0xCC (INT3)
// originalbyte是g_chOrgByte备份
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结构体(出处:MSDN)变量。

1
2
3
4
5
6
7
8
9
10
11
12
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()、WriteProcessMemory() API对被调试进程的内存空间自由进行读写操作。用上面的函数可以向被调试者设置断点(INT3 0xCC)0通过ReadProcessMemory()读取WriteFile() API的第一个字节,并将其存储到g_chOrgByte变量。如图30-12所示,WriteFile()API的第一个字节为0x6A (Windows XP操作系统)。

图30-12 WriteFile()第一个字节

g_chOrgByte变量中存储的是WriteFile() API的第一个字节,后面“脱钩”时会用到。然后使用WriteProcessMemory() API将WriteFile() API的第一个字节更改为0xCC (参考图30-13)。

图30-13 设置OxCC

0xCC是IA-32指令,对应于INT3指令,也就是断点。CPU遇到INT3指令时会暂停执行程序,并触发异常。若相应程序正处于调试中,则将控制权转移到调试器,由调试器处理。这也是一般调试器设置断点的基本原理。

这样一来,被调试进程调用WriteFile()API时,控制权都会转移给调试器。

30.6.5 EXCEPTION_DEBUG_EVENT-OnExceptionDebugEvent()

OnExceptionDebugEvent()是EXCEPTION_DEBUG_EVENT事件句柄,它处理被调试者的INT3指令。代码30-4是OnExceptionDebugEvent()函数代码,下面看一下它的核心部分。

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
77
78
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恢复为orignal byte
WriteProcessMemory(g_cpdi.hProcess, g_pfWriteFile,
&g_chOrgByte, sizeof(BYTE), NULL);

// #2. 获取线程上下文
ctx.ContextFlags = CONTEXT_CONTROL;
GetThreadContext(g_cpdi.hThread, &ctx);

// #3. 获取WriteFile()的param 2、3值
// 函数参数存在于相应进程的栈
// param 2 : ESP + 0x8
// param 3 : ESP + 0xC
ReadProcessMemory(g_cpdi.hProcess, (LPVOID)(ctx.Esp + 0x8),
&dwAddrOfBuffer, sizeof(DWORD), NULL);
// Read DWORD大小的数据放到 dwAddrOfBuffer
ReadProcessMemory(g_cpdi.hProcess, (LPVOID)(ctx.Esp + 0xC),
&dwNumOfBytesToWrite, sizeof(DWORD), NULL);
// // Read DWORD大小的数据放到dwNumOfBytesToWrite

// #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命令之后)
// 简而言之: 执行了INT3后, EIP+1, 需要把EIP+1再还原为EIP原来的值
ctx.Eip = (DWORD)g_pfWriteFile; // 还原EIP
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;
}

OnExceptionDebugEvent()函数代码有些多,接下来分析核心部分。首先,if语句用于检测异常是否为EXCEPTION_BREAKPOINT异常(除此之外,还有大约19种异常,请参考前几节内容)。然后,用if语句检测发生断点的地址是否与kernel32!WriteFile()的起始地址一致(OnCreateProcessDebugEvent()已经事先获取了WriteFile()的起始地址)。若满足条件,则继续执行以下代码。

#1. “脱钩”(删除API钩子)

1
2
WriteProcessMemory(g_cpdi.hProcess, g_pfWriteFile, 
&g_chOrgByte, sizeof(BYTE), NULL);

首先需要“脱钩”(删除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
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
typedef struct _CONTEXT {
ULONG ContextFlags;
ULONG Dr0;
ULONG Dr1;
ULONG Dr2;
ULONG Dr3;
ULONG Dr6;
ULONG Dr7;
FLOATING_SAVE_AREA FloatSave;
ULONG SegGs;
ULONG SegFs;
ULONG SegEs;
ULONG SegDs;
ULONG Edi;
ULONG Esi;
ULONG Ebx;
ULONG Edx;
ULONG Ecx;
ULONG Eax;
ULONG Ebp;
ULONG Eip;
ULONG SegCs;
ULONG EFlags;
ULONG Esp;
ULONG SegSs;
UCHAR ExtendedRegisters[MAXIMUM_SUPPORTED_EXTENSION];
} CONTEXT;

下面是获取线程上下文的代码。

1
2
ctx.ContextFlags = CONTEXT_CONTROL;
GetThreadContext(g_cpdi.hThread, &ctx);

像这样调用GetThreadContext() API (出处:MSDN),即可将指定线程(g_cpdi.hThread)的CONTEXT存储到ctx结构体变量(g_cpdi.hThread是被调试者的主线程句柄)。

1
2
3
4
5
6
BOOL GetThreadContext(
HANDLE hThread,
// handle of thread with context
LPCONTEXT lpContext
// address of context structure
);

#3.获取WriteFile()的param 2、3值

调用WriteFile()函数时,我们要在传递过来的参数中知道param2(数据缓冲区地址)与param3 (缓冲区大小)这2个参数。函数参数存储在栈中,通过#2中获取的CONTEXT.Esp成员可以分别获得它们的值。

1
2
3
4
5
6
7
8
9
// 函数参数存在于相应进程的栈
// param 2 : ESP + 0x8
// param 3 : ESP + 0xC
ReadProcessMemory(g_cpdi.hProcess, (LPVOID)(ctx.Esp + 0x8),
&dwAddrOfBuffer, sizeof(DWORD), NULL);
// Read DWORD大小的数据放到 dwAddrOfBuffer
ReadProcessMemory(g_cpdi.hProcess, (LPVOID)(ctx.Esp + 0xC),
&dwNumOfBytesToWrite, sizeof(DWORD), NULL);
// // Read DWORD大小的数据放到dwNumOfBytesToWrite
  • 存储在dwAddrOfBuffer中的数据缓冲区地址是被调试者(notepad.exe)虚拟内
    存空间中的地址。

  • param2与param3分别为ESP+0x8、ESP+0xC,原因请参考第7章。

#4.~#8. 把小写字母转换为大写字母后覆写WriteFile()缓冲区
获取数据缓冲区的地址与大小后,将其内容读到调试器的内存空间,把小写字母转换为大写字母。然后将修改后的大写字母覆写到原位置(被调试者的虚拟内存)。整个代码不难,结合代码中的注释就能轻松理解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// #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()起始地址

下面将#2中获取的CONTEXT结构体的Eip成员修改为WriteFile()的起始地址。EIP的当前地址为WriteFile()+1 (参考前面“#执行流”中的说明)。

修改好CONTEXT.Eip成员后,调用SetThreadContext() API。

1
2
3
4
5
// #9. 将线程上下文的EIP更改为WriteFile()的首地址
// (当前为WriteFile()+1位置, INT3命令之后)
// 简而言之: 执行了INT3后, EIP+1, 需要把EIP+1再还原为EIP原来的值
ctx.Eip = (DWORD)g_pfWriteFile; // 还原EIP
SetThreadContext(g_cpdi.hThread, &ctx);

下面是SetThreadContext()API (出处:MSDN):

1
2
3
4
BOOL SetThreadContext(
[in] HANDLE hThread,
[in] const CONTEXT *lpContext
);

#10.运行调试进程

一切准备就绪后,接下来就要正常调用WriteFile() API了。调用ContinueDebugEvent() API可以重启被调试进程,使之继续运行。由于在#9已经将CONTEXT.Eip修改为WriteFile()的起始地址,所以会调用执行WriteFile()。

1
2
ContinueDebugEvent(pde->dwProcessId, pde->dwThreadId, DBG_CONTINUE);
Sleep(0);

Sleep(0)有什么用?

首先用源代码运行测试,然后对Sleep(0)语句进行注释处理,再次运行测试(请在notepad中输入文本后,快速反复保存)。比较2种测试结果,并思考有什么不同,以及产生不同的原因。(答案见最后)

#11.设置API “钩子”
最后设置API “钩子”,方便下次钩取操作(若略去该操作,由于#1中已经“脱钩”,WriteFile()API钩取将完全处于“脱钩”状态)。

1
2
WriteProcessMemory(g_cpdi.hProcess, g_pfWriteFile, 
&g_chINT3, sizeof(BYTE), NULL);

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章


第30章 记事本WriteFile() API钩取
https://m0ck1ng-b1rd.github.io/1999/02/28/逆向工程核心原理/第30章 记事本WriteFile() API钩取/
作者
何语灵
发布于
1999年2月28日
许可协议