先抛一个问题:你拿到一个陌生的二进制文件,丢进 Ghidra,反编译窗口里吐出一屏伪代码,函数名全是FUN_00102a48,变量全是local_18、param_2,注释为零。你盯着它看了三分钟,脑子里其实什么都没装进去。这不是能力问题,是所有基于静态分析的工具链都卡在同一个地方——它给你的是“语法”,不是“语义”。GhidraGPT 这类方案想解决的,就是给这堆干巴巴的伪代码补上一层自然的语义翻译,让机器帮你先把函数猜想写好,你再去做验证和深挖。
这篇文章我会从传统逆向分析的痛点讲起,解释为什么大模型天然适合做二进制分析里的“翻译层”,然后给出一套可落地的集成方案:怎么把 Ghidra 的反编译结果抽出来、怎么构造提示词喂给大模型、怎么把模型输出的函数重命名和注释写回工程。适合正在做漏洞挖掘、恶意样本分析、固件逆向的从业者,也适合想用 AI 工具链提升分析效率但还没找到切入点的同学。全程以实操为主,不堆理论。
1. 传统逆向分析的瓶颈:不是工具不行,是“翻译层”缺位
1.1 伪代码可读性差的根源在哪里
很多人第一次用 Ghidra 的 Decompiler,会觉得“这已经很接近 C 了,为什么分析还是慢”?实际上伪代码的“类 C”只是表象。反编译器做的工作是把汇编指令还原成结构化的控制流和变量操作,但它不负责告诉你“这段代码在做什么业务”。比如下面这段典型的 Ghidra 输出:
undefined8 FUN_00102a48(long param_1, int param_2) { int iVar1; size_t sVar2; char local_38[32]; iVar1 = strcmp(*(char **)(param_1 + 0x10), "admin"); if (iVar1 == 0) { sVar2 = strlen(*(char **)(param_1 + 0x10)); memcpy(local_38, *(char **)(param_1 + 0x10), sVar2); return FUN_00101220(local_38, param_2); } return 0; }你能看出它和“登录校验”“字符串拼接”“配置读取”有什么关系吗?看不出。param_1 + 0x10是什么结构体成员?FUN_00101220做什么?local_38缓冲区有多大风险?这些信息分散在十几个函数、几百条交叉引用里,需要分析师逐个跳转、记录、脑内建图。传统工作流里,这个过程靠的是个人经验积累,一个熟练的分析师一天能手动重命名、补注释的函数数量是有限的,而且每换一个二进制样本,这套手搓语义的工作又得从头来一遍。
1.2 分析师的时间都消耗在哪里了
我算过一笔账:假设一个中型固件里有 300 个需要人工关注的函数。每个函数从跳转交叉引用、猜测参数含义、对照字符串引用、确认调用约定,到最后写注释,平均耗时 10 到 15 分钟。这意味着光“给函数起一个像样的名字”就要花掉 30 到 75 个小时。而这还没算那些恶意代码里故意混淆过的函数,它们会让这个时间翻倍。
传统辅助手段也不是没有:Ghidra 有插件可以做函数签名匹配,用 FLIRT/Signature 库识别库函数;也有脚本能根据字符串引用自动加注释;还有数据流分析插件帮忙标记参数来源。但这些手段都是“规则驱动”的——它们只能在已知模式内工作,一旦遇到自定义加密、混淆分发器、或代码被刻意打乱的样本,规则的覆盖能力立刻见底。真正消耗分析师精力的不是识别“这是什么库函数”,而是理解“这段库函数调用组合起来构成了什么业务逻辑”。
这也是为什么大家开始把目光投向大模型。规则不够,就用统计学习到的海量语义来凑。下面进入正题。
2. 大模型为什么能切入二进制分析
2.1 逆向分析本质上是“翻译”任务
如果把逆向分析的链路拆开,它其实是这样的:二进制字节 → 汇编指令 → 伪代码 → 人类可理解的业务语义。前两步 Ghidra 已经做得很好,最后一步是天然的语言模型任务。大模型接受过海量 C/C++ 代码、开源项目、安全博客、漏洞报告的训练,它见过无数种“代码长什么样、注释怎么说”的对应关系。你给它一段伪代码,它可以用极高的概率猜出这段代码的意图——因为这种意图在它见过的开源代码、固件驱动、恶意样本分析报告里有大量相似的原型。
有人可能会质疑:大模型没有真正“执行”代码,它怎么保证猜得对?这个质疑合理。但这里的关键是,我们本来也不是让模型做精确证明,而是让它生成一个“优先假设”。分析师不需要一个 100% 正确的答案,需要一个可以在几分钟内验证的方向。实际使用中,模型对常见逻辑模式的联想能力非常强:strcmp+ 固定字符串 + 分支跳转 → 大概率是登录校验或者权限判断;memcpy+ 栈缓冲区 + 长度来自外部参数 → 大概率是潜在溢出点;一堆异或运算 + 循环 → 大概率是自定义解密。这种联想能力恰恰是传统工具缺失的。
2.2 大模型补的是“上下文拼图”
还有一个容易被忽略的点:Ghidra 的反编译是函数粒度的,但业务语义往往是跨函数的。一个函数只做了解压的一小步,它的输入来自上一个函数,输出又喂给下一个函数。传统分析里,分析师要在多个函数之间来回跳,脑内维护一张“上下文拼图”。大模型不一样,你可以把整条调用链上的反编译结果都塞进上下文里,让它一次性给出跨函数的语义总结。比如从入口函数开始,把FUN_00102a48和它调用的FUN_00101220的伪代码拼在一起,模型就能告诉你“这组函数共同完成了一次配置文件的解析,其中包含对 key 的权限检查”。
这就是 GhidraGPT 这类方案的核心价值:不是替代 Ghidra 的反编译引擎,而是在 Ghidra 之上加一个语义理解层。传统插件的局限在于“利用已知规则”,大模型的价值在于“根据海量先验知识推断未知规则”。两者的组合,覆盖的场景会宽很多。
不过在动手集成之前,先想清楚一件事:你选的模型和调用方式决定了整个方案的效果上限。下面看具体怎么搭。
3. Ghidra 与大模型集成落地的完整路径
3.1 为什么选 Ghidra 而不是闭源工具
说句公道话,很多商业逆向工具的插件生态也不差,但 Ghidra 有几个天然优势,让它特别适合做大模型集成的基座。第一,它开源,Python API 完整,可以做 headless 命令行批处理,这意味着你可以在服务器上批量跑整个固件目录,而不需要打开 GUI 点来点去。第二,Ghidra 的反编译结果是结构化的,函数对象、参数列表、调用图、交叉引用都可以直接通过 API 拿到,方便程序化拼 prompt。第三,它的工程文件是导出的,分析结果可以被脚本自由读写,重命名、加注释这些操作都可以批量写回。
如果你是做自动化分析流水线的,GUI 工具很难嵌入到 CI/CD 里,Ghidra 的 headless 模式几乎是唯一解。后面所有示例都会基于 Ghidra 的 Python API 来做,用的版本是 Ghidra 11.x,语言是 Python 3。
3.2 整体架构与三种接入模式
先看清楚整体流程,再往下看代码就不会晕。整个系统分四层:
- 抽取层:用 Ghidra headless 加载二进制,枚举函数,拿到反编译伪代码和函数属性(地址、大小、调用数量、交叉引用)。
- 构造层:按一定策略筛选函数,拼接反编译结果、调用图信息、字符串引用等,生成 prompt。
- 推理层:调用大模型 API(可以是在线服务,也可以用本地部署的模型),拿到返回的重命名建议和注释。
- 回写层:解析模型输出,调用 Ghidra API 批量重命名函数、添加注释、设置标记。
接入模式有三种,看你的使用场景选:
| 模式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 在线 API(如 OpenAI / 国内大模型接口) | 样本量不大,临时分析 | 效果好,无需本地GPU | 数据出域风险,按量收费 |
| 本地模型(如 Qwen、DeepSeek 蒸馏版、Llama 系列) | 敏感样本、批量处理 | 数据不出域,可长期运行 | 需要较好显卡,效果略逊 |
| 混合模式 | 涉密样本用本地、普通样本用在线 | 兼顾效果与安全 | 链路复杂,需要做路由切换 |
从实际项目的经验看,在线 API 做函数重命名这种短文本任务效果最稳,尤其是对常见架构(x86、ARM)的代码理解能力明显强于小尺寸本地模型。本地模型的好处是可以 7x24 小时跑批,不需要担心限流和费率。我的建议是先拿在线 API 跑通流程,再根据需求决定是否切换本地推理。
3.3 核心实现:抽取、调用、写回三步走
下面是整套方案里最核心的一段 Python 脚本,运行在 Ghidra 的 headless 环境里。逻辑不复杂,我加了详细注释。
# ghidra_gpt_core.py # 运行方式:analyzeHeadless <project_dir> <project_name> -import <target_binary> -postScript ghidra_gpt_core.py # 注意:脚本运行在 Ghidra 内置的 Jython/Python 环境里,API 用 Ghidra 提供的模块 import os import json import time import urllib.request # 需要根据实际模型服务地址修改 API_URL = os.environ.get("LLM_API_URL", "http://127.0.0.1:8000/v1/chat/completions") MODEL_NAME = os.environ.get("LLM_MODEL_NAME", "qwen2.5:14b") API_TIMEOUT = 60 def get_function_decompilation(func): """使用 Ghidra Decompiler 获取函数伪代码""" try: decompiler = DecompInterface() decompiler.openProgram(currentProgram) result = decompiler.decompileFunction(func, 120, monitor) if result and result.decompileCompleted(): return result.getDecompiledFunction().getC() except Exception as e: print("[!] Decompile failed: {} - {}".format(func.getName(), e)) return None def call_llm(func_name, decomp_code, extra_context): """调用大模型接口,返回重命名建议和注释""" prompt = build_prompt(func_name, decomp_code, extra_context) payload = { "model": MODEL_NAME, "messages": [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": prompt} ], "temperature": 0.2, "max_tokens": 512 } req = urllib.request.Request( API_URL, data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"} ) for attempt in range(3): try: with urllib.request.urlopen(req, timeout=API_TIMEOUT) as resp: data = json.loads(resp.read().decode("utf-8")) return data["choices"][0]["message"]["content"] except Exception as e: print("[!] LLM call error (attempt {}): {}".format(attempt + 1, e)) time.sleep(2) return None def parse_llm_output(output): """解析模型输出,提取函数名和注释""" # 这里约定模型按 JSON 格式返回,例如: # {"name": "check_admin_login", "comment": "校验用户是否为管理员", "confidence": "high"} try: # 清理可能的 markdown 噪音 output = output.strip() if output.startswith("```"): output = output.split("\n", 1)[1] if output.endswith("```"): output = output[:-3] return json.loads(output) except json.JSONDecodeError: print("[!] Failed to parse LLM output: {}".format(output[:200])) return None def write_back(func, parsed): """把模型结果写回 Ghidra:重命名函数、加注释""" if not parsed: return new_name = parsed.get("name") comment = parsed.get("comment") confidence = parsed.get("confidence", "medium") if new_name: try: # 重命名后的名字加上前缀,标明是AI生成的,方便追溯 new_name = "ai_" + new_name func.setName(new_name, SourceType.USER_DEFINED) except Exception as e: print("[!] Rename failed: {} - {}".format(func.getName(), e)) if comment: # 在函数头部写入多行注释,标明置信度 full_comment = "[AI] {} (confidence: {})".format(comment, confidence) func.setComment(full_comment) print("[+] {} -> {}".format(func.getEntryPoint(), new_name)) def build_prompt(func_name, decomp_code, extra_context): prompt = "以下是来自二进制文件的一个函数反编译结果。\n" prompt += "函数原名: {}\n".format(func_name) prompt += "关联调用信息:\n{}\n".format(extra_context[:1500]) prompt += "反编译伪代码:\n{}\n".format(decomp_code[:4000]) prompt += "请分析这个函数的作用,严格按JSON格式返回name、comment、confidence。" return prompt def main(): # 获取所有函数,过滤掉太小的和纯库函数 fm = currentProgram.getFunctionManager() all_funcs = list(fm.getFunctions(True)) results = [] for func in all_funcs: # 函数太小没有分析价值 if func.getBody().getNumAddresses() < 5: continue # 跳过已知库函数 if func.isExternal() or "printf" in func.getName() or "malloc" in func.getName(): continue decomp_code = get_function_decompilation(func) if not decomp_code: continue # 收集调用者对当前函数的调用上下文,让模型理解它是在什么场景下被调用的 refs = getReferencesTo(func.getEntryPoint()) call_context = "" for ref in refs: caller = fm.getFunctionContaining(ref.getFromAddress()) if caller: call_context += "caller: {}\n".format(caller.getName()) if len(call_context) > 800: break output = call_llm(func.getName(), decomp_code, call_context) parsed = parse_llm_output(output) if parsed: write_back(func, parsed) results.append({ "addr": str(func.getEntryPoint()), "orig": func.getName(), "ai": parsed.get("name"), "comment": parsed.get("comment") }) # 输出汇总报告 report_path = os.environ.get("REPORT_PATH", "./ai_analysis_report.json") with open(report_path, "w") as f: json.dump(results, f, ensure_ascii=False, indent=2) print("[*] Done. Processed {} functions. Report saved to {}".format(len(results), report_path)) main()这段代码里有两个地方值得展开说。
第一是getReferencesTo获取的调用上下文。如果只单独看一个函数,模型很容易把它的作用理解偏。但如果你告诉它“这个函数是被FUN_00102a48调用的,而FUN_00102a48又读取了字符串'admin'”,模型的推断准确率会明显上升。在实际测试中,带调用上下文的 prompt 比不带上下文的准确率能高出二到三成。
第二是返回结果的解析。模型输出默认是自然语言,这对接下来的程序化处理不友好。所以在 prompt 里强制约定 JSON 格式,并且加了 markdown 代码块的清理逻辑,避免模型输出带格式噪声。这里有个小技巧:把temperature调低到 0.2 左右。逆向分析任务需要的是保守、一致性的推断,不是创造性发散,过高的温度会让模型起名越来越离谱。
代码里我用的是urllib.request做最朴素的 HTTP 请求,实际工程中你可以换用requests或 OpenAI SDK。如果接入的是本地 Ollama 服务,API_URL填http://127.0.0.1:11434/v1/chat/completions就行,兼容性比我这里默认的地址更好,Ollama 的 OpenAI 兼容接口可以直接用同一套代码。
4. 提示词与上下文工程:效果好坏的分水岭
4.1 函数级提示词的设计要点
很多人以为直接把伪代码丢给大模型就完事了,跑完发现输出的一堆函数名全是sub_xxx_do_thing这种毫无区分度的东西,于是得出结论“AI 逆向不靠谱”。其实问题多半出在提示词上。
一个合格的提示词至少要有四个部分:角色设定、反编译代码、调用上下文、约束条件。角色设定让模型进入“安全分析师”模式,约束条件让输出格式可控。下面是一个我在实际项目中验证过的提示词模板,可以直接抄:
你是一名资深二进制安全分析师,擅长从反编译伪代码中推断函数的真实用途。 请根据下面给出的反编译代码和调用上下文,完成以下任务: 1. 为函数起一个简洁、准确的名字(使用 snake_case,动词开头,不超过20个字符) 2. 用一句话说明函数的作用(不超过50个字) 3. 标注你对这个推断的置信度:high / medium / low 4. 如果代码中存在缓冲区溢出、命令注入、路径穿越等明显的安全风险点,列出风险类型 函数原名:{func_name} 调用上下文(当前函数被以下函数调用): {call_context} 反编译伪代码: {decompiled_code} 严格按照以下JSON格式返回,不要输出其他内容: {"name": "", "comment": "", "confidence": "", "risk": ""}注意几个细节。第一,“动词开头”这个约束非常关键。如果不约束,模型容易返回名词短语,比如admin_check而不是check_admin_permission,后者在函数列表里排序、搜索的体验远好于前者。第二,“不超过20个字符”是硬性限制,因为 Ghidra 的函数名窗口和交叉引用列表的空间有限,超长名字会导致分析界面拥挤。第三,“置信度标注”设计得很巧妙,它让模型在高不确定时有机会表达“我不确定”,而不是硬编一个误导性的名字,这比你拿到一堆错误命名然后逐个验证要高效得多。
4.2 跨函数上下文注入的正确姿势
前面提到过,单独分析一个函数的效果有限,但把整个程序的代码全塞进上下文也不现实,受限于模型的上下文窗口长度。我试过把一个 5000 行的大函数拆成多段塞进去,效果反而更差——模型在后半段忘掉了前半段的关键变量。
比较实用的策略是“两级上下文”:
- 一级上下文:当前函数的伪代码 + 其直接调用者的名字和摘要。
- 二级上下文:当前函数引用的字符串列表 + 当前函数调用的子函数名字。
字符串引用极其重要。二进制里硬编码的字符串通常是业务语义的“锚点”,比如一个函数里出现"login success"、"invalid password"、"session expired",那它的用途不言自明。我专门写了一个预处理逻辑,把函数中所有字符串引用的偏移和值抽出来,拼到 prompt 末尾:
该函数引用的字符串常量: 0x10210: "login_success" 0x10230: "invalid_password" 0x10245: "session_token"抽样测试表明,加字符串引用后,模型的“高置信度”回答比例从 40% 提升到 65% 左右。成本几乎为零,但效果立竿见影。原因也很简单——字符串是最接近人类语义的二进制残留物,它比汇编指令更容易让模型建立联想。
4.3 批量处理与重命名策略
批量跑完整个固件后,你会得到一堆ai_前缀的新函数名。我统一加这个前缀是有意的:它明确标识了这些名字是机器生成的猜测,未来人工审计时一眼就能区分哪些是已验证的、哪些是待定的。如果你直接覆盖原有FUN_xxx命名,出了问题想回溯就麻烦了。
批量处理时还有几个实操建议:
- 先从最小的函数开始跑。小函数逻辑简单、模型推断准确率高,先跑通整个流程、建立信心,再处理复杂函数。
- 可以用函数体规模做分级策略:小于 20 行的函数,提示词用简化版;20 到 200 行的用标准版;超过 200 行的,不建议直接整个塞给模型,而是先让 Ghidra 自动拆分或者先用注释标记关键分支。
- 结果写回时保持幂等性。做了重命名的函数下次再跑会被跳过,避免重复覆盖人工修改结果。我在脚本里加了一个判断:如果函数名已经是
ai_开头,就跳过。
5. 实际使用中踩过的坑与排查方案
5.1 模型幻觉导致的错误命名
最大的坑是“看起来合理、实际上完全错误”的命名。模型会根据某些局部特征强行推断一个语义,比如看到字符串"key"就把函数命名为get_key,但实际上这个函数只是在做字符串拼接,并不是获取密钥。
我试过更宽松的提示词,让模型输出“可能的作用 + 依据”,发现这种自由格式输出的幻觉率反而更高。后来在提示词里强制加了一句“如果不确定函数意图,请在 confidence 字段返回 low,不要猜测”。副作用是 low 置信度的答案比例有所上升,但剩下的 high 置信度回答变得相当可靠。
建议处理策略是:只对 confidence=high 的结果自动写回,medium 的存入备注,low 的直接丢弃。这样你至少不会把错误的函数名带进后续分析。fake 函数名一旦建立,靠人工排查成本很高,宁可少自动标注,也不要让错误命名污染工程。
5.2 反编译代码过长导致上下文溢出
遇到过最离谱的情况是一个几十 KB 的反编译函数,直接把模型请求打到超时。这种巨型函数在固件里其实不少见,尤其是一些未经优化的驱动代码或者被混淆过的分发器。
我的解决办法是“前置降级”。反编译结果超过 4000 字符就触发分级:先取函数的前 30% 代码片段,再取包含所有if/else分支头部的摘要,最后取函数尾部,三段拼接后再喂给模型。实测证明,丢失中段的具体操作对意图推断影响不大,因为模型的判断依据更多来自调用关系、字符串引用和关键分支结构,而不是每一行赋值语句。你也可以在 Ghidra 里用“只反编译函数签名 + 局部变量 + 第一个基本块”的方式压缩输入,但那样做丢的信息太多,不推荐。
5.3 限流、超时与成本失控
批量处理一个几百函数的固件时,在线 API 的限流几乎是必然遇到的。脚本里我做了三重防护:每次请求之间加 delay、失败自动重试 3 次、超时时间拉长到 60 秒。但真正治本的方案是先把所有函数的反编译结果缓存到本地,再分批调用模型,这样即使中途断了也能断点续跑,不用重新加载二进制。缓存的 key 用函数地址。
成本方面,我做过一个量化:一个中等复杂度函数,反编译代码大约 1000 字符,加上提示词模板大约 1500 token。按一次批量 300 个函数计算,总 token 数约 50 万,用在线 API 的费用大概在几块钱到几十块钱区间。这个开销对一次严肃的逆向分析任务来说可以忽略不计,但如果你每天跑几百个样本,还是建议切成本地推理或批量夜间跑。
5.4 一个常见问题速查表
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 模型返回空结果 | 上下文过长或 API 超时 | 检查函数体大小,开启降级模式;增大 timeout |
| 函数重命名失败 | 命名冲突或名称含非法字符 | 检查 model 输出是否有空格、特殊符号;给setName加 try/except |
| 大量 low 置信度 | 样本风格与模型训练分布差异大 | 换用更大的模型,或给模型补充更多上下文(字符串、调用链) |
| 批量跑得很慢 | 在线 API 限流 | 加 sleep、开并发(注意限流上限)、改用本地模型 |
| 分析结果不可复现 | 模型输出随机性 | temperature 调到 0,固定 seed(部分服务支持) |
6. 效果上限在哪里:实测对比与适用边界
6.1 一个直观的对比数据
我在某大型 CTF 模拟题和一个固件样本上分别做了测试。测试方法很简单:选 50 个功能明确的函数,人工标注标准答案,再用 GhidraGPT 的流程跑一遍,看模型给出的重命名和注释与标准答案的语义匹配度。
结果大致如下:
| 函数类型 | 数量 | 语义完全匹配 | 部分匹配 | 完全不匹配 |
|---|---|---|---|---|
| 简单工具函数(字符串格式化、内存拷贝包装) | 20 | 16 | 3 | 1 |
| 协议解析相关 | 15 | 7 | 5 | 3 |
| 自定义加密/解密函数 | 10 | 2 | 4 | 4 |
| 状态机/分发器 | 5 | 1 | 2 | 2 |
数字很直观:简单工具函数上,模型表现接近完美;协议解析上可用,但需要人工复核;自定义加密和复杂状态机,基本只能做弱提示,离可用还有距离。我的体感是,GhidraGPT 这类方案目前最适合做“函数列表的第一层标注”,帮助你在一小时内快速建立一个陌生样本的初步功能地图,而不是替代深入分析。
6.2 适用场景建议与风险提示
哪些场景收益最大?我认为有三类:
- 恶意样本分析。样本量大、函数功能碎片化,用 AI 预标注后,分析师只需要聚焦可疑点。
- 固件安全审计。固件里大量配置解析、字符串处理、外设驱动函数,语义模式高度重复,模型准确率很高。
- 闭源组件摸底。拿到一个陌生的第三方库,先自动标注一遍,能快速定位关键入口函数。
哪些场景不建议用?混淆壳、自修改代码、高度虚拟化的样本。这类代码的反编译结果本身就已经失真,模型基于失真输入做出的推断自然不可靠。还有一类是超大型函数,几百行的反编译结果已经超出模型的注意力极限,强跑只会得到一堆空洞的命名。
另外提醒一句:自动重命名会改变你的工作记忆锚点。当你习惯了ai_check_admin_permission这个名字,如果它是错的,你对整个模块的理解都会被带偏。所以我每次跑完批量标注后,都会花半小时随机抽 10 个函数人工验证,确认置信度阈值设得合适,再做后续分析。
在这个项目上,我个人的体会是:别指望大模型直接告诉你漏洞在哪,它最大的价值是帮你把前面 80% 的“这个函数是干嘛的”的体力活干掉,把精力集中到最有疑点的 20% 上。这套方案的演进方向也很清晰——把调用图嵌入、结构化输出、人工反馈闭环加进去,做成一个真正能在日常分析流水线里长期跑的基础设施。