Binary Ninja Python插件:函数指纹批量打标与符号回写
简介:面向安全研究员、逆向工程师及具备Python基础的软件开发者,这份Binary Ninja插件包聚焦二进制反汇编与分析流程的定制化,借助Python API扩展平台功能并实现自动化分析,适合作为插件开发与二次修改的轻量参考。压缩包共6个文件、约9KB,以py插件主逻辑、json与yaml配置、md说明文档为主,辅以license和gitignore等文件,整体结构精简,便于快速查阅。已有540人学习下载,可作为了解Binary Ninja插件机制的入门样本。读者能从中获得插件注册、菜单命令接入、函数与控制流遍历、指令注释写入等参考实现,并理解视图插件、命令行工具和事件处理器等模块化扩展方式;示例中高亮所有调用指令的写法,也可延伸至漏洞检测、恶意软件分析等进阶场景。
1. 从三百个 sub_ 函数说起:这套 Python 插件包把 Binary Ninja 的重复判断固定下来了
样本拖进 Binary Ninja,符号表是空的,函数列表里三百多个sub_xxxx,调用关系像毛线团。第一天我一个个点进去看参数、猜结构、写注释,八小时处理了二十来个,第二天重开工程,前一天脑子里建立的判断全忘光了——这才是逆向里最消耗人的部分,不是看不懂汇编,而是同一套判断你要重复做几百遍,而且做完不留痕。
这套 Python 插件包要解决的就是这个:把“这个函数在干什么”的判断,从人脑里的临时状态,变成能批量执行、能回写数据库、能复用的代码。它面向的是已经在用 Binary Ninja 做二进制分析、并且愿意写几十行 Python 的人——纯粹点点点的用户拿到它收益有限,但只要你会写for func in bv.functions,插件带来的效率提升是数量级的。Python 插件在这个体系里的位置很特殊:它不像 IDAPython 那样被历史包袱拖着,API 更接近现代 Python 的写法,IL 层抽象也更干净,写起来像在操作一个普通的对象图。
2. 目录结构与加载机制:插件从哪来、被谁加载、什么时候生效
2.1 一份插件包的目录清单
插件在 Binary Ninja 里不是一个文件,是一个目录。目录被扫描到、plugin.json被解析通过、__init__.py被 import 成功,这三个条件缺一个,插件就是“不存在”——而且不会给你任何弹窗提示。先看结构:
| 路径 | 职责 | 改动频率 |
|---|---|---|
bn_batch_tagger/plugin.json | 插件元数据,决定加载阶段和类型 | 极低,定型后基本不动 |
bn_batch_tagger/__init__.py | 入口,负责注册命令和 UI 钩子 | 低,加新命令时才改 |
bn_batch_tagger/tagger.py | 主流程,后台任务与回写逻辑 | 高 |
bn_batch_tagger/fingerprints.py | 特征提取与相似度打分 | 高 |
bn_batch_tagger/sigs/common.json | 签名库,纯数据 | 持续追加 |
bn_batch_tagger/ui.py | 弹窗、报告输出、设置项 | 中 |
这里有个血泪经验:插件目录该放用户级目录,不要图省事扔进安装目录下的plugins。安装目录里的东西在版本升级时会被整体替换,你辛苦调的插件连同签名库一起消失,而且删得干干净净,连后悔药都没有。用户级路径可以通过binaryninja.user_plugin_path()拿到,写脚本的时候直接调用这个函数,别硬编码家目录。
拆成多个文件不是为了好看,是为了让“数据”和“逻辑”分离。sigs/common.json是纯数据,团队里谁都能往里加条目,不用碰代码;fingerprints.py只负责“怎么算相似”,不关心签名从哪来。这种拆法在后面做二次开发时省事得多——换一套匹配算法,只动一个文件。
2.2 plugin.json 与入口init.py 的契约
plugin.json字段不多,但每个都有实际作用。我自己的插件用的是嵌套写法,老版本也认扁平写法,两者不要混:
{ "plugin": { "name": "Batch Tagger", "type": ["ui", "helper"], "api": "python3", "description": "按函数指纹批量回写符号、类型与注释", "longdescription": "选中一个或多个函数后,提取调用集合、常量集合与字符串引用,与本地的签名库比对,超过阈值则回写函数名。", "version": "0.1.0", "author": "internal", "license": "MIT" } }type是里面最容易被写错的一个。它不只是分类标签,还决定了插件在什么阶段被加载:标了ui的插件要等界面就绪才加载,标core的在更早的阶段就会跑,标binaryview的则要等 BinaryView 类型注册完成。如果你写的是纯分析逻辑、希望它在无界面批处理里也能用,就别一股脑全写ui,否则在 headless 场景下它压根不会被加载。api必须写python3,写成python或者干脆漏掉,插件会静默跳过——这是最常见的“装完没反应”原因。
入口文件必须叫__init__.py,Binary Ninja 会把插件目录当成一个包来 import。既然是包,相对导入就能用,不需要往sys.path里塞东西:
# bn_batch_tagger/__init__.py import binaryninja as bn from .tagger import TaggerTask from .ui import show_report def _on_tag_address(bv, addr): """右键地址时触发:优先处理多选,退化成当前函数""" func = bv.get_function_at(addr) if func is None: bn.show_message_box("Batch Tagger", "当前地址不属于任何函数") return targets = bv.get_selected_functions() or [func] TaggerTask(bv, targets).start() # 只有存在 UI 会话时才注册菜单项,headless 环境下跳过 if bn.core_ui_enabled(): bn.PluginCommand.register_for_address( "Batch Tagger\\Tag from here", "对当前选中函数批量匹配指纹并回写符号", _on_tag_address, )命令名里的反斜杠是子菜单分隔符,Batch Tagger\Tag from here会在右键菜单里生成一个二级菜单。这个细节不加也不影响功能,但插件一多,菜单会变成一长串没有归属的条目,找起来很烦。
core_ui_enabled()这个判断别省。很多人在 GUI 里调通了就直接拿去跑批处理,结果一导入就报错,因为在没有 UI 的会话里注册命令、创建对话框都会走到空实现上。
2.3 加载时序与上下文判定
插件加载失败是静默的。这一点必须提前接受,否则你会花半小时怀疑人生。判断顺序应该是这样:先确认插件目录名和plugin.json里的name有没有拼错,再去看日志目录里最新的那个文件,搜索你的插件路径,通常能看到 Python 的 traceback。日志文件名各版本有差异,按修改时间挑最新的看就行。
还有一个高频误区:改完代码以为保存就生效。已经注册过的PluginCommand不会因为你改了函数体就重新绑定,UI 里的重载入口对这部分也不完全管用。我一般的做法是改完直接重启,虽然慢十几秒,但省掉了“改了没生效→以为逻辑写错→又改一遍”的循环。
时序上还要注意一点:插件在加载时不应该做重活。有人习惯在模块顶层就把签名库全解析、把所有函数扫一遍,结果 Binary Ninja 启动时间肉眼可见地变长。正确做法是模块顶层只放注册代码,真正的计算推迟到命令被点击的那一刻,用惰性初始化缓存住:
_DB = None def get_db(): global _DB if _DB is None: from .fingerprints import SignatureDB _DB = SignatureDB.load_default() return _DB3. 核心链路拆解:一个批量打标插件从右键菜单到符号回写的全过程
3.1 拿到目标函数集合
入口那几行代码看着简单,实际藏着两个坑。第一个是get_selected_functions()的返回行为:不同版本、不同选中状态下,它可能返回空列表,也可能返回只含当前函数的列表。所以一定要有or [func]兜底,不能直接for f in bv.get_selected_functions()。
第二个坑更要命:不要拿bv.functions直接开迭代。它是一个动态视图,你在循环体里改函数名、改类型,都可能触发重新分析,迭代器当场失效,表现是随机漏函数或者直接崩。稳妥写法是先拍快照:
def collect_targets(bv, scope="all"): if scope == "all": return list(bv.functions) # 快照,避免迭代中被修改 if scope == "named": return [f for f in bv.functions if not f.symbol.auto] raise ValueError(f"unknown scope: {scope}")f.symbol.auto为真表示这个名字是分析引擎自动生成的(也就是那些sub_xxxx),为假表示是用户或签名库写进去的。做批量打标时先筛一遍auto,能省掉大量重复劳动——已经有人工命名的函数,通常不需要你再猜一次。
3.2 BackgroundTaskThread:别在主线程里遍历
三百个函数的指纹提取,如果同步跑,界面会卡到你以为程序挂了。Binary Ninja 提供了BackgroundTaskThread,把它继承下来就能拿到进度显示和取消按钮:
import binaryninja as bn class TaggerTask(bn.BackgroundTaskThread): def __init__(self, bv, funcs, threshold=0.72): super().__init__("Batch Tagger: 正在匹配函数指纹", True) # True = 允许取消 self.bv = bv self.funcs = list(funcs) self.threshold = threshold self.hits = 0 self.errors = 0 def run(self): total = len(self.funcs) db = get_db() for i, func in enumerate(self.funcs): if self.cancelled: bn.log_info("Batch Tagger: 用户取消,已处理 %d/%d" % (i, total)) return self.progress = "%d/%d %s" % (i + 1, total, func.name) try: self._handle(func, db) except Exception as exc: # 后台线程里抛异常会让整个任务静默终止,必须自己吞掉并记录 self.errors += 1 bn.log_error("处理 %#x 失败: %s" % (func.start, exc)) bn.log_info("Batch Tagger 完成: 命中 %d, 失败 %d" % (self.hits, self.errors))三个参数或属性值得单独说。super().__init__的第二个参数传True后,状态栏会出现取消按钮,此时self.cancelled会被置位,你的循环必须主动检查它,否则按钮点了没反应。self.progress是字符串,直接显示在状态栏,格式随意,但建议带上i/total和当前函数名,排查时一眼能看出卡在哪个函数上。异常必须自己捕获——后台线程里未捕获的异常不会弹窗,任务就那么没了,你只会觉得“跑了一半就停了”。
3.3 回写:符号、类型、注释三件套
匹配上之后要写回数据库,三个动作对应三种 API,写错任何一种都会出现“当时看着对、重开工程全没了”的情况:
def _handle(self, func, db): fp = db.fingerprint(func) name, score = db.best_match(fp) if score < self.threshold: return func.name = name # 写入 user symbol func.set_comment(func.start, "[auto] score=%.2f" % score) # 注释挂在函数入口 proto = db.proto_of(name) if proto: ty, _ = self.bv.parse_type_string(proto) if ty is not None: func.set_user_type(ty) # 老版本叫 apply_user_type self.hits += 1func.name = xxx走的是用户符号通道,重开工程还在;如果你用bv.define_auto_symbol之类的接口,那是分析引擎的临时产物,下次重新分析就被覆盖了。类型设置同理,要用set_user_type,别去改自动推导出来的类型。注释一定挂在func.start上,挂到别的地址上在函数列表里看不到。
parse_type_string返回的是一个元组,第二个元素是错误信息,第一个元素失败时可能是None。类型字符串写错(少个分号、参数名带空格)不会抛异常,只会安静地返回None,然后你的类型设置就被跳过了。签名库里存原型时,我建议用最保守的 C 声明形式,别用太花哨的语法。
3.4 MLIL 与 HLIL 的选择:同一段逻辑两种写法
做特征提取到底该看哪一层 IL,是新写插件时第一个要决策的问题。两者差别不是“高级和低级”,而是“稳定和不稳定”:
| 维度 | MLIL | HLIL |
|---|---|---|
| 抽象层级 | 中间层,接近三地址码 | 高层,接近 C 伪代码 |
| 调用表达 | MLIL_CALL的dest明确 | 调用可能被内联、被条件表达式包住 |
| 常量保留 | 编译期常量基本都在 | 部分常量被折叠进结构体偏移 |
| 跨编译器稳定性 | 高 | 中,受优化等级影响大 |
| 适合的场景 | 指纹、调用图、批量匹配 | 生成可读报告、辅助人工判断 |
结论很直接:做指纹用 MLIL,做展示用 HLIL。取被调用地址的写法如下:
from binaryninja import MediumLevelILOperation def callees_of(func): mlil = func.mlil if mlil is None: # 未分析的函数、导入表桩函数都可能拿不到 IL return set() out = set() for block in mlil.basic_blocks: for instr in block: if instr.operation != MediumLevelILOperation.MLIL_CALL: continue dest = instr.dest if dest.operation == MediumLevelILOperation.MLIL_CONST_PTR: out.add(dest.constant) # 直接调用,目标地址是常量 return outfunc.mlil返回None的情况比想象中多——还没排进分析队列的函数、thunk、某些架构的桩代码都会是None,不判空就会在批量跑的时候炸出一堆异常。MLIL_CALL的dest只处理常量指针,也就是直接调用;间接调用(寄存器、内存取值)在这里拿不到确定目标,做指纹时直接忽略即可,因为间接调用的地址本身就不稳定,放进特征里反而降低区分度。
4. 指纹匹配模块:把函数特征做成可复用的签名库
4.1 指纹怎么提:调用集合、常量集合、字符串引用
函数指纹的本质是“一组在编译后仍然保持稳定的特征”。我一般取三类:它调用了谁、它用了哪些立即数、它引用了哪些字符串。这三类在换编译器、换优化等级后仍然大概率保留,而寄存器分配、指令顺序这些全都不可靠。
from dataclasses import dataclass, field from binaryninja import MediumLevelILOperation @dataclass class Fingerprint: callees: frozenset = field(default_factory=frozenset) consts: frozenset = field(default_factory=frozenset) strings: frozenset = field(default_factory=frozenset) def fingerprint(func): mlil = func.mlil fp = Fingerprint() if mlil is None: return fp callees, consts, strings = set(), set(), set() for block in mlil.basic_blocks: for instr in block: op = instr.operation if op == MediumLevelILOperation.MLIL_CALL: dest = instr.dest if dest.operation == MediumLevelILOperation.MLIL_CONST_PTR: callees.add(dest.constant) elif op == MediumLevelILOperation.MLIL_CONST: consts.add(instr.constant & 0xFFFFFFFF) # 截断,避免 32/64 位混存 elif op == MediumLevelILOperation.MLIL_CONST_PTR: addr = instr.constant try: s = func.view.get_string_at(addr) except Exception: s = None if s is not None and s.value: strings.add(s.value) return Fingerprint(frozenset(callees), frozenset(consts), frozenset(strings))常量做& 0xFFFFFFFF截断是踩过坑之后的习惯。同一个库在 32 位和 64 位版本里编译,立即数可能被符号扩展到 64 位,不截断的话两边的常量集合永远对不上,匹配率直接掉一半。字符串用get_string_at拿,失败就跳过——有些引用指向的是宽字符或者未识别的数据段,强行解析只会抛异常。
4.2 匹配与置信度打分
三个集合,怎么合成一个分数?最简单有效的是加权 Jaccard:
WEIGHTS = {"callees": 0.5, "consts": 0.3, "strings": 0.2} def _jaccard(a, b): if not a and not b: return 1.0 # 两边都空,视为一致 if not a or not b: return 0.0 # 一边空一边不空,视为完全不一致 return len(a & b) / len(a | b) def score(fp, ref): return sum(w * _jaccard(getattr(fp, k), getattr(ref, k)) for k, w in WEIGHTS.items())权重的分配有依据:调用集合最能反映函数职责,一个解析配置的函数背后基本都会调到字符串处理、内存分配那几个函数;常量次之,很多工具函数里全是位运算魔数,特征性很强;字符串权重最低,因为日志文本、错误消息在不同版本里改得最频繁,靠它匹配容易误报。
_jaccard里那两个边界条件是关键。“两边都空算 1.0”是为了让两个都是空壳的函数(比如纯 thunk)互相匹配;“一边空一边不空算 0.0”是为了防止空集和任何集合的相似度都变成 1,那样满屏都是误报。这两行不加,批量跑出来的结果基本没法看。
阈值我一般从0.72起步,跑一遍样本看误报再调。低于 0.6 时误报明显变多,尤其是那些只做一层转发的包装函数,它们的指纹几乎是空的,很容易和任何东西都“相似”。
4.3 签名库的存储与落地
签名库用 JSON 存,一个人可读、二能进版本控制、三能手改:
import json, os class SignatureDB: def __init__(self, entries): self.entries = entries # list[dict],每项含 name/proto/fp @classmethod def load_default(cls): path = os.path.join(os.path.dirname(__file__), "sigs", "common.json") with open(path, "r", encoding="utf-8") as fh: raw = json.load(fh) return cls(raw["entries"]) def best_match(self, fp): best_name, best_score = None, 0.0 for item in self.entries: ref = Fingerprint( frozenset(item["fp"]["callees"]), frozenset(item["fp"]["consts"]), frozenset(item["fp"]["strings"]), ) s = score(fp, ref) if s > best_score: best_name, best_score = item["name"], s return best_name, best_score路径拼接必须用os.path.dirname(__file__),不能用相对路径。相对路径的解析基准是进程的当前工作目录,而 Binary Ninja 的工作目录跟插件目录完全没关系——这是插件读数据文件失败的头号原因,报错还特别隐晦,往往是FileNotFoundError里带一个你根本没见过的路径。
best_match是线性扫描,条目上百之后每次调用都要遍历全部,批量跑就会明显变慢。条目少的时候不用管,超过一两千条再考虑按调用集合建倒排索引,先按 callees 交集筛出候选,再算完整分数。过早优化没有意义,但心里要留这根弦。
5. 避坑与排查:插件在真实工程里翻车的五个现场
5.1 加载与环境类
现象:插件目录放进去了,重启后菜单里干干净净,什么都没有。原因:plugin.json里api写成了python、type里写了不认识的值,或者入口文件起名叫main.py而不是__init__.py。这三种情况都不会报错,插件被静默跳过。 解决:拿一个确定能跑的最小插件做对照,逐字段核对;然后去日志目录翻最新的文件,搜插件目录名,traceback 基本都在里面。
现象:GUI 里一切正常,把同一套逻辑放进批处理脚本就报ModuleNotFoundError。原因:插件是靠目录扫描被加载的,插件目录本身不在sys.path里;GUI 环境下由加载器代劳了这件事,脚本环境没人替你做。 解决:脚本里显式把插件目录插进sys.path,更好的做法是把纯计算逻辑(指纹、打分、JSON 读写)拆成不依赖 UI 的模块,只 import 那一层,注册代码留在__init__.py里不碰。
5.2 API 与数据一致性类
现象:批量改名跑到一半,Binary Ninja 直接退出,没有任何报错窗口。原因:在BackgroundTaskThread里调用了update_analysis_and_wait(),和 UI 的分析队列互锁;或者在遍历bv.functions的同时改函数,导致迭代器失效。 解决:遍历前先list(bv.functions)拍快照,后台任务里只做计算和写库,不主动等分析;任务结束后回到主线程调一次bv.update_analysis(),让它异步去排队。
现象:明明刚设置了新类型,接着读func.mlil拿到的还是旧内容,参数个数、调用形式全是老的。原因:IL 是惰性构建并缓存的,类型改了之后需要重新分析才会反映到 IL 上,立刻读只能读到旧对象。 解决:把“写类型”和“读 IL 做二次分析”拆成两个阶段,中间隔一次分析刷新;如果必须一次跑完,就先把要读的信息在设置类型之前全部提取完,再统一回写。
现象:符号和注释都写进去了,关掉工程重新打开,全变回sub_xxxx。原因:写成了自动符号而不是用户符号,或者改动只存在内存里没有落盘到 bndb。 解决:统一走func.name = xxx和set_user_type这类用户通道;批量任务结束后手动存一次快照或保存 bndb,别指望自动保存能覆盖你的所有改动。
注意:这五条里,前两条会浪费你最多时间,因为它们不报错。养成“插件没反应先看日志”的条件反射,比任何调试技巧都管用。
6. 二次开发:把单点插件改造成自己的批处理流水线
跑通单个插件之后,真正有价值的是把它接进流水线。我一般按这个顺序改:
| 改造点 | 落点文件 | 需要的 API |
|---|---|---|
| 目标范围从“选中”扩到“全库” | tagger.py | list(bv.functions)+symbol.auto过滤 |
| 阈值、权重、并发数可配 | ui.py | Settings().register_setting,别硬编码常量 |
| 结果导出成 JSON 报告 | ui.py | show_plain_text_report或直接写文件 |
| 支持无界面批量 | 新增headless.py | 显式sys.path+ 纯逻辑模块 |
从硬编码常量改成配置项,收益比想象中大。团队里每个人对阈值的容忍度不一样,有人宁可漏报也不想误报,有人反过来。把threshold、WEIGHTS、是否覆盖已有符号这几项注册成设置项,别人用你的插件时就不用改代码了。
无界面入口是另一个高频需求。批处理的核心是把BinaryView直接打开、跑完、退出,不经过任何 UI:
# headless.py —— 批量处理目录下所有样本 import os, sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import binaryninja as bn from bn_batch_tagger.tagger import TaggerTask from bn_batch_tagger.fingerprints import SignatureDB def process(path, db): bv = bn.BinaryViewType.get_view_of_file(path) # 不加载 UI if bv is None: return 0 funcs = [f for f in bv.functions if f.symbol.auto] task = TaggerTask(bv, funcs) task.run() # 脚本环境没有事件循环,直接同步跑 run() bv.save_auto_snapshot() return task.hits if __name__ == "__main__": db = SignatureDB.load_default() for name in os.listdir(sys.argv[1]): total = process(os.path.join(sys.argv[1], name), db) print("%s -> %d hits" % (name, total))注意这里直接调了task.run()而不是task.start()。start()依赖线程调度,在脚本环境下主线程会立刻往下走,文件还没处理完就退出了;run()是同步执行,虽然丢了进度显示,但批处理本来就不需要进度条。get_view_of_file拿到的BinaryView没有绑定 UI,所以前面所有依赖core_ui_enabled()的判断都必须能正确跳过,这也是第 2 章强调那个判断的原因。
验证方法很朴素:挑一个已知答案的样本,跑一遍,把输出导出成 JSON 快照,改完代码再跑一遍,用diff对比。差异只应该出现在你预期会变的那几个函数上。我吃过一次亏——调权重的时候顺手改了字符串归一化逻辑,结果三百个函数里有四十多个名字变了,而我是隔了两天才发现的,中间基于那些错误符号写的分析笔记全得重做。从那以后我每次动匹配逻辑,都强制走一遍快照对比,确认 diff 范围可控才继续往下写。
插件这东西,写起来不难,难的是让它在你不在场的时候也稳稳当当跑完。把日志打够、把异常吞好、把配置暴露出来,剩下的就是不断往里加签名条目了。希望帮到你。
本文还有配套的精品资源,点击获取