简介:面向逆向工程与二进制分析学习者的 Python-BinaryNinja 插件包,适合希望用 Python 扩展 Binary Ninja、实现自动化反汇编与自定义分析流程的开发者,也可供安全研究入门者理解插件机制与 API 调用方式。压缩包共 6 个文件、约 9KB,以 Python 脚本为核心,配合 JSON 与 YAML 配置文件承载插件元数据和默认参数,另含 Markdown 说明文档、许可证及 .gitignore,整体结构轻量,便于快速阅读、拆解和二次修改。已有 540 人学习下载,具备一定示例参考价值。内容围绕插件注册、函数与指令遍历、视图交互及注释处理等场景展开,读者可据此理解插件从配置、加载到执行的基本链路,并观察插件目录放置后的菜单命令触发,尝试编写高亮调用指令、提取函数信息或搭建自定义自动化分析脚本,适合作为入门示例、课程演示与二次开发起点。
1. 二十行 Python 插件,把一晚上的手工核对压成三分钟
去年整理一个中等规模的二进制文件,函数三千多个,我拿着 BinaryNinja 一个一个点函数、看字符串交叉引用、手抄进表格。抄到第一百多个的时候发现自己串行了,前面的活全部作废,只能从头再来。那晚之后我把这个重复动作抽成了脚本,从此有了这套 Python 写的 BinaryNinja 插件。
这里要说清一件事:插件不是把逆向变成点一下按钮就出答案,它只是把你已经想清楚的判断固化成代码,让机器替你重复执行。所以这篇不讲空泛的插件有多强,只讲三件事——插件在 BinaryNinja 里到底怎么被加载、哪些重复劳动值得写成 Python 插件、以及我在写这些插件时踩过的坑。刚上手的人可以照着骨架跑通第一个插件,已经写过几个的人可以从避坑清单和性能那几节里找到判断依据。
2. 插件跑在哪:BinaryNinja 的 Python 加载机制先摸清
写插件最容易浪费时间的阶段不是写代码,而是代码写完了发现菜单里根本没有它。我见过不少人在这卡一下午,最后发现只是目录多套了一层。所以动手之前,先把加载链路摸清楚,比急着敲import binaryninja划算得多。
2.1 插件目录、入口文件与加载时机
BinaryNinja 的 Python 插件放在用户插件目录里,路径按平台分三种:Windows 在%APPDATA%\BinaryNinja\plugins,macOS 在~/Library/Application Support/BinaryNinja/plugins,Linux 在~/.binaryninja/plugins。不要凭记忆硬拼路径,在 BinaryNinja 自带的 Python 控制台里敲一句binaryninja.user_plugin_path(),它会把当前实际生效的目录原样打给你,这比查文档靠谱,尤其是你装过多个版本的时候。
目录结构有两种合法形态。单文件插件直接丢一个xxx.py进去;多文件插件则需要一个子目录,目录下必须有__init__.py作为入口。我一般用后者,原因很实际:插件稍微长一点就要拆模块,单文件很快就变成八百行的意大利面,几个月后自己都不想看。
加载时机这块有个血泪经验。插件在 BinaryNinja 启动时扫描一次,改完代码后能不能立刻生效,取决于你改的是什么。单纯改函数体,重载插件一般就能看到效果;但如果改动了PluginCommand.register这类全局注册逻辑,重载后可能出现命令重复注册、菜单里出现两个同名项,或者旧命令还在但指向已失效的模块对象。我的做法是:日常调试用重载,改注册逻辑就直接重启,别跟它较劲。
还有一点值得提前知道:插件目录里的任何语法错误都会让整批插件加载失败,而错误信息往往只出现在日志窗口里,主界面看起来一切正常。所以第一次写插件,先写一个只打印日志的空壳,确认它被加载了,再往里填业务逻辑。这个顺序反过来的话,你会在「代码没错但没反应」上耗掉大量时间。
2.2 三套 API 的边界:核心、界面、中间语言
BinaryNinja 的 Python API 大体分三层,混用是新手最常见的架构性问题。
第一层是binaryninja核心模块,负责二进制视图、函数、符号、交叉引用这些数据层面的东西。这一层不依赖界面,可以在无界面模式下跑,这也是做批量分析的关键——同一份分析逻辑,既能挂在菜单上手动触发,也能在命令行里对一批文件循环执行。
第二层是binaryninjaui,管菜单、动作、上下文菜单、面板这些界面元素。它的特点是必须在有界面的进程里用,而且涉及界面更新的操作要在主线程上做。很多人第一次写带界面的插件,就是在后台线程里直接改界面元素,然后看着程序卡死或者崩掉,还不知道问题出在哪。
第三层是 BNIL,也就是 LLIL、MLIL、HLIL 这几级中间语言表示。这一层是分析插件真正干活的地方:你想知道一个函数干了什么,读汇编是最低效的路径,读 MLIL 或 HLIL 才接近人话。代价是 IL 的生成需要时间,函数第一次访问.mlil属性时会触发提升,大文件上这个开销不小,这一点后面性能那节还会提。
我的分层原则很简单:数据采集全部写在核心层,界面层只负责「拿到用户选了哪个函数」和「把结果显示出来」,中间语言层只在确实需要语义信息时才碰。这样写出来的插件,逻辑部分天然可以在无界面环境下批量跑,调试和复用都省事。
2.3 用最小可跑插件验证环境
在写任何业务逻辑之前,先跑通一个最小插件。这一步的目的是把「环境问题」和「代码问题」分开,否则后面出问题你分不清是哪一类。
# __init__.py —— 最小可加载插件,验证目录和入口是否正确 from binaryninja import PluginCommand, log_info def _hello(bv): # bv 是当前二进制视图,插件被调用时由 BinaryNinja 自动传入 func_count = len(list(bv.functions)) log_info(f"[hello-plugin] file={bv.file.filename} functions={func_count}") PluginCommand.register( "Hello\\Count functions", # 反斜杠用于生成子菜单层级 "打印当前文件路径和函数总数", # 鼠标悬停时显示的说明 _hello, )这段代码里三个参数的含义值得说清楚。第一个参数是菜单路径,Hello\\Count functions会在插件菜单下生成一个Hello子菜单,里面放Count functions这一项,层级靠反斜杠分隔,注意是双反斜杠,单反斜杠在字符串里是转义符。第二个参数是说明文字,写清楚它干什么,三个月后你会感谢自己。第三个参数是回调,签名固定为接收一个bv参数,也就是当前二进制视图对象,不要试图自己去找全局变量。
跑通之后你会得到两个确定的信息:插件目录是对的,注册入口是生效的。接下来所有的问题都可以归到业务代码里,排查范围一下就缩小了。这个习惯我保持了两年多,每次新环境、新版本升级后都先跑一遍最小插件,比直接上大插件然后对着日志猜要快得多。
3. 判断什么该写成插件:五类高频场景与取舍
插件写多了会发现,真正有价值的插件不是功能最花哨的那个,而是每天都会点一次的那个。这一节讲我实际写过、并且一直在用的几类场景,以及更重要的——哪些活不该做成插件。
3.1 批量标注:把逐个人工判断固化成规则
逆向分析里最典型的重复劳动就是标注。改函数名、加注释、按特征打标记,单次操作只要三秒,但三千个函数就是两个半小时,而且中途一被打断就会漏。这类活是插件的完美目标,因为规则明确、执行机械、结果可校验。
核心 API 很直白:func.name = "..."改函数名,func.comment = "..."加函数注释,bv.set_comment_at(addr, "...")在任意地址加注释。写的时候有两个细节要注意。一是改名要用合法标识符,包含空格或者特殊字符的名字在某些导出路径下会出问题,稳妥做法是先做字符替换再赋值。二是批量修改后要确认分析状态,bv.update_analysis_and_wait()会等待分析队列清空,如果你依赖交叉引用信息,修改后最好等一下再读,否则读到的是旧数据。
我一般把规则写成一个小函数,接收函数对象返回布尔值或者新名字,然后遍历bv.functions逐条应用。规则本身用列表管理,想加一条就加一行,不用改遍历逻辑。这么拆分的好处是规则可以单独测试——拿几个已知函数跑一遍,比对结果,比在完整文件上盲试快得多。
3.2 结构化导出:让分析结果能被外部消费
插件自己不是终点。很多时候真正的分析发生在别处:拿函数特征做聚类、和前一次分析结果做差异比对、把字符串引用整理成报告交给别人。这些需求靠界面点不出来,只能导出结构化数据。
可行的导出格式主要是 JSON 和 CSV 两种。JSON 适合嵌套结构,比如一个函数下面挂若干条引用记录;CSV 适合扁平表格,给 Excel 或者脚本做二次处理最方便。我在实际使用中偏向 CSV 打底、JSON 兜底:先导一份扁平表用来看排序和筛选,需要完整上下文时再导一份 JSON。
导出这一层还有个容易忽略的价值:它把插件的能力从「在 BinaryNinja 里能看」变成了「在别的地方也能用」。同一个采集函数,挂在菜单上是交互式插件,被命令行脚本 import 就是批量处理器,一行代码都不用改。这一点在做多个文件的横向对比时特别有用——你不可能手动点开几十个文件一个个导出。
3.3 界面交互与后台任务:别让主线程干重活
带菜单和对话框的插件,作业量一旦上去就必须放后台。原因不复杂:BinaryNinja 的界面跑在主线程上,你在主线程里做一次遍历三千个函数、每个都读 IL 的操作,界面就会全程无响应,进度条不动、取消按钮点不了,用户以为程序死了。
标准解法是继承BackgroundTaskThread,把耗时逻辑塞进run方法,调用.start()启动。这个类自带进度提示和取消标志,在循环里定期检查取消状态,用户点取消后能及时退出,而不是等它跑完。我在遍历函数、遍历字符串、批量导出这几个场景里都用它,代码结构几乎一样,区别只在循环体。
界面交互部分我只保留最必要的能力:获取当前选中的函数、弹一个输入框问导出路径、结束后把结果用文本报告的形式展示出来。复杂的面板、自定义控件我一律不做——投入产出比太低,而且界面代码是最容易随版本变化而失效的部分,功能逻辑反而稳定得多。
3.4 判断依据:三种不该做成插件的情况
讲完该做的,说说不该做的。这个判断力比会写插件更值钱。
第一种是一次性任务。如果某个分析动作你这辈子只会做这一次,写脚本的时间比手工做还长,直接手工。判断标准很土:这件事你会不会做第二遍。会,就写;不会,就别写。
第二种是需要人做判断的任务。比如「这个函数看起来像不像加密例程」,这种判断依赖上下文和经验,硬写成规则会大量误报,最后你还得逐条人工复核,反而更累。这类需求适合做成「辅助」而不是「自动化」:插件把候选函数筛出来排好序,人来拍板。
第三种是依赖不稳定 API 的任务。有些内部接口在不同版本之间会变,你写的插件今天能跑,升级后直接报错。这种情况我会先确认它在当前版本的行为,做好异常捕获和降级,而不是把核心流程押在上面。插件是工具,工具坏了要能定位,不能变成黑匣子。
4. 手写一个可复现插件:从骨架到出结果
这一节把前面讲的东西落成一个完整可用的插件。选的任务很典型:遍历文件里所有字符串,找出每条字符串被哪些代码引用、这些引用落在哪个函数里,最后按函数聚合、按引用数量排序导出 CSV。这个任务在分析陌生文件时几乎是第一步,人工做非常痛苦,代码量却不大。
4.1 骨架与依赖:先把结构定下来
目录结构用一个子目录加入口文件:
plugins/ └── string_profile/ ├── __init__.py # 入口,负责注册命令 └── collector.py # 采集逻辑,可被单独 import拆成两个文件不是过度设计,而是为了让collector.py里不出现任何界面和注册相关的代码。这样在命令行脚本里from collector import build_rows就能直接复用,不用为了跑批量分析去模拟界面环境。
采集部分只依赖binaryninja核心模块里的三样东西:bv.get_strings()拿全部字符串,bv.get_code_refs(addr)拿某个地址的代码引用,bv.get_function_at(addr)把引用地址映射到函数。这三个 API 覆盖了绝大多数「定位到函数」的需求,是插件里出现频率最高的组合。
4.2 核心遍历逻辑:代码与参数说明
# collector.py —— 纯数据采集,不依赖界面 def iter_string_refs(bv): """遍历全部字符串,产出 (字符串内容, 引用地址, 所属函数) 三元组。""" for s in bv.get_strings(): try: text = s.value except Exception: continue # 个别字符串对象取内容失败,跳过不中断整体 for ref in bv.get_code_refs(s.start): func = bv.get_function_at(ref.address) if func is None: continue # 引用落在数据区或未识别区域,这类要单独统计 yield text, ref.address, func def build_rows(bv, max_str_len=120, min_refs=1): """按函数聚合字符串引用,返回按引用数降序排列的列表。""" buckets = {} for text, ref_addr, func in iter_string_refs(bv): item = buckets.setdefault(func.start, { "func_addr": hex(func.start), "func_name": func.symbol.short_name if func.symbol else "", "ref_count": 0, "samples": [], }) item["ref_count"] += 1 if len(item["samples"]) < 20: # 每函数只留前 20 条样本,控制内存 item["samples"].append({ "addr": hex(ref_addr), "text": str(text)[:max_str_len], }) rows = [v for v in buckets.values() if v["ref_count"] >= min_refs] rows.sort(key=lambda r: r["ref_count"], reverse=True) return rows几个参数值得逐个说清楚。max_str_len控制单条字符串截断长度,默认 120 字符,够看出语义又不至于让导出文件爆掉;遇到大段嵌在文件里的文本或者编码过的内容,适当降到 60~80 更好读。min_refs是过滤阈值,默认 1 表示全部保留,实际用的时候我常设成 2 或 3,把只引用了一两条字符串的普通函数滤掉,剩下的高引用函数往往就是日志、配置解析、报错处理这几类,很容易定位。
samples截断到 20 条是个折中。全部保留会让某些超大函数的记录膨胀到几十 MB,导出和打开都变慢;只留前 20 条足够你判断这个函数的性质,真要细看再回去看。这种「聚合时只留样本」的思路在写各类统计插件时都通用,先保证结果集可读,细节按需回查。
4.3 输出与二次消费:CSV 字段怎么设计
# exporter.py —— 扁平化输出,方便表格工具直接打开 import csv def export_csv(rows, out_path): with open(out_path, "w", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f) writer.writerow(["func_addr", "func_name", "ref_count", "sample_refs"]) for r in rows: sample = " | ".join(f'{x["addr"]}:{x["text"]}' for x in r["samples"][:5]) writer.writerow([r["func_addr"], r["func_name"], r["ref_count"], sample])encoding="utf-8-sig"这个细节是踩出来的:不带 BOM 的 UTF-8 在某些表格软件里打开会乱码,加上就正常了。字段顺序也讲究,地址、名字、计数、样本,前两列是标识,第三列是排序依据,第四列是给人看的证据,从左到右正好是「这是谁、有多重要、为什么重要」。
导出之后真正的价值才出现:把几个不同文件的 CSV 放在一起按func_name或者ref_count排序,很快能看出哪些函数在不同文件里行为相似。这种横向对比靠界面是做不到的。我一般还会再写一个几十行的脚本读这些 CSV 做交集,把多个文件里都出现高引用的函数名挑出来,作为下一步分析的入口。
4.4 大文件下的性能:分批、取消与缓存
三千个函数的小文件,上面这段代码跑几秒就完。但文件一大,问题就来了,主要是三个地方。
第一是 IL 提升。上面的采集逻辑只用了交叉引用,没有碰.mlil,所以不需要等 IL 生成,这是刻意的设计——如果某个统计维度确实需要读中间语言,就把它拆成单独的命令,别和轻量采集混在一起。原因很实际:一个函数读一次.mlil可能只要几十毫秒,几千个函数累加起来就是几分钟,而且这个开销在第一次访问时集中爆发,看起来像卡死。
第二是取消响应。把采集逻辑包进后台任务后,在循环里加取消检查:
from binaryninja import BackgroundTaskThread class ProfileTask(BackgroundTaskThread): def __init__(self, bv, out_path): super().__init__("collecting string references...", True) # 第二参数开启取消 self.bv = bv self.out_path = out_path def run(self): rows = [] try: rows = build_rows(self.bv) except Exception as e: print(f"[string_profile] failed: {e}") return if self.cancelled: return # 用户点了取消就不再写文件 export_csv(rows, self.out_path)super().__init__第二个参数传True才会显示取消按钮,这一步漏了的话用户只能等它跑完。self.cancelled在任务开始后由框架维护,检查时机放在主要耗时阶段结束之后是最省事的做法,粒度不必要太细。
第三是重复计算。同一个文件上反复执行插件时,如果每次都从头遍历,体验会很差。简单做法是在模块级用字典按bv.file.filename缓存上一次的结果,文件没变就直接复用。这不是什么高深技巧,但能把重复操作的等待时间从几分钟压到零,对日常使用的手感影响很大。
5. 避坑清单:插件开发里最容易翻车的五件事
下面这几条每一条都是我实实在在花时间填过的坑。写法统一按「现象 → 原因 → 解决」来,方便对照。
5.1 插件不加载:目录里多套了一层
现象:文件放进了插件目录,重启后菜单里什么都没有,日志里也没有任何报错。
原因:把插件目录本身当成了插件。BinaryNinja 扫描的是插件目录下的直接子项,如果你建了两层目录,把__init__.py放在了第二层,扫描器看到的第一层目录里没有入口文件,直接跳过,连报错都不给。
解决:先敲binaryninja.user_plugin_path()确认根目录,再确认__init__.py就在这个根目录的直接子目录里,路径形如plugins/我的插件/__init__.py,中间不能多一层。改完后重启而不是重载,第一次接入时最稳。
5.2 命令点不动或点了没反应:线程用错了
现象:菜单项能看见,点下去程序卡住几秒,然后要么恢复但什么都没发生,要么直接退出。
原因:把耗时逻辑放在了界面回调里同步执行,或者反过来,在后台线程里直接操作了界面对象。这两种都是线程边界用错,前者阻塞主线程,后者跨线程访问界面元素。
解决:界面回调只做三件事——取当前上下文、启动后台任务、立即返回。所有遍历和计算放进BackgroundTaskThread.run,需要更新界面时用框架提供的机制回到主线程,别在后台线程里直接改控件。
5.3 分析到一半卡死:循环里触发了 IL 提升
现象:插件在小文件上跑得好好的,换个大文件跑到一半进度条不动,内存一路涨。
原因:采集逻辑里读了func.mlil之类的属性,每个函数第一次访问都触发中间语言生成,大文件上这个开销巨大;同时结果里保留了完整对象引用,导致内存回收不掉。
解决:拆开轻量采集和重量采集,能在符号、交叉引用层面解决的不碰 IL;确实要读 IL 的,遍历前先bv.update_analysis_and_wait()把分析队列跑空,遍历中只提取需要的字段(地址、字符串、整数常量),不要把函数对象本身存进结果列表。另外每处理若干条就检查一次取消状态,别等全部跑完。
5.4 重载后菜单出现两个同名项,或者旧项点了报错
现象:改了几行代码,重载插件后菜单里同一个命令出现两次,点旧的那个直接抛异常。
原因:重载只重新执行了模块代码,但上一次注册的命令对象还挂在菜单上,指向的已经是失效的旧模块状态。改得越多,残留越乱。
解决:动到注册逻辑就完整重启,别图省事重载。如果确实需要频繁重载,可以在注册前用框架提供的注销接口清理同名命令,但这本身也容易出错,我的习惯是注册逻辑定稿后基本不再改,改的都是被调用的实现函数。
5.5 导出文件中文乱码或者路径报错
现象:CSV 在表格软件里打开,中文字段全是乱码;或者导出时抛出找不到路径的错误。
原因:编码上,写文件默认编码在部分平台不是 UTF-8,读的一方按另一套编码解析就乱了。路径上,Windows 下用户目录和临时目录的写法与类 Unix 不同,硬拼字符串很容易少个分隔符或者指向不存在的目录。
解决:写文件统一显式指定encoding="utf-8-sig";路径统一用os.path.join拼接,用户目录用os.path.expanduser("~")取,不要写死盘符。导出前先判断目标目录是否存在,不存在就创建,比让用户在报错信息里猜要友好得多。
6. 把插件用成习惯:一份模板和上线前的验证清单
插件写到第四个的时候,我停下来做了一件事:把公共部分抽成模板。所有插件共用同一套骨架——入口文件只负责注册,采集逻辑独立成模块,导出逻辑独立成模块,后台任务统一继承同一个基类。这样新插件的开发流程变成填空:改采集函数、改导出字段、改菜单名,二十分钟出第一个可用版本。这个决定后来省下的时间,比插件本身带来的还多。
模板目录我固定成这个形态,可以直接照搬:
| 位置 | 职责 | 是否需要改 |
|---|---|---|
__init__.py | 注册命令、绑定回调 | 每个插件改菜单名 |
collector.py | 遍历二进制、产出结构化数据 | 每个插件重写 |
exporter.py | 序列化成 CSV 或 JSON | 改字段名 |
task.py | 后台任务包装、取消处理 | 基本不动 |
README.md | 用法和已知限制 | 必写 |
上线前我会强制过一遍这几条,都是被坑过的教训:
- 在无界面模式下 import 一遍采集模块,确认它没有隐式依赖界面对象
- 拿一个函数个位数的小文件跑通,再拿目标大文件跑一遍,记录耗时
- 手动点一次取消按钮,确认任务真的停下来了,而不是等它跑完
- 导出的 CSV 用表格软件打开,确认中文和特殊字符不乱码
- 换一台没装过插件的机器,按 README 走一遍,看能不能复现
回过头看,写插件这件事的门槛比我一开始想得低得多,真正花时间的从来不是 API 调用,而是搞清加载机制、划清线程边界、想明白哪些活值得自动化。这套骨架和小节里的采集逻辑,我整理成了可以直接放进插件目录的结构,按上面写的路径放进去、重启就能在菜单里看到,剩下的就是把你手头那些每天重复十几次的动作逐个替进去。从那以后我每次准备手工点第二十遍的时候都会先停一下,问自己这东西能不能写成插件——这个习惯帮我省下的时间,远超写代码本身的成本。希望帮到你。
本文还有配套的精品资源,点击获取