从零开始编写简易调试器
2023-4-5 18:1:44 Author: 看雪学苑(查看原文) 阅读量:21 收藏


本文为看雪论坛精华文章

看雪论坛作者ID:st0ne

有一个项目是使用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,结构体如下:

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

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

(1)建立调试会话。

① 创建进程 CreateProcess,参数dwCreationFlags给DEBUG_ONLY_THIS_PROCESS,其他参数正常给。
② 附加进程DebugActiveProcess。

(2)循环接收调试事件WaitForDebugEvent。

(3)处理调试事件,接收到需要处理的调试事件(比如int 3)之后,接收用户输入,执行命令。

(4)返回处理结果ContinueDebugEvent。

(5)脱离被调试进程DebugActiveProcessStop。

框架代码如下:

环境:win11、x86汇编、radasm

.386.model flat, stdcall  ;32 bit memory modeloption casemap :none  ;case sensitive include Stdlib.Incinclude windows.incinclude kernel32.incinclude user32.incinclude Comctl32.incinclude shell32.incinclude msvcrt.inc includelib kernel32.libincludelib user32.libincludelib Comctl32.libincludelib shell32.libincludelib 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断点断下时输入,那么我们来加一下接收用户输入的代码。

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.1 原理

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

软件断点的本质是在指定地址处写了一个会触发异常的指令,最常用的是int 3指令(也可以不是int3,只要是可以抛异常的指令就行,帮比如特权指令),机器码是0xCC,当CPU运行到0xCC的时候,经过一系列异常派发,最终调试器会接收到异常调试事件,此时DEBUG_EVENT.dwDebugEventCode是EXCEPTION_DEBUG_EVENT,DEBUG_EVENT的第三个成员此时是EXCEPTION_DEBUG_INFO。

typedef struct _EXCEPTION_DEBUG_INFO {  EXCEPTION_RECORD ExceptionRecord; //异常记录  DWORD            dwFirstChance;    //第1次还是第2次异常} EXCEPTION_DEBUG_INFO, *LPEXCEPTION_DEBUG_INFO;

异常记录结构体中保存了关于异常的信息。

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。

5.2 设置断点

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

① 解析用户输入之后,取得要下断点的地址。
② 保存指定的地址的1字节数据。
③ 对指定地址写入0xCC。
④ 保存断点处地址和原有的1字节数据,以便于后续的断点删除和断点展示使用。
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     retSetBP endp

5.3 触发断点

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

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

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

;...;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异常和单步异常。

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    retOnException endp

在处理int3断点时,首先需要判断一下,是否是系统断点,如果是系统的初始断点就不用处理(eip已经指向下一条指令了,返回就能正常运行),如果是自己下的断点就需要特殊处理。

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    retOnBreakPoint endp

处理单步异常

;触发单步断点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    retOnSingleStep endp

当我们调用OutPutDebugStringA/W时,本质上也是发送调试事件给调试器,我们可以在调试器里面接收到打印的日志,并且输出,这里需要注意的是,调试字符串的地址是被调试进程的地址,不是调试器的,所以需要跨进程读写内存。

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    retOnOutPutDebugString endp

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

BOOL GetThreadSelectorEntry(  [in]  HANDLE      hThread,  [in]  DWORD       dwSelector,  [out] LPLDT_ENTRY lpSelectorEntry);

8.1 原理

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

① 单步步过,遇到call指令跳过,其他指令都是直接走到下一条指令。
② 单步步入,遇到call进去,其他指令都是直接走到下一条指令。

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

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

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

8.2 反汇编引擎

Capstone是一个多架构的反汇编框架,支持多种CPU架构和多种文件格式。它是一个开源项目,可以在许多平台上免费使用。Capstone提供了一个易于使用的API,可以将二进制代码反汇编为汇编代码,以及提取出汇编代码中的操作数和操作数类型。

我们使用capstone就可以判断当前的指令是否是call,以及call指令的长度,下面来看学习capstone的用法。

首先需要下载capstone的库,这里可以选择直接下载二进制文件(DLL和lib)或者是下载源码自己编译,我这里就直接下载(https://www.capstone-engine.org/download.html)二进制文件了。

下载下来之后发现又头文件(include),静态库(capstone.lib)和动态库(capstone.dll、capstone_dll.lib),这里选择使用静态库。

创建一个vs的控制台工程,将include文件夹和capstone.lib复制到vs工程目录下,在链接器->输入->附加依赖项中添加lib文件,然后写代码测试:

#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。

// 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 单步步入

步骤:

① 在接受用户输入时,判断是t,设置tf=1,然后返回。
② 在处理单步异常时,判断是t命令下的单步异常,则接收用户输入,与遇到int3断点时自动下的单步(为了恢复0xCC)区分开来。
;...;输入t命令时;t.if @szCmd[0] =='t'    invoke SetT    ret.endif;... ;设置单步断点SetT proc    ;设置tf标志位    invoke SetTF    mov g_IsSingleStepMaual, TRUE ;标记一下这个单步断点是手动下的    retSetT endp ;给调试线程的eflags的tf位设置1SetTF 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    retSetTF 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     retOnSingleStep endp

8.4 单步步过

步骤:

(1)用户输入p时,用反汇编引擎判断当前指令是不是call。

① 不是call,直接设置tf位。
② 是call,对下一条指令下int3,并且记录断点索引,便于后续删除。

    (2)触发int3断点时,如果是p指令下的int3断点,则只需要删除断点,不需要下单步断点去恢复CC。

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    retSetp_ 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    retOnBreakPoint endp

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

9.1 x64dbg的trace功能

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

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

点击日志文件,选择保存到的文件路径,点击确定就从当前eip开始自动单步。

当执行到满足暂停条件之后,执行就会下来,这时候可以去看日志文件,发现每执行一次单步,就会向文件中写入一条日志文本:

0062E8F2 mov ebp, esp0062E8F4 sub esp, 0x100062E8F7 mov eax, dword ptr ds:[0x00699FE8]0062E8FC and dword ptr ss:[ebp-0x8], 0x00062E900 and dword ptr ss:[ebp-0x4], 0x00062E904 push ebx0062E905 push edi0062E906 mov edi, 0xBB40E64E0062E90B cmp eax, edi0062E90D mov ebx, 0xFFFF00000062E912 je 0x0062E9210062E914 test ebx, eax0062E916 je 0x0062E9210062E918 not eax0062E91A mov dword ptr ds:[0x00699FEC], eax0062E91F jmp 0x0062E9810062E981 pop edi0062E982 pop ebx0062E983 leave0062E984 ret

od的trace也差不多。

9.2 代码实现

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

(1)在用户输入trace命令之后,设置单步异常eflags.TF=1,并设置tarce标志。

(2)单步异常来了之后,判断tarce标志,如果正在tarce,就判断暂停条件。

① 如果满足暂停条件就停下,并将trace标志清除。
② 如果不满足就继续设置单步异常eflags.TF=1。

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

首先接受用户输入,判断如果输入的是trace命令,则调用OnTrace函数。

;...;trace    invoke crt_strcmp, addr @szCmd, offset g_szTrace    .if eax == 0    invoke SetTrace, @uOpData    ret.endif;...

在SetTrace函数中,调用Setp_函数来下单步步过的命令,利用之前写的代码完成,并且设置trace标志与终止地址,然后就返回继续执行。

SetTrace proc EndAddr: DWORD    ;设置单步p    invoke Setp_     ;设置trace标志与终止地址    mov g_bTrace, TRUE    mov eax, EndAddr    mov g_dwTraceEnd, eax    ret SetTrace endp

此时可能有两种结果:

① 因单步异常停下来。
② 因int3异常停下来。

这两种情况分别区分于p命令时遇到call和没遇到call,那么需要在这两个地方停下来处理。

触发单步断点时,如果trace标志为真,则表示正在trace,调用OnTrace函数。

;触发单步断点OnSingleStep proc  pExcption: ptr EXCEPTION_RECORD;...     ;正在trace    .if g_bTrace        invoke OnTrace    .endif;...    retOnSingleStep endp

触发int3断点时,如果trace标志为真,则表示正在trace,调用OnTrace函数。

;触发int3断点OnBreakPoint proc pExcption: ptr EXCEPTION_RECORD;...     ;如果正在trace,继续设置单步p    .if g_bTrace        invoke OnTrace    .endif;...    retOnBreakPoint endp

这两处统一调用OnTrace函数处理。

在OnTrace函数中,先获取当前eip,如果当前按eip等于要暂停的地址,则清除标志,接受用户输入,否则单纯打印一下当前寄存器环境和汇编指令,然后继续设置单步步进。

;正在TraceOnTrace 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.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位的取值:

    00 — 执行

    01 — 写

    10 — IO断点

    11 — 读

    (2)LEN位的取值:

    00 — 1-byte length.

    01 — 2-byte length.

    10 — Undefined (or 8 byte length, x64).

    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的硬件断点命令格式。

ba e/r/w 1/2/4 addr

当用户输入上述格式命令时,解析断点类型、长度、地址,传入SetHardWareBp作为参数。

;...;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,最后设置线程上下文。

;设置硬件断点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。

;触发单步断点OnSingleStep proc  pExcption: ptr EXCEPTION_RECORD;...    ;恢复硬件执行断点    .if g_bIsHsbp        mov g_bIsHsbp, FALSE        invoke RestoreHbp    .endif     ;触发硬件断点    invoke IsHbp    .if eax        invoke OnHbp    .endif     retOnSingleStep endp

在OnHbp函数中,首先通过Dr6寄存器的低4位判断是哪个断点触发了。

然后计算出对应断点的类型和长度,如果是执行断点,则设置Dr7.Lx=0禁用此断点,然后设置Eflags.TF=1单步断点,最后将线程环境设置回去。

;触发硬件断点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 设置断点

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

bmp type len addr     //设置断点bml                    //显示所有断点bmc    index            //删除指定断点

在处理用户输入时,如果用户输入的是bmp,则解析地址、类型、长度,并调用SetBmp来设置断点。

;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(断点地址-占用的页面),一个断点可能占用多个页面,所以可能占用多个数组项。

;设置内存断点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     retSetBmp endp

11.4 触发断点

当我们将内存页的属性设置为PAGE_NOACCESS之后,任何对内存的访问将会导致EXCEPTION_ACCESS_VIOLATION(C05异常),那么我就在异常事件的处理中来响应C05异常,处理我们的内存断点。

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     retOnException endp

在OnC05中,我们从DEBUG_EVENT中获取异常事件结构体EXCEPTION_DEBUG_INFO ,在异常事件结构体中获取异常记录结构体EXCEPTION_RECORD。

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。

最后,遍历断点数组,通过判断断点的范围和类型来看这个断点是不是下的断点,是的话停下来接收用户输入。

;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    retOnC05 endp

11.5 恢复断点

在单步断点中,判断上面讲的内存断点的单步标志,然后调用VirtualProtectEx来重新设置内存属性为PAGE_NOACCESS。

;内存断点.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来删除断点,传入参数是断点的序号,也就是在断点数组中的索引。

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

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

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

;删除内存断点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、单步已经实现完成了,剩下的功能就是显示反汇编、显示修改数据、寄存器、运行到返回等小功能了,那么这个简易的调试器就算是完成了,感谢阅读。

看雪ID:st0ne

https://bbs.kanxue.com/user-home-887003.htm

*本文由看雪论坛 st0ne 原创,转载请注明来自看雪社区

# 往期推荐

1、Realworld CTF 2023 The_cult_of_8_bit详解
2、在 Windows下搭建LLVM 使用环境
3、深入学习smali语法
4、安卓加固脱壳分享
5、Flutter 逆向初探
6、一个简单实践理解栈空间转移

球分享

球点赞

球在看

点击“阅读原文”,了解更多!


文章来源: http://mp.weixin.qq.com/s?__biz=MjM5NTc2MDYxMw==&mid=2458500902&idx=2&sn=13bbd7dcc0679a36701177a6b2da3f79&chksm=b18e8fac86f906baaf111a29fbd8021b37d39988fb3dcd20062f1f33811331a84d71697fe1de#rd
如有侵权请联系:admin#unsafe.sh