Binary Ninja Python插件开发:从零实现自动化逆向分析
简介这份资源是面向逆向工程与二进制安全方向的 Python 开发者整理的 Binary Ninja 插件开发资料包适合已具备一定 Python 基础、希望借助脚本扩展反汇编分析流程的安全研究员与软件开发者。包内共 6 个文件以 py 脚本、json 配置、yaml 参数、md 说明文档为主辅以 gitignore 与 license压缩包整体约 9KB体量轻巧但结构完整便于直接放入插件目录加载使用。内容围绕 Binary Ninja 的 Python API 展开涵盖插件注册方式、视图与菜单项集成、函数与控制流遍历、指令注释等典型操作并给出高亮调用指令的示例脚本读者可据此理解插件从编写到加载的完整链路。目前已有 539 人学习下载适合作为入门 Binary Ninja 插件开发、快速搭建自定义分析工具的参考起点。1. 二进制分析插件到底在解决什么痛点逆向分析一个没有符号表的二进制文件时最耗时的环节往往不是理解核心算法而是在反汇编视图里手动追踪交叉引用、逐个函数标注类型、反复切换窗口比对数据流。Binary Ninja 作为一款交互式反汇编器提供了 Python API 和插件机制允许把重复性的分析动作固化成自动化流程。这个标题指向的就是如何用 Python 为 Binary Ninja 编写插件把「人肉翻汇编」变成「脚本跑分析」。适合读这篇的人有三类一是刚接触 Binary Ninja、想从手动分析过渡到脚本化的安全研究员二是已经会用 IDA Python 但想迁移到 Binary Ninja 生态的逆向工程师三是需要批量处理固件样本、做自动化漏洞挖掘的从业者。插件能做的事包括但不限于自动识别加密常量、批量重命名函数、提取特定指令序列、生成调用图报告。下面从环境搭建讲到插件发布中间穿插我踩过的坑。2. 插件运行机制与最小可运行框架2.1 Binary Ninja 的 Python 插件加载路径Binary Ninja 启动时会扫描几个固定目录来加载 Python 插件。不同平台的路径不一样但逻辑一致用户级插件目录优先于系统级。以常见桌面环境为例用户插件目录通常位于用户配置文件夹下的plugins子目录中具体路径可以在 Binary Ninja 的设置界面里查看。把.py文件放进去重启后就会出现在 Plugins 菜单里。插件文件有两种组织方式单文件脚本和带__init__.py的包目录。单文件适合功能简单的工具包目录适合需要拆分模块的复杂插件。我一般先用单文件跑通逻辑确认稳定后再拆成包。# 最小插件示例在 Plugins 菜单注册一个命令 from binaryninja import PluginCommand, log_info def hello_binaryview(bv): 接收当前 BinaryView 对象打印基本信息 log_info(f当前文件: {bv.file.filename}) log_info(f架构: {bv.arch.name}) log_info(f函数数量: {len(bv.functions)}) # 注册命令第一个参数是菜单显示名第二个是回调函数 PluginCommand.register( MyPlugin\\Hello BinaryView, 打印当前二进制文件的基本信息, hello_binaryview )这段代码的关键在于PluginCommand.register的三个参数菜单路径用反斜杠分隔层级描述文本会显示在菜单项旁边回调函数接收一个BinaryView对象。BinaryView是 Binary Ninja 对加载文件的抽象所有分析操作都通过它进行。log_info输出到日志窗口调试时比print更可靠因为print在某些执行环境下会被吞掉。2.2 理解 BinaryView、Function 与 Symbol 三层对象模型写插件绕不开三个核心对象BinaryView代表整个加载的文件Function代表识别出的函数Symbol代表符号信息。它们之间的关系是一个BinaryView包含多个Function每个Function关联若干Symbol。# 遍历所有函数并输出地址与名称 def list_functions(bv): for func in bv.functions: # func.start 是函数起始地址func.name 是符号名 # 没有符号的函数会显示为 sub_xxxx 形式 log_info(f0x{func.start:x} {func.name} 指令数: {len(list(func.instructions))}) PluginCommand.register( MyPlugin\\List Functions, 列出所有函数及其指令数量, list_functions )func.instructions返回一个迭代器包含该函数的所有指令。len(list(...))这种写法在函数很大时会消耗内存更稳妥的方式是用计数器累加。func.name在没有符号表时会自动生成sub_加地址的占位名插件可以通过func.name new_name直接重命名这个修改会反映到反汇编视图里。2.3 用 BackgroundTask 处理耗时分析如果插件要对几百个函数做批量分析直接在主线程跑会卡住界面。Binary Ninja 提供了BackgroundTask来把耗时操作放到后台线程。from binaryninja import BackgroundTask, PluginCommand, log_info def analyze_all_functions(bv): 在后台线程中分析所有函数 def worker(): count 0 for func in bv.functions: # 这里放实际分析逻辑比如检测特定指令模式 for instr in func.instructions: if instr[1].__class__.__name__ Call: count 1 break log_info(f包含函数调用的函数数量: {count}) return count # 创建后台任务第一个参数是任务描述 task BackgroundTask(分析函数调用, worker) task.start() PluginCommand.register( MyPlugin\\Analyze All, 后台统计包含函数调用的函数, analyze_all_functions )BackgroundTask的worker函数不能直接操作界面元素只能做数据计算。如果需要在分析过程中更新进度条可以用task.progress属性。注意worker里不要调用bv.update_analysis()这类会触发重新分析的方法否则可能造成死锁。3. 从零写一个函数调用图提取插件3.1 确定插件功能边界与数据结构这个插件的目标是遍历所有函数提取每个函数调用的其他函数生成一张调用关系表并支持导出为文本格式。数据结构用字典嵌套外层键是调用者函数起始地址内层值是被调用者地址列表。from binaryninja import PluginCommand, log_info import json def build_call_graph(bv): 构建函数调用图返回邻接表 graph {} for func in bv.functions: callees [] for instr in func.instructions: # 指令元组结构: (地址, 指令对象) addr, insn instr # 通过指令对象的 API 判断是否为调用指令 if insn.operation.name.startswith(CALL): # 获取调用目标地址 target insn.dest.constant if target is not None: callees.append(target) graph[func.start] callees return graph这里用insn.operation.name.startswith(CALL)来判断调用指令比用类名判断更通用因为不同架构的调用指令名称可能不同。insn.dest.constant获取直接调用的目标地址间接调用如call rax返回None需要单独处理。实际插件里我会把间接调用也记录下来标记为indirect。3.2 把调用关系映射到函数名并导出拿到地址后需要映射回函数名方便阅读。Binary Ninja 的bv.get_function_at(addr)可以根据地址查函数对象。def export_call_graph(bv): 导出调用图到 JSON 文件 graph build_call_graph(bv) output {} for caller_addr, callee_addrs in graph.items(): caller_func bv.get_function_at(caller_addr) caller_name caller_func.name if caller_func else fsub_{caller_addr:x} callee_names [] for addr in callee_addrs: callee_func bv.get_function_at(addr) if callee_func: callee_names.append(callee_func.name) else: callee_names.append(fsub_{addr:x}) output[caller_name] callee_names # 写入文件路径用当前二进制文件名加后缀 out_path bv.file.filename _callgraph.json with open(out_path, w, encodingutf-8) as f: json.dump(output, f, indent2, ensure_asciiFalse) log_info(f调用图已导出: {out_path}) PluginCommand.register( MyPlugin\\Export Call Graph, 导出函数调用关系到 JSON 文件, export_call_graph )bv.get_function_at对不在函数起始地址的调用目标会返回None比如调用到函数中间的情况。这时用sub_加地址兜底。导出路径直接拼在原始文件名后面避免覆盖原文件。ensure_asciiFalse保证中文符号名不会被转义。3.3 在界面中注册右键菜单与快捷键除了 Plugins 菜单插件还可以注册到反汇编视图的右键菜单操作更顺手。from binaryninja import PluginCommand, BinaryView def rename_by_pattern(bv, func): 根据指令模式重命名函数 # 示例如果函数内包含特定常量重命名为 crypto_xxx for instr in func.instructions: addr, insn instr if insn.operation.name CMP: # 检查比较的立即数 if insn.operands[1].constant 0xDEADBEEF: func.name fcrypto_check_{func.start:x} log_info(f重命名: {func.name}) return # 注册到函数右键菜单 PluginCommand.register_for_function( MyPlugin\\Rename by Pattern, 根据指令模式重命名当前函数, rename_by_pattern )register_for_function的回调函数接收两个参数BinaryView和当前选中的Function。这样用户右键点击某个函数就能直接触发。insn.operands返回操作数列表operands[1].constant获取第二个操作数的立即数值。不同架构的操作数顺序可能不同写插件时要先确认目标架构的指令格式。4. 插件开发中容易翻车的五个地方4.1 修改函数名后界面不刷新现象插件里用func.name new_name改了名字日志显示成功但反汇编视图里还是旧名字。原因Binary Ninja 的界面更新有延迟或者修改没有触发通知机制。解决改完名字后调用bv.update_analysis()强制刷新或者用func.name new_name之后手动触发bv.notify_data_changed()。我一般会在批量重命名结束后统一调一次update_analysis避免每个函数都触发刷新导致卡顿。4.2 后台线程里访问界面对象导致崩溃现象插件在BackgroundTask里调用bv.get_function_at时程序直接退出没有异常信息。原因Binary Ninja 的某些 API 不是线程安全的在后台线程访问会触发底层保护机制。解决把所有需要访问BinaryView的数据提前在主线程收集好传给后台线程做纯计算。如果必须在后台访问用bv.thread_safe上下文管理器包住但性能会下降。我的习惯是主线程遍历收集地址列表后台线程只做数据加工。4.3 插件加载失败但没有报错提示现象把.py文件放进插件目录重启后 Plugins 菜单里没有出现注册的命令。原因插件文件在导入阶段就抛了异常但 Binary Ninja 默认不显示导入错误。解决打开日志窗口Log把日志级别调到 Debug重新加载插件。常见导入错误包括from binaryninja import xxx里 xxx 不存在、缩进混用 Tab 和空格、文件编码不是 UTF-8。我习惯在插件文件开头加一行import sys; sys.stderr.write(plugin loading\n)来确认文件是否被执行到。4.4 遍历大量函数时内存暴涨现象插件处理一个包含上万个函数的二进制文件时内存占用从几百 MB 涨到几 GB。原因用list(bv.functions)或list(func.instructions)把所有对象一次性加载到内存。解决始终用迭代器直接遍历不要转成列表。如果需要在多次遍历中复用数据只存地址和名称这类轻量信息不要存完整的指令对象。func.instructions返回的迭代器每次遍历都会重新生成不会缓存所以重复遍历大函数时要注意性能。4.5 不同架构下指令判断逻辑失效现象在 x86 上跑得好好的插件换到 ARM 固件上就提取不到调用关系。原因不同架构的指令名称和操作数结构不同用硬编码的CALL判断在 ARM 上会漏掉BL、BLX等指令。解决用 Binary Ninja 提供的架构无关 API。比如判断是否为调用指令可以用insn.operation.name配合架构前缀或者直接检查指令是否有dest操作数且操作数类型为Constant。更稳妥的方式是查 Binary Ninja 的指令语义文档用insn.operation的属性来判断而不是硬编码字符串。5. 让插件更耐用的三个进阶技巧5.1 用设置项让插件行为可配置硬编码的参数在换目标时会很麻烦。Binary Ninja 提供了Settings机制让插件可以注册自己的配置项用户在界面里就能改。from binaryninja import Settings, PluginCommand, log_info # 注册一个字符串设置项 Settings().register_group(myplugin, My Plugin Settings) Settings().register_setting( myplugin.pattern, { title: 匹配模式, type: string, default: DEADBEEF, description: 要搜索的十六进制常量 } ) def search_pattern(bv): pattern Settings().get_string(myplugin.pattern) target int(pattern, 16) for func in bv.functions: for instr in func.instructions: addr, insn instr for op in insn.operands: if op.constant target: log_info(f命中: 0x{addr:x} in {func.name}) break PluginCommand.register( MyPlugin\\Search Pattern, 搜索配置的常量, search_pattern )register_setting的 JSON 字符串里type支持string、number、boolean等。用户可以在设置界面里修改myplugin.pattern的值插件读取时用Settings().get_string获取。这样同一个插件可以适配不同的分析目标不用改代码。5.2 用 AnalysisContext 做增量分析如果插件需要在每次分析新函数时自动运行可以注册AnalysisContext而不是等用户手动触发。from binaryninja import AnalysisContext, PluginCommand def on_function_analyzed(bv, func): 每次函数分析完成后自动调用 # 只处理新分析的函数避免重复 if func.analysis_skipped: return # 示例自动标注包含浮点常量的函数 for instr in func.instructions: addr, insn instr if FLOAT in insn.operation.name.upper(): func.add_tag(float_ops, 包含浮点运算) break # 注册分析上下文 ctx AnalysisContext() ctx.register_function_analyzed(on_function_analyzed)register_function_analyzed的回调在每次函数分析完成后触发。func.add_tag给函数打标签标签会显示在界面里方便筛选。注意这个回调会被频繁调用里面不要做耗时操作否则会拖慢整体分析速度。我一般只做轻量的标记重分析留给手动触发的命令。5.3 导出报告时保留可追溯的地址信息插件生成的报告如果只有函数名换一个二进制文件后就没法对照了。我的习惯是在报告里同时保留地址和名称。def export_report(bv): lines [] for func in bv.functions: # 格式: 地址 名称 指令数 lines.append(f0x{func.start:08x}\t{func.name}\t{len(list(func.instructions))}) out_path bv.file.filename _report.txt with open(out_path, w, encodingutf-8) as f: f.write(\n.join(lines)) log_info(f报告已导出: {out_path})地址用08x补齐八位方便对齐阅读。\t分隔比空格更利于后续用脚本解析。导出文件名带原始二进制文件名避免多个样本的报告混在一起。这个习惯来自一次血泪经验之前只导出了函数名结果两个样本的函数名高度相似对照时完全分不清哪个是哪个只能重新跑一遍。写 Binary Ninja 插件最深的体会是不要一上来就追求功能全面先把一个最小的命令跑通确认加载、注册、回调这条链路没问题再往上堆逻辑。我见过太多插件因为导入阶段的一个拼写错误就整个不工作而日志里什么提示都没有。现在我的习惯是每加一个功能就重启一次 Binary Ninja 验证虽然麻烦但比最后一次性调试五个错误要快得多。希望帮到你。本文还有配套的精品资源点击获取