首页
社区
课程
招聘
[原创]从0开始编写简易调试器
2023-2-20 07:36 20058

[原创]从0开始编写简易调试器

2023-2-20 07:36
20058

目录

 

目前在科锐学习三阶段,有一个项目是使用x86汇编始编写一个windows端的控制台调试器的过程,记录一下学习编写调试器的过程

1 调试框架

windows的调试框架是依托于异常体系,由事件驱动

 

这里的事件指的是调试事件(DebugEvent),整个用户态异常的处理过程如下:

  • 当CPU执行到一些特殊的指令,比如int3int1或者是除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,如果是继续处理异常,则会返回到用户态,执行用户态异常分发KiUserExceptionDispatcherVEH->SEH->UEH,如果处理完之后还是没能将异常处理成功,则继续通过ZwRaiseException返回内核,然后就是第二次派发给调试器,如果调试器还是没有处理成功,则将异常发送给异常处理端口(csrss.exe),然后结束进程

在以上过程中,调试器、VEH、SEH都可以成功处理异常,然后返回到异常触发现场继续执行代码

 

示意图如下:

 

2. 调试事件

上面说到,当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种,而除了异常事件之外,其他的八种事件仅仅是通知调试器一下,并不需要调试器做出什么回应,调试器需要关注的是异常事件,被调试进程中触发的所有异常都会发送两次给调试器,对于调试器来说,最重要的就是三大断点(软件、硬件、内存)和单步,都是通过异常来实现的

3. 编写调试器的步骤

经过上面两节的学习,应该对调试体系有了一些认识,接下来就来看一下,编写一个简单的命令行调试器的一般步骤:

  1. 建立调试会话
    1. 创建进程 CreateProcess,参数dwCreationFlagsDEBUG_ONLY_THIS_PROCESS,其他参数正常给
    2. 附加进程DebugActiveProcess
  2. 循环接收调试事件WaitForDebugEvent
  3. 处理调试事件,接收到需要处理的调试事件(比如int 3)之后,接收用户输入,执行命令
  4. 返回处理结果ContinueDebugEvent
  5. 脱离被调试进程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

4 用户输入

在控制台版的调试器中,什么时候用户可以输入呢?可以参考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       
;...

5. 软件断点

5.1 原理

我们在使用OD时,经常会对一条汇编指令按下F2下断点,在程序运行到这个地址的时候OD就会停下,这就是对这个地址下了一个软件断点

 

软件断点的本质是在指定地址处写了一个会触发异常的指令,最常用的是int 3指令(也可以不是int3,只要是可以抛异常的指令就行,帮比如特权指令),机器码是0xCC,当CPU运行到0xCC的时候,经过一系列异常派发,最终调试器会接收到异常调试事件,此时DEBUG_EVENT.dwDebugEventCodeEXCEPTION_DEBUG_EVENTDEBUG_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异常时,ExceptionCodeEXCEPTION_BREAKPOINT

5.2 设置断点

所以,我们自己编写调试器实现软件断点也使用int3指令,要设置一个int3断点很简单,步骤如下:

  1. 解析用户输入之后,取得要下断点的地址
  2. 保存指定的地址的1字节数据
  3. 对指定地址写入0xCC
  4. 保存断点处地址和原有的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
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

5.3 触发断点

在触发断点之后,调试器会接收到int3异常,我们在int3异常中需要做一些事情来消除我们下的int3断点的影响,因为此时int3指令已经执行完了,而原有保存在这里的指令并没有执行,步骤如下:

  1. 恢复0xCC覆盖的指令
  2. 设置此线程的eip-1,重新指向被0xCC覆盖的指令
  3. 设置单步异常(eflags.tf=1),执行完这条指令之后,马上又会回到调试器,重新写上0xCC以便于下次再次触发断点

首先,响应调试事件中的异常事件

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

6. 调试字符串

当我们调用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

7. 段寄存器

调用函数GetThreadSelectorEntry 可在3环获取段描述符,解析段描述符就可以拿到段的基址和界限

1
2
3
4
5
BOOL GetThreadSelectorEntry(
  [in]  HANDLE      hThread,
  [in]  DWORD       dwSelector,
  [out] LPLDT_ENTRY lpSelectorEntry
);

8. 单步断点

8.1 原理

单步断点是是调试器的一个最重要的功能,就是在OD中按下F8或者F7之后运行到下一条指令,分两种情况:

  1. 单步步过,遇到call指令跳过,其他指令都是直接走到下一条指令
  2. 单步步入,遇到call进去,其他指令都是直接走到下一条指令

单步步过很好处理,只需要在用户输入命令之后,设置Elfags.TF=1,那么被调试进程则会在执行完这条机器指令之后抛出单步异常(0x80000004),然后就会被我们的调试器接收到,停下来继续接收用户的输入就好了

 

单步步过就有一点麻烦了,首先的判断当前这条指令是不是call指令,如果是call的话就需要对下一条指令下一个软件断点,然后返回继续运行,直到被调试程序执行到这个断点,就将这个断点删除,注意跟自己下的软件断点区分开来,自己下的需要再次设置单步来重设CC,而因为单步步过下的软件断点只需要恢复被CC覆盖的指令,而不需要设置单步后重设CC

 

还有两个问题就是,我们如何得知当前指令是call、以及下一条指令的地址是多少,要算出来这两个,就需要反汇编引擎的支持

8.2 反汇编引擎

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

接下来就编写单步的代码

8.3 单步步入

步骤:

  1. 在接受用户输入时,判断是t,设置tf=1,然后返回
  2. 在处理单步异常时,判断是t命令下的单步异常,则接收用户输入,与遇到int3断点时自动下的单步(为了恢复0xCC)区分开来
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

8.4 单步步过

步骤:

  1. 用户输入p时,用反汇编引擎判断当前指令是不是call
    1. 不是call,直接设置tf位
    2. 是call,对下一条指令下int3,并且记录断点索引,便于后续删除
  2. 触发int3断点时,如果是p指令下的int3断点,则只需要删除断点,不需要下单步断点去恢复CC
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

9. 追踪

调试器的追踪功能就是自动单步执行指令并记录执行过的指令,比如在x64dbg下使用Trace功能

9.1 x64dbg的trace功能

首先点击跟踪,步进直到条件满足,意思是一直自动单步步入,直到满足某个条件

 

 

在弹出的窗口中可以填写自动单步的终点的条件,比如eip=0x12345678,然后在日志文本中填写要记录的信息,这里的信息需要使用x64dbg的字符串格式化功能,比如{p:eip} {i: eip},{}就相当于C语言的printf,冒号前面的i和p相当于格式化符号,i表示指定地址处的汇编指令,p表示将指定数据格式化成十六进制地址的形式,冒号写数据,详细说明见x64dbg手册

 

点击日志文件,选择保存到的文件路径,点击确定就从当前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也差不多

9.2 代码实现

我们要在自己的调试器中实现trace功能的话也很简单,步骤如下:

  1. 在用户输入trace命令之后,设置单步异常eflags.TF=1,并设置tarce标志
  2. 单步异常来了之后,判断tarce标志,如果正在tarce,就判断暂停条件
    1. 如果满足暂停条件就停下,并将trace标志清除
    2. 如果不满足就继续设置单步异常eflags.TF=1

但是要支持暂停条件就有点麻烦,那就写简单一点,暂时只支持trace 0x12345678,单步执行到地址0x12345678就停下,然后单步可选择是单步步入或者是单步步入,这里暂时写死单步步过,下面来看代码实现

 

首先接受用户输入,判断如果输入的是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

此时可能有两种结果:

  1. 因单步异常停下来
  2. 因int3异常停下来

这两种情况分别区分于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

10. 硬件断点

10.1 原理

硬件断点是intel CPU层面支持的一种断点,故得名硬件断点,在调试器中,可以通过设置被调试进程的DR0~7这8个调试寄存器来设置硬件断点

 

硬件断点最多只能设置4个,支持执行、读、写三种断点,对于读写断点,还支持设置长度(1、2、4、8字节)

 

由于硬件断点是通过寄存器来设置的,众所周知,每个线程都有自己的寄存器环境,所以硬件断点是线程相关的,比如给线程A设置的硬件断点,线程B并不会触发,虽然CPU支持设置"全局的"硬件断点,但是windows并不支持

 

由于设置硬件断点并不需要修改代码,不像软件断点那样需要修改指令,所以硬件断点有自己独特的应用场景,比如一段代码会被解密后执行,或者说一段内存会被填充代码之后执行,那么在填充之前,下CC断点是没有用的,因为填充内存时CC会被覆盖掉,这时候就需要硬件断点了,无论代码怎么改,只要走到了这个地址,硬件执行断点就会断下来

10.2 设置方法

下面是CR0~CR7寄存器的结构

设置硬件断点的步骤可分为三步

 

上面8个寄存器,我们需要使用的分为两类:

  1. 写入断点地址,DR0~DR3用来保存4个断点的地址,如果是执行断点就是执行到这个地址就断下,如果是读、写断点,则在读、写这个地址的时候就断下
  2. 设置断点类型和长度,DR7的16~31位一共16位,分别表示4个断点的类型和长度,比如16、17位表示CR0处的断点的类型(执行、读、写),18、19位表示CR0处的断点的长度(1、2、4)

    1. RW位的取值:
      1. 00 — 执行
      2. 01 — 写
      3. 10 — IO断点
      4. 11 — 读
    2. LEN位的取值
      1. 00 — 1-byte length.
      2. 01 — 2-byte length.
      3. 10 — Undefined (or 8 byte length, x64).
      4. 11 — 4-byte length.

  3. 启用断点,DR7的0~7位的每两位分别表示对应的断点是否启用,比如L0=1表示CR0处的断点启用,L0=1表示断点是线程相关的,G0=1表示是进程相关的,但是windows不支持,所以Gx位都给0就好了,第8位和第9位是L位和G位的大开关,如果要启动局部断点,那么LE位是必须置1的,当然GE位也是没用的

  4. 触发断点,硬件断点触发的异常也是单步异常,DR6寄存器的B0~B3为1时表示当前触发的单步异常是由哪个硬件断点触发的

注意:如果rw位设置为0的话是执行断点,此时len位只能为0

 

现在我们知道了硬件断点该如何设置,还有一个问题就是如何给线程设置DR寄存器的值?使用GetThreadContext\SetThreadContext即可

10.2 设置断点

我们在自己的调试器中模拟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位来判断断点是否启用,如果没有启用则使用这个位置来保存新的断点,如果四个断点都启用就打印提示断点用完了并且返回

 

然后通过参数dwTypedwLen来设置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

10.3 触发断点

触发断点也分两种情况:

  1. 执行断点,触发执行断点时线程eip指向断点的地址,因为CPU可以在取指之前就知道这个地址要触发int1异常,所以就不执行指令直接抛异常了,对于这种情况,我们需要禁用此断点,否则会一直抛这个异常,然后设置单步,等这条指令执行完触发单步异常时,再来重启断点
  2. 读、写断点,而对于读写断点,CPU至少需要到译码阶段才能知道会触发断点,此时eip以及指向下一条指令了,所以触发读写断点时,eip指向下一条指令,对于这种情况,不需要特殊处理

硬件断点触发的也是单步异常,所以在单步异常的响应函数中增加判断,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

11. 内存断点

11.1 原理

内存断点也是调试器不可缺少的重要功能,在x64dbg中,我们可以下内存的读、写、执行断点,内存断点的原理是将指定内存修改为不可访问,然后当执行/读/写这块内存的时候,就会触发内存访问异常C05,这时我们的调试器就可以接收到异常,然后再判断是否是因内存断点指定的地址处抛的异常,如果是的话则触发断点断下,如果不是断点处(修改内存属性的单位是一个页,触发异常的位置很可能不是下断点的位置),则继续执行

 

当CPU抛出内存访问异常时,是因为这条指令的执行违反的内存属性,所以这条指令是不可能执行成功的,所以当抛出异常时,eip还是指向当前的指令处,所以,无论有没有触发断点,都需要将内存属性恢复,然后设置单步断点和标志,在下一次单步来的时候重新设置内存属性为不可访问

 

核心的原理就是这样,但是还有很多细节需要考虑

11.2 内存的问题

在开始写内存断点之前,我们需要考虑一下问题:

 

1. 内存不存在

 

​ 指定内存不存在的话,需要在下内存断点之前,需要调用VirtualQueryEx函数来查询内存是否存在

 

2. 断点重叠

 

​ 断点重叠,由于内存的最小单位是一个页(4096字节),所以设置内存的属性也是按页来设置的,如果说一个页内设置多个断点的话,那么如果直接修改内存,保存内存页原属性的话,第一个断点保存的是正常的,后面的断点保存的属性都是被第一个断点修改之后的属性,那么恢复内存属性的时候就会出问题,所以我们需要一张表,保存所有被修改过内存属性的页面,由于下断点的时候,修改内存属性只需要暴力修改成不可访问就行了,触发断点再来判断是不是我们需要的断点(r/w),所以,这张表中只需要保存内存页的基址

 

3. 断点跨页

 

​ 我们的内存断点要支持任意长度,那么就必须要考虑跨页的问题,一个断点可能会跨2个甚至多个页,那么在恢复内存属性的时候,也必须恢复多个页的内存属性,那就必须将这个断点和页的关系保存下来,便于日后恢复的时候查询,那就还需要一张断点表和一张断点-内存表

 

综上所属,我们需要三张表:

 

断点表:保存断点的地址、长度和类型

 

内存页表:保存被修改过内存属性的页基址,内存页原属性

 

断点-内存表:保存一个断点占了的内存页。比如断点设在了0x401000上,但是长度为0x2000,那么就占了0x401000和0x402000这两个页,需要保存0x401000-0x401000、0x401000-0x402000这两项

 

在设置断点时,需要先计算出断点所占的页面,然后去内存页表中查询页面是否已经被修改过了,如果修改过了就直接不用修改内存属性,如果没修改过就需要修改页面属性,加入页面表中,然后再将断点加入断点表,断点对应几张内存页加入断点-内存表中

 

在触发断点时,我们从断点表中找到触发的断点,遍历断点-内存表,找到对应的内存页,然后查询每个内存页是否在断点-内存表中还有其他的断点在用,如果在用,则只要删除断点-内存表中自己这一项,不去修改内存属性,因为别的断点还要用,如果断点对应的内存页已经没有其他断点用了,那么就恢复内存属性,并且在内存页表中删除对应的内存页,最后删除断点表中的断点

11.3 设置断点

这里我们采用如下的形式来设置、显示、删除内存断点

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

11.4 触发断点

当我们将内存页的属性设置为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::
    • 第一个元素:
      • 0:读内存触发的异常
      • 1:写内存触发的异常
      • 8:触发了DEP
    • 第二个元素:内存的地址

所以通过数组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

11.5 恢复断点

在单步断点中,判断上面讲的内存断点的单步标志,然后调用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

11.6 删除断点

前面软/硬件断点都忘记说怎么删除断点了,不过这两个断点删除起来也简单,软件断点将CC覆盖的指令恢复,将断点从数组中删除;硬件断点将DR7.Lx位置0就好了

 

内存断点删起来因为三张表的存在有点麻烦

 

这里定义DelBmpl来删除断点,传入参数是断点的序号,也就是在断点数组中的索引

 

首先从断点数组中保存对应断点到局部变量,然后将数组中这个断点清空

 

然后,遍历断点-内存页数组,找到这个断点涉及的页面,保存到局部变量,然后清空断点-内存页数组中这个断点的数据

 

再来遍历此断点对应的内存页,对于每个页,再次遍历断点-内存页数组,如果在断点-内存页还有这个内存页,说明还有其他断点在用这个内存页,那就说明都不做,如果没有其他断点用这个内存页了,那就从内存页表中删除这个内存页的项,再恢复这个内存页的属性

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期学员


[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课

最后于 2023-3-26 17:56 被st0ne编辑 ,原因: 修改内容
上传的附件:
收藏
点赞39
打赏
分享
打赏 + 5.00雪花
打赏次数 1 雪花 + 5.00
 
赞赏  orz1ruo   +5.00 2023/02/21 精品文章~
最新回复 (22)
雪    币: 1918
活跃值: (313)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
wangez8 2023-2-20 07:41
2
1
多谢老师分享!等待下集!
雪    币: 59
活跃值: (2231)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
NutCracker 2023-2-20 10:23
3
2
DR7的RW位=11并非表示Read,而是Read or Write。X86并不支持单纯的Read硬件断点,像Soft-ICE虽然允许用户设置bpmb es:bx R这样的断点,但它是通过先设bpmb es:bx RW断点,等断点发生时再判断变量的值有没有改变实现的。微软这个蠢货到现在还没有在VS中实现Read硬件断点估计是不知道这个技巧。
雪    币: 59
活跃值: (2231)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
NutCracker 2023-2-20 10:28
4
2
另外,表示执行断点时习惯用法是用字母x,而不是e。像linux里面表示文件的权限也是用r/w/x,Soft-ICE里面表示硬件执行断点也是用bpmb es:bx x。
雪    币: 12058
活跃值: (15384)
能力值: ( LV12,RANK:240 )
在线值:
发帖
回帖
粉丝
pureGavin 2 2023-2-20 10:30
5
1
感谢分享
雪    币: 2099
活跃值: (3766)
能力值: ( LV6,RANK:80 )
在线值:
发帖
回帖
粉丝
st0ne 1 2023-2-21 00:45
6
0
NutCracker DR7的RW位=11并非表示Read,而是Read or Write。X86并不支持单纯的Read硬件断点,像Soft-ICE虽然允许用户设置bpmb es:bx R这样的断点,但它是通过先设bpmb ...
学习了学习了
雪    币: 340
活跃值: (897)
能力值: ( LV9,RANK:220 )
在线值:
发帖
回帖
粉丝
noword_forever 5 2023-2-21 09:13
7
2
capstone支持多种CPU指令集,所以它太大了,怎么也说不上是轻量级。
如果只是针对x86/64的话,建议用udis86
雪    币: 1501
活跃值: (3260)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
小希希 2023-2-21 09:38
8
1
非常棒,感谢分享
雪    币: 6
活跃值: (995)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
菜鸟也想飞 2023-2-21 09:40
9
1
感谢分享!
雪    币: 2099
活跃值: (3766)
能力值: ( LV6,RANK:80 )
在线值:
发帖
回帖
粉丝
st0ne 1 2023-2-21 17:34
10
1
noword_forever capstone支持多种CPU指令集,所以它太大了,怎么也说不上是轻量级。 如果只是针对x86/64的话,建议用udis86
确实不是轻量级的,我改一下
雪    币: 827
活跃值: (3500)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
逆向爱好者 2023-3-21 07:58
11
1
逆向开源建议用c,c++
雪    币: 5080
活跃值: (4384)
能力值: ( LV5,RANK:65 )
在线值:
发帖
回帖
粉丝
gamehack 2023-3-21 08:57
12
1
感谢分享,写了这么多,辛苦辛苦!
雪    币: 2099
活跃值: (3766)
能力值: ( LV6,RANK:80 )
在线值:
发帖
回帖
粉丝
st0ne 1 2023-3-26 17:58
13
0
逆向爱好者 逆向开源建议用c,c++
没办法,项目要求用32位汇编写
雪    币: 576
活跃值: (2035)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
kakasasa 2023-3-26 21:17
14
1
感谢分享,写的认真啊
雪    币: 235
活跃值: (346)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
asm_zhang 2023-4-1 00:25
15
0
45期来了
雪    币: 2099
活跃值: (3766)
能力值: ( LV6,RANK:80 )
在线值:
发帖
回帖
粉丝
st0ne 1 2023-4-1 16:30
16
0
asm_zhang 45期来了[em_13]
加油
雪    币: 197
活跃值: (209)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
一只小菜鸡Y 2023-4-10 09:25
17
0
科锐的太猛了  写汇编版本的
雪    币: 411
活跃值: (1170)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
foxkinglxq 2023-6-4 23:37
18
0
这个是32位的   
雪    币: 411
活跃值: (1170)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
foxkinglxq 2023-6-16 18:40
19
0
楼主 内存断点的代码 没有在这个文件里面吗?  
雪    币: 1006
活跃值: (369)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
ranshu 2023-6-18 19:36
20
0
感谢分享。谢谢
雪    币: 19299
活跃值: (28933)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
秋狝 2023-6-18 22:13
21
1
感谢分享
雪    币: 4
活跃值: (124)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
PUSHOP 2023-10-6 19:55
22
0
感谢分享,谢谢。
雪    币: 729
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
moyujojo 2023-10-11 17:42
23
0
好厉害
游客
登录 | 注册 方可回帖
返回