☰
从OD到x64dbg:源码级调试器的编译、插件开发与脱壳实践
2026/10/11 22:27:24 网站建设 项目流程

简介:x64dbg源码包是一套面向Windows平台的完整开源二进制调试器工程,与OllyDbg属同类工具,适用于逆向分析、恶意软件排查和脱壳进阶。压缩包体积不到5MB,共含一千八百余个文件,主体为C++架构:三百余个头文件与近三百个源文件承载调试器逻辑,少量汇编文件覆盖x86/x64指令及SSE、MMX、FPU等扩展;文档部分拥有五百余个Markdown与近百个RST文件,从编译部署到插件开发均有说明,另有三百余张图片、数十个Qt界面文件、工程配置和批处理脚本,共同构成可视化交互与自动构建体系。目前已有58人学习下载。借助源码可完整观察反汇编引擎、断点管理、单步跟踪和插件系统等核心机制,理解Windows调试器的工作原理,理清各模块之间的调用关系;结合构建脚本还能自行编译定制版本,对深入研究调试器实现、逆向分析与脱壳实践具有直接参考价值。

1. 把 x64dbg 源码装进自己的工具箱:OD 用户迁移调试器的一条低门槛路

做过脱壳的人手里基本都有一版 OllyDbg,但真正拿它调试 x64 程序时才知道有多难受:解析不全、断点不稳、反调试一绕就崩。x64dbg 源码这个开源调试器,正好是这类需求里最稳的替代路线——它把调试器界面、调试内核和插件 SDK 全部开放,既能当作 OD 的平替日常使用,也能改源码、写插件,做成自己的逆向调试工具。这份资源适合两类人:一类是从 OD 迁移过来、需要 x64 调试能力的逆向从业者;另一类是正在做课程设计或工具开发、想把调试循环和命令系统改成自己版本的工程师。下面从源码结构、编译、核心机制到踩坑点,按能复现的顺序拆完。

2. 为什么选 x64dbg:与 OD 的差异、源码结构、Qt 与调试核心的边界

2.1 为什么放弃 OllyDbg 转向 x64dbg:x64 支持、反调试与二次开发自由度

先给结论:OllyDbg 在设计初期就没把 x64 当作主线,即使后来出了 2.x,x64 模块的断点、反调试绕过的能力也明显不如 x86 那边成熟;而 x64dbg 从架构上就是 x64 优先,x86 只是兼容。对脱壳场景,x64dbg 还内置了一套反调试对抗的插件生态,常用做法是把 ScyllaHide 这类插件挂进去,处理 IsDebuggerPresent 和 NtQueryInformationProcess 检测,这在 OD 上要折腾很久。

再看二次开发自由度。OD 的插件接口是公开的,但源码一直没有完整开源,想改断点触发器、改命令解析逻辑,只能停留在插件层面;x64dbg 则从 GUI 到调试核心全部开放,命令系统、表达式求值器、断点管理器、脚本引擎都在源码里。换句话说,OD 能做的事它基本都能做,OD 做不到的源码级修改,它也能做。下面是 OD 与 x64dbg 的对比,选型时可以直接套用:

对比项OllyDbgx64dbg
x64 调试支持弱,常有误判x64 优先,x86 兼容
源码未完整开源完全开源
插件 SDK有,但文档散有 pluginsdk,接口稳定
反调试对抗需额外插件且易崩插件生态成熟
表达式简单算术支持寄存器、模块、引号内符号、运算

这一段的重点是:如果你只做 x86 小样本,OD 还能用;一旦要调试 x64 程序、要处理反调试、要改调试器行为,x64dbg 源码是迁移成本最低的起点。

2.2 源码目录拆解:断点、表达式、插件代码分别在哪

拿到源码包之后第一步不是急着堆编译环境,而是先看一眼目录,知道改什么去哪儿。x64dbg 源码的顶层结构非常清晰,常见布局是 src 下一等分的核心模块:

目录作用你会动它的场景
src/dbg调试内核,断点、表达式、命令、寄存器状态都在这加命令、改断点逻辑、调反调试
src/guiQt 界面,反汇编视图、寄存器视图、栈视图改显示、加面板、调快捷键
src/bridgeGUI 与内核之间的桥接层,数据结构定义给两者之间传新数据
src/pluginsdk插件开发用的头文件与接口定义写插件、编译第三方插件
src/plugins官方示例插件,脚本、命令、菜单的模板抄代码、复制成自己的插件

如果沿着一条命令走,比如命令行里敲 bp kernel32.VirtualAlloc,它会先被 GUI 层接收,走 bridge 传给 src/dbg 的命令处理器,命令处理器调用断点管理接口把 VirtualAlloc 的地址转换成实际地址,再在目标进程里写 0xCC。整个过程里,表达式求值器负责把kernel32.VirtualAlloc这种符号字符串解析成数值,断点管理器负责记录断点列表和命中行为,二者都在 src/dbg 下。所以当你想给调试器加一条新命令,核心改的是 src/dbg 里的命令表,而不是 GUI。

2.3 Qt 界面与调试内核的边界:改控件不动核心的写法

这是新手最容易犯的错:想给反汇编窗口加一个右键菜单,直接在 GUI 里找到DisassemblyView::contextMenuEvent就开始写,结果发现拿不到当前调试状态。原因是 GUI 和调试内核跑在不同线程,GUI 只能通过 bridge 里的接口发请求,不能直接读写调试器的全局状态。

我一般会遵循一个边界原则:凡是展示层的事,只改 src/gui;凡是行为层的事,只改 src/dbg;两者之间要传新数据,先在 src/bridge 里加结构体定义,再在两边各自实现收发。这样做的好处是编译时不会出现「GUI 改了一行,调试内核整条链接失败」的情况。

对脱壳这种重操作场景,大部分实际需求都用命令和插件解决,GUI 最好保持原样,等核心逻辑稳定了再去美化界面。这个边界意识在后面的避坑部分还会再碰到一次,因为它就是二次开发里最常见的崩溃来源。

3. 把源码跑起来:编译环境、子模块与 CMake 构建参数

3.1 前置环境:VS、Qt 与 Git 的版本搭配

编译 x64dbg 需要四样东西:Visual Studio、Qt、Git、CMake。从实际编译经验来看,最省事的是 VS2019 或 VS2022 搭配 Qt 5.15.x 的 msvc 预编译套件。这里强调一点:Qt 的下载包有很多编译器变体,必须选和 VS 一致的,比如 VS2022 就用 msvc2019_64 或 msvc2022_64,千万不要用 MinGW 变体,否则后面 CMake 阶段就会因为编译器不匹配翻车。

组件推荐版本作用
Visual Studio2019 / 2022,装 C++ 桌面开发编译器和链接器
Qt5.15.x,选择 msvc 编译器套件GUI 界面运行库
CMake3.16 以上工程生成和构建
Git任意较新版本拉取源码和子模块

之所以特别强调 Qt 5 而不是 Qt 6,是因为源码里很多控件写法是按 Qt 5 的行为写的,Qt 6 不是不能用,但会出现一些兼容性改动,要额外花时间修。对一份要复现的资源来说,稳定比新版本号重要。还有一个细节:安装 VS 时记得勾选「使用 C++ 的桌面开发」工作负载,只装一个 VS 本体没有 C++ 工具链,cmake 阶段会直接失败。

3.2 克隆与子模块更新:三行命令带出整个依赖

x64dbg 源码不是一个单仓库,它把反汇编引擎、解压库、调试帮助库拆成了子模块,所以克隆命令必须带 --recursive,否则拉下来的源码是缺件儿的。下面是常见做法:

git clone --recursive https://github.com/x64dbg/x64dbg.git x64dbg_src cd x64dbg_src git submodule update --init --recursive

第一条命令把主仓库和所有子模块一次性拉下来;第三条命令用于补全:如果中途网络断了导致某个子模块目录为空,执行它可以把缺的子模块重新初始化。注意--recursive是递归拉取,不只会把 BeaEngine、capstone 这些顶层依赖拉下来,还会继续拉它们自己的依赖。

如果你的源码包已经先把仓库打包好了,第一步可以跳过,但要确认子模块目录里面有实际文件,而不是一个写着 commit 哈希的指针文件。怎么看?进入子模块目录,看有没有 CMakeLists.txt、源文件这些内容;如果只有空目录结构,说明子模块没有初始化,还是要执行第三条命令。

3.3 CMake 构建与工程生成:参数对照

源码根目录有 CMakeLists.txt,构建流程是先 cmake 生成 Visual Studio 工程,再编译。我常用的命令如下:

mkdir build && cd build cmake .. -G "Visual Studio 17 2022" -A x64 -DCMAKE_PREFIX_PATH=C:/Qt/5.15.2/msvc2019_64 cmake --build . --config Release --parallel 4

第二行里-G指定生成器,VS2022 对应的是 "Visual Studio 17 2022",VS2019 对应 "Visual Studio 16 2019";-A x64指生成 64 位工程,这也是 x64dbg 主程序所在的目标平台;CMAKE_PREFIX_PATH指向 Qt 安装目录下具体的一套编译器套件目录,CMake 会从那里找 Qt5Widgets、Qt5Core 等组件。第三行用--config Release编译 Release 版本,--parallel 4表示用 4 个进程并行编译,机器核多可以改成 8。

如果 cmake 阶段报找不到 Qt,优先检查CMAKE_PREFIX_PATH是否写到了msvc2019_64这一级,而不是 Qt 根目录。写到根目录的话,CMake 能找到 Qt 但可能匹配到别的编译器套件,编译时依然会错。这个参数是我每次重建环境都会反复确认的一行,写错它浪费的时间比写代码还多。

3.4 首次运行前的目录检查

编译结束后,默认产物在 build/bin/x64 下。在运行之前花一分钟确认几个文件:x64dbg.exe、x64dbg.dll、Qt5Core.dll、Qt5Widgets.dll。前两个是编译产物,后两个如果不在输出目录里,就把 Qt 安装目录下的 bin 目录加进系统 PATH,或者直接复制过去。这一步不做,启动时会出现找不到 Qt 动态库的报错,第一次跑的人经常以为是自己编译失败了,其实是运行库没到齐。

还要检查有没有 plugins 目录,没有就手动建一个。x64dbg 会把插件放在与主程序同一级的一个目录里,后面写插件时要放进去。确认完这几点,双击 x64dbg.exe,能打开并附加一个测试进程,就算编译链路通了。顺便说一句,第一次启动会问你配置文件的保存位置,保持默认即可,这个配置目录后面调试插件和脚本文件时还会用到。

4. 核心机制解读:断点、表达式、命令系统与插件接口

4.1 命令系统与表达式求值器:命令行不只是「调试输入框」

x64dbg 的命令行承担了 OD 命令框的职责,但它更接近一个完整的表达式解析器。你在命令行里敲dump eip,它会先把eip解析成当前指令地址,再打开 dump 窗口;敲bp kernel32.VirtualAlloc,它会先把kernel32.VirtualAlloc解析成模块导出表里的实际地址,再在对应位置下断。这个解析链路是表达式求值器做的,它支持寄存器名、模块名、引号包起来的符号、十六进制常量,以及运算符组合。

实际用的时候,几个最频繁的表达式长这样:

表达式写法含义
eip / rip当前指令地址
[eip]从当前指令地址读取一个指针值
kernel32.VirtualAlloc解析模块导出符号为地址
"MyModule.dll+0x1234"模块基址加偏移

由于表达式求值器在源码里是独立模块,你在二次开发时可以直接复用它:自己写一个插件命令,接收用户输入后交给表达式解析,得到数值再传给断点或内存接口。这样一来,你的插件里不需要自己实现一套符号解析,省掉了很多重复劳动。这也是 x64dbg 二次开发比 OD 插件省力的关键点之一。

4.2 断点管理实现要点:软件断点、硬件断点与日志断点

断点是脱壳时的主战场。x64dbg 的断点管理分三类,对应不同场景:

  • 软件断点:把内存里的指令字节替换成 0xCC(INT3),程序执行到该地址时触发中断。优点是数量不限,缺点是被校验代码检测到修改,所以脱壳时常配合 ScyllaHide 绕检测。
  • 硬件断点:使用 CPU 调试寄存器 DR0-DR3 实现,不修改指令字节,能避开代码校验,但最多同时下 4 个。
  • 内存断点:基于内存页的访问权限,在数据被读写的时候触发,适合观察内存访问,但性能开销大,不适合频繁访问的区域。

在 GUI 里右键断点列表可以切换类型,也可以给断点挂条件。条件表达式用的还是刚才那个求值器,比如想只在某个 API 的返回值为 0 时中断,就在断点编辑框里写一条类似eax==0的表达式,命中时求值器先算一次,不成立就继续跑。这对还原壳的调用流程非常有用:先下 API 断点,再用条件过滤掉中间大量无意义的调用。

从源码角度看,断点记录的布局、命中回调、条件判断,都在 src/dbg 的断点管理模块里,插件可以通过 SDK 提供的断点接口读取断点列表和状态。这一层吃透之后,写自动化脚本时就清楚该在哪里插入自己的逻辑。

4.3 插件接口:plug_init 到菜单挂接的最短路径

插件的入口不是常用的 WinMain 或 DllMain,而是几个固定导出的函数,其中最重要是 plug_init。它会在插件加载后被调试器调用,框架代码在 src/pluginsdk 里定义。你在插件里要做的第一件事是从 initStruct 拿到插件句柄,然后用它注册命令或菜单项。流程如下:加载插件 → 调用 plug_init → 插件注册命令、菜单、表达式函数 → 调试器进入正常工作;卸载或退出时调用 plug_stop,释放资源。

需要注意插件接口是 C 风格的导出函数,即使你写 C++ 也要用 PLUG_EXPORT 声明成 extern "C",否则名字修饰会导致加载器认不出入口。这个点很多第一次写 x64dbg 插件的人都会翻车,编译出来看着是 dll,加载器却说你没有导出 plug_init。

4.4 一个最小插件骨架:注册自定义命令

直接给一个能通过编译的最小骨架,我通常拿它当所有插件的起点:

#include "pluginsdk/plugin.h" #include "pluginsdk/_plugins.h" static int hPlugin; static bool cbMySample(int argc, char* argv[]) { dprintf("[mysample] command called, argc=%d, argv[0]=%s\n", argc, argv[0]); return true; } PLUG_EXPORT bool plug_init(PLUG_INITSTRUCT* initStruct) { hPlugin = initStruct->pluginHandle; _plugin_registercommand(hPlugin, "mysample", cbMySample, true); return true; } PLUG_EXPORT bool plug_stop() { _plugin_unregistercommand(hPlugin, "mysample"); return true; }

逻辑说明:plug_init 优先执行,取出插件句柄;_plugin_registercommand把mysample注册成调试器命令行里可用的命令;用户在命令行敲mysample,回调 cbMySample 被触发,dprintf 把参数打印到日志窗口;plug_stop 里反注册命令,避免退出时留下悬空回调。

参数说明:_plugin_registercommand的第三个参数是命令回调的函数指针,第四个参数 true 表示这个命令只在调试暂停状态可用。如果你的命令需要在程序运行时就生效,比如自动附加和设置硬件断点,要把它改成 false,否则运行状态下敲命令会被调试器拒绝。这个细节在写自动化插件时特别容易忽略。

5. 避坑指南:编译、运行与二次开发的五个常见问题

这一章是血泪经验,每一条都是踩过之后才记进 check list 的。按「现象 → 原因 → 解决」写,遇到可以直接对照。

5.1 编译期:子模块目录为空

现象:git clone 完成,目录里 BeaEngine、capstone 对应的子模块文件夹是空的,编译时爆一堆找不到头文件。

原因:克隆时没带--recursive,或者拉取过程中子模块初始化失败,主仓库下来了,嵌套的依赖没下来。

解决:回到源码根目录执行git submodule update --init --recursive,等子模块全部拉完再继续编译。如果子模块更新反复中断,换一个更稳定的时间段重试,不要跳过它直接 build,后面反汇编引擎缺失会让整个编译失败。验证方法也很简单:进到子模块目录里,能看到源文件和 CMakeLists.txt 就说明有内容,如果只有一个空壳目录,那还是没拉全。

注意:判断子模块是否完整,不要只看目录存在,要看里面有实际源码文件。常见做法是看文件数和 CMakeLists.txt 是否都在。

5.2 Qt 配置错位:编译期找不到库、运行期缺 DLL

现象一:cmake 阶段报错Could not find a package configuration file ... Qt5WidgetsConfig.cmake,或者编译头文件时报cannot open QtCore/qglobal.h。

原因:CMAKE_PREFIX_PATH写到了 Qt 根目录,或 Qt 套件和 VS 版本不匹配,CMake 找到了 Qt 但选错了编译器套件。

现象二:编译成功,双击 x64dbg.exe 报错缺少 Qt5Core.dll 或 Qt5Widgets.dll。

原因:编译产物目录只放了工程自身的输出,Qt 运行库没有被自动复制进来,PATH 里也没有 Qt 的 bin 目录。

解决:两件事一起做。第一,把CMAKE_PREFIX_PATH精确到具体的 msvc 套件目录,比如C:/Qt/5.15.2/msvc2019_64;第二,把 Qt 的 bin 目录里Qt5Core.dll、Qt5Widgets.dll、Qt5Gui.dll复制到产物目录,或者直接加进 PATH。我一般会写一个小批处理,每次编译完自动复制:

set QTDIR=C:\Qt\5.15.2\msvc2019_64 xcopy /d /y %QTDIR%\bin\Qt5*.dll build\bin\x64\

这个批处理只复制比目标新的 DLL,不会每次全量拷贝。环境里同时装多个 Qt 版本时,CMake 可能优先捡到旧版本,所以最好在 cmake 命令里显式指定路径,别把 root 层级整个塞进 PATH。

5.3 运行期:反调试对抗不足导致目标崩溃

现象:附加一个带反调试的程序,x64dbg 刚附加成功就被目标检测到,目标主动崩溃或退出。

原因:目标程序通过IsDebuggerPresent、NtQueryInformationProcess或时间差检测调试器存在,x64dbg 默认没有对这些做隐藏。

解决:在插件目录放好 x64dbg 配套的反调试插件,常见做法是 ScyllaHide,并在插件设置里勾选目标可能用到的检测项。实际脱壳时,我一般先把反调试绕过打开,再附加进程,不然下完断点也白下。如果目标还有自校验,还需要配合硬件断点而不是软件断点,因为软件断点改字节容易被完整性检查发现。这个思路在 OD 时代就有效,在 x64dbg 里只是实现得更顺。

5.4 二次开发:插件加载失败与改内核失控

现象一:自己按 SDK 模板编译好的插件,copy 到插件目录后列表里看不到;或者加载成功后一敲命令就崩溃。

原因:插件放错目录、位数与主程序不一致,或者入口函数没有按 C 风格导出,导致加载器虽然把 DLL 载入了,但找不到 plug_init。

解决:确认扩展名是 .dll,且放到了 x64dbg.exe 的指定插件目录下;编译 x64dbg 对应位数的插件,别用 32 位配置生成 64 位插件;检查导出函数是否是PLUG_EXPORT bool plug_init(...)的原型,用 dumpbin 看一下导出表:

dumpbin /exports myplugin.dll

如果导出表里没有 plug_init,说明名字修饰问题没处理好,加extern "C"重新编译。还有一种常见坑是把插件调试代码放在 plug_init 里跑大量 GUI 操作,干扰了加载流程,至少在 init 阶段先只做注册,其他逻辑放到命令回调里。

现象二:改了 src/dbg 里的断点处理逻辑,调试目标时偶发 Access Violation。

原因:调试内核在子线程里处理事件,你在界面线程直接读写了内核的数据结构,没有走 bridge。

解决:回到 2.3 节的边界原则,把内核改动限制在事件下发链路里,需要与 GUI 通信就通过 bridge 消息。如果你只是想要「断点命中后多打印一条记录」,优先用插件 SDK 在命令回调里做,别直接改内核,这是性价比最高的方案。改内核之前先想清楚一个问题:这个功能是否只能在内核层实现?大多数时候答案都是否定的。

6. 进阶技巧:把脱壳断点做成一条命令

脱壳调试最烦的其实不是某个断点不会下,而是每次都要重复敲一串 API 断点,漏掉一个就得从头再来。x64dbg 支持把命令行操作保存成脚本文件,也可以按 4.4 节的方式注册成自定义命令。下面是一个典型的脱壳断点组合,放在脚本文件里:

bp kernel32.VirtualProtect bp kernel32.VirtualAlloc bp kernel32.LoadLibraryW bp kernel32.GetProcAddress run

在脚本窗口里加载执行,程序会依次对这几个 API 下断,然后跑起来。VirtualProtect/VirtualAlloc 是壳申请和修改内存权限时几乎必调的,LoadLibraryW/GetProcAddress 是导入表还原时绕不开的路口。断点命中后,先看日志窗口里的调用栈,判断是壳的 API 还是程序自己的调用,再用条件断点过滤高频调用。

如果你已经在做自己的调试器插件,更好的做法是把这套断点组合注册成一条命令,在回调里用调试器 SDK 提供的执行入口逐条下发,命令名称就叫unpack。从那以后,我每次编译完新版本,都会强制跑一遍这个脚本验证断点和表达式求值器没有被改崩,省掉了大量黑匣子式的排错时间。社区里基于这套命令和脚本接口衍生出的自动化玩法,比如把 x64dbg 接进大模型工具链的 MCP 方案,底层依赖的也还是这份源码里最基础的命令和插件能力。这算是我从 x64dbg 源码上收获最大的一条习惯:先用脚本把坐标固定下来,再动手改核心逻辑,希望帮到你。

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

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

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

立即咨询