目前在科锐学习三阶段,有一个项目是使用x86汇编始编写一个windows端的控制台调试器的过程,记录一下学习编写调试器的过程
windows的调试框架是依托于异常体系,由事件驱动的
这里的事件指的是调试事件(DebugEvent),整个用户态异常的处理过程如下:
当CPU执行到一些特殊的指令,比如int3
、int1
或者是除0再或者是在3环执行了特权指令,CPU就会触发异常,触发异常的具体动作是保存现场环境(CONTEXT)然后去执行IDT
表中对应的内核中断函数,比如in3
就是执行 KiTrap03
,在中断函数中执行一些特定异常的处理工作,之后就会执行到内核的异常派发函数KiDispatchException
,在KiDispatchException
判断EPROCESS.DebugPort
是否为NULL,如果不为NULL则说明存在3环调试器,则通过激活DebugPort
中的同步事件来通知3环调试器,有调试事件来了,然后调用KeWaitForSingleObject
等待调试器的返回
再回到调试器,调试器在启动/附加到被调试进程之后(所谓建立调试会话),就会进入一个循环,不停的调用WaitForDebugEvent
等待调试事件的到来,这个函数就是在等待DebugPort
中的同步事件,一旦调试事件到来,则处理调试事件,然后返回处理的结果,返回之后再次进入WaitForDebugEvent
等待调试事件
调试器返回之后,内核中的KeWaitForSingleObject
函数就会执行完毕返回,此时这就是调试器的第一次暂停,调试器返回的结果可以是处理完毕回到异常触发现场继续执行DBG_CONTINUE
,也可以是继续处理异常DBG_EXCEPTION_NOT_HANDLED
,如果是继续处理异常,则会返回到用户态,执行用户态异常分发KiUserExceptionDispatcher
:VEH->SEH->UEH
,如果处理完之后还是没能将异常处理成功,则继续通过ZwRaiseException
返回内核,然后就是第二次派发给调试器,如果调试器还是没有处理成功,则将异常发送给异常处理端口(csrss.exe),然后结束进程
在以上过程中,调试器、VEH、SEH都可以成功处理异常,然后返回到异常触发现场继续执行代码
示意图如下:
上面说到,当CPU触发异常,就会走到内核态的异常分发函数,内核异常分发函数判断如果存在3环调试器,就发送调试事件给3环调试器,这个调试事件就是最关键的一个结构体
所谓发送调试事件给调试器,就是将的DEBUG_EVENT
这个内核结构体挂在DebugObject
的事件链表上,然后激活DebugObject
上的等待事件
调试器这边,调用完WaitForDebugEvent
之后,也会进入内核,主要的逻辑在NtWaitDebugEvent
中,当DebugObject
中的等待事件被激活,就从事件链表中取出一个内核态的DEBUG_EVENT
结构体,返回3环后转换成3环的DEBUG_EVENT
,注意这个3环的跟0环的不一样,对于我们应用层的调试器开发,只需要关心3环的DEBUG_EVENT
,结构体如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
typedef struct _DEBUG_EVENT {
DWORD dwDebugEventCode;
/
/
调试事件的种类
DWORD dwProcessId;
/
/
进程
id
DWORD dwThreadId;
/
/
线程
id
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种,而除了异常事件之外,其他的八种事件仅仅是通知调试器一下,并不需要调试器做出什么回应,调试器需要关注的是异常事件,被调试进程中触发的所有异常都会发送两次给调试器,对于调试器来说,最重要的就是三大断点(软件、硬件、内存)和单步,都是通过异常来实现的
经过上面两节的学习,应该对调试体系有了一些认识,接下来就来看一下,编写一个简单的命令行调试器的一般步骤:
CreateProcess
,参数dwCreationFlags
给DEBUG_ONLY_THIS_PROCESS,其他参数正常给DebugActiveProcess
WaitForDebugEvent
ContinueDebugEvent
DebugActiveProcessStop
框架代码如下:
环境:win11、x86汇编、radasm
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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
|
.
386
.model flat, stdcall ;
32
bit memory model
option casemap :none ;case sensitive
include Stdlib.Inc
include windows.inc
include kernel32.inc
include user32.inc
include Comctl32.inc
include shell32.inc
include msvcrt.inc
includelib kernel32.lib
includelib user32.lib
includelib Comctl32.lib
includelib shell32.lib
includelib msvcrt.lib
assume fs:nothing
.data
g_hProcess dd
0
g_szExe db
"winmine.exe"
,
0
g_CreateProcessFailed db
"CreateProcessFailed!"
,
0
g_szEXCEPTION_DEBUG_EVENT db
"EXCEPTION_DEBUG_EVENT"
,
0
g_szCREATE_THREAD_DEBUG_EVENT db
"CREATE_THREAD_DEBUG_EVENT "
,
0
g_szCREATE_PROCESS_DEBUG_EVENT db
"CREATE_PROCESS_DEBUG_EVENT"
,
0
g_szEXIT_THREAD_DEBUG_EVENT db
"EXIT_THREAD_DEBUG_EVENT"
,
0
g_szEXIT_PROCESS_DEBUG_EVENT db
"EXIT_PROCESS_DEBUG_EVENT"
,
0
g_szLOAD_DLL_DEBUG_EVENT db
"LOAD_DLL_DEBUG_EVENT"
,
0
g_szUNLOAD_DLL_DEBUG_EVENT db
"UNLOAD_DLL_DEBUG_EVENT"
,
0
g_szOUTPUT_DEBUG_STRING_EVENT db
"OUTPUT_DEBUG_STRING_EVENT "
,
0
g_szRIP_EVENT db
"RIP_EVENT"
,
0
g_szFmt db
"%s"
,
0dh
,
0ah
,
0
.code
StartDbg proc
LOCAL @si: STARTUPINFO
LOCAL @pi: PROCESS_INFORMATION
LOCAL @de: DEBUG_EVENT
LOCAL @dwContinueStatus: DWORD
;
1.
启动调试进程
invoke CreateProcess, NULL, offset g_szExe, NULL, NULL, NULL, DEBUG_ONLY_THIS_PROCESS, NULL, NULL, addr @si, addr @pi
.
if
eax
=
=
0
;启动失败
invoke crt_printf, g_szFmt, offset g_CreateProcessFailed
ret
.endif
invoke CloseHandle, @pi.hThread
mov eax, @pi.hProcess
mov g_hProcess, eax
;
2.
循环接收调试事件
.
while
TRUE
invoke RtlZeroMemory,addr @de, size @de
invoke WaitForDebugEvent, addr @de, INFINITE
;默认处理为继续执行
mov @dwContinueStatus, DBG_CONTINUE
;
3.
处理调试事件
.
if
@de.dwDebugEventCode
=
=
EXCEPTION_DEBUG_EVENT
invoke crt_printf, offset g_szFmt, offset g_szEXCEPTION_DEBUG_EVENT
.endif
.
if
@de.dwDebugEventCode
=
=
CREATE_THREAD_DEBUG_EVENT
invoke crt_printf,offset g_szFmt, offset g_szCREATE_THREAD_DEBUG_EVENT
.endif
.
if
@de.dwDebugEventCode
=
=
CREATE_PROCESS_DEBUG_EVENT
invoke crt_printf,offset g_szFmt, offset g_szCREATE_PROCESS_DEBUG_EVENT
.endif
.
if
@de.dwDebugEventCode
=
=
EXIT_THREAD_DEBUG_EVENT
invoke crt_printf,offset g_szFmt, offset g_szEXIT_THREAD_DEBUG_EVENT
.endif
.
if
@de.dwDebugEventCode
=
=
EXIT_PROCESS_DEBUG_EVENT
invoke crt_printf,offset g_szFmt, offset g_szEXIT_PROCESS_DEBUG_EVENT
.endif
.
if
@de.dwDebugEventCode
=
=
LOAD_DLL_DEBUG_EVENT
invoke crt_printf, offset g_szFmt, offset g_szLOAD_DLL_DEBUG_EVENT
.endif
.
if
@de.dwDebugEventCode
=
=
UNLOAD_DLL_DEBUG_EVENT
invoke crt_printf, offset g_szFmt, offset g_szUNLOAD_DLL_DEBUG_EVENT
.endif
.
if
@de.dwDebugEventCode
=
=
OUTPUT_DEBUG_STRING_EVENT
invoke crt_printf,offset g_szFmt, offset g_szOUTPUT_DEBUG_STRING_EVENT
.endif
.
if
@de.dwDebugEventCode
=
=
RIP_EVENT
invoke crt_printf, offset g_szFmt, offset g_szRIP_EVENT
.endif
;
4.
返回处理结果
invoke ContinueDebugEvent, @de.dwProcessId, @de.dwThreadId, @dwContinueStatus
.endw
ret
StartDbg endp
start:
invoke StartDbg
invoke ExitProcess,
0
;
########################################################################
end start
|
在控制台版的调试器中,什么时候用户可以输入呢?可以参考windbg,如果不强行暂停的话,就只能在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
|
OnBreakPoint proc pExcption: ptr EXCEPTION_RECORD
LOCAL @szUserInput[MAX_PATH]: CHAR
LOCAL @szCmd[MAX_PATH]: CHAR
LOCAL @uOpData: DWORD
;接收用户输入
invoke crt_gets, addr @szUserInput
;处理用户输入
invoke crt_sscanf, addr @szUserInput, offset g_szInputFmt, addr @szCmd, addr @uOpData
;恢复运行
OnBreakPoint endp
OnException proc pDebugEvent: ptr DEBUG_EVENT
mov esi, pDebugEvent
assume esi: ptr DEBUG_EVENT
;int3断点
.
if
[esi].u.Exception.pExceptionRecord.ExceptionCode
=
=
EXCEPTION_BREAKPOINT
invoke OnBreakPoint, addr [esi].u.Exception
.endif
OnException endp
;...
;
3.
处理调试事件
.
if
@de.dwDebugEventCode
=
=
EXCEPTION_DEBUG_EVENT
invoke OnException, addr @de
invoke crt_printf, offset g_szFmt, offset g_szEXCEPTION_DEBUG_EVENT
.endif
;...
|
我们在使用OD时,经常会对一条汇编指令按下F2下断点,在程序运行到这个地址的时候OD就会停下,这就是对这个地址下了一个软件断点
软件断点的本质是在指定地址处写了一个会触发异常的指令,最常用的是int 3指令(也可以不是int3,只要是可以抛异常的指令就行,帮比如特权指令),机器码是0xCC,当CPU运行到0xCC的时候,经过一系列异常派发,最终调试器会接收到异常调试事件,此时DEBUG_EVENT.dwDebugEventCode
是EXCEPTION_DEBUG_EVENT
,DEBUG_EVENT
的第三个成员此时是EXCEPTION_DEBUG_INFO
1
2
3
4
|
typedef struct _EXCEPTION_DEBUG_INFO {
EXCEPTION_RECORD ExceptionRecord;
/
/
异常记录
DWORD dwFirstChance;
/
/
第
1
次还是第
2
次异常
} EXCEPTION_DEBUG_INFO,
*
LPEXCEPTION_DEBUG_INFO;
|
异常记录结构体中保存了关于异常的信息
1
2
3
4
5
6
7
8
|
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
就是异常码,当因为调试器接收到int3异常时,ExceptionCode
为EXCEPTION_BREAKPOINT
所以,我们自己编写调试器实现软件断点也使用int3指令,要设置一个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
|
SetBP proc to: DWORD
LOCAL @Int3Index: DWORD
;判断是否还可以继续下断点
.
if
g_dwInt3Cnt >
=
256
;断点数量已满,无法再下断点
invoke crt_printf, offset g_szInt3CntIsFull
ret
.endif
inc g_dwInt3Cnt
;找到保存断点的数组下标
invoke GetPosToSaveInt3
.
if
(eax
=
=
-
1
) || (eax >
=
g_dwInt3Cnt)
;断点数量已满,无法再下断点
invoke crt_printf, offset g_szInt3CntIsFull
ret
.endif
mov @Int3Index, eax
lea eax, [eax
*
4
]
mov ebx, offset g_arrInt3Addr
add eax, ebx
mov edi, to
mov dword ptr [eax], edi ;保存写int3的地址
mov edx, offset g_arrInt3CoverIns
add edx, @Int3Index
invoke ReadMem, to, edx,
1
;保存int3覆盖的指令
;向被调试进程指定地址写入
0xCC
invoke WriteMem, to, offset g_InsCC,
1
ret
SetBP endp
|
在触发断点之后,调试器会接收到int3异常,我们在int3异常中需要做一些事情来消除我们下的int3断点的影响,因为此时int3指令已经执行完了,而原有保存在这里的指令并没有执行,步骤如下:
首先,响应调试事件中的异常事件
1
2
3
4
5
6
7
8
|
;...
;
3.
处理调试事件
.
if
@de.dwDebugEventCode
=
=
EXCEPTION_DEBUG_EVENT
invoke crt_printf, offset g_szFmt, offset g_szEXCEPTION_DEBUG_EVENT
invoke OnException, addr @de
mov @dwContinueStatus, eax
.endif
;...
|
响应异常事件中和int3异常和单步异常
1
2
3
4
5
6
7
8
9
10
11
|
OnException proc pDebugEvent: ptr DEBUG_EVENT
mov esi, pDebugEvent
assume esi: ptr DEBUG_EVENT
.
if
[esi].u.Exception.pExceptionRecord.ExceptionCode
=
=
EXCEPTION_BREAKPOINT ;int3
invoke OnBreakPoint, addr [esi].u.Exception
.elseif [esi].u.Exception.pExceptionRecord.ExceptionCode
=
=
EXCEPTION_SINGLE_STEP ;单步
invoke OnSingleStep, addr [esi].u.Exception
.endif
ret
OnException endp
|
在处理int3断点时,首先需要判断一下,是否是系统断点,如果是系统的初始断点就不用处理(eip已经指向下一条指令了,返回就能正常运行),如果是自己下的断点就需要特殊处理
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
|
OnBreakPoint proc pExcption: ptr EXCEPTION_RECORD
LOCAL @hThread: HANDLE
LOCAL @ThreadCtx: CONTEXT
LOCAL @dwCurAddr: DWORD
LOCAL @dwBpIdx: DWORD
invoke RtlZeroMemory, addr @ThreadCtx, size @ThreadCtx
.
if
g_IsSysBp
=
=
TRUE
;如果是系统断点,直接运行
invoke crt_printf, offset g_szSysBp
mov g_IsSysBp, FALSE
.
else
;如果不是系统断点,就认为是自己的断点
invoke crt_printf, offset g_szSelfBp
;打开当前停下来的线程
mov esi, g_pDebugEvent
assume esi: ptr DEBUG_EVENT
invoke OpenThread, THREAD_ALL_ACCESS, FALSE, [esi].dwThreadId
mov @hThread, eax
;获取线程环境,计算断点的地址
mov @ThreadCtx.ContextFlags, CONTEXT_CONTROL
invoke GetThreadContext, @hThread, addr @ThreadCtx
mov eax, @ThreadCtx.regEip
mov @dwCurAddr, eax
dec @dwCurAddr ;int3断点停下来的位置是
0xCC
指令的下一条指令
;遍历断点数组,找到当前断点的索引
invoke FindBp, @dwCurAddr
mov @dwBpIdx, eax
.
if
eax !
=
-
1
;恢复
0xCC
覆盖的
1
字节指令
mov eax, offset g_arrInt3CoverIns
add eax, @dwBpIdx
invoke WriteMem, @dwCurAddr, eax,
1
.endif
;设置eip
-
1
dec @ThreadCtx.regEip
;设置单步断点tf寄存器,用于恢复
0xCC
or
@ThreadCtx.regFlag,
0100h
invoke SetThreadContext, @hThread, addr @ThreadCtx
mov g_IsNeedWriteCC,TRUE ;在下一次单步异常来的时候,是否应该重写CC
mov eax, @dwCurAddr
mov g_dwAddrWriteCC, eax ;往哪里写
.endif
;TODO:打印地址、指令、寄存器
invoke UserInput
ret
OnBreakPoint endp
|
处理单步异常
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
;触发单步断点
OnSingleStep proc pExcption: ptr EXCEPTION_RECORD
LOCAL @dbCC: CHAR
mov @dbCC,
0cch
;判断是否是因需要重写CC而下的单步
.
if
g_IsNeedWriteCC
=
=
TRUE
mov g_IsNeedWriteCC, FALSE
invoke WriteMem, g_dwAddrWriteCC, addr @dbCC,
1
;重写CC
.endif
;TODO: 如果是手动输入的单步 f7 f8,那就需要停下来接收用户输入,否则直接返回
mov eax, DBG_CONTINUE
ret
OnSingleStep endp
|
当我们调用OutPutDebugStringA/W时,本质上也是发送调试事件给调试器,我们可以在调试器里面接收到打印的日志,并且输出,这里需要注意的是,调试字符串的地址是被调试进程的地址,不是调试器的,所以需要跨进程读写内存
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
|
OnOutPutDebugString proc pDebugEvent: ptr DEBUG_EVENT
LOCAL @szDbgStr[MAX_PATH
*
2
+
2
]: CHAR
LOCAL @szStr[MAX_PATH
+
1
]: CHAR
invoke RtlZeroMemory, addr @szDbgStr, MAX_PATH
*
2
+
2
invoke RtlZeroMemory, addr @szStr, MAX_PATH
+
1
mov esi, pDebugEvent
assume esi: ptr DEBUG_EVENT
lea edi, [esi].u.DebugString
assume edi: ptr OUTPUT_DEBUG_STRING_INFO
invoke ReadMem, [edi].lpDebugStringData, addr @szDbgStr, [edi].nDebugStringiLength
.
if
[edi].fUnicode
;
unicode
to ansi
invoke WideCharToMultiByte, CP_ACP,
0
, addr @szDbgStr,
-
1
, addr @szStr, MAX_PATH, NULL, NULL
.
else
invoke crt_strcpy, addr @szStr, addr @szDbgStr
.endif
;控制台输出
invoke crt_printf, offset g_szDebugStr, addr @szStr
ret
OnOutPutDebugString endp
|
调用函数GetThreadSelectorEntry
可在3环获取段描述符,解析段描述符就可以拿到段的基址和界限
1
2
3
4
5
|
BOOL
GetThreadSelectorEntry(
[
in
] HANDLE hThread,
[
in
] DWORD dwSelector,
[out] LPLDT_ENTRY lpSelectorEntry
);
|
单步断点是是调试器的一个最重要的功能,就是在OD中按下F8或者F7之后运行到下一条指令,分两种情况:
单步步过很好处理,只需要在用户输入命令之后,设置Elfags.TF=1,那么被调试进程则会在执行完这条机器指令之后抛出单步异常(0x80000004),然后就会被我们的调试器接收到,停下来继续接收用户的输入就好了
单步步过就有一点麻烦了,首先的判断当前这条指令是不是call指令,如果是call的话就需要对下一条指令下一个软件断点,然后返回继续运行,直到被调试程序执行到这个断点,就将这个断点删除,注意跟自己下的软件断点区分开来,自己下的需要再次设置单步来重设CC,而因为单步步过下的软件断点只需要恢复被CC覆盖的指令,而不需要设置单步后重设CC
还有两个问题就是,我们如何得知当前指令是call、以及下一条指令的地址是多少,要算出来这两个,就需要反汇编引擎的支持
Capstone是一个多架构的反汇编框架,支持多种CPU架构和多种文件格式。它是一个开源项目,可以在许多平台上免费使用。Capstone提供了一个易于使用的API,可以将二进制代码反汇编为汇编代码,以及提取出汇编代码中的操作数和操作数类型。
我们使用capstone就可以判断当前的指令是否是call,以及call指令的长度,下面来看学习capstone的用法
首先需要下载capstone的库,这里可以选择直接下载二进制文件(DLL和lib)或者是下载源码自己编译,我这里就直接二进制文件了
下载下来之后发现又头文件(include),静态库(capstone.lib)和动态库(capstone.dll、capstone_dll.lib),这里选择使用静态库
创建一个vs的控制台工程,将include文件夹和capstone.lib复制到vs工程目录下,在链接器->输入->附加依赖项中添加lib文件,然后写代码测试:
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 <stdio.h>
#include <inttypes.h>
#include <capstone/capstone.h> //包含头文件
#define CODE "\x55\x48\x8b\x05\xb8\x13\x00\x00"
int
main(void)
{
csh handle;
/
/
引擎句柄
cs_insn
*
insn;
/
/
指令结构体
size_t count;
if
(cs_open(CS_ARCH_X86, CS_MODE_64, &handle) !
=
CS_ERR_OK)
/
/
打开句柄
return
-
1
;
count
=
cs_disasm(handle, CODE, sizeof(CODE)
-
1
,
0x1000
,
0
, &insn);
/
/
反汇编机器码
if
(count >
0
) {
size_t j;
for
(j
=
0
; j < count; j
+
+
) {
printf(
"0x%"
PRIx64
":\t%s\t\t%s\n"
, insn[j].address, insn[j].mnemonic,
/
/
循环输出汇编指令
insn[j].op_str);
}
cs_free(insn, count);
/
/
释放内存
}
else
printf(
"ERROR: Failed to disassemble given code!\n"
);
cs_close(&handle);
/
/
关闭引擎句柄
return
0
;
}
|
调用cs_disasm函数我们可以反汇编一段机器码,并且可以指定反汇编出来的指令条数,传出参数是cs_insn结构体指针,每一个cs_insn结构体代表一个机器指令,从中我们可以获取汇编指令的操作码和操作数,机器指令的长度,接下来我们编写一个DLL,封装反汇编的代码,使我们从汇编层面使用更方便
新建DLL工程,配置如上,导出函数DisAsmOne
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
|
/
/
dllmain.cpp : 定义 DLL 应用程序的入口点。
#include "pch.h"
#include "../include/capstone/capstone.h"
BOOL
APIENTRY DllMain(HMODULE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved
)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break
;
}
return
TRUE;
}
/
/
返回值:汇编代码的长度
/
/
参数:机器指令的地址、机器指令的长度、指令的Eip、输出的汇编代码的地址、保存汇编代码的缓冲区大小
EXTERN_C __declspec(dllexport)
uint32_t __stdcall DisAsmOne(uint8_t
*
pCode, uint32_t uCodeSize, uint32_t uEip, uint8_t
*
pAsm, uint32_t uAsmSize)
{
/
/
初始化Capstone引擎
csh handle;
cs_insn
*
insn;
cs_err err
=
cs_open(CS_ARCH_X86, CS_MODE_32, &handle);
if
(err !
=
CS_ERR_OK) {
return
0
;
}
/
/
解析指令并输出
size_t count
=
cs_disasm(handle, pCode, uCodeSize, uEip,
1
, &insn);
if
(count
=
=
0
)
{
return
0
;
}
sprintf_s(
(char
*
const)pAsm,
uAsmSize,
"%s %s"
,
insn[
0
].mnemonic,
insn[
0
].op_str);
DWORD dwSize
=
insn
-
>size;
cs_free(insn, count);
/
/
关闭Capstone引擎
cs_close(&handle);
return
dwSize;
}
|
接下来就编写单步的代码
步骤:
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
|
;...
;输入t命令时
;t
.
if
@szCmd[
0
]
=
=
't'
invoke SetT
ret
.endif
;...
;设置单步断点
SetT proc
;设置tf标志位
invoke SetTF
mov g_IsSingleStepMaual, TRUE ;标记一下这个单步断点是手动下的
ret
SetT endp
;给调试线程的eflags的tf位设置
1
SetTF proc
LOCAL @hThread: HANDLE
LOCAL @ctx: CONTEXT
mov esi, g_pDebugEvent
assume esi: ptr DEBUG_EVENT
invoke OpenThread, THREAD_ALL_ACCESS, FALSE, [esi].dwThreadId ;打开调试线程
mov @hThread, eax
mov @ctx.ContextFlags, CONTEXT_CONTROL
invoke GetThreadContext, @hThread, addr @ctx ;获取线程环境
or
@ctx.regFlag,
0100h
;设置TF位
invoke SetThreadContext,@hThread, addr @ctx ;设置线程环境
invoke CloseHandle, @hThread
ret
SetTF endp
;触发单步断点
OnSingleStep proc pExcption: ptr EXCEPTION_RECORD
LOCAL @dbCC: CHAR
mov @dbCC,
0cch
;要重写CC而下的单步断点,重写CC
.
if
g_IsNeedWriteCC
=
=
TRUE
mov g_IsNeedWriteCC, FALSE
invoke WriteMem, g_dwAddrWriteCC, addr @dbCC,
1
.endif
;手动输入的单步断点,,什么都不需要做,接收用户输入
.
if
g_IsSingleStepMaual
mov g_IsSingleStepMaual, FALSE
invoke UserInput
.endif
ret
OnSingleStep endp
|
步骤:
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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
|
Setp_ proc
LOCAL @Eip: DWORD
LOCAL @Code[
10h
]: BYTE
LOCAL @Asm[
100h
]: BYTE
LOCAL @CodeLen: DWORD
;获取Eip
invoke GetEip
mov @Eip, eax
;获取Eip处的机器指令
invoke ReadMem,@Eip, addr @Code,
10h
;反汇编
invoke DisAsmOne, addr @Code,
10h
, @Eip, addr @Asm,
100h
mov @CodeLen, eax
;判断下一条指令是不是call
invoke crt_strstr, addr @Asm, offset g_szCall
.
if
eax
=
=
NULL
;不是call,直接设置t就可以了
invoke SetT
.
else
;是call
;需要在call指令的条指令下int3断点,并且设置p标志
mov ebx, @Eip
add ebx, @CodeLen
invoke SetBP, ebx
mov g_dwPbpIndex, eax ;记录P下的软件断点的索引,以便于删除
mov g_IsPBp, TRUE ;p的标志
.endif
ret
Setp_ endp
;触发int3断点
OnBreakPoint proc pExcption: ptr EXCEPTION_RECORD
LOCAL @hThread: HANDLE
LOCAL @ThreadCtx: CONTEXT
LOCAL @dwCurAddr: DWORD
LOCAL @dwBpIdx: DWORD
invoke RtlZeroMemory, addr @ThreadCtx, size @ThreadCtx
.
if
g_IsSysBp
=
=
TRUE
;如果是系统断点,直接运行
invoke crt_printf, offset g_szSysBp
mov g_IsSysBp, FALSE
.
else
;打开当前停下来的线程
mov esi, g_pDebugEvent
assume esi: ptr DEBUG_EVENT
invoke OpenThread, THREAD_ALL_ACCESS, FALSE, [esi].dwThreadId
mov @hThread, eax
;获取线程环境,计算断点的地址
mov @ThreadCtx.ContextFlags, CONTEXT_CONTROL
invoke GetThreadContext, @hThread, addr @ThreadCtx
mov eax, @ThreadCtx.regEip
mov @dwCurAddr, eax
dec @dwCurAddr ;int3断点停下来的位置是
0xCC
指令的下一条指令
;遍历断点数组,找到当前断点的索引
invoke FindBp, @dwCurAddr
mov @dwBpIdx, eax
.
if
eax !
=
-
1
;恢复
0xCC
覆盖的
1
字节指令
mov eax, offset g_arrInt3CoverIns
add eax, @dwBpIdx
invoke WriteMem, @dwCurAddr, eax,
1
.endif
;设置eip
-
1
dec @ThreadCtx.regEip
.
if
g_IsPBp
;p指令下的int3断点,不需要单步之后恢复CC
mov g_IsPBp, FALSE
;从数组中删除断点
invoke DelBp, g_dwPbpIndex
.
else
;正经的int3断点,需要单步之后恢复CC
;如果不是系统断点,就认为是自己的断点
invoke crt_printf, offset g_szSelfBp
;设置单步断点tf寄存器,用于恢复
0xCC
or
@ThreadCtx.regFlag,
0100h
mov g_IsNeedWriteCC,TRUE ;在下一次单步异常来的时候,是否应该重写CC
mov eax, @dwCurAddr
mov g_dwAddrWriteCC, eax ;往哪里写
.endif
invoke SetThreadContext, @hThread, addr @ThreadCtx
invoke CloseHandle, @hThread
.endif
invoke UserInput
ret
OnBreakPoint endp
|
调试器的追踪功能就是自动单步执行指令并记录执行过的指令,比如在x64dbg下使用Trace功能
首先点击跟踪,步进直到条件满足,意思是一直自动单步步入,直到满足某个条件
在弹出的窗口中可以填写自动单步的终点的条件,比如eip=0x12345678,然后在日志文本中填写要记录的信息,这里的信息需要使用x64dbg的字符串格式化功能,比如{p:eip} {i: eip}
,{}就相当于C语言的printf,冒号前面的i和p相当于格式化符号,i表示指定地址处的汇编指令,p表示将指定数据格式化成十六进制地址的形式,冒号写数据,详细说明见
点击日志文件,选择保存到的文件路径,点击确定就从当前eip开始自动单步
当执行到满足暂停条件之后,执行就会下来,这时候可以去看日志文件,发现每执行一次单步,就会向文件中写入一条日志文本:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
0062E8F2
mov ebp, esp
0062E8F4
sub esp,
0x10
0062E8F7
mov eax, dword ptr ds:[
0x00699FE8
]
0062E8FC
and
dword ptr ss:[ebp
-
0x8
],
0x0
0062E900
and
dword ptr ss:[ebp
-
0x4
],
0x0
0062E904
push ebx
0062E905
push edi
0062E906
mov edi,
0xBB40E64E
0062E90B
cmp
eax, edi
0062E90D
mov ebx,
0xFFFF0000
0062E912
je
0x0062E921
0062E914
test ebx, eax
0062E916
je
0x0062E921
0062E918
not
eax
0062E91A
mov dword ptr ds:[
0x00699FEC
], eax
0062E91F
jmp
0x0062E981
0062E981
pop edi
0062E982
pop ebx
0062E983
leave
0062E984
ret
|
od的trace也差不多
我们要在自己的调试器中实现trace功能的话也很简单,步骤如下:
首先接受用户输入,判断如果输入的是trace命令,则调用OnTrace
函数
1
2
3
4
5
6
7
8
|
;...
;trace
invoke crt_strcmp, addr @szCmd, offset g_szTrace
.
if
eax
=
=
0
invoke SetTrace, @uOpData
ret
.endif
;...
|
在SetTrace
函数中,调用Setp_
函数来下单步步过的命令,利用之前写的代码完成,并且设置trace标志与终止地址,然后就返回继续执行
1
2
3
4
5
6
7
8
9
10
11
|
SetTrace proc EndAddr: DWORD
;设置单步p
invoke Setp_
;设置trace标志与终止地址
mov g_bTrace, TRUE
mov eax, EndAddr
mov g_dwTraceEnd, eax
ret
SetTrace endp
|
此时可能有两种结果:
这两种情况分别区分于p命令时遇到call和没遇到call,那么需要在这两个地方停下来处理
触发单步断点时,如果trace标志为真,则表示正在trace,调用OnTrace
函数
1
2
3
4
5
6
7
8
9
10
11
|
;触发单步断点
OnSingleStep proc pExcption: ptr EXCEPTION_RECORD
;...
;正在trace
.
if
g_bTrace
invoke OnTrace
.endif
;...
ret
OnSingleStep endp
|
触发int3断点时,如果trace标志为真,则表示正在trace,调用OnTrace
函数
1
2
3
4
5
6
7
8
9
10
11
|
;触发int3断点
OnBreakPoint proc pExcption: ptr EXCEPTION_RECORD
;...
;如果正在trace,继续设置单步p
.
if
g_bTrace
invoke OnTrace
.endif
;...
ret
OnBreakPoint endp
|
这两处统一调用OnTrace
函数处理
在OnTrace
函数中,先获取当前eip,如果当前按eip等于要暂停的地址,则清除标志,接受用户输入,否则单纯打印一下当前寄存器环境和汇编指令,然后继续设置单步步进
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
;正在Trace
OnTrace proc
invoke GetEip
.
if
eax
=
=
g_dwTraceEnd ;到达终止条件,标志清除,接受用户输入
mov g_bTrace, FALSE
mov g_dwTraceEnd,
0
invoke UserInput
.
else
;还没到终止条件,打印当前指令,继续设置单步异常
invoke ShowContext
invoke Setp_
.endif
ret
OnTrace endp
|
硬件断点是intel CPU层面支持的一种断点,故得名硬件断点,在调试器中,可以通过设置被调试进程的DR0~7这8个调试寄存器来设置硬件断点
硬件断点最多只能设置4个,支持执行、读、写三种断点,对于读写断点,还支持设置长度(1、2、4、8字节)
由于硬件断点是通过寄存器来设置的,众所周知,每个线程都有自己的寄存器环境,所以硬件断点是线程相关的,比如给线程A设置的硬件断点,线程B并不会触发,虽然CPU支持设置"全局的"硬件断点,但是windows并不支持
由于设置硬件断点并不需要修改代码,不像软件断点那样需要修改指令,所以硬件断点有自己独特的应用场景,比如一段代码会被解密后执行,或者说一段内存会被填充代码之后执行,那么在填充之前,下CC断点是没有用的,因为填充内存时CC会被覆盖掉,这时候就需要硬件断点了,无论代码怎么改,只要走到了这个地址,硬件执行断点就会断下来
下面是CR0~CR7寄存器的结构
设置硬件断点的步骤可分为三步
上面8个寄存器,我们需要使用的分为两类:
设置断点类型和长度,DR7的16~31位一共16位,分别表示4个断点的类型和长度,比如16、17位表示CR0处的断点的类型(执行、读、写),18、19位表示CR0处的断点的长度(1、2、4)
启用断点,DR7的0~7位的每两位分别表示对应的断点是否启用,比如L0=1表示CR0处的断点启用,L0=1表示断点是线程相关的,G0=1表示是进程相关的,但是windows不支持,所以Gx位都给0就好了,第8位和第9位是L位和G位的大开关,如果要启动局部断点,那么LE位是必须置1的,当然GE位也是没用的
注意:如果rw位设置为0的话是执行断点,此时len位只能为0
现在我们知道了硬件断点该如何设置,还有一个问题就是如何给线程设置DR寄存器的值?使用GetThreadContext\SetThreadContext
即可
我们在自己的调试器中模拟windbg的硬件断点命令格式
1
|
ba e
/
r
/
w
1
/
2
/
4
addr
|
当用户输入上述格式命令时,解析断点类型、长度、地址,传入SetHardWareBp
作为参数
1
2
3
4
5
6
7
8
9
|
;...
;ba
.
if
@szCmd[
0
]
=
=
'b'
&& @szCmd[
1
]
=
=
'a'
invoke crt_sscanf, addr @szUserInput, offset g_szBaFmt,
addr @szCmd, addr @dwType, addr @dwLen, addr @dwAddr
invoke SetHardWareBp, @dwAddr, @dwType, @dwLen
.
continue
.endif
;...
|
在函数SetHardWareBp
中,我们首先获取线程上下文环境,然后通过Dr7.Lx位来判断断点是否启用,如果没有启用则使用这个位置来保存新的断点,如果四个断点都启用就打印提示断点用完了并且返回
然后通过参数dwType
和dwLen
来设置Dr7中对应断点的LEN和RW位,以及将对应的Dr7.Lx位置1,将Dr7.LE位置1,最后设置线程上下文
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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
|
;设置硬件断点
SetHardWareBp proc dwAddr: DWORD, dwType: DWORD, dwLen: DWORD
LOCAL @Context: CONTEXT
LOCAL @dwIdx: DWORD
LOCAL @dwRwLen: DWORD
mov @dwIdx,
-
1
invoke RtlZeroMemory,addr @Context, size @Context
mov @dwRwLen,
0
;获取context
invoke GetContext, addr @Context
;设置断点地址,通过Dr7.Lx位来判断能不能用
mov ebx, dwAddr
mov edx, @Context.iDr7
and
edx,
1h
.
if
edx
=
=
0
;DR0可用
mov @Context.iDr0, ebx
mov @dwIdx,
0
jmp FIND
.endif
mov edx, @Context.iDr7
and
edx,
4h
.
if
edx
=
=
0
;DR1可用
mov @Context.iDr1, ebx
mov @dwIdx,
1
jmp FIND
.endif
mov edx, @Context.iDr7
and
edx,
10h
.
if
edx
=
=
0
;DR3可用
mov @Context.iDr2, ebx
mov @dwIdx,
2
jmp FIND
.endif
mov edx, @Context.iDr7
and
edx,
40h
.
if
edx
=
=
0
;DR4可用
mov @Context.iDr3, ebx
mov @dwIdx,
3
jmp FIND
.endif
.
if
@dwIdx
=
=
-
1
;没有寄存器可用
invoke crt_printf, offset g_szNoHbpEmpty
ret
.endif
FIND:
mov @dwRwLen,
0
;设置断点类型和长度
.
if
dwType
=
=
'e'
;执行断点, rw
=
00
or
@dwRwLen,
0
mov dwLen,
1
.elseif dwType
=
=
'r'
;读断点, rw
=
11
or
@dwRwLen,
3
.elseif dwType
=
=
'w'
;写断点, rw
=
01
or
@dwRwLen,
1
.endif
mov eax, dwLen
sub eax,
1
shl eax,
2
or
@dwRwLen, eax
mov eax, @dwIdx
lea eax, [eax
*
4
]
mov ecx, eax
shl @dwRwLen, cl
mov eax, @dwRwLen
shl eax,
16
or
@Context.iDr7, eax
;启用大开关
or
@Context.iDr7,
100h
;启用特定断点的开关
mov eax,
1
mov ebx, @dwIdx
lea ebx, [ebx
*
2
]
mov ecx, ebx
shl eax, cl
or
@Context.iDr7, eax
;设置线程环境
and
@Context.iDr6,
0
invoke SetContext, addr @Context
ret
SetHardWareBp endp
|
触发断点也分两种情况:
硬件断点触发的也是单步异常,所以在单步异常的响应函数中增加判断,IsHbp
函数通过判断dr6寄存器的低4位来返回是否触发了硬件断点,从而调用OnHbp
响应硬件断点
g_bIsHsbp
是硬件执行断点触发后设置的标志,为1表示这是硬件执行断点之后的一个单步,需要恢复断点,函数RestoreHbp
也是简单的将Dr7.Lx位置1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
;触发单步断点
OnSingleStep proc pExcption: ptr EXCEPTION_RECORD
;...
;恢复硬件执行断点
.
if
g_bIsHsbp
mov g_bIsHsbp, FALSE
invoke RestoreHbp
.endif
;触发硬件断点
invoke IsHbp
.
if
eax
invoke OnHbp
.endif
ret
OnSingleStep endp
|
在OnHbp
函数中,首先通过Dr6寄存器的低4位判断是哪个断点触发了
然后计算出对应断点的类型和长度,如果是执行断点,则设置Dr7.Lx=0禁用此断点,然后设置Eflags.TF=1单步断点,最后将线程环境设置回去
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
|
;触发硬件断点
OnHbp proc
LOCAL @Context: CONTEXT
LOCAL @dwIdx: DWORD
invoke crt_printf, offset g_szHbp
invoke GetContext,addr @Context
;invoke ShowContext
.
if
@Context.iDr6 &
1
; 第一个断点
mov @dwIdx,
0
.elseif @Context.iDr6 &
2
;第二个断点
mov @dwIdx,
1
.elseif @Context.iDr6 &
4
;第三个断点
mov @dwIdx,
2
.elseif @Context.iDr6 &
8
;第四个断点
mov @dwIdx,
3
.endif
mov eax, @Context.iDr7
mov cl,
16
shr eax, cl
mov ecx, @dwIdx
lea ecx, [ecx
*
4
]
shr eax, cl
and
eax,
3
.
if
eax
=
=
00
;执行断点
;硬件断点停下来的时候,eip就等于断点设置的地址
;禁用此执行断点
xor edx, edx
mov edx,
1
mov ecx, @dwIdx
lea ecx, [ecx
*
2
]
shl edx, cl
not
edx ;
00000001
-
>
11111110
and
@Context.iDr7, edx
;设置单步断点,等下一次单步异常来的时候再设置此执行断点
or
@Context.regFlag,
100h
invoke SetContext,addr @Context
mov g_bIsHsbp, TRUE
mov ecx, @dwIdx
mov g_dwHbpIdx, ecx
.endif
;内存读写断点停下来的时候,eip等于触发断点的下一行指令的地址
;所以不需要设单步
and
@Context.iDr6,
0
invoke SetContext, addr @Context
;接受用户输入
invoke UserInput
ret
OnHbp endp
|
内存断点也是调试器不可缺少的重要功能,在x64dbg中,我们可以下内存的读、写、执行断点,内存断点的原理是将指定内存修改为不可访问,然后当执行/读/写这块内存的时候,就会触发内存访问异常C05,这时我们的调试器就可以接收到异常,然后再判断是否是因内存断点指定的地址处抛的异常,如果是的话则触发断点断下,如果不是断点处(修改内存属性的单位是一个页,触发异常的位置很可能不是下断点的位置),则继续执行
当CPU抛出内存访问异常时,是因为这条指令的执行违反的内存属性,所以这条指令是不可能执行成功的,所以当抛出异常时,eip还是指向当前的指令处,所以,无论有没有触发断点,都需要将内存属性恢复,然后设置单步断点和标志,在下一次单步来的时候重新设置内存属性为不可访问
核心的原理就是这样,但是还有很多细节需要考虑
在开始写内存断点之前,我们需要考虑一下问题:
1. 内存不存在
指定内存不存在的话,需要在下内存断点之前,需要调用VirtualQueryEx
函数来查询内存是否存在
2. 断点重叠
断点重叠,由于内存的最小单位是一个页(4096字节),所以设置内存的属性也是按页来设置的,如果说一个页内设置多个断点的话,那么如果直接修改内存,保存内存页原属性的话,第一个断点保存的是正常的,后面的断点保存的属性都是被第一个断点修改之后的属性,那么恢复内存属性的时候就会出问题,所以我们需要一张表,保存所有被修改过内存属性的页面,由于下断点的时候,修改内存属性只需要暴力修改成不可访问就行了,触发断点再来判断是不是我们需要的断点(r/w),所以,这张表中只需要保存内存页的基址
3. 断点跨页
我们的内存断点要支持任意长度,那么就必须要考虑跨页的问题,一个断点可能会跨2个甚至多个页,那么在恢复内存属性的时候,也必须恢复多个页的内存属性,那就必须将这个断点和页的关系保存下来,便于日后恢复的时候查询,那就还需要一张断点表和一张断点-内存表
综上所属,我们需要三张表:
断点表:保存断点的地址、长度和类型
内存页表:保存被修改过内存属性的页基址,内存页原属性
断点-内存表:保存一个断点占了的内存页。比如断点设在了0x401000上,但是长度为0x2000,那么就占了0x401000和0x402000这两个页,需要保存0x401000-0x401000、0x401000-0x402000这两项
在设置断点时,需要先计算出断点所占的页面,然后去内存页表中查询页面是否已经被修改过了,如果修改过了就直接不用修改内存属性,如果没修改过就需要修改页面属性,加入页面表中,然后再将断点加入断点表,断点对应几张内存页加入断点-内存表中
在触发断点时,我们从断点表中找到触发的断点,遍历断点-内存表,找到对应的内存页,然后查询每个内存页是否在断点-内存表中还有其他的断点在用,如果在用,则只要删除断点-内存表中自己这一项,不去修改内存属性,因为别的断点还要用,如果断点对应的内存页已经没有其他断点用了,那么就恢复内存属性,并且在内存页表中删除对应的内存页,最后删除断点表中的断点
这里我们采用如下的形式来设置、显示、删除内存断点
1
2
3
|
bmp
type
len
addr
/
/
设置断点
bml
/
/
显示所有断点
bmc index
/
/
删除指定断点
|
在处理用户输入时,如果用户输入的是bmp,则解析地址、类型、长度,并调用SetBmp
来设置断点
1
2
3
4
5
6
7
8
9
10
11
12
13
|
;bmp
.
if
@szCmd[
0
]
=
=
'b'
&& @szCmd [
1
]
=
=
'm'
&& @szCmd[
2
]
=
=
'p'
invoke crt_sscanf, addr @szUserInput, offset g_szBaFmt,
addr @szCmd, addr @dwType, addr @dwLen, addr @dwAddr
.
if
@dwType
=
=
'r'
mov @dwType,
0
.
else
mov @dwType,
1
.endif
invoke SetBmp, @dwAddr, @dwType, @dwLen
.
continue
.endif
|
在SetBmp
中,首先遍历断点所占的内存页,调用IsBadAddr
判断内存是否存在,如果不存在则提示下断点失败,IsBadAddr
中调用VirtualQueryEx
来获取内存属性,如果内存的状态为MEM_FREE,则说明被调试程序没有申请这块内存
然后,遍历断点所占页,看是否已经在被修改过属性的页面数组g_arrPageMod
中,如果存在,就不用修改属性,先前已经被修改过了,如果不存在,则调用VirtualProtectEx
修改内存属性为PAGE_NOACCESS
,然后加入到被修改过属性的页面数组g_arrPageMod
中
紧接着保存断点信息(地址、长度、类型)到断点数组g_arrBmp
最后,保存断点和页面的对应关系到断点页面数组g_arrBmpAndPage
(断点地址-占用的页面),一个断点可能占用多个页面,所以可能占用多个数组项
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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
|
;设置内存断点
SetBmp proc dwAddr: DWORD, dwType: DWORD, dwLen: DWORD
LOCAL @MemInfo: MEMORY_BASIC_INFORMATION
LOCAL dwPages: DWORD ;断点所占的页面数
LOCAL @dwBegin: DWORD
LOCAL @dwEnd: DWORD
LOCAL @dwOldPro: DWORD
mov eax, dwAddr
and
eax,
0FFFFF000h
mov @dwBegin, eax ;起始页面
mov eax, dwAddr
add eax, dwLen
mov @dwEnd, eax ;终止地址
;遍历断点涉及的每个页面,看是否内存存在
mov edi, @dwEnd
mov esi, @dwBegin
.
while
esi < edi
invoke IsBadAddr, esi
.
if
eax
=
=
0
;断点范围内,有内存页不存在,直接返回
invoke crt_printf, offset g_szNoMemTip
ret
.endif
add esi,
01000h
;下一个页
.endw
;遍历页面数组,如果当前断点涉及的页面不在数组中,则加入数组,修改页面属性
mov edi, @dwEnd
mov esi, @dwBegin
.
while
esi < edi
invoke IsInPageArr, esi
.
if
eax
=
=
1
;在数组内,不需要处理
add esi,
1000h
.
continue
.endif
;不在数组内,需要设置修改页面的内存属性为NO_ACCESS,加入页面数组
invoke FindEmptyInPageArr ;从页面数组中找到可用的位置存放页面
.
if
eax
=
=
-
1
;断点已满,下不了了
invoke crt_printf, offset g_szNoBmpPosTip
ret
.endif
lea ebx, [offset g_arrPageMod
+
eax
*
size PageMod]
assume ebx: ptr PageMod
;保存页面起始地址
mov [ebx].base , esi
push ebx
;修改内存属性
;TODO: 下面如果下断点失败,要恢复这个内存属性
invoke VirtualProtectEx, g_hProcess, esi,
01000h
, PAGE_NOACCESS, addr @dwOldPro
;保存页面属性
pop ebx
mov eax, @dwOldPro
mov [ebx].oldPro, eax
add esi,
01000h
;下一个页
.endw
;保存断点到断点数组
invoke FindEmptyInBmpArr
.
if
eax
=
=
-
1
;断点已满,下不了了
invoke crt_printf, offset g_szNoBmpPosTip
ret
.endif
mov ebx, size Bmp
mul ebx
add eax, offset g_arrBmp
mov ecx, eax
assume ecx: ptr Bmp
mov eax, dwAddr
mov [ecx].address, eax
mov eax, dwType
mov [ecx].type_, eax
mov eax, dwLen
mov [ecx].
len
, eax
;保存断点和页面的对应关系到断点页面数组
mov esi, @dwBegin
mov edi, @dwEnd
.
while
esi < edi
invoke FindEmptyInBmpAndPageArr ;找到断点
-
页面数组的空闲位置
mov ecx, size BmpAndPage
mul ecx
add eax, offset g_arrBmpAndPage
assume eax: ptr BmpAndPage
;写入数组
mov ecx, dwAddr
mov [eax].bmp_addr, ecx
mov [eax].page_base, esi
add esi,
01000h
.endw
ret
SetBmp endp
|
当我们将内存页的属性设置为PAGE_NOACCESS
之后,任何对内存的访问将会导致EXCEPTION_ACCESS_VIOLATION
(C05异常),那么我就在异常事件的处理中来响应C05异常,处理我们的内存断点
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
OnException proc pDebugEvent: ptr DEBUG_EVENT
LOCAL @dwRet: DWORD
mov esi, pDebugEvent
assume esi: ptr DEBUG_EVENT
.
if
[esi].u.Exception.pExceptionRecord.ExceptionCode
=
=
EXCEPTION_BREAKPOINT ;int3
invoke OnBreakPoint, addr [esi].u.Exception
.elseif [esi].u.Exception.pExceptionRecord.ExceptionCode
=
=
EXCEPTION_SINGLE_STEP ;单步
invoke OnSingleStep, addr [esi].u.Exception
.elseif [esi].u.Exception.pExceptionRecord.ExceptionCode
=
=
EXCEPTION_ACCESS_VIOLATION ;内存访问异常
invoke OnC05
.endif
ret
OnException endp
|
在OnC05
中,我们从DEBUG_EVENT中获取异常事件结构体EXCEPTION_DEBUG_INFO
,在异常事件结构体中获取异常记录结构体EXCEPTION_RECORD
1
2
3
4
5
6
7
8
|
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
是异常码,比如内存访问异常就是0xC0000005
ExceptionAddress
是触发异常的指令的地址NumberParameters
是数组ExceptionInformation
的大小ExceptionInformation
是参数数组,当异常码是C05时,数组大小为2::
所以通过数组ExceptionInformation
我们就可以获取到这次内存访问异常的所有信息
首先调用IsSelfC05
来判断这次C05异常是否是因为调试器修改内存属性而导致的,IsSelfC05
内部通过遍历被修改过属性的内存页表来判断,如果不是则返回DBG_EXCEPTION_NOT_HANDLED
否则,调用VirtualProtectEx
来恢复内存属性
为了让内存断点恢复,也需要设置一个单步断点,做一个标记,记录这个内存页面的基址,当下一个单步断点来的时候,重新将内存属性设置为PAGE_NOACCESS
最后,遍历断点数组,通过判断断点的范围和类型来看这个断点是不是下的断点,是的话停下来接收用户输入
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
|
;C05异常
OnC05 proc
LOCAL @dwOldPro: DWORD
LOCAL @exp_addr
mov esi, g_pDebugEvent
assume esi: ptr DEBUG_EVENT
mov ebx, [esi].u.Exception.pExceptionRecord.ExceptionInformation[
4
*
1
] ;注意不是ExceptionAddress,这是指令的地址
mov @exp_addr, ebx ;触发异常的地址,如果是内存读写异常,则是读写的那个地址,
and
@exp_addr,
0FFFFF000h
;取页基址
;判断是否是因为调试器修改内存属性而导致的C05
invoke IsSelfC05, @exp_addr
.
if
eax
=
=
-
1
;如果不是因为调试器主动触发的,则直接返回
mov g_dwContinueStatus, DBG_EXCEPTION_NOT_HANDLED
ret
.endif
mov @dwOldPro, eax
;恢复内存属性
invoke VirtualProtectEx,g_hProcess, @exp_addr,
1000h
, @dwOldPro, addr @dwOldPro
;保存这个内存页的基址
mov eax, @exp_addr
mov g_dwCurBmpPage, eax
;下单步,置标志,单步来了之后重新设置内存页的属性为NO_ACCESS
invoke SetTF
mov g_bIsBmpSbp, TRUE
;判断是否触发断点,如果触发断点,就停下来,否则直接返回
invoke IsTrigBmp
.
if
eax
invoke crt_printf, offset g_szTrigBmp
;接收用户输入
invoke UserInput
.endif
ret
OnC05 endp
|
在单步断点中,判断上面讲的内存断点的单步标志,然后调用VirtualProtectEx
来重新设置内存属性为PAGE_NOACCESS
1
2
3
4
5
6
7
|
;内存断点
.
if
g_bIsBmpSbp
;恢复内存属性
invoke VirtualProtectEx, g_hProcess, g_dwCurBmpPage,
1000h
, PAGE_NOACCESS, addr @dwOldPro
mov g_dwCurBmpPage,
0
mov g_bIsBmpSbp, FALSE
.endif
|
前面软/硬件断点都忘记说怎么删除断点了,不过这两个断点删除起来也简单,软件断点将CC覆盖的指令恢复,将断点从数组中删除;硬件断点将DR7.Lx位置0就好了
内存断点删起来因为三张表的存在有点麻烦
这里定义DelBmp
l来删除断点,传入参数是断点的序号,也就是在断点数组中的索引
首先从断点数组中保存对应断点到局部变量,然后将数组中这个断点清空
然后,遍历断点-内存页数组,找到这个断点涉及的页面,保存到局部变量,然后清空断点-内存页数组中这个断点的数据
再来遍历此断点对应的内存页,对于每个页,再次遍历断点-内存页数组,如果在断点-内存页还有这个内存页,说明还有其他断点在用这个内存页,那就说明都不做,如果没有其他断点用这个内存页了,那就从内存页表中删除这个内存页的项,再恢复这个内存页的属性
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
79
80
81
82
83
84
85
86
87
|
;删除内存断点
DelBmp proc dwIdx: DWORD
LOCAL @Bmp: Bmp
LOCAL @BmpAndPage[
10h
]: BmpAndPage ;不会有断点占
10
个页吧
LOCAL @PageMod: PageMod
LOCAL @i: DWORD
LOCAL @j: DWORD
LOCAL @dwOld: DWORD
mov @j,
0
mov @i,
0
invoke RtlZeroMemory, addr @Bmp, size @Bmp
invoke RtlZeroMemory,addr @BmpAndPage,
10h
*
size BmpAndPage
invoke RtlZeroMemory, addr @PageMod, size PageMod
;获取序号指定的断点结构体
mov eax, dwIdx
;断点序号超过范围
.
if
eax >
256
ret
.endif
mov ecx, size Bmp
mul ecx
add eax, offset g_arrBmp
assume eax: ptr Bmp
mov esi, eax
;暂存断点
invoke crt_memcpy, addr @Bmp, eax, size Bmp
;删除断点
invoke RtlZeroMemory, esi, size Bmp
;删除断点
-
内存页
.
while
@i <
256
mov eax, @i
mov ecx, size BmpAndPage
mul ecx
add eax, offset g_arrBmpAndPage
mov esi, eax
assume esi: ptr BmpAndPage
mov eax, @Bmp.address
.
if
[esi].bmp_addr
=
=
eax
;此内存断点对应的内存页,先拷贝到局部数组中保存
mov edx, @j
invoke crt_memcpy, addr @BmpAndPage[ edx
*
size BmpAndPage], esi, size BmpAndPage
inc @j
;删除此项
invoke RtlZeroMemory, esi, size BmpAndPage
.endif
inc @i
.endw
;遍历此断点对应的内存页,看有没有其他的断点在使用
;如果有,就不用删除内存页表中的项
;如果没有,则删除内存页表中的项
lea ebx, @BmpAndPage
mov @i,
0
mov edx, @j
.
while
@i < edx
mov eax, @i
mov ecx, size BmpAndPage
mul ecx
add eax, ebx
mov esi, eax
assume esi: ptr BmpAndPage
invoke IsInarrBmpAndPage, [esi].page_base
.
if
eax
=
=
1
;如果其他断点也占用了这块内存,那就不需要删除,不需要恢复内存属性
.
continue
inc @i
.endif
;从内存页表中删除内存页,恢复内存属性
invoke FindAndDelPageInArrPageMod, [esi].page_base
mov edi, eax
invoke VirtualProtectEx, g_hProcess, [esi].page_base,
1000h
, edi, addr @dwOld
inc @i
.endw
ret
DelBmp endp
|
到这里调试器主要的软件、硬件、内存断点、trace、单步已经实现完成了,剩下的功能就是显示反汇编、显示修改数据、寄存器、运行到返回等小功能了,那么这个简易的调试器就算是完成了,感谢阅读。
科锐44期学员
更多【从0开始编写简易调试器】相关视频教程:www.yxfzedu.com