调试框架
- 特点:事件驱动,事件响应。 Win32 程序是消息驱动响应的基址,而在调试器则是事件驱动响应,有事件则处理,无事件则去做别的事。
- 事件:整个调试框架是建立在异常的基础之上的基本单位。
- 响应:通过事件循环体,持续的获取事件,处理事件。
创建调试会话
创建进程
创建进程主要是通过 CreateProcess
函数来实现的,该函数的原型如下:
BOOL CreateProcess(LPCTSTR lpApplicationName, // name of executable moduleLPTSTR lpCommandLine, // command line stringLPSECURITY_ATTRIBUTES lpProcessAttributes, // SDLPSECURITY_ATTRIBUTES lpThreadAttributes, // SDBOOL bInheritHandles, // handle inheritance optionDWORD dwCreationFlags, // creation flagsLPVOID lpEnvironment, // new environment blockLPCTSTR lpCurrentDirectory, // current directory nameLPSTARTUPINFO lpStartupInfo, // startup informationLPPROCESS_INFORMATION lpProcessInformation // process information);
下面是 CreateProcess
函数的一些参数的说明:
lpApplicationName
:指向可执行文件的路径或者文件名。lpCommandLine
:指向一个以 null 结尾的字符串,包含了命令行参数。lpProcessAttributes
和lpThreadAttributes
:用于指定进程和线程的安全性属性,默认为 NULL。bInheritHandles
:指示新进程是否继承父进程的句柄。dwCreationFlags
:指定进程的创建标志,例如是否创建一个新的控制台窗口、创建方式等。lpEnvironment
:指定新进程的环境变量,如果为 NULL,则继承父进程的环境变量。lpCurrentDirectory
:指定新进程的当前工作目录,如果为 NULL,则继承父进程的当前目录。lpStartupInfo
:指向一个STARTUPINFO
结构体,用于指定新进程的一些启动参数,例如窗口大小、显示方式等。lpProcessInformation
:指向一个PROCESS_INFORMATION
结构体,用于接收新进程的相关信息,例如进程句柄和线程句柄。CreateProcess
函数成功执行时会返回非零值,否则返回零。
针对 dwCreationFlags
参数,有 DEBUG_ONLY_THIS_PROCESS
和 DEBUG_PROCESS
两个标志参数用于指定创建进程时的调试模式:
DEBUG_PROCESS
:调试器会受到目标进程及由目标进程创建的所有子进程中发生的调试事件。DEBUG_ONLY_THIS_PROCESS
:调试器只收到目标进程的调试事件,对子进程不响应。
调试器通常设置的都是 DEBUG_ONLY_THIS_PROCESS
标志位。
附加
如果想要通过附加调试某一进程需要使用 DebugActiveProcess
函数,该函数定义如下:
BOOL DebugActiveProcess(DWORD dwProcessId // process to be debugged
);
dwProcessId
:要调试的目标进程的进程标识符(PID)。- 函数调用成功时,返回值为非零值;否则,返回值为零。
- 该函数附加进程效果与创建进程使用
DEBUG_ONLY_THIS_PROCESS
效果类似。
如果想脱离附加的进程可以使用 DebugActiveProcessStop
函数,该函数定义如下:
BOOL DebugActiveProcessStop(DWORD dwProcessId
);
dwProcessId
:要调试的目标进程的进程标识符(PID)。- 函数调用成功时,返回值为非零值;否则,返回值为零。
然而有时脱离附加的进程会导致被附加的进程退出,这时候就需要在调用 DebugActiveProcessStop
函数之前调用 DebugSetProcessKillOnExit
来防止被调试进程退出,该函数定义如下:
BOOL DebugSetProcessKillOnExit ( BOOL KillOnExit);
KillOnExit
:指定调试器在退出时是否终止被调试的进程。如果设置为TRUE
,调试器在退出时将终止被调试的进程;如果设置为FALSE
,调试器在退出时不会终止被调试的进程。- 函数调用成功时,返回值为非零值;否则,返回值为零。
循环接受调试事件
等待调试事件使用的是 WatiForDebugEvent
函数,该函数定义如下:
BOOL WaitForDebugEvent(LPDEBUG_EVENT lpDebugEvent, // debug event informationDWORD dwMilliseconds // time-out value
);
lpDebugEvent
:一个指向DEBUG_EVENT
结构的指针,用于接收调试事件的信息。DEBUG_EVENT
结构包含了调试事件的类型和相关的数据,如调试进程、线程、异常等。dwMilliseconds
:等待调试事件的超时时间,以毫秒为单位。如果设置为INFINITE
(0xFFFFFFFF),则表示无限等待,直到有调试事件发生。如果设置为零,则表示不等待,立即返回。- 函数调用成功时,返回值为非零值;否则,返回值为零。
接收调试事件的 DEBUG_EVENT
结构体定义如下:
typedef struct _DEBUG_EVENT { DWORD dwDebugEventCode; DWORD dwProcessId; DWORD dwThreadId; union { EXCEPTION_DEBUG_INFO Exception; CREATE_THREAD_DEBUG_INFO CreateThread; CREATE_PROCESS_DEBUG_INFO CreateProcessInfo; EXIT_THREAD_DEBUG_INFO ExitThread; EXIT_PROCESS_DEBUG_INFO ExitProcess; LOAD_DLL_DEBUG_INFO LoadDll; UNLOAD_DLL_DEBUG_INFO UnloadDll; OUTPUT_DEBUG_STRING_INFO DebugString; RIP_INFO RipInfo; } u;
} DEBUG_EVENT, *LPDEBUG_EVENT;
dwDebugEventCode
:表示调试事件的类型。可以是以下值之一:EXCEPTION_DEBUG_EVENT
:异常事件。CREATE_THREAD_DEBUG_EVENT
:线程创建事件。CREATE_PROCESS_DEBUG_EVENT
:进程创建事件。EXIT_THREAD_DEBUG_EVENT
:线程退出事件。EXIT_PROCESS_DEBUG_EVENT
:进程退出事件。LOAD_DLL_DEBUG_EVENT
:动态链接库加载事件。UNLOAD_DLL_DEBUG_EVENT
:动态链接库卸载事件。OUTPUT_DEBUG_STRING_EVENT
:输出调试字符串事件。RIP_EVENT
:RIP(调试错误)事件。
dwProcessId
:调试事件所属的进程标识符(PID)。dwThreadId
:调试事件所属的线程标识符(TID)。u
:一个联合体,用于存储不同类型的调试事件数据。根据dwDebugEventCode
的不同值,使用相应的字段来访问具体的调试事件数据。
处理调试事件
调试器具体代码实现。
提交处理结果
在处理调试事件时被调试进程是处于挂起的状态,因此提交处理结果是告诉被调试进程是否继续运行。提交处理结果的函数是 ContinueDebugEvent
,该函数定义如下:
BOOL ContinueDebugEvent(DWORD dwProcessId, // process to continueDWORD dwThreadId, // thread to continueDWORD dwContinueStatus // continuation status
);
dwProcessId
:要继续运行的进程的标识符(PID)。dwThreadId
:要继续运行的线程的标识符(TID)。dwContinueStatus
:继续运行的状态。它可以是以下值之一:DBG_CONTINUE
:继续运行被调试进程。DBG_EXCEPTION_NOT_HANDLED
:异常未被处理,系统按照正常的异常处理流程派发异常。
- 函数返回一个布尔值,表示操作是否成功。如果函数调用成功,返回值为非零值;如果函数调用失败,返回值为零。
环境搭建
所需工具都可以在附件中下载。
由于汇编代码可读性差,后面改用 C++ 实现(感觉w老师自己都蚌埠住了)。
汇编开发环境
由于这里使用的是汇编来编写调试框架,因此需要安装 RadASM(IDE) 和 MASM32(开发环境)。
- RadASM 直接下载
RadASM-XXX-FullPackage.zip
然后解压到安装目录即可。 - MASM32 就点下载链接然后一路默认安装即可,注意盘符最好选 C 盘,因为我这里 RadASM 是在 C 盘找 MASM32 的。
因为我们开发的是命令行式的调试器,因此创建项目时工程类型选择 Colsole App
,其他默认设置即可。
反汇编引擎
反汇编引擎这里我使用的是 Udis86
。该项目在 github 下载的源码缺少文件无法编译,因此我是从这里下载的源码进行编译。
由于该项目使用汇编调用过于麻烦,因此我先实现了一个提供汇编的接口项目 MuUdis86Dll
:
#include "pch.h"
#include "udis86.h"#pragma comment(lib, "libudis86.lib")extern "C" {__declspec(dllexport) DWORD GetAsm(BYTE *pCode,DWORD nCodeLen,DWORD nEIP,CHAR *szAsmBuf,DWORD dwAsmBufLen) {ud u;ud_init(&u);ud_set_mode(&u, 32);ud_set_pc(&u, nEIP);ud_set_input_buffer(&u, pCode, nCodeLen);ud_set_syntax(&u, UD_SYN_INTEL);ud_disassemble(&u);uint32_t nLen = ud_insn_len(&u);strcpy_s(szAsmBuf, dwAsmBufLen, ud_insn_asm(&u));return nLen;}
}
之后只需要在汇编项目中导入该项目及接口即可调用 GetAsm
函数实现反汇编。注意这里我没有声明 GetAsm
的调用约定因此默认采用 __cdecl
调用约定,因此在汇编中需要再改接口声明中加 C
来声明调用约定。
includelib MyUdis86Dll.libGetAsm proto C pCode:LPBYTE,nCodeLen:DWORD,nEip:DWORD,szAsmBuf:LPSTR,dwAsmBufLen:DWORD
软件断点
什么是软件断点
软件断点即 CC 断点或 int3 断点,OD中的快捷键F2,使用率也是最多的断点类型。以调试方式创建的进程,必定会有一个系统断点。
软件断点实现思路
- 断得下来:需要再软件断点位置写入
int3(0xcc)
。 - 走的过去:断点处的指令能够正常执行不受影响。
- 触发 int3 异常后需要还原断点位置的指令。
- 由于 int3 指令被执行了,因此还需要将 eip 寄存器减 1(int3 指令长度)。
- 下次还来:下次执行到断点位置时还能断下来。
- 由于触发 int3 异常后调试器将断点位置的指令恢复,因此在执行完断点位置的指令后需要再次在该位置写入 int3 指令。
- 可以在触发 int3 异常时 TF 标志寄存器(EFLAGS 标志寄存器第 8 位)置位,这样在执行下一条指令的时候会触发单步异常。
- 调试器在接收到该异常的时候可以恢复 int3 断点。
代码实现
创建断点实际上就是在断点位置写入 \xCC
。
void HandleBpCmd(const std::vector<std::tstring> &args) {if (args.size() != 2) {std::tcout << _T("Invalid Command") << std::endl;return;} PVOID lpBreakPoint = (PVOID) tcstoull(args[1]);if (m_BreakPointList.count(lpBreakPoint)) {return;}m_Process.ReadMemory(lpBreakPoint, &m_BreakPointList[lpBreakPoint], sizeof(m_BreakPointList[lpBreakPoint]));m_Process.WriteMemory(lpBreakPoint, (LPVOID) &INT3, sizeof(INT3));}
如果调试事件 EXCEPTION_DEBUG_EVENT
到来且异常类型为断点异常 EXCEPTION_BREAKPOINT
那么:
- 将 TF 标志寄存器置位。
- 修正 eip 寄存器减 1 。
- 恢复断点处的指令。
m_lpNeedRecoverBreakPoint
指向断点位置,以便后续EXCEPTION_SINGLE_STEP
异常到来时恢复断点。- 进入命令行交互。
case EXCEPTION_BREAKPOINT: {if (m_bIsSysBreakPoint) {std::cout << _T("EXCEPTION_BREAKPOINT") << std::endl;m_bIsSysBreakPoint = FALSE;} else {CONTEXT ctx{};ctx.ContextFlags = CONTEXT_CONTROL;m_Process.GetContext(ctx);ctx.Eip--;if (m_BreakPointList.count((LPVOID) ctx.Eip)) {ctx.EFlags |= 0x100;m_lpNeedRecoverBreakPoint = (LPVOID) ctx.Eip;m_Process.WriteMemory((LPVOID) ctx.Eip, &m_BreakPointList[(LPVOID) ctx.Eip], sizeof(m_BreakPointList[(LPVOID) ctx.Eip]));} m_Process.SetContext(ctx);}return PareseCommandLine();}
如果调试事件 EXCEPTION_DEBUG_EVENT
到来且异常类型为单步异常 EXCEPTION_SINGLE_STEP
且 m_lpNeedRecoverBreakPoint
不为 NULL 则需要将断点回复。
case EXCEPTION_SINGLE_STEP: {if (m_lpNeedRecoverBreakPoint != NULL) {m_Process.WriteMemory(m_lpNeedRecoverBreakPoint, (LPVOID) &INT3, sizeof(INT3));m_lpNeedRecoverBreakPoint = NULL;}}
效果展示
效果如下:
skydbg> u 0x772d78f1
772D78F1 mov dword [ebp-0x4], 0xfffffffe
772D78F8 mov ecx, [ebp-0x10]
772D78FB mov [fs:0x0], ecx
772D7902 pop ecx
772D7903 pop edi
772D7904 pop esi
772D7905 pop ebx
772D7906 leave
skydbg> bp 772D7902
skydbg> g
772D7902 pop ecx
skydbg> u
772D7902 pop ecx
772D7903 pop edi
772D7904 pop esi
772D7905 pop ebx
772D7906 leave
772D7907 ret
772D7908 int3
772D7909 int3
单步
单步命令实现思路
单步指令分为单步步入(t
命令)和单步步过(p
命令),这两条命令只有在 call 指令处会有所不同。
- 单步步入:逢 call 则入
- 设置 TF 标志位即可
- 单步步过:逢 call 则过
- 如果当前指令不是 call 则与单步步入相同
- 否则在 call 的下一条指令设置临时断点
单步命令代码实现
为了能够让单步步入,单步步过,用户断点三者能够和谐相处,OnException
实现如上图所示。
注意:单步异常到来时当前指令还没有执行,而断点异常到来时当前指令(int3)已经被执行。
DWORD OnException(DEBUG_EVENT& de) {switch (de.u.Exception.ExceptionRecord.ExceptionCode) {case EXCEPTION_BREAKPOINT: {if (m_bIsSysBreakPoint) {std::cout << _T("EXCEPTION_BREAKPOINT") << std::endl;m_bIsSysBreakPoint = FALSE;} else {CONTEXT ctx{};ctx.ContextFlags = CONTEXT_CONTROL;m_Process.GetContext(ctx);ctx.Eip--;if (m_BreakPointList.count((LPVOID) ctx.Eip)) {ctx.EFlags |= 0x100;m_lpNeedRecoverBreakPoint = (LPVOID) ctx.Eip;m_Process.WriteMemory((LPVOID) ctx.Eip, &m_BreakPointList[(LPVOID) ctx.Eip], sizeof(m_BreakPointList[(LPVOID) ctx.Eip]));} if ((LPVOID) ctx.Eip == m_lpSignelStepBreakPoint.first) {if (!m_BreakPointList.count((LPVOID) ctx.Eip)) {m_Process.WriteMemory((LPVOID) ctx.Eip, &m_lpSignelStepBreakPoint.second, sizeof(m_lpSignelStepBreakPoint.second));}m_lpSignelStepBreakPoint = {};m_nSingleStepCountP--;} m_Process.SetContext(ctx);}return PareseCommandLine();}case EXCEPTION_SINGLE_STEP: {if (m_lpNeedRecoverBreakPoint != NULL) {m_Process.WriteMemory(m_lpNeedRecoverBreakPoint, (LPVOID) &INT3, sizeof(INT3));m_lpNeedRecoverBreakPoint = NULL;}if (m_nSingleStepCountT || m_nSingleStepCountP) {if (m_nSingleStepCountT) { m_nSingleStepCountT--; assert(m_nSingleStepCountP == 0);}if (m_nSingleStepCountP) {m_nSingleStepCountP--;assert(m_nSingleStepCountT == 0);}CONTEXT ctx{};ctx.ContextFlags = CONTEXT_CONTROL;m_Process.GetContext(ctx);if (m_BreakPointList.count((LPVOID)ctx.Eip)) {ctx.EFlags |= 0x100;m_lpNeedRecoverBreakPoint = (LPVOID) ctx.Eip;m_Process.WriteMemory((LPVOID) ctx.Eip, &m_BreakPointList[(LPVOID) ctx.Eip], sizeof(m_BreakPointList[(LPVOID) ctx.Eip]));m_Process.SetContext(ctx);}return PareseCommandLine();}}}}
针对单步步入命令只需要 TF 置位。
void HandleTCmd(const std::vector<std::tstring> &args) {if (args.size() > 2) {std::tcout << _T("Invalid Command") << std::endl;return;}if (!args.empty()) {m_nSingleStepCountT = args.size() == 2 ? tcstoull(args[1]) : 1;}CONTEXT ctx{};ctx.ContextFlags = CONTEXT_CONTROL;m_Process.GetContext(ctx);ctx.EFlags |= 0x100;m_Process.SetContext(ctx);}
单步步过命令需要判断指令是否是 call 指令,如果不是 call 指令处理方式和单步步过相同,否则需要再 call 指令的下一条指令设置临时断点。
void HandlePCmd(const std::vector<std::tstring>& args) {if (args.size() > 2) {std::tcout << _T("Invalid Command") << std::endl;return;}if (!args.empty()) {m_nSingleStepCountP = args.size() == 2 ? tcstoull(args[1]) : 1;}LPVOID lpEip = m_Process.GetPC();std::string Code, Asm;m_Process.ReadMemory(lpEip, Code, 16);DWORD dwCodeLen = m_Asm.GetOneAsm(Code, lpEip, Asm);if (Asm.starts_with("call")) {m_lpSignelStepBreakPoint.first = (LPVOID) ((SIZE_T) lpEip + dwCodeLen);m_Process.ReadMemory(m_lpSignelStepBreakPoint.first, &m_lpSignelStepBreakPoint.second, sizeof(m_lpSignelStepBreakPoint.second));m_Process.WriteMemory(m_lpSignelStepBreakPoint.first, (LPVOID) &INT3, sizeof(INT3));} else {CONTEXT ctx{};ctx.ContextFlags = CONTEXT_CONTROL;m_Process.GetContext(ctx);ctx.EFlags |= 0x100;m_Process.SetContext(ctx);}}
这里我实现单步支持 trace 功能,即一次可以执行多步并记录执行过的命令,因此对于单步步入和步过我都记录了这条命令的剩余执行次数。在用户命令交互函数 PareseCommandLine
中,如果单步命令还有剩余执行次数则直接调用对应命令的处理函数自动执行。
DWORD PareseCommandLine() {ShowOneAsm(m_Process.GetPC());if (m_nSingleStepCountT) {assert(m_nSingleStepCountP == 0);HandleTCmd({});return DBG_CONTINUE;}if (m_nSingleStepCountP) {assert(m_nSingleStepCountT == 0);HandlePCmd({});return DBG_CONTINUE;}...
效果展示
首先 trace 功能能够不受断点影响,且断点不受 trace 功能影响。
skydbg> u 0x772d78f1
772D78F1 mov dword [ebp-0x4], 0xfffffffe
772D78F8 mov ecx, [ebp-0x10]
772D78FB mov [fs:0x0], ecx
772D7902 pop ecx
772D7903 pop edi
772D7904 pop esi
772D7905 pop ebx
772D7906 leave
skydbg> bp 772D7902
skydbg> bp 772D7903
skydbg> bp 772D7904
skydbg> g
772D7902 pop ecx
skydbg> t 5
772D7903 pop edi
772D7904 pop esi
772D7905 pop ebx
772D7906 leave
772D7907 ret
skydbg> u 772D7902 6
772D7902 int3
772D7903 int3
772D7904 int3
772D7905 pop ebx
772D7906 leave
772D7907 ret
步过功能遇到 call 指令不会步入且临时断点自动去除。
skydbg> p 5
772D7905 pop ebx
772D7906 leave
772D7907 ret
772D1D3E call 0x7728b6c4
772D1D43 cmp byte [0x7734d1ae], 0x0
skydbg> u 772D1D3E 3
772D1D3E call 0x7728b6c4
772D1D43 cmp byte [0x7734d1ae], 0x0
772D1D4A jnz 0x772d1de5
利用单步异常实现反调试
主要利用了调试器先于 SEH 接管单步异常来实现反调试的特性(实际上这里的单步异常换成其他异常也是可以的)。
硬件断点
调试寄存器
IA-32 处理器定义了 8 个调试寄存器,分别称为 DR0~DR7 。这个 8 个寄存器结构如下图所示:
DR0~DR3
:调试地址寄存器,用于保存 4 个硬件断点的地址。DR4~DR5
:保留未使用。DR6
:调试状态寄存器,用于在调试事件发生时向调试器报告详细信息。B0~B3
:如果其中任何一个置位,则表示是相应的DR0~DR3
断点引发的调试陷阱。BD
:检测到访问调试寄存器,这一位与DR7
的GD
位相联系,当GD
位被置为 1,而且 CPU 发现了要修改调试寄存器(DR0~DR7
)的指令时,CPU 会停止继续执行这条指令,把BD
位设为 1,然后把执行权交给调试异常(#DB
)处理程序。BS
:单步,这一位与标志寄存器的TF
位相联系,如果该位为 1,则表示异常是由单步执行(single step)模式触发的。与导致调试异常的其他情况相比,单步情况的优先级最高,因此当此标志为 1 时,也可能有其他标志也为 1。BT
:任务切换,这一位与任务状态段(TSS)的T
标志(调试陷阱标志,debug trap flag)相联系。当 CPU 在进行任务切换时,如果发现下一个任务的 TSS 的T
标志为 1,则会设置BT
位,并中断到调试中断处理程序。DR6
寄存器的值建议在每次异常提交之前清除。
DR7
:调试控制寄存器,用于进一步定义断点的中断条件。R/W0~R/W3
:读写域,分别与DR0~DR3
这 4 个调试地址寄存器相对应,用来指定被监控地址的访问类型。00
:执行断点01
:写断点10
:386 和 486 不支持此组合。对于以后的 CPU,可以通过把 CR4 寄存器的 DE(调试扩展)位设为 1 启用该组合,其含义为“当相应地址进行输入输出(即 I/O 读写)时中断”11
:读写断点,但是从该地址读取指令除外。
LEN0~LEN3
:长度域, 分别与DR0~DR3
这 4 个调试地址寄存器相对应,用来指定被监控区域的长度。00
:1 字节长01
:2 字节长10
:8 字节长(奔腾 4 或至强 CPU)或未定义(其他处理器)11
:4 字节长- 注意:如果对应
R/Wn
为 0(即执行指令中断),那么这里的设置应该为 0 。
L0~L3
:局部断点启用, 分别与DR0~DR3
这 4 个调试地址寄存器相对应,用来启用或禁止对应断点的局部匹配。- 如果该位设为 1,当 CPU 在当前任务(线程,寄存器是线程独占的)中检测到满足所定义的断点条件时便中断,并且自动清除此位。
- 如果该位设为 0,便禁止此断点。
G0~G3
:全部断点启用,分别与DR0~DR3
这 4 个调试地址寄存器相对应,用来全局启用或禁止对应的断点(实测没有用,要想真正设置区局断点需要遍历进程中的所有线程然后都设置Ln
,x64dbg 和 OllyDbg 都是这么实现的)。- 如果该位设为 1,当 CPU 在任何任务中检测到满足所定义的断点条件时便中断,不会自动清除此位。
- 如果该位设为 0,便禁止此断点。
LE
和GE
:启用局部或者全局(精确)断点。从 486 开始的 IA-32 处理器都忽略这两位的设置。此前这两位是用来启用或禁止数据断点匹配的。对于早期的处理器,当设置有数据断点时,需要启用本设置,这时CPU会降低执行速度,以监视和保证当有指令要访问符合断点条件的数据时产生调试异常。GD
:启用或禁止对调试寄存器的保护。当设为 1 时,如果 CPU 检测到将修改调试寄存器(DR0~DR7
)的指令,CPU 会在执行这条指令前产生一个调试异常。