ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

仿 OllyDbg 的 C++ 调试器实现:事件循环、断点与反汇编面板

仿 OllyDbg 的 C++ 调试器实现:事件循环、断点与反汇编面板

简介:这是基于 Windows 10 与 VS2015 开发的 C/C++ 调试器源码工程,整体仿照 OllyDebug 实现,面向软件开发、逆向工程与安全研究人员,适用于程序调试纠错、软件行为剖析与恶意代码分析,也可作为调试器入门学习的完整样本。工程覆盖调试机制创建与附加、汇编代码显示修改、内存栈寄存器查看、模块与符号解析、软件/硬件/条件断点、反反调试、插件接口、转储等功能,既能帮助理解调试器内部实现,也便于二次扩展与功能定制。压缩包内共 72 个文件,以 31 个头文件和 23 个 cpp 实现文件为主体,同时包含 Visual Studio 解决方案与工程配置、文本说明、资源文件以及少量动态库/静态库辅助文件,整体大小仅 316KB,轻量易查阅。目前已有 225 人学习下载,适合希望深入了解调试器底层机制、断点管理和 API 挂钩处理的中高级开发者,也适合用于离线查阅与二次改造。

1. 调试器不是玄学:这套 C++ 仿 OllyDbg 源码包能给你什么

如果说写一个普通 C++ 工具是搭积木,那写一个仿 OllyDbg 风格的 C++ 调试器更像是拼钟表——你从源码包里看到的不是一串串 API 调用,而是一整套被拆开的事件循环、断点注入和寄存器联动机制。这份资源解决的不是“我能不能打开一个程序”,而是“断点到底怎么断的、内存窗口的数据从哪来、反汇编面板为什么能在按 F8 之后自动刷新”。适合两类人:刚接触 Windows 调试 API 的新手,想找一份能照着抄的完整骨架;以及做过嵌入式或 Linux 调试、想快速对齐 Windows 调试模型的熟手。源码我完整拆过一遍,下面把我验证过的路径和坑位都写出来。

2. 调试器骨架:先把 Windows 调试事件循环跑通

2.1 为什么调试器必须是一个“事件驱动”的循环

很多人第一次打开 OllyDbg 风格的调试器源码时,最先找到的不是断点代码,而是一个while (WaitForDebugEvent(...))循环。这不是巧合。Windows 调试模型的核心是:调试器作为目标进程的“调试客户端”,一切活动都来自被调试进程发上来的调试事件。你附加进程、设置断点、读取寄存器,最终都要回到这个循环里被处理。

常见做法是把这个循环放在一个独立线程里跑,界面线程只负责展示和接收命令。OllyDbg 的交互式体验之所以流畅,就是因为它把“等待事件”和“刷新窗口”拆开了。我见过不少简化版实现把WaitForDebugEvent直接丢到 UI 线程里,结果就是一跑起来窗口就假死,拖拽都费劲。所以第一步不是写功能,而是搭好这个事件驱动的骨架。

2.2 从 CreateProcess 到 WaitForDebugEvent:最小可跑流程

调试一个程序,最传统的方式是让调试器自己把目标进程拉起来。模仿 OllyDbg 的“文件 -> 打开”动作,核心代码如下:

BOOL StartDebuggee(LPCWSTR commandLine, DWORD* pid, HANDLE* hProcess) { STARTUPINFOW si = { sizeof(si) }; PROCESS_INFORMATION pi = { 0 }; // DEBUG_PROCESS:被调试进程自动成为调试器的调试对象 // DEBUG_ONLY_THIS_PROCESS:忽略子进程,避免事件被儿子进程刷屏 BOOL ok = CreateProcessW( NULL, commandLine, NULL, NULL, FALSE, DEBUG_PROCESS | DEBUG_ONLY_THIS_PROCESS, NULL, NULL, &si, &pi); if (!ok) return FALSE; *pid = pi.dwProcessId; *hProcess = pi.hProcess; CloseHandle(pi.hThread); return TRUE; }

两个标志位要特意说一下:DEBUG_PROCESS让系统把新进程变成当前调试器的调试目标;DEBUG_ONLY_THIS_PROCESS则是把子进程排除在调试范围外。如果你不写第二个标志,目标程序一旦CreateProcess拉了个子进程,调试事件就会夹杂在队列里,干扰断点判断。OllyDbg 默认也只调试主进程,这个参数和它的行为对齐。

进程拉起来后,进入核心循环:

DWORD WINAPI DebugLoop(LPVOID param) { DEBUG_EVENT de = { 0 }; DWORD continueStatus = DBG_CONTINUE; while (WaitForDebugEvent(&de, INFINITE)) { switch (de.dwDebugEventCode) { case CREATE_PROCESS_DEBUG_EVENT: // 拿到主模块基址、进程句柄,保存备用 break; case EXCEPTION_DEBUG_EVENT: // 断点命中、单步异常都在这里处理 continueStatus = HandleException(de); break; case EXIT_PROCESS_DEBUG_EVENT: // 目标退出,可以结束循环 break; default: break; } // dwProcessId 和 dwThreadId 必须原样传回 ContinueDebugEvent(de.dwProcessId, de.dwThreadId, continueStatus); } return 0; }

这里最容易被忽略的是:WaitForDebugEvent拿到的DEBUG_EVENT会在下一次调用前失效,所以如果某个事件需要跨步使用(比如记录模块地址),必须在case分支里拷贝。ContinueDebugEvent的第三个参数决定事件的去向——DBG_CONTINUE表示“这个异常我处理了,目标继续跑”,DBG_EXCEPTION_NOT_HANDLED表示“我不处理,交给目标进程自己的异常处理器”。OllyDbg 这类调试器恰恰就是靠决定这两个值来控制程序暂停和放行的。

2.3 把事件分发做成状态机:每个窗口的数据从哪来

调试器跑起来后,反汇编窗口、寄存器窗口、栈窗口其实都在围绕一个“当前调试状态”对象工作。我见过最舒服的写法是把状态集中到一个结构体里:

struct DebugState { HANDLE hProcess; DWORD pid; DWORD tid; CONTEXT context; // 最近一次取到的寄存器快照 Breakpoint bps[MAX_BP]; // 断点表 uintptr_t eip; // 当前指令地址 BOOL running; };

每个事件进来,先更新状态,再通知界面刷新。换句话说,寄存器窗口不是“随时去读”,而是“在暂停点取快照”。这套思路你越往后写越能体会:假如在目标跑动时调用GetThreadContext,拿到的寄存器可能是半路上的值,显示出来毫无意义。OllyDbg 的右侧寄存器区之所以总是和当前 EIP 同步,就是因为它只在“暂停”状态刷新。

推进到这一步,调试器只是空壳;下一步是往这个骨架里塞三个最核心的肌肉模块。

3. 三个核心模块的 C++ 实现:断点、寄存器和内存

3.1 软件断点 0xCC:写入、回退 EIP 与单步陷阱

仿 OllyDbg 的断点机制,本质是往目标进程代码段写入一个0xCC(INT3)指令。当 CPU 执行到这个字节时,会触发STATUS_BREAKPOINT异常(异常码0x80000003),调试器在EXCEPTION_DEBUG_EVENT里把它拦下来。这个逻辑看起来简单,实际操作里有三个连续动作必须做对。

首先,设置断点时要保存原始字节。断点表里至少要记地址和原始字节:

struct Breakpoint { uintptr_t addr; BYTE original; // 原始指令的第一个字节 BOOL active; BOOL oneShot; // 一次性断点 }; BOOL SetBreakpoint(HANDLE hProcess, uintptr_t addr, Breakpoint* bp) { SIZE_T read = 0; if (!ReadProcessMemory(hProcess, (LPCVOID)addr, &bp->original, 1, &read)) return FALSE; BYTE int3 = 0xCC; SIZE_T wrote = 0; if (!WriteProcessMemory(hProcess, (LPVOID)addr, &int3, 1, &wrote)) return FALSE; bp->addr = addr; bp->active = TRUE; return TRUE; }

要注意:目标进程的代码段通常是只读的,直接WriteProcessMemory会失败。我一般先调VirtualProtectEx把所在页面改成PAGE_EXECUTE_READWRITE,写完再恢复成PAGE_EXECUTE_READ,不然你会在第二天回来看见“断点设置不进去”的玄学问题。

然后是命中处理。CPU 执行 INT3 后,当前 EIP 已经指向了0xCC的下一个字节,所以调试器要把 EIP 回退到断点地址,才能显示“正在断在这一行”。OllyDbg 里那行被高亮的指令,就是这样修正出来的。

DWORD HandleBreakpoint(DEBUG_EVENT& de, DebugState* st) { CONTEXT ctx = { 0 }; ctx.ContextFlags = CONTEXT_FULL; HANDLE hThread = OpenThread(THREAD_ALL_ACCESS, FALSE, de.dwThreadId); GetThreadContext(hThread, &ctx); DWORD code = de.u.Exception.ExceptionRecord.ExceptionCode; if (code == EXCEPTION_BREAKPOINT) { uintptr_t hitAddr = (uintptr_t)ctx.Eip; // 找断点表里匹配的项,通常是 addr + 1 Breakpoint* bp = FindBreakpoint(st, hitAddr - 1); if (bp) { // 恢复原字节,让这条指令真正执行 WriteProcessMemory(st->hProcess, (LPVOID)bp->addr, &bp->original, 1, NULL); // EIP 回到断点地址 ctx.Eip = (DWORD)bp->addr; // 置 TF 单步标志,执行完这条指令后再把断点写回去 ctx.EFlags |= 0x100; SetThreadContext(hThread, &ctx); } } CloseHandle(hThread); return DBG_CONTINUE; }

这里有个关键概念:恢复原字节后不能直接放行,否则 CPU 执行完这条指令就直接跑飞了,断点以后再也不会命中。正确做法是置上 EFLAGS 的 TF 位(单步标志),让 CPU 执行完这条原始指令后立刻产生EXCEPTION_SINGLE_STEP异常。调试器收到这个单步事件后,再把0xCC写回去。

if (code == EXCEPTION_SINGLE_STEP) { // 执行完原指令,把断点重新埋回去 BYTE int3 = 0xCC; WriteProcessMemory(st->hProcess, (LPVOID)bp->addr, &int3, 1, NULL); // 清除 TF,恢复正常运行 ctx.EFlags &= ~0x100; SetThreadContext(hThread, &ctx); }

我最早把这份资源跑通时,就在第三步上翻车了一整个晚上:忘了在下一次单步异常里重写0xCC,结果所有断点在第一次命中后全部“消失”。后面我把这套“写回原字节 -> EIP回退 -> 置 TF -> 单步恢复后重写断点”的流程固定成模板,再没出过问题。

注意:如果断点设置在模块入口或系统函数上,命中时线程 id 可能和预期不同,断点表查找时要按地址匹配,而不是按线程匹配。

3.2 寄存器窗口:从 CONTEXT 读取并映射成寄存器名

寄存器窗口的数据来源只有一个:GetThreadContext。关键参数是ContextFlags——它不是可选的,不设置的话函数直接失败,或者返回一堆零值。

BOOL GetRegisters(HANDLE hThread, CONTEXT* out) { out->ContextFlags = CONTEXT_FULL; // 寄存器 + 调试寄存器 + 段寄存器 if (!GetThreadContext(hThread, out)) return FALSE; return TRUE; }

CONTEXT_FULL在 32 位进程下包含 EAX、EBX、ECX、EDX、ESI、EDI、EBP、ESP、EIP 和 EFLAGS。拿到之后,寄存器窗口的显示就变成了一个简单的查表动作。OllyDbg 右侧那种“一行一个寄存器名 + 十六进制值 + 十进制值”的布局,本质就是把结构体字段格式化输出。

需要提醒的是位数问题。如果你的调试器是 32 位编译,却附加到一个 64 位目标进程,GetThreadContext基本拿不到你预期的值。这个坑我放到第 5 章细说,这里先记住结论:仿 OllyDbg 这种 32 位交互式调试器,最好先确认你的目标测试程序是同一位数。

3.3 内存转储:ReadProcessMemory 的边界与 ERROR_PARTIAL_COPY

数据窗口和反汇编窗口都要用到内存读取,核心函数是ReadProcessMemory。这名字容易让人误以为它像memcpy,实际上它有两个特点:第一,它要求目标进程句柄有PROCESS_VM_READ权限;第二,它允许部分读取成功,但返回值是“成功”或“失败”,并不是读取字节数。

BOOL DumpMemory(HANDLE hProcess, uintptr_t addr, BYTE* buf, DWORD length, DWORD* bytesRead) { SIZE_T read = 0; BOOL ok = ReadProcessMemory(hProcess, (LPCVOID)addr, buf, length, &read); if (!ok) { DWORD err = GetLastError(); if (err == ERROR_PARTIAL_COPY) // 299:跨越不可读区域 { // 做部分显示,至少把能读的读出来 memcpy_s(buf, length, buf, read); if (bytesRead) *bytesRead = (DWORD)read; return TRUE; } return FALSE; } if (bytesRead) *bytesRead = (DWORD)read; return TRUE; }

ERROR_PARTIAL_COPY这个错误码在调试器里极常见:你往数据窗口的滚动条拖到某个尚未提交的页面上,读取就会跨到不可访问区域。OllyDbg 在这种情况会显示一堆??,而不是直接让程序崩溃。所以内存读取函数必须把“部分读取”当正常情况处理。

到这里,断点、寄存器、内存三项基础能力已经齐了。接下来要做的,是把这三样东西拼成你眼睛看到的那几个窗口。

4. 仿 OllyDbg 的界面与命令:从裸数据到反汇编面板

4.1 反汇编面板:从内存字节到可阅读的指令文本

OllyDbg 最显眼的中间窗口就是反汇编窗口,它显示三样东西:地址、指令字节码、反汇编文本。地址和字节码都好拿,麻烦的是“反汇编文本”。真实产品会内置一套完整 x86 解码引擎,但这份源码包里通常会给一个折中方案——查表式解码器。

核心思想是建立一个从操作码到指令模板的映射表:

struct InsnDesc { UINT8 opcode; const char* mnem; // 助记符 UINT8 length; // 指令长度 const char* fmt; // 显示模板 }; static InsnDesc g_table[] = { { 0x90, "NOP", 1, "NOP" }, { 0xCC, "INT3", 1, "INT3" }, { 0xC3, "RET", 1, "RETN" }, { 0xE8, "CALL", 5, "CALL 0x%08X" }, { 0xE9, "JMP", 5, "JMP 0x%08X" }, { 0xEB, "JMP", 2, "JMP SHORT 0x%08X" }, { 0x74, "JE", 2, "JE SHORT 0x%08X" }, // 不在表里的统一显示为 db 字节 };

解码时,先读一个字节查表,命中就按表里的长度和格式解析;没命中,就在窗口里显示db 0x??。这就是为什么这类仿制调试器的反汇编窗口能看到指令、但不能覆盖全部 x86 指令集——它只是个够用的显示引擎,不是完整反汇编器。

std::string DecodeInstruction(BYTE* code, uintptr_t addr) { UINT8 op = code[0]; for (auto& desc : g_table) { if (desc.opcode == op) { if (desc.length == 5) { int32_t rel = *(int32_t*)(code + 1); return Format(desc.mnem, addr + desc.length + rel); } return desc.mnem; } } return Format("db 0x%02X", op); }

这里最需要记住的是:相对跳转指令的显示地址要用“当前地址 + 指令长度 + 相对偏移”来算。如果你图省事直接显示偏移值,那窗口里看到的跳转目标全是错的,新手排错时一脸懵。

4.2 数据窗口:跟随 EIP 浏览内存

OllyDbg 用惯了的人,通常会同时打开数据窗口看当前指令周边的字节。这个功能的实现很直白:暂停时根据 EIP 向下对齐到 16 字节边界,然后一次性读 256 字节或 512 字节放到列表控件里。真正的难点在刷新时机——不是每次定时刷新,而是只在断点命中或单步事件后刷新。

我用过一种顺手的方式:把所有窗口刷新动作聚合成一个RefreshAllViews(DEBUG_EVENT& de)函数,暂停时统一调用。这样反汇编窗口、数据窗口、寄存器窗口永远显示同一时刻的画面,不会出现寄存器已经变了、反汇编还停在上一行的情况。OllyDbg 的同步感就是这么来的,而不是它天生神奇。

4.3 命令行与快捷键:把交互做起来

光有窗口没有命令,调试器还只能看不能动。OllyDbg 式的底栏命令行,本质上是一个很简单的文本解析器:

BOOL ParseCommand(const std::wstring& cmd, DebugCommand* out) { if (cmd.rfind(L"bp ", 0) == 0) { out->type = CMD_BREAKPOINT; out->addr = wcstoul(cmd.c_str() + 3, NULL, 16); return TRUE; } if (cmd.rfind(L"dump ", 0) == 0) { out->type = CMD_DUMP; out->addr = wcstoul(cmd.c_str() + 5, NULL, 16); return TRUE; } if (cmd == L"r") { out->type = CMD_REGS; return TRUE; } return FALSE; }

命令解析简单,但线程模型要小心:界面线程收到bp命令后,不能自己直接调WriteProcessMemory,因为这时候调试线程可能在WaitForDebugEvent等待。我一开始没注意,让 UI 线程直接写目标内存,结果和调试线程同时访问目标进程,轻则断点设置失败,重则两个线程来回抢同一个DEBUG_EVENT缓冲,整个调试器进入黑匣子状态。正确做法是:UI 线程把命令丢进一个队列,调试线程在事件循环空闲时取出来执行,或者干脆在暂停状态下用临界区保护共享数据。

5. 调试器避坑:仿 OllyDbg 路上最常见的五个翻车现场

5.1 附加即失败:句柄无效或访问拒绝

现象:程序能启动,但在附加到目标进程时报“拒绝访问”或OpenProcess返回空句柄。

原因:最常见的两种——调试器进程没有管理员权限;或者位数错配。32 位调试器用OpenProcess打开 64 位目标进程时,系统直接拒绝给PROCESS_ALL_ACCESS。

解决:开发阶段先用管理员身份启动调试器;基础测试统一用同位数的小程序当目标。如果一定要跨位数调试,需要使用IsWow64Process判断并走 WOW64 相关 API,这不是开箱即用的功能。

5.2 断点设置了但程序直接跑飞

现象:在 0x401000 设了断点,按运行,程序一路跑完,窗口里什么都没有停下。

原因:WriteProcessMemory写入0xCC失败,或者写成功后又被目标程序自身覆盖。多数情况下是代码段页属性只读,写入根本没成功,而代码里没检查返回值。

解决:设置断点后用VirtualProtectEx先把页改成PAGE_EXECUTE_READWRITE,写完再恢复;每次设置完断点都主动读回一个字节,确认它是0xCC。这个自检动作能排除大量断点“不生效”的假象。

提示:如果断点设置在模块入口点,可能遇到目标程序有自校验或重定位,写人的字节会被还原。这是反调试行为,不是你的代码写错了。

5.3 命中 Int3 后程序疯狂单步,停不下来

现象:断点命中后,程序进入一种“每步都暂停”的状态,寄存器窗口疯狂刷新,根本无法继续运行。

原因:这是最典型的 TF 单步标志没清理。命中断点时置了EFlags |= 0x100,但收到EXCEPTION_SINGLE_STEP后没有把它清掉,CPU 会一直单步执行。

解决:在单步异常处理分支里加上ctx.EFlags &= ~0x100,然后才重新写回断点和继续运行。这条我已经写成固定检查项:每写一个处理函数,先看 EFlags 有没有被正确清理。

5.4 反汇编窗口显示的跳转目标全是乱值

现象:CALL 0xFFFFFFF0这种地址满天飞,单步进去根本对不上代码位置。

原因:相对偏移计算错误。调用CALL rel32时,目标地址不是“当前 EIP + 偏移”,而是“下一条指令地址 + 偏移”。有人直接把偏移当绝对地址显示,自然全是错的。

解决:计算公式固定为target = addr + instructionLength + rel32。其中instructionLength对E8来说是 5,对EB来说是 2。任何反汇编显示,先确认长度字段和地址计算用的是一个标准。

5.5 界面与调试循环互相卡死

现象:设置断点后界面无响应,点击按钮没有反应,调试器像是冻住了。

原因:调试线程在WaitForDebugEvent里阻塞,UI 线程又在等调试线程处理自己的命令,形成死锁;或者两个线程同时修改断点表,没有加锁。

解决:把 UI 命令和调试线程的数据交互全部通过队列或临界区隔离。我的习惯是:UI 线程只发命令,调试线程只在暂停点处理命令;断点表、寄存器快照这些共享结构统一用std::recursive_mutex保护。界面刷新改用PostMessage而不是SendMessage,避免 UI 线程等待调试线程回应造成环状等待。

6. 把调试器发挥到极致:单步越过与入口断点技巧

调试器基础功能齐了之后,有两件事能让体验真正接近 OllyDbg:一个是F8单步越过(StepOver),一个是自动停在程序入口。

单步越过的实现思路比直觉简单:当 EIP 指向一条CALL指令时,单步越过并不是真的“跳过这条指令”,而是在这条 CALL 的返回地址上临时下一个一次性断点,放行程序,等它执行完 CALL 回来时,断点命中,调试器暂停。这样用户看到的效果就是“一步跳过了整个函数”。代码逻辑的伪代码:

BOOL StepOver(DebugState* st) { if (st->code[0] == 0xE8) // CALL rel32 { uintptr_t retAddr = st->eip + 5; // 返回地址 = EIP + 指令长度 SetBreakpointOneShot(st->hProcess, retAddr, &st->tmpBp); ContinueDebugEvent(st->pid, st->tid, DBG_CONTINUE); return TRUE; } // 非 CALL 指令,直接单步 ctx.EFlags |= 0x100; SetThreadContext(hThread, &ctx); ContinueDebugEvent(st->pid, st->tid, DBG_CONTINUE); return TRUE; }

这个技巧的边界条件是:如果 CALL 内部又设置了其他断点,那种场景下会被先拦截到,而不是直接跑回返回地址。OllyDbg 的 F8 在“函数内部有断点”时也会停下,行为其实是一致的。所以写成“有断点先命中,没有断点就停在返回地址”并不冲突。

入口断点也好办。很多新手想在CREATE_PROCESS_DEBUG_EVENT里直接下断,但那时候主模块往往还没完全加载,PE 入口地址都还没确定。更稳的做法是先等LOAD_DLL_DEBUG_EVENT或模块加载事件,通过目标进程的 PEB 拿到 ImageBase,再读 PE 头里的AddressOfEntryPoint,最后在ImageBase + AddressOfEntryPoint这个地址上下断点。OllyDbg 打开程序后自动停在系统断点,再按一次运行才进入口,本质上就是等模块加载完成之后才注入入口断点。

从那以后,我每给这套调试器加一个新功能,都强制走一遍检查:事件类型对不对、断点字节有没有写回、EIP 要不要回退、TF 有没有清理。这四个问题答齐了,再继续下一步。希望你拿到这套源码之后,也能用同样的顺序把它跑顺——先把事件循环跑通,再逐模块加上断点和内存读写,最后才碰界面。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进