逆向工程核心原理-30

逆向工程核心原理-第30章-记事本WriteFile()API钩取

30.1 技术图表(见第29章)-调试技术

由于该技术借助”调试”钩取, 所以能够进行与用户更具交互性(interctive)的钩取操作. 也就是说, 这种技术会像用户提供简单的接口, 使用户能够控制目标进程的运行, 并且可以自由使用进程内存. 使用调试钩取技术前, 先要了解一下调试器的构造.

30.2 关于调试器的说明

30.2.1 术语

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

30.2.2 调试器功能

调试器用来确定被调试者是否真唱运行, 发现程序错误. 调试器能够

30.2.3 调试器工作原理

调试进程经过注册后, 每当被调试者发生调试事件时, OS就会暂停其运行, 并向调试器报告相应事件. 调试器对相应事件做适当处理后, 使被调试者继续运行.

  • 一般的异常(Exception)也属于调试事件.
  • 若相应进程处于非调试, 调试事件会在其自身的异常处理或OS的异常处理机制中被处理掉.
  • 调试器无法处理或不关心的调试事件最终由OS处理.

Untitled

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

Untitled

运行示例文件钩取程序hookdbg.exe, 需要输入两个参数, 第二个参数目标钩取进程的PID

Untitled

我们再notepad中输入一些文字

Untitled

然后点击保存文件

Untitled

我们回到终端观察情况

Untitled

可以看到小写的输入被转换成了大写的字母, 我们再打开保存文件检验

Untitled

30.5 工作原理

我们已经知道了要保存文件, 所调用的函数为WriteFile()

接下来使用OD打开notepad.exe, 并再WriteFile()API地址处设下断点

我这里有一个好用的插件就直接用了

Untitled

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;//类型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]);//将第二个参数--我们输入的进程PID从字符串类型转换成整数
if (!DebugActiveProcess(dwPID)) {//该函数作用就是使当前进程成为调试器, 附加到目标PID进程上, 附加成功返回非零, 附加失败返回零
printf("DebugActiveProcss(%d) failed!!!\n"
"Error Code = %d\n", dwPID, GetLastError);
return 1;
}

DebugLoop();//进入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)) {//等待调试事件, 等待的是调试器, 被调试者正常运行
//第一个参数用于记录调试事件信息, 第二参数表示等待的事件, 这里的INFINITE(无限)表示一直等待
dwContinueStatus = DBG_CONTINUE;//用于该函数最后的ContinueDebugEvent()函数, 解释见下
//被调试进程生成或附加事件
if (CREATE_PROCESS_DEBUG_EVENT == de.dwDebugEventCode) {//在发生了调试事件以后, de存储了调试事件的信息
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);
//根据dwContinueStatus, 如果其值为DBG_CONTINUE, 则为处理正常, 如果其值为DBG_EXCEPTION_NOT_HANDLED
//则表示无法处理, 或希望SEH来处理
}
}

大致的作用是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;//hFile文件调试信息的偏移量
DWORD nDebugInfoSize;//文件中调试信息的大小
LPVOID lpThreadLocalBase;//指向数据块的指针
LPTHREAD_START_ROUTINE lpStartAddress;//线程起始地址指针
LPVOID lpImageName;//hFile所指向的文件的名称的指针
WORD fUnicode;//指示文件名是ANSII还是Unicode, Unicode为1, ANSII为0
} CREATE_PROCESS_DEBUG_INFO, *LPCREATE_PROCESS_DEBUG_INFO;

typedef struct _DEBUG_EVENT {
DWORD dwDebugEventCode;//标识调试类型
/*
CREATE_PROCESS_DEBUG_EVENT 报告创建进程调试事件
CREATE_THREAD_DEBUG_EVENT 报告创建线程调试事件
EXCEPTION_DEBUG_EVENT 报告异常调试事件
EXIT_PROCESS_DEBUG_EVENT 报告退出进程调试事件
EXIT_THREAD_DEBUG_EVENT 报告退出线程调试事件
LOAD_DLL_DEBUG_EVENT 报告加载动态链接库 (DLL) 调试事件
OUTPUT_DEBUG_STRING_EVENT 报告输出调试字符串调试事件
RIP_EVENT 报告 RIP 调试事件(系统调试错误)
UNLOAD_DLL_DEBUG_EVENT 报告卸载 DLL 调试事件
*/
DWORD dwProcessId;// 发生调试事件的进程的PID
DWORD dwThreadId;// 发生调试事件的线程的TID
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;

//从其成员可以看到CREATE_PROCESS_DEBUG_INFO结构体更多的是对被调试者的文件创建信息的描述
//而DEBUG_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
/*全局变量的变化
g_pfWriteFile = NULL -->> 指向了WriteFile的首地址
g_cpdi --> 进程调试信息(包含PID, TID等等)
g_chINT3 = 0xCC
g_chOrgByte = 0 --> 0x6A
*/

BOOL OnCreateProcessDebugEvent(LPDEBUG_EVENT pde)//传入的参数是CreateProcess调试事件信息
{
// 首先获得了kernel32的模块句柄, 再从中获取WriteFile的API地址
g_pfWriteFile = GetProcAddress(GetModuleHandleA("kernel32.dll"), "WriteFile");

// API Hook - WriteFile()
// 更改第一个字节为 0xCC
// orginal byte 是 g_ch0rgByte 备份
memcpy(&g_cpdi, &pde->u.CreateProcessInfo, sizeof(CREATE_PROCESS_DEBUG_INFO));
//将API起始地址原来的0x6A字节码保存再g_chOrgByte中, 为之后的脱钩做准备
ReadProcessMemory(g_cpdi.hProcess, g_pfWriteFile,
&g_chOrgByte, sizeof(BYTE), NULL);
//将0xCC写入到API起始地址内存中, 成为了断点
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
/*全局变量记录
* g_pfWriteFile = WriteFile()的起始地址
* g_cpdi --> 进程调试信息(包含PID, TID等等)
* g_chINT3 = 0xCC
* g_chOrgByte = 0 --> 0x6A
*/
BOOL OnExceptionDebugEvent(LPDEBUG_EVENT pde)//传入的参数是Exception调试事件信息
{
CONTEXT ctx;//上下文
PBYTE lpBuffer = NULL;
DWORD dwNumOfBytesToWrite, dwAddrOfBuffer, i;//需要写入的数据块大小, 数据块指针
//前缀dw表示其类型为DWORD
PEXCEPTION_RECORD per = &pde->u.Exception.ExceptionRecord;//异常记录

//判断是否是INT3(断点)异常
if (EXCEPTION_BREAKPOINT == per->ExceptionCode) {
//判断发生异常的地址为我们目标WriteFileAPI的地址时
if (g_pfWriteFile == per->ExceptionAddress) {
//脱钩(将0xCC改回0x6A)
WriteProcessMemory(g_cpdi.hProcess, g_pfWriteFile,
&g_chOrgByte, sizeof(BYTE), NULL);//将g_chOrgByte中的原来的值重新写回WriteFile的起始地址
//获取线程上下文(寄存器, 栈....)
ctx.ContextFlags = CONTEXT_CONTROL;
GetThreadContext(g_cpdi.hThread, &ctx);//参一线程句柄, 参二用于存储上下文的结构体

//获取第二个参数, 保存在dwAddrOfBuffer中, 为写入数据的地址
ReadProcessMemory(g_cpdi.hProcess, (LPVOID)(ctx.Esp + 0x8),//这里需要修改编译选项: x64改为x86, 否则会报错
&dwAddrOfBuffer, sizeof(DWORD), NULL);
//获取第三个参数, 保存在dwAddrOfBuffer中, 为写入数据的大小
ReadProcessMemory(g_cpdi.hProcess, (LPVOID)(ctx.Esp + 0xC),
&dwNumOfBytesToWrite, sizeof(DWORD), NULL);

//在调试器中分配临时缓冲区
lpBuffer = (PBYTE)malloc(dwNumOfBytesToWrite + 1);
memset(lpBuffer, 0, dwNumOfBytesToWrite + 1);//先使用0来填充这段内存

//复制被调试者WriteFile()的缓冲区到调试器的临时缓冲区
ReadProcessMemory(g_cpdi.hProcess, (LPVOID)dwAddrOfBuffer,
lpBuffer, dwNumOfBytesToWrite, NULL);//跨进程读取数据, 从被调试者进程中读取数据到调试器内存中
//被调试者的数据起始地址dwAddrOfBuffer
//调试器的读入地址lpBuffer
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);//跨进程写入数据
//调试器中的数据lpBuffer写入到被调试者的dwAddrOfBuffer内存中

//释放调试器的临时缓冲区(已经完成了它的任务: 暂时存放需要修改的数据, 暂时存放修改后的数据, 相当于tmp)
free(lpBuffer);

//将线程上下文的EIP更改为WriteFile地址(相当于EIP - 1), 这是脱钩的第二步
ctx.Eip = (DWORD)g_pfWriteFile;
SetThreadContext(g_cpdi.hThread, &ctx);//将结构体ctx作为被调试者的上下文

//运行被调试进程
ContinueDebugEvent(pde->dwProcessId, pde->dwThreadId, DBG_CONTINUE);//相当于F9

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(为了继续钩取)
  • 控制权返还给被调试者
文章作者: LamのCrow
文章链接: http://example.com/2022/04/18/逆向工程核心原理-第 4eaea/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 LamのCrow