Binary Ninja插件实战:用Python玩转字符串交叉引用与固件分析
简介面向二进制安全分析与逆向工程场景这是一份Binary Ninja的Python插件项目适合安全研究员、逆向工程师及Python开发者。插件演示了如何利用Python API定制Binary Ninja实现自动反汇编、函数信息提取和调用指令高亮等实用功能解决手动分析效率低下的问题同时展示从插件注册到命令发布的标准流程。压缩包共6个文件以Python脚本为功能主体辅以JSON与YAML配置、Markdown说明文档、许可证及gitignore规范文件整体仅9KB结构轻量、层次清晰便于快速定位核心代码。目前已有538人学习浏览适合希望低成本入门Binary Ninja插件开发的读者。通过研读该插件源码可系统掌握插件注册机制、API调用方式、基本块与控制流图的遍历技巧还能参考配置文件的写法和规范并在此基础上扩展实现自动化漏洞检测、恶意软件分析等高级功能显著提升二进制分析效率。1. Binary Ninja 插件Python 生态里最顺手的二进制二次开发入口打开一份没有符号表的固件第一件事往往不是直接看反汇编而是先找出那些能揭示代码意图的字符串登录提示、命令白名单、密钥格式、错误日志模板。用 Python 的 Binary Ninja 生态把一个「字符串 → 交叉引用 → 函数」的插件链路自动化是我在实际固件分析里做得最多的事。这篇笔记从 string_xref_report 这个插件出发拆它的文件骨架、核心 Python API、命令行批量分析以及我踩过的几个插件坑。适合刚开始写 Binary Ninja 插件、或者以前只写过 IDA Python 想横向迁移的读者后半部分会讲到无界面批量跑样本目录等你手头样本上来了会正好用得上。2. 插件骨架与 UI 接入让插件在菜单里被点到自己动手写第一个 Binary Ninja 插件之前我花了整整半个下午找「插件到底放哪里」。答案其实很简单Binary Ninja 启动时会扫描固定插件目录目录里带init.py 的文件夹会被当作 Python 模块导入单个 .py 文件也会被扫到。所以插件落地只有两件事——文件放进去、代码里注册命令。2.1 插件目录用户级目录优先不要动安装目录Binary Ninja 扫描的插件位置有用户级和系统级两层平台用户级插件目录Windows%APPDATA%\Binary Ninja\plugins\Linux / macOS~/.binaryninja/plugins/系统级目录在 Binary Ninja 安装目录下面一般放着官方内置插件。我刚开始写过一阵往安装目录塞插件的操作后来每次软件升级都被清掉后悔药都没得吃。从那之后我只用用户级目录升级再也不丢插件。在用户目录里建插件项目文件夹# Linux / macOS mkdir -p ~/.binaryninja/plugins/string_xref_report # Windows PowerShell New-Item -ItemType Directory -Path $env:APPDATA\Binary Ninja\plugins\string_xref_report这段命令唯一的作用是创建插件目录。目录名会显示在插件管理列表里所以取一个一眼能看出功能的名称比什么都重要string_xref_report 就是「字符串交叉引用报告」。Windows 下注意 %APPDATA% 变量展开PowerShell 里通常已经帮你展开不需要再手动拼全路径。想确认当前 Binary Ninja 究竟读了哪个目录菜单 Tools → Manage Plugins → Open Plugin Folder 会直接打开用户级插件文件夹这比手动找路径快得多。2.2 最小骨架init.py 与 plugin.json插件文件夹里的文件组织方式我习惯这样string_xref_report/ ├── __init__.py # 插件入口只做注册动作 ├── plugin.json # 插件元数据管理界面显示用 └── reporter.py # 具体分析逻辑init.py 是 Python 模块的入口Binary Ninja 导入插件时首先执行的就是它。我一般不在init.py 里放具体逻辑只放 PluginCommand.register 调用真正干活的部分留到 reporter.py 里好处是加载更快、顶层逻辑也干净。plugin.json 不是必需的但没有它插件会在管理列表里显示得很丑后续分发时别人也看不到作者和版本信息。一个能用的最小版本长这样{ plugin: { name: String Xref Report, version: 0.1.0, description: 遍历所有字符串的交叉引用按敏感关键词给函数打分, author: your_name, api: { python: 3 }, platform: [linux, windows, macos] } }这段 JSON 里plugin 是根节点下面 name 决定管理界面里显示的名字description 会在插件列表里当摘要用。api 声明的是 Python 版本现在 Binary Ninja 基本都以 Python 3 为主老插件里写的 python2.7 在新版本里会直接不加载所以这里务必写 python 3。platform 声明支持的平台写全平台通常没问题但如果你的插件用了 Linux 专属机制就把这里收窄。plugin.json 字段在不同版本之间有过小幅调整如果发现某个字段不生效先看 Binary Ninja 安装目录里官方插件的写法以官方现状为准。2.3 PluginCommand.register把插件挂进菜单最小可运行的init.py 只需要做一件事把一个回调函数注册成菜单命令。from binaryninja.plugin import PluginCommand from binaryninja import log_info # 菜单命令触发时会调用这个回调参数 bv 是当前打开的二进制文件视图 def on_report(bv): log_info([string_xref_report] 插件已触发但分析逻辑还没写) # TODO: 把真正逻辑挪到 reporter.py PluginCommand.register( 字符串交叉引用报告, # 菜单里显示的命令名 扫描字符串引用并按敏感关键词给函数打分, # 工具栏里的说明文字 on_report # 回调函数 )这里的关键是回调函数签名Binary Ninja 调用的这个函数必须接收一个参数这个参数就是当前分析窗口对应的 BinaryView 对象后续所有分析都从它展开。PluginCommand.register 前面两个参数分别是对外名称和说明用户实际通过菜单里的「插件」栏点命令名来触发你的代码。注册动作发生在模块被导入时。也就是说 Binary Ninja 启动时会 import 整个插件目录你的init.py 里 import 过的所有依赖都会在这个阶段被加载。如果插件依赖第三方库比如 requests 或者 numpy我一般把 import 语句放在回调函数内部而不是模块顶部这样 Binary Ninja 启动时不至于因为某个库缺失而直接报插件加载失败。刚写完插件去菜单里找命令时有一个容易绕弯的地方命令名用中文还是英文会直接影响菜单里的排序和搜索。我建议命令名用中文因为说明文字里可以写清楚功能但插件目录名和 Python 模块名一律用英文跨界工具最怕编码问题。2.4 加载失败时怎么找原因日志比 print 可靠插件第一次加载成功率通常不高。为了不让 Binary Ninja 因为某个 import 错误直接跳过整个插件我会在init.py 里包一层异常逻辑from binaryninja import log_error try: from binaryninja.plugin import PluginCommand from reporter import run_report # 自己的分析模块延迟导入 except Exception as e: log_error(string_xref_report 初始化失败: %s % e) PluginCommand None if PluginCommand: PluginCommand.register(字符串交叉引用报告, 扫描字符串引用, run_report)这段代码的思路是插件初始化失败不要静默直接把异常写进 Binary Ninja 日志同时把注册动作放在条件判断里避免一半模块加载成功、一半失败时出现更奇怪的报错。日志入口在 GUI 的 Log 窗口里能看到这也是后面所有排错的基础。提示改完插件代码后菜单里经常还是旧行为。最省心的办法是重启 Binary Ninja如果想要快一点可以在插件管理界面找重新加载入口但带多个 .py 文件的插件不一定能完整重载遇到这种玄学情况直接重启。3. 核心 API 实战把字符串交叉引用变成函数可疑度打分插件骨架搭好之后核心工作是分析逻辑。我用这个插件做的是遍历所有字符串找出包含敏感关键词的项再顺着交叉引用找到哪些函数在使用它们最后按引用次数给函数打分。整个过程只用 Binary Ninja 的字符串枚举、代码引用和函数对象三块 API。3.1 枚举全部字符串bv.stringsBinaryView 对象上的 strings 属性可以直接迭代出二进制文件里识别到的所有字符串。每一条字符串至少带三个信息起始地址、长度、原始内容。for s in bv.strings: text str(s) # 字符串内容 raw bv.read(s.start, s.length) # 原始字节保留编码现场 if len(text) 8: continue # 太短的字符串大多没有分析价值 print(hex(s.start), s.length, text[:80], raw[:16].hex())逻辑说明这里先取长短过滤然后打印地址、长度、前 80 个字符的文本内容以及前 16 字节的十六进制。参数 s.start 是字符串在虚拟地址空间里的起始地址s.length 是字节长度这两个字段配合 bv.read 能拿到原始字节比直接依赖 str(s) 更稳。我在真实插件里不会打印全部字符串因为在 10MB 以上的固件里字符串可能上万条print 一次就刷屏。正确做法是先做关键词过滤只留下需要追踪的项。3.2 用 get_code_refs 找引用它的指令字符串放到内存里之后一定有一段代码在引用它的地址。bv.get_code_refs(addr)返回所有引用 addr 的代码位置每个位置对象上挂着指令地址和所属函数。for s in bv.strings: raw bv.read(s.start, s.length) if badmin not in raw.lower(): continue for ref in bv.get_code_refs(s.start): fn ref.function if fn is None: continue print(引用字符串的函数:, fn.name, hex(fn.start)) print(引用指令地址:, hex(ref.address))逻辑说明先判断原始字节里有没有目标关键词命中才继续查引用。ref.function 给出包含该引用指令的函数ref.address 给出具体指令的虚拟地址。这里判断 fn is None 是必须的因为部分引用可能落在 Binary Ninja 尚未识别成函数的指令片段上直接访问 fn.name 会抛异常。多数情况 get_code_refs 够用但有一种边界情况字符串地址先被写进数据区再通过数据区间接引用。比如固件把一批命令字符串指针存在一个全局表里代码通过查表跳转访问。这时候只用 code refs 查不到最终用户需要额外调用bv.get_data_refs(s.start)找数据引用再把数据引用的地址作为二级线索继续追。data_refs list(bv.get_data_refs(s.start)) code_refs list(bv.get_code_refs(s.start)) print(data refs:, len(data_refs), code refs:, len(code_refs))这个双查的行为只推荐对高价值字符串执行也就是过滤后命中关键词的那些项。对全部字符串做双查会让整个插件的耗时翻几倍。3.3 给函数打分并排序把上面的逻辑聚合一下每个函数每引用一个敏感字符串可疑度加 1。最后按分数降序输出分数最高的函数最值得人工跟进。from collections import defaultdict SENSITIVE_KEYWORDS [bpassword, bpasswd, bkey, btoken, bsecret] def score_functions(bv): scores defaultdict(int) for s in bv.strings: raw bv.read(s.start, s.length) # 取原始字节避免解码撞上非法序列 lowered raw.lower() if not any(kw in lowered for kw in SENSITIVE_KEYWORDS): continue for ref in bv.get_code_refs(s.start): fn ref.function if fn is None: continue scores[fn] 1 return scores逻辑说明defaultdict(int) 让每个函数从 0 开始累加命中一次就加 1。关键词列表是可配置参数实际使用里我会按固件类型调整网络设备固件加 shell、ip、wifi 这类词工控固件加 modbus、coil、register。越贴近目标场景排序结果越有用。分数算完之后要排序输出。函数对象不能直接放进 JSON输出阶段需要转成基本类型def run_report(bv): scores score_functions(bv) if not scores: print(没有命中任何敏感字符串) return rank sorted(scores.items(), keylambda item: item[1], reverseTrue) for fn, cnt in rank[:20]: print(%4d %s %s % (cnt, fn.name, hex(fn.start)))逻辑说明sorted 的 key 指定按第二个字段分数降序排列切片 [:20] 只取前 20 个函数。fn.name 可能为空因为二进制分析阶段未必能从符号表拿到名字遇到空名字时输出地址也一样可读。完整插件里我会把 sort 结果再过滤一遍只保留分数大于 1 的项减少干扰。4. 避坑指南Binary Ninja Python 插件最容易翻车的五个场景这些坑我几乎全踩过一遍有的耗费半天才查出原因。整理成五条每条按「现象 → 原因 → 解决」的格式写照着排查能少走很多弯路。4.1 坑一插件改了又改菜单里永远是旧代码现象改完init.py 里的命令名回到 Binary Ninja 菜单里看还是旧名字。回头确认文件确实保存了但界面里就是不变。原因Binary Ninja 通常只在启动时导入插件模块。中途改代码不会触发重新导入当前进程里跑的还是旧模块对象。更隐蔽的是有时候插件管理界面已经触发了重新加载但回调函数引用的子模块还留在 Python 的 sys.modules 缓存里于是整体表现还是旧的。解决最可靠的办法是重启 Binary Ninja。想快一些可以在插件菜单里找 reload 相关入口但对带多个 .py 文件的插件reload 不一定完整。我现在遇到「改了没反应」第一反应是把插件涉及的模块从 sys.modules 里清掉再重新导入不过这个动作只写进诊断脚本平时不手动执行。4.2 坑二print 输出像进了黑洞现象插件里写满 print菜单里也能看到命令名但控制台里什么都看不到一度怀疑是不是又没加载。原因Binary Ninja 的 GUI 模式不一定会把 Python 的 print 重定向到你能看到的 stdout输出进了黑匣子而不是没跑。解决统一使用binaryninja.log_info/log_warn/log_error。这些调用走 Binary Ninja 自己的日志通道在 Log 窗口里能看到。我习惯在每个阶段输出一条关键日志例如「已加载 1024 条字符串」「找到 17 处引用」排错时能直接看出卡在哪一步。4.3 坑三字符串对象和 str 直接比较翻车现象想在报告里判断某个字符串是不是 admin_token写if str(s) admin_token大部分时候成立偶尔完全不命中。原因二进制里的字符串可能带换行符、空终止符、非 UTF-8 编码。str(s) 的转换规则在不同文件格式下并不一致宽字符串场景下强转结果还会混入额外字节。应该把它当作一个带地址和长度的数据视图而不是一个干净的 str。解决比较时绕开 str直接用bv.read(s.start, s.length)拿原始字节再对原始字节做 lower 和关键词判断。过滤条件先在 raw bytes 上过一遍命中后再查交叉引用既准又快。4.4 坑四改了注释和名字关了软件全没了现象用插件给关键函数改名、加注释退出 Binary Ninja 再打开所有改动恢复原状。原因分析会话里的对象修改不一定立即写入数据库。只有显式保存分析结果时改动才落盘直接关软件可能全部丢失。解决写回型插件要在回调结束时显式触发保存逻辑或者在插件里弹提示让用户确认保存。string_xref_report 只读不改所以不存在这个问题。这也提醒设计原则把「读型分析」和「写回型标注」分开写回型单独处理保存时机。4.5 坑五全量遍历跑出超长耗时现象把一个 40MB 的固件拖进 Binary Ninja插件跑起来要等几分钟界面像是卡死。原因get_code_refs单次查询开销不小对几千条字符串逐条查询总耗时取决于二进制规模。另外Binary Ninja 打开文件时默认做全量反汇编分析插件在分析还没结束时就开始跑两者叠加更慢。解决三个方向。第一过滤前置只对命中关键词的字符串查引用第二缩小范围先通过bv.functions划定感兴趣的地址区间把库函数排除掉第三必要时在加载阶段关掉自动分析改成手动触发。常见做法是把关键词列表过滤后的字符串控制在几百条以内再跑引用速度通常能接受。5. 接进自动化流程命令行批量分析与 JSON 导出Binary Ninja 插件不一定只在 GUI 里跑。样本量上来以后一个个打开文件再点菜单手会废掉。把同一个逻辑放到脚本环境里跑才是真正能用的姿势。5.1 无界面模式用 binaryninja.load 在脚本里分析Binary Ninja 的 Python API 可以在没有界面的情况下加载文件、执行分析和导出结果。实现方式是把插件逻辑写成一个独立脚本在 Binary Ninja 自带的 Python 环境里运行。# headless_report.py # 这个脚本必须在 Binary Ninja 自带的 Python 环境里执行 import sys import binaryninja def main(path): bv binaryninja.load(path) if bv is None: print(加载失败:, path) sys.exit(1) from reporter import run_report # 复用插件里的分析逻辑 run_report(bv) bv.close() if __name__ __main__: main(sys.argv[1])逻辑说明binaryninja.load 返回一个已加载并完成基础分析的 BinaryView 对象等价于 GUI 里打开文件的产物。run_report 是从插件模块里复用的函数把之前写的打分逻辑原样搬过来。bv.close() 必须调用否则重复处理多个文件时句柄占用会越积越多。这里要注意运行环境。binaryninja 包必须用 Binary Ninja 自带的 Python 解释器导入系统里的原生 Python 通常找不到这个模块。不同版本的自带 Python 位置不一样最简单的办法是先在 GUI 的脚本控制台里执行import binaryninja; print(binaryninja.__file__)拿到路径后再到脚本里用。调用方式大概是/path/to/binaryninja/python/python3 headless_report.py ./firmware.bin没有界面时print 的输出会直接回到终端标准输出。所以这类脚本里用 print 反而比 log_info 方便日志通道更贴近 GUI 语义终端脚本直接用 print 更直白。5.2 把结果导出 JSON 交给下游命令行场景下运行结果最好落成结构化文件。JSON 是最通用的选择但函数对象不能直接 json.dump必须转成字典。def score_to_dict(bv): result [] for fn, cnt in score_functions(bv).items(): result.append({ addr: fn.start, name: fn.name, score: cnt, }) return result import json with open(report.json, w, encodingutf-8) as fp: json.dump(score_to_dict(bv), fp, ensure_asciiFalse, indent2)逻辑说明fn.start 是函数入口地址fn.name 是函数名score 是可疑度。ensure_asciiFalse 让中文内容原样写入文件再配合 encodingutf-8下游 Python 脚本读取时不会碰到编码问题。导出的字段根据下游需求调整。如果是给其他分析脚本用我会额外带上函数所在的基本块数量、指令条数用于过滤那些太小的短函数如果只是人工看则保持当前三个字段就够。5.3 批量处理整个目录的注意点批量分析是整个自动化流程的终点。对目录下所有固件执行分析把结果按文件名收集起来import json import sys from pathlib import Path import binaryninja input_dir Path(sys.argv[1]) results {} for fw in input_dir.glob(*): if fw.suffix.lower() not in {.bin, .elf, .img}: continue bv binaryninja.load(str(fw)) if bv is None: results[fw.name] {error: load_failed} continue try: results[fw.name] score_to_dict(bv) except Exception as e: results[fw.name] {error: str(e)} finally: bv.close() with open(batch_report.json, w, encodingutf-8) as fp: json.dump(results, fp, ensure_asciiFalse, indent2)逻辑说明glob 按扩展名过滤文件白名单只有 .bin、.elf、.img。每个文件单独 try/except 包裹单个样本分析失败不会中断整个批次。finally 里保证 bv.close() 一定会执行。参数说明扩展名白名单按实际样本类型调整如果你处理的是 .hex 或者无扩展名的裸固件要先把白名单补全。真正的批处理脚本我还会加一个「最大文件体积」参数比如超过 200MB 的样本直接跳过避免个别异常大的文件卡死整个任务。注意无界面加载文件时Binary Ninja 仍会执行默认分析流程大文件加载会明显变慢。批量跑之前先拿单个中等样本估算单文件耗时再决定要不要拆成多组并行。6. 验证插件的三个习惯从能跑变成好用插件写完功能也正常直接拿去用我建议慢一步。下面三个习惯贯穿我后来写的所有 Binary Ninja 插件每个都救过我一回。6.1 手边常备一个最小样本用几十行 C 代码编译一个小二进制故意放几个敏感字符串和交叉引用。我一般把这个 fake_fw.c 留在插件仓库的 tests 目录cat tests/fake_fw.c EOF #include string.h #include stdio.h int check_admin(const char *p) { return strcmp(p, admin_token) 0; } int main(void) { return check_admin(wrong) ? 1 : 0; } EOF gcc -O0 -o tests/fake_fw tests/fake_fw.c这个样本因为文件小Binary Ninja 分析基本是瞬间完成的。插件改动后先拿它验证能避免在真实固件上等几分钟才发现逻辑错了。6.2 把判定条件写成断言而不是肉眼判断我见过最浪费时间的验证方式跑一遍插件用眼睛扫输出觉得「大概对了」就收工。次数一多迟早会放过一个边界错误。我会在每次跑完后追加一段断言逻辑import binaryninja from reporter import score_functions def test_fake_fw(): bv binaryninja.load(tests/fake_fw) if bv is None: raise AssertionError(最小样本加载失败) scores score_functions(bv) assert len(scores) 0, 样本里明明有 admin_token为什么零命中 top max(scores.values()) assert top 1, 至少应该命中一次字符串引用 bv.close() print(test_fake_fw ok)这段断言只检查两件事样本能被加载、敏感字符串至少命中一处。你别嫌它简单真发生过我改了关键词列表后全量匹配失效靠这个断言立刻抓出来。从那以后我每次给插件加新功能都强制走一遍最小样本、断言、真实样本三步踩坑次数明显变少。希望你也能用同样的习惯省掉那些深夜排错的时间。希望帮到你。本文还有配套的精品资源点击获取