第一次在终端里看到 AI Agent 自己发出 step-into 命令、然后根据返回值调整断点位置的时候我确实有点恍惚。做了这么多年逆向工程大部分时间都花在盯着反汇编窗口、一遍遍下断点、记录寄存器、看内存变化上现在这些东西正在被一个 REA 循环接管。所谓 REA我在本文里指的是 Reason推理 Execute执行 Adapt调整三段式智能体循环它通过 x64dbg-MCP 这个桥接服务把 AI 模型和 x64dbg 调试器连接起来。过去我们讨论 AI 辅助逆向更多是让模型帮忙看伪代码现在模型可以直接操作反编译器和调试器这是完全不同的场景。这篇文章适合安全研究员、CTF 玩家、恶意样本分析人员以及所有对 AI Agent 开发感兴趣的人。我会从为什么需要 Agent、桥接层怎么设计到实际跑一个完整调试任务、常见坑怎么排完整说一遍。1. REA Agent 循环从“问一句答一句”到“自己动手调试”1.1 为什么逆向工程需要 Agent传统逆向里最耗时间的其实不是“看懂一条指令”而是在“找位置”和“跟状态”。一个函数入口到了要看参数从哪来一个跳转判断失败了要回退看哪个内存被改了一个算法疑似是加密例程要反复跑十几遍对比输入输出。这些步骤脚本化程度很低因为每一步的选择都依赖上一步的观察结果。普通脚本只能做“流水线式”操作遇到分支就得停下来等人工。Agent 正好补上这个缺口。它可以把“观察-假设-验证”这个过程自动化而且能保留每一步的上下文。对我个人而言最早让我真正提起兴趣的并不是某个大模型能读懂多少汇编而是它能自己决定“下一步做什么”。一旦 Agent 有了操作调试器的工具它就从一个会说话的助手变成了一个能上手的同事。这正是 REA 循环的核心意义不是让模型一次性给出答案而是让模型在一次次的调试反馈中逼近答案。我经常打一个比方传统调试像是人肉修车一个人拿着扳手趴在车底下每拧一下都要探出头来看REA x64dbg-MCP 像是给修车师傅装了一套机械臂和仪表盘大脑负责判断要拧哪里机械臂负责执行仪表盘每秒钟把扭矩、角度、温度全部反馈回来。大脑不需要自己把手伸进去只需要看懂反馈并调整策略。这个转变对效率和体验都是质变。1.2 REA 循环的三段式结构我这里说的 REA不是某个特定框架而是一种循环流程。Reason 阶段负责根据当前掌握的寄存器值、反汇编片段、内存快照和之前的执行结果生成关于代码行为的新假设Execute 阶段把这些假设转换成具体的调试器操作比如在疑似校验函数的关键比较指令上下断点或者读某个缓冲区Adapt 阶段把执行结果与预期比较决定下一步是继续深入、回退重试还是换个方向。这个循环听起来简单真正跑起来有几个细节需要留意。第一Reason 阶段输出不能只是一个口头结论必须包含“为什么”因为后续 Adapt 阶段需要知道假设的依据否则一旦结果和预期不符Agent 没法判断是假设错了还是执行错了。第二Execute 阶段必须把工具调用参数化不能把地址硬编码在提示词里否则每次 ASLR 一变化整套流程就废了。第三Adapt 阶段必须有明确的策略候选比如“对比内存失败后尝试读调用栈回溯”而不是泛泛地说“如果失败就重试”。一个最简的 REA 循环伪代码大概是这样的state attach_and_pause(process) while not task_finished: analysis reason(state, history) tool_name analysis.tool arguments resolve_addresses(analysis.arguments, state.module_map) result mcp_call(tool_name, arguments) state update_state(state, result) history.append((tool_name, arguments, result)) strategy adapt(history, task_goal) if strategy backtrack: breakpoint_stack.pop_and_restore()这个循环放在真实逆向场景里关键性能瓶颈反而不在模型推理速度而在历史记录的结构。我们做完第一个 Demo 后很快就发现如果 history 里塞满了几千行原生态的反汇编结果任何模型都会在第四次循环之后开始忘掉最初的入口地址。这个问题我在第 4 部分专门展开这里先记住一个原则history 里保存的应该是“提炼后的结论”而不是原始工具返回值。1.3 为什么选 MCP 作为接线层MCPModel Context Protocol本质上是一套标准化的工具调用协议它规定了大模型如何发现工具、如何传参数、如何接收调用结果。选择 MCP 而不是直接让模型生成 x64dbg 脚本最直接的优点是隔离。模型不需要知道 x64dbg 的内部插件 API 长什么样只需要把“我需要在 0x401000 下一处条件断点”这个意图表达出来MCP 服务端负责翻译成真正能执行的调试器命令。另一个好处是可替换性。今天我在桥接层后面挂的是 x64dbg明天如果换一个兼容调试接口的工具Agent 端几乎不用改代码只需要替换 MCP 服务的实现。这种松耦合对还在快速迭代的方案非常重要。我们早期踩过一个坑直接在 system prompt 里塞了一套 x64dbg 命令格式让模型自行拼接。结果模型经常把命令拼错一个 feature 改起来还要同步修改提示词维护成本极高。迁移到 MCP 之后工具参数由协议格式和 schema 约束明显稳定很多。如果拿生活里的例子类比MCP 就像是调音台背后的统一音频接口。无论你是接调音台、声卡还是效果器前面的混音师只需要操作同一块面板而不必关心背后具体是哪个型号的设备。MCP 屏蔽掉硬件差异让模型面对的是一个统一的“调试器仪表面板”。2. x64dbg-MCP 桥接设计把调试器变成 Agent 的“手”和“眼睛”2.1 桥接层架构与职责一个能实际工作的 x64dbg-MCP 桥接层至少包含两部分一部分是运行在 x64dbg 进程里的插件端它负责接收来自 MCP 工具定义层的请求并调用 x64dbg 的调试接口另一部分是独立的 MCP 服务进程它运行在模型与调试器之间维护工具列表、参数校验和结果返回。插件端和服务端之间可以用本地 TCP 或 HTTP 通信也可以用文件管道。为什么不让 MCP 服务端直接挂在 x64dbg 进程内原因很实际调试器是很容易被搞崩的软件。你下错了断点、写坏了内存、运行到一个非法指令插件端可能直接崩溃。如果 MCP 服务端和调试器同进退那么 Agent 的整个工作流都会瞬间中断。拆成两个进程后即使插件端崩了服务进程还能保留之前的对话历史并通知 Agent“调试器需要重新附加”而不是丢失全部上下文。桥接层还有一个容易被忽略的职责状态同步。x64dbg 不是一问一答式的交互工具它自己会持续执行代码、触发断点、改变寄存器。MCP 工具输出的是“返回时”的状态快照但 Agent 如果想做更准确的推理往往需要知道“断点触发时”的上下文。我们在桥接层里维护了一份轻量级状态快照每当 x64dbg 暂停下来就把寄存器、当前指令、栈顶几项内容打包作为额外上下文传给 MCP 服务端。这为 REA 循环的 Reason 阶段提供了更丰富的输入。2.2 Agent 动作空间哪些 MCP 工具必须暴露桥接层到底要暴露哪些工具决定了 Agent 能不能完成特定类型的逆向任务。工具太多会让模型在选工具时犯难太少又会让调试卡在某个操作上。我在最小可用集合里保留了下面这些既覆盖了单步执行、断点、内存读写也覆盖了回归分析、调用栈检查和模块地址解析。工具名参数示例用途attach_processpid, event_code附加到目标进程并暂停break_atexpression, condition设置条件断点step_into/step_overthread_id单步进入或跨过run_untilexpression, timeout_ms运行到某个地址或表达式成立read_registersthread_id读取通用寄存器与标志位read_memoryaddress, size读取内存块write_memoryaddress, data, confirm修改内存需要确认disassemble_ataddress, count获取反汇编行get_callstackthread_id, depth获取当前调用栈get_module_map无获取已加载模块与基址列表search_literalpattern, start_addr, end_addr搜索字节或字符串模式save_hit_traceaddress, depth记录执行到该点时的关键上下文run_until是使用频率最高的工具但它也是最容易把 Agent 带入陷阱的。很多刚开始接触这套方案的朋友会直接让 Agent 运行到某个地址然后无限期等待。实际上我们应该给每个run_until都带超时参数并且在内部实现里规定“进入运行状态后每 50 毫秒检查一次目标地址是否命中”。如果超时工具返回RunTimeout而不是一直卡住。这样 Agent 就能进入Adapt 阶段主动下临时断点再运行。read_memory的建议上限是 4KB。一次读取太多内存不仅拖慢调试流程而且返回的大块原始字节很容易污染模型上下文。如果一个逆向任务确实需要读 1MB 缓冲区更好的方式是在桥接层做一个“按页读取并计算哈希”的工具让 Agent 只看到摘要而不是所有字节。这个设计理念贯穿整个工具集给 Agent 的应该是观察摘要而不是原始数据流。2.3 参数设计与安全边界工具集暴露得越多“误操作”风险也越大。尤其是write_memory和run_until一旦参数错误轻则让调试目标崩溃重则覆盖掉关键数据导致整轮分析作废。所以我们在桥接层里做了三层防护。第一层是参数校验。write_memory必须带一个非空 confirm 字段并且该字段不能由模型自己生成而要从之前读取的内存快照里复述部分字节。这听起来很低级但能显著减少模型在幻觉下乱写内存的概率。第二层是读操作和写操作分离。普通 REA 循环只能调用读类和执行类工具写类工具只有在一个单独的“修改模式”下才开放并且每次写操作都要记录到操作日志。第三层是过程审计。每一个工具调用都记录完整的入参、返回值、时间戳方便事后复盘。毕竟 Agent 跑一个多小时中间可能做了二十步操作如果没有审计出了问题根本不知道怎么回溯。安全边界不只是针对误操作也包含了运行环境隔离。我给 Agent 设置的目标程序全部运行在虚拟环境里路径白名单固定调试器禁止附加到任何非白名单进程。这样即使模型发生严重误导也不会影响到宿主机系统。这不是危言耸听我们在测试中就遇到过模型为了读取某个全局变量主动要求“附加到 pid xxx”而那个 pid 是宿主系统里的另一个良性进程。白名单机制及时拦住了这次误操作。3. 实操让 Agent 独立完成一次“从陌生二进制到关键算法识别”3.1 搭建最小可跑的 REA x64dbg-MCP准备一个最小可跑的环境不需要很复杂。硬件上有一台普通 x64 Windows 虚拟机就够了软件上准备三部分x64dbg 本体、一个能加载到 x64dbg 的插件端、一个独立的 MCP 服务进程。模型接口可以接本地区量化模型也可以用云端 API但为了调试方便我建议先在本机跑一个延迟低的小模型。桥接层的配置最好也做成文件而不是把参数写死在代码里。例如{ debugger: { name: x64dbg, plugin_port: 9123, timeout_ms: 5000 }, mcp: { allowed_tools: [ attach_process, break_at, step_into, read_registers, read_memory, disassemble_at, get_callstack ], write_mode: false, white_list_pids: [1234, 5678] } }注意allowed_tools是一个最小集合示例。实际跑复杂任务我会把run_until、get_module_map也加进去但一开始别加太多让 Agent 先适应简单的单步和读取。MCP 服务端的核心逻辑就是把工具名映射到具体函数一个简化版注册表类似tools { break_at: exec_break_at, step_into: exec_step_into, read_registers: exec_read_registers, read_memory: exec_read_memory, disassemble_at: exec_disassemble_at, } def handle_call(tool_name, arguments): if tool_name not in tools: return {error: funknown tool: {tool_name}} return tools[tool_name](**arguments)这一步跑通之后建议用一条最简单的工具链做冒烟测试让 Agent 附加到一个测试进程读取 EAX 到 RAX 的值再读 RIP 指向地址的一段反汇编。如果模型能连续完成三次工具调用而不乱序就可以进入下一个阶段了。3.2 一个典型的 Agent 调试会话为了讲清楚 REA 循环是怎么跑的我这里用一个模拟程序“模拟项目X”来演示。它读入一个序列号字符串经过一个转换后与某个全局常量比较。任务目标是“找到序列号校验的对比点恢复判断逻辑。”Agent 的首次推理会倾向于找入口。它可能会先调用get_module_map看到模块基址 0x140000000然后调用disassemble_at扫描入口附近的调用。我们给它的任务目标里写明了要找“对比”和“全局常量”所以它很快就主动在cmp指令上下一步断点。这里最关键的并不是模型一开始猜得准不准而是它能根据反汇编结果自我修正。会话中途会有下面这样的日志片段[Agent] Reason: 当前 candidate 位于模块偏移 0x11A0尾部是一条 je目标是 0x1400012A0。我需要判断 je 的跳转条件是否与输入字符串相关。 [Agent] Execute: break_at(0x1400011FC, condition ! 0) [Result] 断点设置成功 [Agent] Execute: run_until(0x1400011FC, timeout_ms5000) [Result] Breakpoint hit [Agent] Adapt: 命中 0x1400011FC 时RCX 是字符串指针RDX 是常量地址。先读两者内容再做比较。这个循环看起来简单但里面每一步都需要桥接层提供准确反馈。如果没有get_module_mapAgent 可能会直接拿固定地址下断点而忽略 ASLR 重定位如果没有read_memory它就无法验证字符串内容与常量之间的匹配逻辑如果没有超时控制run_until 可能卡死。所以真正影响任务成败的不是模型聪明不聪明而是桥接层给出的每个反馈是否稳定清洗。会话快结束时Agent 会根据对比指令两侧的数据推断出算法逻辑并输出类似结论“该程序将输入字符串逐字节加 0x5A 后与全局常量 0x14000C010 处的 16 字节数组比较其中最后两个字节是固定填充。”这个结论是否完全准确先不论重要的是它能够通过调试证据来支撑结论而不是凭空生成。3.3 结果解析与逆向报告输出让 Agent 直接输出长篇分析报告在真实工作流里用处不大。我更推荐让它在每次循环结束时写入一条结构化笔记最终汇总成报告。笔记结构可以是 JSON 或者 Markdown 表格关键字段包括发现的地址、相关指令、寄存器证据、内存内容摘要、推理可信度。一个合理的报告片段是## 关键发现 - 校验函数入口模块基址 0x11A0 - 对比指令位置模块基址 0x11FC - 比较逻辑输入字符每字节异或 0x5A 后与全局表逐个对比 - 全局表地址模块基址 0xC010长度 16 - 证据链read_memory(input_ptr, 16) 得到原始序列号连续三次断点均命中对比分支为什么要强调“结构化笔记”原因是逆向任务往往会拖很长Agent 在前面步骤中得到的结论如果不落盘保存后面很容易被新信息覆盖。这个“笔记工具”不需要很复杂可以直接让 Agent 基于一个本地文本文件操作每写入一条结构化记录时都保留时间戳。最终报告就是从这些记录里提炼出来的而不是让模型边回忆边写。加上这一步之后我们测试的完整任务恢复成功率提升了一大截。4. 常见问题与排查技巧实录4.1 调试器挂起或 run 命令超时这是我在整个方案里遇到最多的一个问题。Agent 调用run_until之后程序进入了一个不可预期的循环或者等待输入调试器迟迟没有暂停。如果你没有设置超时Agent 会在同步等待中长时间无响应看起来很像是冻结了。解决这个问题有两个层次第一是在工具调用层加超时第二是在 Agent 层增加“超时后主动暂停”的策略。更实用的技巧是在每次run_until之前先下一到两个安全断点。比如先在看好的函数返回处下断点再运行到目标地址这样即使中间逻辑走偏也会在返回处停下来。Agent 看到返回断点命中就会意识到流程和预期不符从而进入 Adapt 阶段进行回退而不是傻傻等着。如果你的调试目标会阻塞在系统 API 上那么超时之后直接suspend process可能会让线程停留在不可重入的代码里。我们的经验是遇到wait类 API 时先尝试在调用栈里找到返回地址把返回地址加进断点列表再用pause打断当前等待。这样线程虽然被打断但重新运行后还有机会继续走到返回地址不会彻底卡死。4.2 上下文爆炸与记忆漂移当 Agent 连续读取了五六块内存、反汇编了几百条指令之后大模型很容易陷入上下文爆炸。症状是它开始重复询问已经读过的内容或者突然把某个寄存器的值和另一个函数的参数混在一起。我在前文提到过“笔记工具”这是解决记忆漂移最有效的手段。具体做法是把 REA 循环中的历史记录分成两类一类是原始工具返回记录会被压缩成摘要另一类是 Agent 主动写入的笔记完整保留。笔记由模型自己控制何时写入但我在提示词里强制要求“每完成三次工具调用应该把当前可信结论写一次笔记。如果发现与前面结论冲突先记录冲突再做下一步操作。”这个要求能极大减少模型在长任务中丢失原始目标的情况。另一种做法是给每条反汇编结果加一个“信息预算”。disassemble_at默认只返回 20 行当 Agent 想扩大范围时它会主动发起一次新调用而不是让上次调用返回 100 行。这样做的好处是把大块文本切割成可控的上下文增量避免模型一次处理过多指令后产生混淆。4.3 ASLR 与地址不稳定Windows 程序默认开启 ASLR每次运行基址都可能会变。如果 Agent 在下断点时使用了绝对地址任务的第二次运行就可能全部失效。我在桥接层里做了一个“地址翻译”机制任何工具传入的地址在插件端都会被统一处理如果是模块名偏移的格式就实时计算当前基址如果是绝对地址就直接使用。这还不够。Agent 自己也需要学会模块偏移的表达。我们会在系统提示词里给出建议“所有静态地址都用模块名偏移描述例如 mian.exe0x11A0不要使用绝对地址除非是运行时动态获取到的地址。”因为模型一旦把固定地址写进笔记后续循环就会持续生效如果 ASLR 重定位那些笔记就变成了错误记忆来源。我在实际调试中也遇到过一个特例某些被调试程序会加壳或者每次启动都重新布局。这种情况模块偏移也不可靠。这时需要 Agent 先走到系统断点读取进程环境块再根据模块表动态计算入口逻辑。这个能力不是天然具备的需要你在桥接层暴露get_module_map并让 Agent 在任务开始时调用一次。4.4 模型幻觉与误操作防护模型在逆向任务里的幻觉表现通常不是“编造整个程序逻辑”而是在细节上“自圆其说”。比如读取内存返回了 0x41 0x41 0x00 0x00模型可能觉得这是字符串 AA但实际上这可能是两个 short 值。防护思路是强制模型引用原始十六进制证据。我在提示词里要求它输出任何结论时必须附上对应的内存字节和指令编码如果只有结论没有证据判定为不可信。误操作更多出现在write_memory上。前面说过写操作单独设一个模式。这里提供一个补充技巧在允许写模式打开时要求 Agent 先读原始内存再在调用参数里把“修改前后对比”写清楚比如before: 0x41 0x42, after: 0x43 0x44。桥接端会对 before 字段与当前内存做校验不匹配就拒绝写入。这个技巧看似笨拙但在防止模型乱改数据上非常有效。另外不要把所有信任都交给模型的“反思能力”。一旦 Agent 连续出现两次不符合预期的工具调用应该由桥接层主动介入。可以在配置里设定“单任务最大错误次数”超过后强制停止并让人类接管。这个功能是后期加的加了之后我们测试的稳定性提升很明显。4.5 常见问题速查表现象可能原因解决思路run_until 长时间不返回程序陷入循环或等待 API设置合理超时超时后暂停并在返回地址下断点Agent 突然忘了任务目标上下文过长导致记忆漂移每三次工具调用强制写笔记压缩原始返回第二次运行断点失效ASLR 导致静态地址变化统一使用模块名偏移启动时获取模块基址模型频繁误调用写内存工具权限边界不清晰读写分离写操作增加确认与前后对比校验调试器插件崩溃被调试程序异常导致插件崩溃插件与 MCP 服务分进程保留历史并支持重挂反汇编结果污染上下文单次返回指令太多限制行数增加信息预算工具这个表是我在实际项目中维护的第一版速查表之后每次踩坑都会补一条。现在最让我头疼的反而不是技术问题而是如何让 Agent 在“灵活探索”和“稳定执行”之间取得平衡。REA 循环给了它足够的自由度但如果没有这些边界约束自由度就会变成失控。我个人在实际操作中的体会是这套方案目前真正适合的场景是“半自动化逆向分析”也就是把 Agent 当作一个非常熟悉调试器操作的实习生它能完成重复跟踪、快速验证、证据收集但关键判断仍然需要人来复核。别指望一次跑完就输出满分报告更合理的工作流是“Agent 预分析 人工复审”。最后再分享一个小技巧在 Agent 的系统提示词里加一句“每次发现新疑点时先复述一遍当前可信的内存证据再提出下一步假设”就这一句话能让整体误判率下降很多。后面我打算继续把断点策略和路径覆盖率反馈接进 Adapt 阶段让 Agent 不再只会直线跟踪而是学会像老逆向工程师一样规划多条探索路径。