【软件逆向-Agentic IDA Pro - LLM 驱动的逆向分析专家】此文章归类为:软件逆向。
项目地址:github.com/magiclf-ai/agentic-ida-pro
版本:IDA Pro 9.3
核心理念:LLM 主导策略决策,工具执行具体动作,全流程可追溯、可验证、可审计
Agentic IDA Pro 核心思想是:
逆向分析的典型路径是:
1 | 入口定位 -> 调用链展开 -> 函数语义分析 -> 数据结构恢复 -> 类型传播 -> 验证 |
这个路径的关键难点是:它不是固定流程图,而是"观察-决策-执行-再观察"的循环过程。
脚本擅长执行固定步骤,但不擅长在证据变化时动态改计划。而 LLM 正好擅长"弱结构决策"——也就是在不完全信息下做下一步规划。
所以我们把角色拆开:
| 层级 | 负责方 | 职责 |
|---|---|---|
| 决策层 | LLM | 观察证据、规划下一步、选择工具 |
| 执行层 | Tool | 做确定性动作(搜索、反编译、创建结构体) |
| 状态层 | Task Board / Knowledge | 沉淀结论、管理进度 |
| 审计层 | Observability / Acceptance | 记录日志、验收结果 |
这就是 Agentic 化真正的价值:不是"替代分析师",而是把分析师的方法论做成能复盘、能审计、能扩展的系统。
先看主路径:
1 2 3 4 5 6 7 8 9 10 11 | 用户请求 ↓ReverseExpertAgentCore(主策略循环) ↓Tool Layer(search/xref/decompile/create_structure/...) ↓IDAClient(HTTP 通信层) ↓ida_service.daemon(IDA 进程内串行执行) ↓IDB 变更 + 反编译结果 |
再看横向能力:
| 组件 | 作用 |
|---|---|
| 任务与知识 | TaskBoard + WorkingKnowledge,管理进度与沉淀认知 |
| 子任务并行 | spawn_subagent 异步派生,结果自动回流 |
| 上下文压缩 | prune_context_messages + compress_context_8block |
| 运行验收 | 备份、快照、结构体 diff、失败判定 |
| 可观测性 | 会话、turn、tool、事件入库 SQLite,可视化回放 |
从工程角度看,这个架构有两个关键取舍:
很多多 Agent 系统失败,原因是"每个 Agent 都在做所有事"。我们反过来做:每个 Agent 只做自己最擅长、最可验证的一段。
职责:
submit_output 完成收敛约束:
submit_outputsubmit_output检索本地 artifacts 中的可复用模板与历史片段,为主 Agent 提供"路径 + 片段 + 使用建议"。
审核证据链是否闭环,识别"字段命名看起来对了但证据不足"的风险点,给出最小补证动作。
给主 Agent 输出"下一轮唯一关键动作",把动作描述成可执行、可验证、可回退的形式。
对历史消息做高密度压缩,保留可执行记忆,删除噪音上下文。
这两者共同构成了我们最有特色的一段:CodeAct 式执行环。
下面是一个真实的结构体恢复任务执行日志。我们将逐轮剖析 Agent 的决策过程、工具调用和验证闭环。
分析关键函数并恢复结构体定义,给出证据链
这是一个典型的逆向分析需求:不仅要找出程序的核心逻辑,还要把散落在代码中的数据结构还原出来,并且每个结论都要有迹可循。
Agent 首先创建任务列表,把大目标拆成可追踪的子任务:
1 2 3 4 5 6 7 8 9 | [Tool Calls]- create_task: { "tasks": [ {"title": "定位关键函数入口/热点函数", "priority": "high"}, {"title": "采集关键函数伪代码与符号使用证据", "priority": "high"}, {"title": "创建/迭代结构体定义并应用类型验证", "priority": "high"}, {"title": "形成证据链报告", "priority": "normal"} ] } |
为什么要先创建任务板?
在长时间的分析过程中,LLM 容易"迷失方向"——忘记自己已经做了什么、还需要做什么。任务板把这些状态外化,每完成一个子任务就标记为 done,形成清晰的可审计路径。
任务创建成功后,Agent 开始执行第一个任务:定位入口函数。
1 2 3 4 5 | [Tool Calls]- search: { "pattern": "(?i)\\b(main|wmain|winmain|dllmain|start|entry)\\b", "target_type": "symbol" } |
Tool 返回:
1 2 3 4 5 6 | # Search Results- total_count: 2## Items1. [symbol/function] main @ 0x11402. [symbol/function] start @ 0x14c0 |
找到了两个关键符号:main 和 start。接下来需要确认它们的调用关系。
1 2 | [Tool Calls]- xref: {"target": "main", "target_type": "symbol", "direction": "to"} |
Tool 返回:
1 2 3 4 5 6 7 8 9 | # Xref Results- total_count: 5## Items1. ea=0x3fd8 -> target=0x4060 [__libc_start_main]2. start+0x1f -> target=0x4060 [__libc_start_main]3. start+0x18 -> target=0x1140 [main] ← 关键证据4. start+0x1f -> target=0x3fd8 [__libc_start_main_ptr]5. ea=0x420 -> target=0x56e [aLibcStartMain] |
证据解读:
xref 的结果清晰地展示了调用链:start 函数在偏移 +0x18 处调用了 main(地址 0x1140)。这是标准的 C 程序启动流程:start 作为真正的程序入口,通过 __libc_start_main 最终调用用户定义的 main。
Agent 立即更新任务状态,记录这一关键证据:
1 2 3 4 5 6 | [Tool Calls]- set_task_status: { "task_ref": "t001", "status": "in_progress", "note": "已定位入口符号: main@0x1140, start@0x14C0;xref 证据: start+0x18 -> main" } |
入口定位完成,接下来深入分析 main 函数。
1 2 | [Tool Calls]- decompile_function: {"function_name": "main"} |
Tool 返回的伪代码(节选):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | __int64 __fastcall main(int a1, char **a2, char **a3){ __int64 ptr; // [rsp+8h] [rbp-50h] ptr = (__int64)calloc(1u, 0x28u); // 分配 0x28 字节 if ( ptr ) { *(_DWORD *)(ptr + 8) = 256; // offset +8 写入 256 *(_QWORD *)ptr = 0x5244485F544E4F43LL; // offset +0 写入 magic *(_QWORD *)(ptr + 32) = sub_15B0; // offset +32 写入函数指针 // ... v6 = calloc(1u, 0x38u); // 分配 0x38 字节 // ... v10 = calloc(v7, 0x38u); // 分配数组,元素大小 0x38 }} |
关键发现:
ptr = calloc(1, 0x28) —— 分配 40 字节,很可能是某个结构体*(ptr + 8) = 256 —— offset 8 可能是版本号字段*ptr = 0x5244485F544E4F43 —— offset 0 写入一个 8 字节常量,可能是 magic*(ptr + 32) = sub_15B0 —— offset 32 写入函数指针calloc(1, 0x38) 和 calloc(v7, 0x38) —— 说明还有另外两种大小为 56 字节的结构体但仅凭 main 的代码还不够,Agent 决定同时反编译 main 中调用的子函数,获取更完整的字段使用证据:
1 2 3 4 5 | [Tool Calls]- decompile_function: {"function_name": "sub_1780"}- decompile_function: {"function_name": "sub_15B0"}- decompile_function: {"function_name": "sub_15F0"}- decompile_function: {"function_name": "sub_16C0"} |
sub_1780 的反编译结果(应用类型前):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | __int64 __fastcall sub_1780(__int64 a1, int a2){ __int64 v2; if ( !a1 ) return v13 - __readfsqword(0x28u); v2 = *(_QWORD *)(a1 + 16); // offset +16 作为链表头 if ( !v2 ) goto LABEL_13; while ( *(_DWORD *)v2 != a2 ) // 比较 offset 0 { v2 = *(_QWORD *)(v2 + 32); // offset +32 作为 next 指针 if ( !v2 ) goto LABEL_13; } v4 = (*(__int64 (**)(void))(a1 + 32))(v2, 16, 0); // ... 更多字段访问 ...} |
证据解读:
a1 + 16 被解引用作为链表头,说明 offset 16 是 head 指针v2 + 32 是 next 指针,说明 node 结构体的 offset 32 是 nexta1 + 32 被当作函数指针调用,验证了 main 中 *(ptr + 32) = sub_15B0 的推测sub_15F0 的反编译结果(应用类型前):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | unsigned __int64 __fastcall sub_15F0(__int64 a1){ __int64 v1; v1 = *(_QWORD *)(a1 + 16); // offset +16 作为某个指针 if ( v1 ) { v4 = *(unsigned int *)(a1 + 24); // offset +24 是计数 if ( (_DWORD)v4 ) { v5 = *(_QWORD *)(a1 + 8) & 0xFFFLL; // offset +8 被读取 v6 = v1 + 56 * v4; do { *(_DWORD *)(v1 + 52) |= 1u; // offset +52 被置位 *(_QWORD *)(v1 + 40) += v5; // offset +40 被累加 v1 += 56; // 步长 56,说明是数组 } while ( v1 != v6 ); } }} |
证据解读:
a1 + 16 指向的内存以 56 字节为步长遍历,且每个元素有 offset 52 和 40 的访问main 中 calloc(v7, 0x38)(0x38 = 56)对应,说明 v1 指向的是 field_desc 数组Agent 更新任务状态,记录已采集的证据:
1 2 3 4 5 6 | [Tool Calls]- set_task_status: { "task_ref": "t002", "status": "in_progress", "note": "已反编译 main@0x1140 获得容器与节点构建逻辑;已提取符号使用:calloc(0x28) 容器、calloc(0x38) 节点、calloc(v7,0x38) 字段数组" } |
基于前面收集的证据,Agent 开始构建结构体定义。
container 结构体推导:
| 偏移 | 大小 | 证据来源 | 语义推断 |
|---|---|---|---|
| +0x00 | 8 | *ptr = 0x5244485F544E4F43 |
magic 字段 |
| +0x08 | 4 | *(ptr + 8) = 256 |
version |
| +0x0C | 4 | ++*(ptr + 12) |
node_count |
| +0x10 | 8 | *(ptr + 16) 作为链表头 |
head 指针 |
| +0x18 | 8 | *(ptr + 24) 指向尾节点 |
tail 指针 |
| +0x20 | 8 | *(ptr + 32) = sub_15B0 |
hash 函数指针 |
总大小:0x28(40 字节),与 calloc(1, 0x28) 吻合。
node 结构体推导:
| 偏移 | 大小 | 证据来源 | 语义推断 |
|---|---|---|---|
| +0x00 | 4 | *v6 = v33 |
id |
| +0x04 | 4 | v6[1] = v4 |
flags |
| +0x08 | 8 | *(v8 + 8) = time(0) |
ts(时间戳) |
| +0x10 | 8 | *(v8 + 16) = v11 |
fields 指针 |
| +0x18 | 4 | v8[6] = v7 |
field_count |
| +0x1C | 4 | 未观察到访问 | unk1C(占位) |
| +0x20 | 8 | 遍历 v2 + 32 |
next 指针 |
| +0x28 | 8 | 双向链表逻辑 | prev 指针 |
| +0x30 | 8 | *(v8 + 48) = v20 |
process 函数指针 |
总大小:0x38(56 字节),与 calloc(1, 0x38) 吻合。
field_desc 结构体推导:
| 偏移 | 大小 | 证据来源 | 语义推断 |
|---|---|---|---|
| +0x00 | 32 | snprintf(..., 32, ...) 写入 name |
name[32] |
| +0x20 | 4 | *(v14 - 6) = v12 & 3 |
kind |
| +0x24 | 4 | 未观察到访问 | unk24 |
| +0x28 | 8 | *(v14 - 2) = 8 * v12 |
value |
| +0x30 | 4 | *(v14 - 1) = 4 << (v12 % 3) |
size |
| +0x34 | 4 | sub_15F0 中置位操作 |
flags |
Agent 使用 create_structure 创建这三个结构体:
1 2 3 4 | [Tool Calls]- create_structure: {"name": "field_desc", "c_decl": "struct field_desc { char name[32]; unsigned int kind; unsigned int unk24; unsigned __int64 value; unsigned int size; unsigned int flags; };"}- create_structure: {"name": "node", "c_decl": "struct node { unsigned int id; unsigned int flags; unsigned __int64 ts; struct field_desc *fields; unsigned int field_count; struct node *next; struct node *prev; void (*process)(struct node*); };"}- create_structure: {"name": "container", "c_decl": "struct container { unsigned __int64 magic; unsigned int version; unsigned int node_count; struct node *head; struct node *tail; __int64 (*hash)(void *buf, __int64 len, __int64 unused); };"} |
Tool 返回(确认结构体已落地):
1 2 3 | struct field_desc { ... }; // size=0x38 (56), mutation_effective=truestruct node { ... }; // size=0x38 (56), mutation_effective=truestruct container { ... }; // size=0x28 (40), mutation_effective=true |
mutation_effective=true 表示结构体已成功写入 IDA 数据库。这是关键一步:结构体不再是"纸面分析",而是真正落地到了 IDB 中。
结构体创建完成,但这只是开始。接下来必须验证:应用这些类型后,反编译代码是否真的变得更易读了?
Agent 在 main 函数中应用类型:
1 2 3 4 5 6 7 8 9 | [Tool Calls]- set_identifier_type: { "function_name": "main", "operations": [ {"kind": "local", "name": "ptr", "c_type": "struct container *"}, {"kind": "local", "name": "v8", "c_type": "struct node *"} ], "redecompile": true } |
Tool 返回(重反编译后的 main 函数):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | __int64 __fastcall main(int a1, char **a2, char **a3){ struct container *ptr; ptr = (struct container *)calloc(1u, 0x28u); if ( ptr ) { ptr->version = 256; ptr->magic = 0x5244485F544E4F43LL; ptr->hash = (__int64 (*)(void *, __int64, __int64))sub_15B0; // ... v8->fields = v11; v8->process = v20; if ( ptr->head ) { tail = ptr->tail; ptr->tail = v8; v8->prev = tail; tail->next = v8; } // ... head = ptr->head; for ( i = head; i; i = i->next ) { process = i->process; if ( process ) process(i); } }} |
验证效果:
ptr->version = 256 替代了 *(_DWORD *)(ptr + 8) = 256ptr->magic 替代了直接赋值常量v8->fields、v8->process、v8->prev、v8->next 全部变成了有意义的字段名i = i->next 清晰可读process(i) 不再是一堆强制类型转换同样的验证在子函数中进行:
重反编译后的 sub_1780:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | __int64 __fastcall sub_1780(struct container *a1, unsigned int a2){ struct node *head; head = a1->head; while ( head->id != a2 ) { head = head->next; if ( !head ) goto LABEL_13; } v4 = a1->hash(head, 16, 0); field_count = head->field_count; if ( (_DWORD)field_count ) { fields = head->fields; while ( 1 ) { field_count = fields->size + field_count; if ( fields->value > 0x10000 || (fields->flags & 4) != 0 ) break; if ( ++fields == v8 ) return; } }} |
重反编译后的 sub_15F0:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | unsigned __int64 __fastcall sub_15F0(struct node *a1){ struct field_desc *fields; fields = a1->fields; if ( fields ) { field_count = a1->field_count; if ( (_DWORD)field_count ) { v5 = a1->ts & 0xFFF; v6 = &fields[field_count]; do { fields->flags |= 1u; fields->value += v5; ++fields; } while ( fields != v6 ); } }} |
重反编译后的 sub_16C0:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | unsigned __int64 __fastcall sub_16C0(struct node *a1){ struct field_desc *fields; fields = a1->fields; if ( fields ) { field_count = a1->field_count; if ( (_DWORD)field_count ) { v5 = &fields[field_count]; do { fields->flags |= 2u; if ( fields->kind == 1 ) fields->size *= 2; ++fields; } while ( v5 != fields ); } }} |
闭环验证总结:
| 函数 | 应用前 | 应用后 | 改进 |
|---|---|---|---|
| main | *(_DWORD *)(ptr + 8) = 256 |
ptr->version = 256 |
字段语义清晰 |
| main | v2 = *(_QWORD *)(a1 + 16) |
head = a1->head |
链表操作可读 |
| sub_1780 | *(_QWORD *)(a1 + 32))(v2, 16, 0) |
a1->hash(head, 16, 0) |
函数指针调用明确 |
| sub_15F0 | `*(_DWORD *)(v1 + 52) | = 1` | `fields->flags |
| sub_16C0 | v1[8] == 1 |
fields->kind == 1 |
条件判断可读 |
Agent 更新任务状态,标记结构体恢复任务完成:
1 2 3 4 5 6 | [Tool Calls]- set_task_status: { "task_ref": "t003", "status": "done", "note": "结构体已创建并通过类型应用验证闭环:create_structure 确认大小;set_identifier_type 后伪代码字段访问全部替换为有名字段" } |
所有子任务完成后,Agent 将关键发现沉淀到知识区:
1 2 3 4 5 6 7 8 | [Tool Calls]- knowledge_write: { "confirmed_facts": "main 使用 calloc(1,0x28) 分配容器对象...", "evidence": "search: main@0x1140; xref: start+0x18 -> main; decompile main...", "hypotheses": "container::hash 的第三参数目前固定为 0,可能是保留参数", "open_questions": "field_desc::unk24 未见读写证据,需在其它函数中找交叉引用", "do_not_repeat": "不在无偏移访问证据时猜测字段含义" } |
这种知识沉淀机制使得分析过程可持续:如果后续发现新的证据,可以回到知识区更新假设;如果另一个子 Agent 在别处发现了 unk24 的访问,可以补充到这里。
最终的任务板状态:
1 2 3 4 5 6 7 | ## Task Status (Current)- summary: total=4, active=0, done=4, blocked=0- [x] t001 (high) 定位关键函数入口/热点函数 [status=done]- [x] t002 (high) 采集关键函数伪代码与符号使用证据 [status=done]- [x] t003 (high) 创建/迭代结构体定义并应用类型验证 [status=done]- [x] t004 (normal) 形成证据链报告 [status=done] |
所有任务都已闭环,Agent 提交最终输出:
1 2 3 4 5 6 | [Tool Calls]- submit_output: { "summary": "已完成关键函数分析并恢复 3 个核心结构体(container/node/field_desc)", "key_findings": "入口: main@0x1140; container 大小 0x28; node 大小 0x38; field_desc 大小 0x38", "artifacts": "IDA Structures: container/node/field_desc; 类型应用验证: main/sub_1780/sub_15F0/sub_16C0" } |
项目里 tool 调用遵循几条硬规则:
bind_tools + docstring 是参数语义基线ERROR: 时先做最小修复再重试从实现上看,我们采用了"动态绑定工具集":
这套设计的优势是:策略灵活,但工具边界清晰。
很多系统把"执行 IDAPython"当成一个黑盒工具:脚本失败了就失败了。我们做的是"脚本失败也能自修复"的可循环执行器。
execute_idapython 的路径大致是:
系统会自动生成子任务板,例如:
search/read_file)这正是 CodeAct 思路在工程里的落地:
我们明确阻断了破坏性结构操作,例如 idc.del_struc。理由很简单:一旦删错结构体,证据链和验收基线都会被污染。
我们在提示词和执行器里都写了"高风险 API 规避策略":
idc/idautils/ida_hexrays 等稳定模块这套机制让"会写脚本"升级成"可控地修脚本并完成任务"。
我们把任务管理做成了第一等公民,而不是日志附属品。
任务板支持:
create_task(单条/批量)set_task_status(todo/in_progress/blocked/done/cancelled)edit_taskget_task_board知识库支持:
confirmed_facts —— 已验证的事实hypotheses —— 待验证的假设open_questions —— 尚未解答的问题evidence —— 证据来源next_actions —— 下一步建议do_not_repeat —— 避免重复踩坑为什么这很关键?因为它把"模型短期上下文"转换成"可持续记忆"。尤其在长链路逆向任务里,没有这层结构化记忆,系统几乎一定会重复劳动。
长周期分析常见问题是上下文污染:历史消息越多,模型越容易掉焦点。我们设计了两层机制:
prune_context_messages:按 Message ID 定点折叠compress_context_8block:触发 8-block 蒸馏快照蒸馏不是"摘要文学",而是保留可执行记忆:
这让系统可以在长会话中保持"方向稳定 + 成本可控"。
所有会话进入 SQLite,包含:
sessions —— 会话级别元数据turns —— 轮次级别信息messages —— 完整消息链turn_tools —— 工具调用记录session_events —— 事件日志并配有前端时间线,可回看"某一轮为什么调用了这个工具、失败点是什么、后续如何修复"。
run_reverse_expert_agent.py 在执行期自动完成:
而且定义了明确失败条件:
这意味着:系统不是"跑完就算成功",而是"满足质量门槛才算成功"。
很多自动化工具止步于"输出结构体定义",但 Agentic IDA Pro 强制要求验证闭环。这是因为:
ptr->version = 256 这样的代码,才能确认 offset 8 真的是版本号。长链任务中,LLM 容易"遗忘"之前做过什么。任务板的价值在于:
submit_output 会被拒绝,防止"半成品"输出与任务板不同,知识区沉淀的是跨任务的长期认知。这使得多个子 Agent 可以协作分析:一个 Agent 发现了线索,写入知识区;另一个 Agent 在别处找到了答案,回来补充。
从研究脉络看,我们受到两类工作启发:
但项目不是直接照搬论文,而是做了工程化改造:
简单说:论文解决"能不能做",工程系统解决"能不能长期稳定地做"。
| 风险 | 对策 |
|---|---|
| 模型幻觉 | 每步操作必须有工具证据,不能只凭"我觉得" |
| 破坏性操作 | 显式阻断删除结构体等高危 API |
| 失败不可追溯 | 所有操作写入 SQLite,支持轮级回放 |
| 伪成功 | 必须有实际变更(结构体 diff)才算完成 |
| 无限循环 | 最大迭代次数 + 子任务超时机制 |
1 2 3 | export OPENAI_API_KEY='your-api-key'export OPENAI_BASE_URL='http://your-llm-endpoint/v1'export OPENAI_MODEL='gpt-5.2' |
.\src\scripts\run_windows_bridge.bat
1 2 3 4 | PYTHONPATH=src python src/scripts/run_reverse_expert_agent.py \ --ida-url http://127.0.0.1:5000 \ --request "分析关键函数并恢复结构体定义,给出证据链" \ --max-iterations 40 |
更多【软件逆向-Agentic IDA Pro - LLM 驱动的逆向分析专家】相关视频教程:www.yxfzedu.com