☰
x64dbg源码深度解析:调试器原理与插件开发实战指南
2026/10/2 1:05:15 网站建设 项目流程

简介:x64dbg 是适用于 Windows 的开源二进制调试器,功能与 OllyDbg(OD)类似,服务逆向工程、脱壳与恶意软件分析场景,可对没有源代码的可执行程序进行动态调试和插件扩展。压缩包共 1826 个文件,大小约 4.66MB;内容包括 cpp/h 核心源码、asm 汇编示例、cmake/bat 构建脚本,以及 md 文档、png 截图、ui 界面文件等,目录层次清楚,便于按模块阅读。预览中的 avx512、sse、mmx、fpu 汇编片段显示其底层指令覆盖较全,适合关注调试器内部实现、反汇编引擎和插件 API 的读者研读。当前已有 57 人浏览学习,对于想基于真实项目学习调试器开发或进行二次改造的逆向爱好者而言,这是一份体积紧凑但信息密度较高的开源资源,可节省自行检索源码结构的时间。

1. 把 x64dbg 源码看透:不只是 OD 的替代品,更是理解调试器底层的活教材

在逆向和脱壳这个圈子里,x64dbg 早就不需要太多介绍了。如果你还在用 OllyDbg(OD)跟 64 位程序较劲,你会越来越难受:OD 对 x64 的支持几乎停在十年前,动不动就崩、一调试就卡,遇到反调试直接傻眼。x64dbg 的出现正好补上了这块短板,它不只是界面长得像 OD,更重要的是它开源——整个调试引擎、反汇编模块、插件 SDK 全部裸露在你面前。这意味着你不仅能拿它当工具用,还能翻开源码看明白断点、单步、内存断点、异常分发这些机制到底是怎么实现的,甚至可以改源码定制出属于自己的调试器。这篇文章适合两类人:一是想从 OD 迁到 x64dbg 的逆向从业者,二是想深入理解调试器实现原理、或者打算做插件开发的源码爱好者。

2. 源码架构拆解:拿到手先分清六个模块,别一头扎进 GUI 里出不来

2.1 顶层目录与模块边界:gui、dbg、bridge 各管什么

x64dbg 的源码包解压之后,第一眼看到的是几十个文件夹,如果没有一个整体视角,很容易迷失在细节里。我一般拿到源码会先看根目录下的src结构,这里的边界划分其实非常清晰。核心就三块:gui是 Qt 写的界面层,dbg是调试引擎本体,bridge是两者之间的通信桥梁。

先说dbg目录,这是整个项目的心脏。它负责所有跟调试目标进程直接打交道的逻辑:设置断点、处理异常、读写内存、管理线程栈、反汇编分析、符号解析、模块枚举,这些东西全部在dbg里完成。它不关心界面长什么样,只暴露接口给上层调用。

gui目录则是你看到的那个窗口程序,反汇编视图、寄存器面板、堆栈窗口、内存 dump 视图,全部由 Qt 绘制。它通过bridge目录里定义的命令接口跟dbg通信,比如你按 F9 继续运行,界面层只是发出一条命令字符串,真正的SetBPX、StepOver这些操作发生在dbg层。这种桥接模式让前后端完全解耦,你调试的时候偶尔发现界面卡住,基本能判断是dbg线程在做耗时操作,而不是 GUI 本身的问题。

还有几个相对独立的子模块值得留意:pluginsdk是给第三方插件开发者用的头文件和接口定义;lzma、zlib这些是内置的第三方库,用于解压和压缩;x64dbg和x32dbg各自的启动入口也在src底下。建议初读源码时把精力集中在dbg目录,GUI 部分等需要改界面时再看也不迟。

2.2 调试循环(Debug Loop)的核心机制:WaitForDebugEvent 与命令队列

调试器最底层的引擎其实是一个循环,这个循环的基础是 Windows 提供的调试 API。x64dbg 的dbg模块里有一个核心函数负责处理这个循环,它的骨架用伪代码表达大致是下面这个样子:

// 调试主循环的简化示意(源码位于 dbg/debugger.cpp) while (g_bRunLoop) { DEBUG_EVENT de = { 0 }; // 1. 阻塞等待调试事件,timeout 设为 INFINITE if (!WaitForDebugEvent(&de, INFINITE)) { // 出错处理:GetLastError() continue; } // 2. 统一交给事件处理器分发 bool bHandled = HandleDebugEvent(&de); // 3. 判断状态:是否处于暂停态、是否有用户命令待处理 if (g_bPause) { PrintDbgView("Debugger paused.\n"); // 暂停时处理用户输入的命令队列 ProcessCommandQueue(); } // 4. 恢复目标进程继续运行 ContinueDebugEvent(de.dwProcessId, de.dwThreadId, bHandled ? DBG_CONTINUE : DBG_EXCEPTION_NOT_HANDLED); }

这段循环是整个调试器能“动”起来的核心逻辑。WaitForDebugEvent是 Windows 调试 API 的基石,它会把目标进程里发生的所有关键事件——断点触发、异常抛出、线程创建、模块加载、进程退出——塞进一个DEBUG_EVENT结构体里交给调试器。x64dbg 拿到事件后就调用HandleDebugEvent做分发,比如收到EXCEPTION_BREAKPOINT就知道是 int3 断点命中,收到LOAD_DLL_DEBUG_EVENT就触发模块加载通知。

参数的关注点在于ContinueDebugEvent的第三个参数:对断点异常返回DBG_CONTINUE,对非调试器关心的异常返回DBG_EXCEPTION_NOT_HANDLED,把决定权交还给系统。这个细节极其重要,如果你在自定义调试器里把每个异常都吞掉,目标进程的错误处理逻辑会全部失效,脱壳时壳的异常反调试逻辑也有可能失灵。

2.3 断点管理的完整生命周期:从 SetBPX 到内存断点、硬件断点的分派

断点是调试器的灵魂,x64dbg 的断点管理在dbg模块里做得非常系统。它把断点分成三大类:软件断点(int3)、硬件断点(DR0-DR3 寄存器)、内存断点(页面权限触发)。每种断点的生命周期都走同一条路径:设置→触发→处理→移除。

// dbg/breakpoint.cpp 中的断点设置核心逻辑(简化) bool SetBreakpoint(BP_TYPE type, duint addr, BPNOTIFY notify) { // 根据类型分派 switch (type) { case BP_NORMAL: { // 软件断点 // 读原字节,保存后用 0xCC 覆盖 unsigned char original = 0; MemRead(addr, &original, 1); gc_bpdata[addr].original = original; unsigned char int3 = 0xCC; MemWrite(addr, &int3, 1); FlushInstructionCache(); // 关键!不刷新指令缓存会拿到旧指令 break; } case BP_HARDWARE: { // 硬件断点数量有限(x64 下最多 4 个) // 需要从 DR0-DR3 里找一个空闲槽位 int slot = FindFreeHardwareSlot(); SetHardwareBreakpoint(slot, addr, 1 /* size */, 0 /* execute */); break; } case BP_MEMORY: { // 内存断点原理:修改页面属性为 PAGE_GUARD DWORD oldProtect = 0; VirtualProtectEx(hProcess, (LPVOID)addr, 1, PAGE_EXECUTE_READ | PAGE_GUARD, &oldProtect); break; } } return true; }

这里边最容易翻车的是软件断点的FlushInstructionCache。我在自研调试器的时候漏过这一步,结果断点设置后首次命中正常,第二次就出现“断点飞掉”的诡异现象——原因就是 CPU 的指令缓存里还留着旧字节。MemRead和MemWrite是dbg层封装好的进程内存读写接口,背后是ReadProcessMemory和WriteProcessMemory。

硬件断点只有 4 个槽位是硬限制,这是 x64 CPU 架构决定的。x64dbg 源码里FindFreeHardwareSlot会遍历当前线程上下文里的调试寄存器,找到闲置的 DR 寄存器就写入。它的局限在于 DR0-DR3 只能存地址,长度和条件要靠 DR7 控制。内存断点则是利用PAGE_GUARD页面属性实现“访问即触发”,它的代价是性能极差,不适合在频繁访问的内存区域下断点,否则程序会慢如蜗牛。

3. 从源码编译出你自己的 x64dbg:环境准备与完整构建流程

3.1 构建环境清单:Qt 版本、编译器选型与几个易错依赖

想玩转源码,第一步是先把它构建出来跑起来。x64dbg 的构建文档在BUILD.md里写得很清楚,但历史上的坑不少。我根据自己在多台机器上编译的经验,把环境整理成了一张表:

组件推荐版本注意点
Visual Studio2019 或 2022必须勾选“使用 C++ 的桌面开发”工作负载
Qt5.15.x(开源版)msvc2019_64 套件
CMake3.16 以上建议用 VS 自带的或单独安装最新版
Windows SDK10.0.19041+安装 VS 时默认带上即可
Python3.x(可选)编译脚本增强工具需要

编译前要把Qt的bin目录加到PATH环境变量里,否则qmake和windeployqt找不到。还有一个经常让人卡半天的依赖:x64dbg 源码里用到了一个名为DeviceIOctl类型的 DDK 头,如果在windows.h和winioctl.h的包含顺序上出问题,会报一堆莫名其妙的宏重定义错误。我一般会先把WIN32_LEAN_AND_MEAN定义好,减少无关头文件的干扰。

3.2 分步构建:用 CMake 生成工程并用 VS 编译,三个需要手动调整的开关

构建流程本身不复杂,但有几个开关不看源码根本不知道含义。我用命令行方式做完整构建,步骤是固定的:

# 1. 拉取源码(如果你拿到的源码包已经解压,跳过这一步) git clone --recursive https://github.com/x64dbg/x64dbg.git cd x64dbg # 2. 创建构建目录并配置 mkdir build && cd build # 这行命令是关键:-DQT_DIR 指向 Qt 的安装路径 cmake .. -G "Visual Studio 16 2019" \ -A x64 \ -DQT_DIR=C:/Qt/5.15.2/msvc2019_64 \ -DBUILD_DEMO=OFF \ -DENABLE_LTO=OFF # 3. 编译 release 版本 cmake --build . --config Release --parallel 8

-DBUILD_DEMO是官方文档里存在的一个演示项目开关,默认关闭即可。ENABLE_LTO是链接时代码优化,实测打开后编译时间会显著变长,但生成的二进制体积和速度优化并不明显,我建议关掉加速构建。构建完成后,输出目录会生成x32\x32dbg.exe和x64\x64dbg.exe两个可执行文件。

编译完成只是第一步,运行前还要把 Qt 的运行库拷贝到可执行文件旁边。官方构建脚本里有这步操作,你手动编译时容易漏:

# 4. 部署 Qt 依赖到可执行文件目录 windeployqt --release --no-translations build/x64/Release/x64dbg.exe windeployqt --release --no-translations build/x32/Release/x32dbg.exe

3.3 构建后自检:用命令行参数启动,验证调试器能附加到进程

构建产物能不能用,最直接的验证方式是跑一次实际的调试流程。我会先用自带命令行模式做冒烟测试,不点 GUI,直接命令行加载目标程序:

# x64dbg 支持命令行启动并执行脚本 x64dbg.exe -open "C:\test\sample.exe" -run "bp MessageBoxW; g"

上面这条命令的意思很明确:打开sample.exe,设置MessageBoxW断点,然后继续运行直到断点命中。如果调试器能正常停在MessageBoxW入口处,说明驱动层、断点引擎和命令解析全部工作正常。如果这一步没反应,多半是调试引擎初始化失败或者符号加载路径有问题,要在 GUI 里看Log标签页的输出。

我自己的习惯是跑完冒烟测试后再用 x64dbg 附加到notepad.exe上玩一轮,附加调试比启动调试更容易暴露权限问题和反调试冲突,值得养成这个习惯。

4. 自己写一个插件:把 x64dbg 的插件 SDK 用起来,扩展右键菜单和命令

4.1 插件 SDK 的核心接口:plugin_init、plugin_command 与导出符号表

x64dbg 的生态之所以强,就是因为插件 API 设计得简单直白。你只需要写一个 DLL,导出几个特定名字的函数,调试器启动时就会自动加载。pluginsdk目录里的plugin.h和plugin_common.h是全部你需要包含的头文件。

插件的入口约定非常固定。核心就三个回调函数:

// plugin.cpp —— 一个最小可编译的 x64dbg 插件骨架 #include "plugin.h" // 插件初始化:加载时被调用一次 extern "C" __declspec(dllexport) void plugin_init() { _plugin_registercommand("MyDumpCmd", my_dump_command, true); _plugin_registercommand("MyInfoCmd", my_info_command, true); } // 插件卸载时的清理 extern "C" __declspec(dllexport) void plugin_stop() { _plugin_unregistercommand("MyDumpCmd"); _plugin_unregistercommand("MyInfoCmd"); } // 自定义命令的实际执行函数 static bool my_dump_command(int argc, char** argv) { if (argc < 2) { _plugin_logputs("用法: MyDumpCmd [表达式]"); return false; } duint addr = _expression_eval(argv[1]); _plugin_logprintf("当前地址: %p\n", (void*)addr); return true; } static bool my_info_command(int argc, char** argv) { _plugin_logputs("MyInfoCmd 执行成功"); return true; }

这份骨架里的_plugin_registercommand是关键 API,它把命令名跟函数绑定起来。_expression_eval是 sdk 里提供的表达式求值函数,可以直接使用用户输入的寄存器名、地址表达式,比如MyDumpCmd eip就拿到了当前指令指针。_plugin_logprintf负责把输出打到 x64dbg 的日志窗口。

插件编译出来后就是一个 DLL,放到x64dbg可执行文件所在目录下的plugins子目录即可,注意x64dbg.exe加载plugins\x64下的,x32dbg.exe加载plugins\x32下的,别放错目录。放错位置是最常见的“插件未加载”原因。

4.2 实战扩展:给反汇编右键菜单加一个“显示调用方信息”的功能

命令行插件只是入门,真正常用的是给反汇编界面加右键菜单项。x64dbg 的 SDK 提供了菜单接口,可以在指定菜单位置插入条目。

// 在 plugin_init() 中追加菜单注册 extern "C" __declspec(dllexport) void plugin_init() { // 注册命令 _plugin_registercommand("Callers", cmd_callers, true); // 注册菜单:在反汇编右键菜单里追加一组入口 int hMenu = _plugin_menuaddentry(plugin_handle, MENU_ENTRY_CALLERS, "查看调用方"); // 绑定菜单回调 callback_register(hMenu, menu_callback); } // 菜单点击后的回调 static void menu_callback(CBTYPE cbType, PLUG_CB_MENUENTRY* info) { if (info->hEntry == MENU_ENTRY_CALLERS) { // 获取当前光标处的地址 duint cur = _gui_disasmgetselection(); _plugin_logprintf("正在分析调用方: %p\n", (void*)cur); // 在这里调用你自己的分析逻辑,枚举引用该地址的指令 analyze_callers(cur); } }

这段代码里的_gui_disasmgetselection是 GUI 接口,返回反汇编窗口当前选中的地址。菜单 ID 需要自己声明一个常量,控制好每个菜单项的唯一性。analyze_callers是我自己写的函数,内部可以用_reg_getcontext获取寄存器,或者用_dbg_getfunction获取所在函数范围,再遍历指令找 CALL 引用。

实际用下来,这个功能非常实用:定位某个关键 API 时,右键一点就能看到所有调用点,省去了手动切到Reference窗口的功夫。写插件并不需要改调试器核心代码,但这套 SDK 的接口设计本身就值得细读。

4.3 插件的构建配置与调试技巧:VS 工程设置、日志输出定位加载失败

插件 DLL 的构建配置有几个必须注意的设置,否则编出来的 DLL 根本加载不了。对x64dbg.exe来说,配置平台必须选x64,x32dbg.exe则对应x86。运行库要用多线程 DLL(/MD),不能选静态链接(/MT),否则 CRT 冲突会让插件注入直接失败。

比较隐蔽的一个坑是“导出符号被裁剪”。如果你在 VS 里建 DLL 工程后没有把上面那些函数标注__declspec(dllexport),链接器默认不会导出它们,x64dbg 加载时发现没有plugin_init导出就直接跳过。排查方法是用dumpbin /exports MyPlugin.dll查看导出表,确认函数名是plugin_init、plugin_stop、plugin_cb这些,如果名字被修饰成了?_plugin_init@@...,检查工程是否定义了__cdecl导出约定。

插件加载失败时的日志定位,重点是 x64dbg 的日志窗口第一行有没有Plugin loaded信息。如果日志窗口里连插件加载事件都没出现,要么是 DLL 的依赖库缺失,要么是导出表不对。我遇到过一次插件 DLL 依赖了一个不存在的第三方库,x64dbg 加载时直接跳过,日志里只有一句 “Failed to load plugin” 的提示,用Dependency Walker查依赖才定位到问题。

5. 避坑指南:x64dbg 源码使用中最常踩的 5 个坑

5.1 x64dbg 调试时显示“无法启动调试器”

现象:点启动后日志窗口直接报错,目标程序没有启动。 原因:最常见的是目标程序路径权限不足,或者调试器本身没有管理员权限——Vista 以后,附加到受保护进程需要管理员权限,普通权限会被拒绝。 解决:用管理员身份运行 x64dbg。如果还不行,检查杀毒软件是否拦截了调试器创建调试进程的行为,Windows Defender 的“基于虚拟化的安全性”也偶尔会干扰调试器附加。

5.2 x64dbg 的“单步执行”不走,点一次 F8 后程序直接跑飞

现象:在某个代码区域按下 F8 单步,本应进入下一行指令,结果调试器直接变成“运行中”,再也停不下来。 原因:这条路径上有反调试代码。反调试技术里有一种手法是把断点寄存器和调试寄存器清空,或者用NtContinue跳转到特殊位置。如果你的断点被壳顺带清除了,单步就会失效。 解决:先用hide debugger插件(比如ScyllaHide)隐藏调试器痕迹,再尝试单步。还是不行的话,改用硬件断点或内存断点避开指令级检测。

5.3 软件断点下多了之后调试速度明显变慢

现象:程序里下的断点超过几十个之后,操作开始卡顿。 原因:软件断点每命中一次,就要经历“恢复原字节→单步→重新下断点”的完整周期,断点多了这个开销会累积。 解决:核心逻辑只保留 2-3 个关键断点,多用条件断点替代逐个单步。条件断点在 x64dbg 里可以直接在断点对话框里写表达式(比如eax == 0x1234),等于在断点命中后才做条件判断,省掉大量无意义的暂停。

5.4 附加进程后寄存器窗口全是 0,代码也看不到

现象:附加到正在运行的进程时,寄存器面板显示的全是零,反汇编窗口也没有有效内容。 原因:进程的线程上下文没同步。附加调试跟启动调试不同,它需要把当前所有线程的上下文拉到界面,如果主线程停在哪一个不稳定的内核态,寄存器显示就可能错乱。 解决:在 x64dbg 的命令行执行pause强制暂停所有线程,再刷新视图。或者干脆先在 Windows 的任务管理器里把进程“挂起”,再附加。

5.5 自己编译的 x64dbg 一打开就闪退

现象:编译成功,但双击x64dbg.exe就闪退,连窗口都看不到。 原因:十之八九是 Qt 插件依赖缺失,或者是编译配置里开的某些优化导致不兼容。内部 Qt 插件路径和编译时设置的路径不一致也会引发闪退。 解决:直接复制 Qt 安装目录里的plugins文件夹到 x64dbg 可执行文件旁边,保证platforms\qwindows.dll能找到。然后用命令行方式启动:x64dbg.exe -d或查看 Windows 事件查看器的崩溃日志,定位是哪一行代码触发崩溃。

6. 进阶玩法:把 x64dbg 当“半自动化调试器”用,命令脚本与条件断点的组合技巧

调试脱壳程序时,全程手动操作非常低效,x64dbg 的命令脚本能力可以帮你把常规流程自动化。我最常用的方式是把“下断 + 记录 + 继续”这套循环写成一个脚本,在命令行里直接跑。

举一个实际场景:脱一个简单的 UPX 壳,需要定位 OEP(原始入口点)。UPX 壳的典型特征是末尾有一个jmp跳到原始入口。这个跳转的特征是pushad保存寄存器,解压结束后popad加jmp指令。你可以用脚本自动搜索这一段特征:

// oep_find.txt —— 一段 x64dbg 脚本,自动找 UPX OEP 特征 // 创建脚本文件后用 x64dbg 的命令行 "script" 命令执行 var target = 0x401000; var end = 0x406000; loop: // 在范围 [target, end) 中搜索 popad 指令(0x61) find target, end, 61 cmp $result, 0; je not_found // 检查 popad 之后是否有 jmp 指令(0xE9 相对跳转) find $result+1, $result+5, E9 cmp $res, 0; je no_jmp // 如果找到,打印地址并暂停 log "OEP 候选: $result+1" pause no_jmp: set target, $result+1 jmp loop not_found: log "未找到 OEP"

这段脚本用到了 x64dbg 脚本引擎里的find命令,它的参数包括开始地址、结束地址和要搜索的字节序列。log命令把信息输出到日志窗口,pause暂停调试让脚本停止。用这种方法可以快速从壳的末尾代码区筛出popad + jmp组合。

条件断点是第二个高价值技巧。手动给一个 API 下断点时,把它设成“只在参数等于某个值时才停”:

// 命令行断点写法:MessageBoxW 断点,仅当 4 个参数里的第一个参数是 0 时才停 bp MessageBoxW, rdx == 0 // 也可以配合 [esp+..] 或寄存器写复杂条件 bp CreateFileW, [rsp+0x28] != 0

这里rdx在 x64 调用约定下是第四个参数。如果断点条件不满足,调试器不会暂停,代码继续跑,性能损耗极小。对高频调用的 API 来说,不用条件断点做好筛选的话,手点“继续”会点到你怀疑人生。

最后分享一个我自己的习惯:用 x64dbg 的x64dbg.ini做初始配置,把常用脚本路径加到“初始化脚本”选项里,每次启动自动执行。我还在脚本里默认做了几件事:清空日志、关闭自动更新、设定hide debugger的默认配置。从那以后,我每次拿到新版本或新机器,都强制走一遍“编译 → 冒烟测试 → 跑初始化脚本 → 附加真实程序验证”这套流程,确保不会有环境差异带来的奇怪问题。希望这篇笔记能帮你在 x64dbg 的源码和插件开发路上少踩几个坑。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询