☰
Binary Ninja Python插件开发指南:从环境搭建到调用链自动化分析
2026/10/14 23:51:18 网站建设 项目流程

简介:这套插件面向二进制分析平台Binary Ninja,采用Python编写,主要服务于逆向工程、恶意代码分析与安全研究场景。它通过Python API接入Binary Ninja核心功能,可辅助控制反汇编流程、提取函数与变量信息、实现自定义视图与自动化分析逻辑,也可作为构建复杂自动化分析工作流的实用部件,降低重复手工操作成本。压缩包包括六个文件,核心为Python源码文件,配合插件清单文件与默认配置文件,负责插件注册和参数设定;另附说明文档、开源许可证与版本管理忽略文件,工程结构规范清晰,便于阅读和修改。整个压缩包仅九KB,轻量易用。已有五百三十九人浏览学习,适合作为初次接触Binary Ninja插件开发的参考样例,既能直接放入插件目录加载体验,也可对照其代码与配置学习插件编写和注册机制,为后续扩展更多分析能力、构建个人逆向工具集打下基础。

1. Python-BinaryNinja的插件:把二进制分析里的重复劳动交给脚本

Python-BinaryNinja的插件,简单说就是把二进制分析里那些靠人手重复点的体力活变成可复用的自动化步骤:批量标注释、追踪交叉引用、按特征给函数改名、在反汇编视图中高亮危险调用路径。我最早入坑时只在图形界面上到处点右键,一个样本分析下来大半时间都花在“从调用关系里人工找线索”上,后来把逻辑写成插件,才意识到真正该花时间的是判断而不是点击。这类插件的价值不在于会写几句调用 API,而在于把“分析思路”本身固化下来。适用的人是逆向工程师、恶意样本分析者和做漏洞挖掘的同学,哪怕刚接触二进制分析,只要会 Python 基础语法,也能用插件把工具变成自己的。

2. 搭建 Python 插件环境:插件目录、加载顺序与最小可运行插件

2.1 插件系统的加载协议:官方扫描哪些目录,顺序是什么

Binary Ninja 的插件系统不像普通脚本那样由用户手动导入,而是启动时扫描固定目录,把每个插件目录里的 Python 文件当作一个插件包加载进来。常见做法是把它理解成“目录即插件”:一个文件夹就是一个插件,文件夹下至少有一个入口文件,启动时由平台负责导入并注册命令。

这个目录有两类,优先级不同。默认情况下,安装在二进制 Ninja 安装目录下的 plugins 目录会被扫描,用户配置目录下的 plugins 目录也会被扫描。用户目录优先于安装目录,因为同名的插件在用户目录里存在时,加载顺序更靠前,能覆盖掉旧版本。目录结构一般长这样:

BinaryNinja/ └── plugins/ ├── sample_plugin/ │ ├── __init__.py │ ├── core_logic.py │ └── config.py └── another_plugin/ ├── __init__.py └── main.py

这里的关键是__init__.py。平台在扫描插件目录时,会把这个文件当作插件包的入口执行。写在__init__.py里的代码会在启动阶段运行,所以注册命令、加载配置、初始化全局对象这些操作要放在这里;而真正耗时的分析逻辑,建议放进其他模块,避免拖慢启动速度。

有个容易被忽略的约束:插件目录名里不要带空格和中文,加载器对路径处理比较直接,路径里一旦出现非 ASCII 字符,在某些版本上会出现目录扫描不到或导入失败的问题。我一般统一用下划线命名插件目录,功能模块名也有意保持小写,避免后续在跨平台使用时出现大小写不一致的麻烦。

2.2 写出第一支插件:三个步骤验证命令能注册、能运行

先不追求复杂功能,只需要一个能在右键菜单或命令面板里出现、并且点一下就有反应的插件。在插件目录下创建__init__.py,写入最小编码:

from binaryninja import * from binaryninja.interaction import show_message_box def hello_plugin(bv, function): """给当前函数写一条插件标记,用来确认脚本真的被执行了。""" function.comment = "hello from python plugin" log_info("plugin executed on function: " + function.name) PluginCommand.register( "PythonSample\\Hello Function", "给当前函数写一条注释,验证插件通道", hello_plugin )

这段代码干的事很简单:定义了一个hello_plugin回调函数,它接收bv和function两个参数,前者是当前二进制文件的整体视图,后者是当前光标所在的函数对象。函数体里直接给当前函数设置了一条注释,并用log_info输出日志。PluginCommand.register是注册入口,第一个参数是命令路径,斜杠用来生成菜单层级;第二个参数是悬停提示;第三个参数是真正执行的回调。

参数说明上有个细节:bv这个变量名不是约定俗成的摆设,它是BinaryView类型的实例,几乎所有的全局分析操作都要从它身上发起,比如枚举函数、读取字符串、查找交叉引用。回调函数里的function参数只有在“当前上下文有函数”时才有效;如果你在反汇编视图的空白处触发命令,某些平台的回调会把function传成None,所以插件里凡是用了function的地方,都要先做一次空值判断。

写完文件后,重启 Binary Ninja,在菜单栏的 Tools 里应该能看到PythonSample这一项,点开子菜单执行Hello Function,再切到函数列表看注释区域。如果菜单没出现,优先去日志面板看导入异常,最常见的错误是from binaryninja import *执行时因为环境不匹配抛了ImportError。这一版跑通后,后续所有复杂思路都可以在这个框架上加模块,不需要再折腾环境。

3. 深入 Binary Ninja Python API:从 BinaryView 到函数、指令与 BNIL

3.1 对象模型速览:四个核心对象之间怎么串起来

写插件最怕一头扎进代码里,却不清楚每个 API 对象到底代表哪一层数据。Binary Ninja 的 Python API 分层比较清晰,核心是四类对象:BinaryView代表整个被分析文件,Function代表一个函数,BasicBlock代表一段连续指令块,Instruction代表单条指令。这个模型和常见反汇编工具的区别在于,它不是只面向汇编文本,而是面向“分析结果”,很多属性是平台已经算好的,插件直接取就行。

整理成一张对照表,方便在写代码时快速定位该用哪个对象:

对象获取方式典型职责
BinaryView回调参数bv加载文件、枚举函数、读取字符串、查询全局交叉引用
Functionbv.get_function_at(addr)函数名、调用者、被调用者、返回类型、平台信息
BasicBlockfunction.basic_blocks块内指令序列、出边入边、支配关系
Instructionblock.get_instructions()汇编文本、地址、长度、低层级 IL 映射

实际写插件时,枚举函数的写法很固定:for func in bv.functions能拿到所有已识别函数。每一个func对象上可以直接读func.name、func.start、func.return_type。想要看一个函数调用了哪些其他函数,直接遍历func.callees就行,不用自己解析指令操作数。这些属性是平台在分析阶段就构建好的,插件层只是读取,速度比逐条反汇编快得多。

一个容易踩坑的点:function.basic_blocks是平台分析后的结果,不一定按地址大小顺序排列。如果你要按执行顺序处理块,不要相信列表顺序,应该遍历块的出边block.out_edges来模拟控制流。反过来,如果只是想批量扫描函数内所有指令,直接多次套循环遍历所有块是最高效的,不必刻意模拟执行路径。

3.2 不要只盯着汇编文本:LLIL 与 MLIL 才是数据流的关键

很多入门级插件作者习惯拿block.get_instructions()出来的字符串做匹配,比如去判断某条指令是不是call,再去解析操作数里的地址。这种做法写起来快,但遇到指令别名、间接调用、Thumb 模式切换时很容易翻车。Binary Ninja 的优势在于它把指令翻译成了多层中间语言,其中LLIL是低层级 IL,和汇编指令近乎一一对应;MLIL是中层 IL,做了栈效果消除、常量传播和死代码清理,更接近高级语言的语义。

我最常用的套路是让插件直接跑在medium_level_il上:

from binaryninja import * def scan_calls(bv, function): count = 0 for block in function.medium_level_il: for il in block: if il.operation == MediumLevelILOperation.MLIL_CALL: count += 1 log_info("call at %s -> %s" % (hex(il.address), il)) log_info("total medium-level calls: %d" % count) for func in bv.functions: if func.name == "main": scan_calls(bv, func)

这个脚本遍历main函数的 MLIL 指令,只抓MLIL_CALL操作。拿到的il对象带有真实的内存地址,打印出来的内容也已经是格式化后的可见形式,方便确认逻辑是否正确。MediumLevelILOperation.MLIL_CALL是一个枚举成员,比较操作时记得用==,不要用is,因为枚举对象在某些版本上不是单例。

参数说明上要注意:function.medium_level_il返回的是块集合,不是指令列表,所以外层必须再套一层for block in ...。如果你发现某个函数里medium_level_il是空的,先检查这个函数是否被平台识别为函数,或者该地址区域是否被平台标记为数据。另一个常见问题是 MLIL 只在实际需要时生成,第一次访问某些冷函数可能触发按需分析,响应会慢几十毫秒,这在插件里属正常现象,不是死循环。

这一层的价值在于,插件不用再手工解析call指令的寻址模式是call rax还是call qword [rip+0x1234],MLIL 已经把目标简化成一个统一的对象。哪怕你想做更长链的数据流分析,比如从参数赋值追溯到来源,IL 层也能提供迭代器来查定义和使用关系,这比盯着反汇编文本猜语义要可靠太多。

4. 插件实战:写一个自动生成函数调用链并批量加注释的脚本

4.1 从“当前函数”出发:输出直接调用者与被调用者

有了前面的 API 基础,第一个有实用价值的插件可以是“调用关系导出”。逆向分析时经常遇到这种情况:看到一个关键函数,想知道谁调用了它、它又调用了谁。每个人都能在图形界面里点开调用图,但样本多了以后,每次都靠手点效率很低。插件可以把这个动作压缩成一个右键菜单命令,并把结果直接写到注释里。

先做最基础的版本,输出当前函数的调用关系到日志:

from binaryninja import * def dump_callers(bv, function): if function is None: log_warn("no current function") return log_info("=== %s @ %s ===" % (function.name, hex(function.start))) for caller in function.callers: log_info("caller: %s @ %s" % (caller.name, hex(caller.start))) for callee in function.callees: log_info("callee: %s @ %s" % (callee.name, hex(callee.start))) PluginCommand.register( "CallGraph\\Dump Current Function", "输出当前函数的直接调用者和被调用者", dump_callers )

代码的核心逻辑只有三行:function.callers返回所有直接调用当前函数的函数列表,function.callees返回当前函数直接调用的所有函数。平台在分析阶段已经完成了调用图的构建,插件不需要逐条指令去匹配call目标,这正是前面强调“多利用分析结果”的意义。

补充一个参数说明:function.callers和function.callees都只包含“直接”调用关系。如果你的需求是找间接调用链,比如 A 调用 B、B 调用 C,而你想从 A 一步到 C,那就不能只依赖这个属性,需要自己写一个简单的 BFS 遍历来做路径展开。常见做法是维护一个待处理队列,反复把callees中未访问的新函数压入队列,同时把访问过的函数记录起来防止死循环。这个扩展只涉及标准 Python 的集合操作,不需要额外依赖。

4.2 把调用链写成注释:可复用的批量标记器

日志输出适合调试,但真正分析时,日志会被大量信息淹没。更有用的做法是把调用关系直接写回到注释里,让反汇编视图变成一张自带标记的地图。下面这段代码把当前函数的所有直接调用者,以“自动标记”前缀写入对应调用点:

from binaryninja import * def mark_call_sites(bv, function): if function is None: return for caller in function.callers: # caller 是 Function 对象,start 是这个函数的入口地址 bv.set_comment_at(caller.start, "[Auto] caller of %s" % function.name) log_info("marked %d callers" % len(function.callers)) PluginCommand.register( "CallGraph\\Mark Callers", "标记所有调用当前函数的函数入口", mark_call_sites )

这段代码体现出一个常见的分析套路:不直接找“哪条 call 指令调用了目标函数”,而是先把范围内所有函数过一遍callees,再看看结果里有没有目标函数,从而倒推出调用点。对于大型二进制,这种反向查询比逐个指令匹配更稳定,因为平台的调用图构建已经处理了间接调用。

参数上要注意set_comment_at的第一个参数是一个具体的虚拟地址,不是函数对象。注释可以加在任何地址上,不管是函数入口还是指令中间地址。还需要注意,如果一个地址已有其他插件写入的注释,新注释不会自动合并,而是会被覆盖。我一般会在注释内容里加上固定前缀,比如[Auto],这样检查和清理时能快速区分哪些是插件生成的、哪些是人工写的。

真正处理大型工程时,整个流程可以划分成三步:先通过命令行参数或固定地址指定关注的函数集合,然后遍历所有函数构建调用关系,最后批量写注释。为了不让界面卡死,建议把“写注释”的阶段放到用户主动触发时执行,而不是在插件加载阶段全量运行。实测下来,一个中等规模的固件有几万个函数,全量遍历调用图通常不到一秒,但几万个注释一次性写入反汇编数据库时,UI 刷新会明显停顿。所以我把批量标记功能拆成两个命令:一个只统计数量,一个真正执行写入,避免误触浪费时间。

5. 插件开发避坑:加载失败、API 误用与 UI 卡死排查

5.1 插件目录放对了,菜单里却没有新命令

现象:插件文件按照文档放进了用户插件目录,重启后菜单里找不到任何新命令,日志面板也没报错。

原因:多数情况下是__init__.py文件里做了顶层导入,但某个依赖模块在当前环境不存在,导入异常被平台静默吞掉。另一个常见原因是命令注册时用了非法的路径分隔符,比如菜单路径里带了反斜杠,或者路径的父级菜单名写成了纯数字开头。平台会把命令路径按层级展开成菜单,非法字符会导致整个注册失败。

解决:给插件入口加一个最外层的异常捕获,把任何导入异常都用log_error明确打出来。同时打开日志面板确认没有红色输出。再检查命令路径里的斜杠是否统一为\\,菜单名尽量避免使用中文和特殊符号。最后用缩小法验证:把__init__.py临时删到只剩一个log_info("plugin loaded"),确认日志能出现,再逐步加功能代码。

5.2 回调函数里总是拿不到当前函数,执行时报 None

现象:插件装在右键菜单里,点击后界面没反应,日志里出现AttributeError: 'NoneType' object has no attribute 'name'。

原因:回调函数的第二个参数代表当前上下文选中的函数,但如果你是在数据段、字符串视图或者没有函数的空白地址上触发命令,这个参数就是空对象。插件代码里直接访问了function.name,自然崩了。

解决:回调函数体入口统一做一次空值判断,拿不到函数时要么返回,要么把命令设计成自动回退到“目标函数由插件内部分析得出”的模式。我自己写插件时养成了一个习惯:所有带function参数的回调,第一行必然是if function is None: return,哪怕后续逻辑一块都不会用到这个参数,也先挡住空值。

5.3 遍历 MLIL 时出现各种奇怪的迭代器异常

现象:用for il in block:遍历中间语言指令,部分函数上报TypeError: object of type 'NoneType' is not iterable,地址还每次都不一样。

原因:不是块是空的,而是平台对某些地址的 IL 生成是按需完成的。在访问高频函数时没有问题,但遇到平台没有自动分析过的冷函数,第一次遍历会触发按需分析,这个过程中如果插件同时修改了数据库状态,会返回空迭代结果。

解决:先对block做一次空值判断,再对迭代结果使用list()转成快照再遍历。不要在多线程或者后台任务里修改注释,写操作尽量集中在主线程触发,避免与平台自身的按需分析发生竞争。如果插件流程复杂,宁可先把需要的 IL 数据复制出来处理,也不要边分析边写库。

5.4 插件分析速度很慢,UI 卡成白屏

现象:点一次插件命令,界面无响应二十秒,CPU 占用很高,最终提示脚本超时。

原因:插件在 UI 线程里做了大量重复的遍历,比如每个函数都重新枚举所有指令,或者用了多层循环去匹配字符串。代码在纯逻辑上没错,但复杂度是 O(n²),稍微放大二进制规模就扛不住。

解决:先缩小分析范围,插件只处理当前函数或当前选中的函数集合,不要默认全库扫描;需要全库分析时,把计算结果缓存到本地文件或者临时对象里,第二次运行直接读缓存。再看是否有重复 API 调用,比如在循环里反复调用bv.get_function_at,这种查询应该提前拿到函数列表后一次性批量处理。最直接的止损办法是给插件加一个时间阈值,超过阈值就中止当前遍历并输出已处理部分,避免无限等待。

5.5 插件在无界面模式下与图形界面模式行为不一致

现象:插件在 GUI 里运行正常,换到命令行无界面模式跑同一个二进制,结果少了一部分函数,甚至直接报错退出。

原因:无界面模式下部分交互 API 不可用,比如弹窗、消息框、用户输入框;同时平台在无界面模式下不会做和 GUI 模式完全一样的自动分析预热,部分延迟分析没有触发,函数数量会比 GUI 模式少。

解决:插件代码里显式区分交互操作和纯计算逻辑。凡是需要弹窗的步骤,无界面模式下自动跳过,改成通知栏日志输出。纯计算逻辑在无界面模式下执行前,先调用对应的分析接口强制补齐数据,例如遍历所有块之前先等平台完成函数分析,或者直接调用bv.update_analysis()等待事件完成。这个做法会引入轻微的等待时间,但能保证两种模式下结果一致。

6. 把插件养成一个能持续使用的习惯:小样本回归、调试开关与后续扩展

插件写完不是结束,真正难的是让它长期可用。平台版本一升级,API 可能变;换一个不同架构的样本,IL 语义也可能有差异。我现在的做法是在插件里内置一个调试开关和一个小样本集,每次改动插件后用同一个目录下的几个测试文件跑一遍,对比输出是否和预期一致。小样本集不用复杂,两个 Linux ELF、一个固件镜像、一个带大量间接调用的样本,就足以暴露大多数问题。

调试开关的写法很简单,给插件增加一个模块级的常量和一个写日志的辅助函数:

import time from binaryninja import log_info DEBUG = True LOCAL_LOG_PATH = "/tmp/bn_plugin_debug.log" def _debug(msg): if not DEBUG: return line = "%s | %s\n" % (time.strftime("%H:%M:%S"), msg) with open(LOCAL_LOG_PATH, "a", encoding="utf-8") as fp: fp.write(line)

把这个函数放在所有业务流程的入口处,关键步骤各打一行日志,运行完后直接看本地文件。遇到平台版本升级后功能消失的情况,第一件事不是改业务逻辑,而是打开这个日志确认插件有没有被加载、命令有没有注册、回调有没有执行。三步一查,就能快速定位是环境问题还是代码问题。

进阶扩展方向上,值得做的是给命令面板设置快捷键,以及对函数列表做批量批量处理。快捷键绑定在插件里注册即可,注意不要和平台默认快捷键冲突;批量处理要控制粒度,按函数逐个处理并配合进度回调,避免长时间卡界面。更进一步的玩法是结合数据流 API 做污点追踪,从一个敏感函数出发,追踪哪些外部输入能流向它的参数,这在漏洞挖掘场景里是实打实的效率提升,但这一步需要先吃透 MLIL 的数据流模型,不要急着直接上。

一开始我把插件写得太重,总想一步到位把所有分析自动化,结果每换一个样本都要重新调参数,反而没有徒手分析快。后来调整思路,把插件当成“能记住我分析习惯的工具”,只封装那些最稳定、最不依赖特定样本的步骤,效果反而好很多。先跑通最小命令,再逐层加功能,碰到玄学问题先查日志再动手改代码,这是所有插件开发里最能止血的经验。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询