使用IDA逆向Solana程序

  • passkeyra
  • 发布于 2025-03-22 19:35
  • 阅读 18

本文介绍了作者开发的用于IDA Pro的Solana eBPF处理器插件,该插件旨在简化Solana程序的逆向工程。文章详细描述了Solana VM的架构、与rBPF的区别、syscalls的工作原理、字符串检测技术以及如何利用FLIRT技术进行函数识别,并分享了Solana IDA签名的生成方法。

使用 IDA 逆向 Solana 程序

有一天,我决定逆向一个 Solana 程序,却发现我常用的工具 IDA 并不支持它。唯一可用的选项是 Solana 的命令行实用程序、一个 Binary Ninja 插件和一个长期未维护的 Ghidra 插件。可能还有其他的实现,但对于 IDA 来说——逆向工程师中最广泛使用的工具之一——没有任何合适的。

在深入研究了 Solana 的架构并阅读了一些源代码后,我决定填补这一空白,为 IDA 开发一个 Solana 处理器插件,该插件现已在 Github 上发布:

GitHub - Decurity/solana-ebpf-ida-processor: Solana Virtual Machine bytecode processor for IDA Pro

Solana 原理

首先,简单介绍一下区块链。Solana VM (SVM) 的核心是使用修改版的 eBPF——这项技术最初用于过滤 Linux 中的网络数据包。随着时间的推移,它演变成一个在特权上下文中运行安全程序的环境。

有几种 SVM 实现。其中之一是 Solana rBPF —— Rust 虚拟机的一个分支,由 Solana Labs 开发,现在由 Anza 团队维护。此实现由 Agave 验证器使用,并允许程序通过解释器运行,或在使用即时编译到 x86-64 架构后执行。

Solana 中的智能合约被称为程序。它们被编译成 ELF 文件,并在部署后存储在链上。每个程序都有一个初始的 entrypoint() 函数,执行从这里开始。在执行过程中,程序可以访问 SVM 底层定义的特殊系统调用,实现诸如日志记录、调用其他程序、计算哈希、检索 VM 状态信息等重要功能。

由于 LLVM 编译器基础设施,Solana 程序可以使用任何以 LLVM 的 BPF 后端为目标的语言编写。然而,Rust 仍然是 Solana 开发中最流行的选择。

之前的 Solana 逆向解决方案

正如我之前提到的,我遇到了一些现有的反汇编 SVM 程序的解决方案。 其中一些是:

  • bn-ebpf-solana — 由 OtterSec 团队开发,这是一个使用 Binary Ninja 逆向 Solana 程序的绝佳插件
  • ghidra-ebpf — Ghidra eBPF 插件的一个分支,增加了 Solana 支持,但已经三年没有更新了

但是,我个人更喜欢使用 IDA。 因此,在进行了简短的研究之后,我决定开始开发自己的 IDA 插件。

IDA 处理器

让我们谈谈 IDA 用于支持不同架构的处理器模块。 这些模块允许 IDA“理解”指令集并反汇编为特定架构编译的相应二进制文件。

处理器可以由任何使用 C++ 或 Python 编写,并且基本上应该具有:

  • 定义的汇编器,带有一组指令和寄存器
  • 一个指令解码回调 ( ev_ana_insn),用于将指令解码为特殊的 insn_t 结构体
  • 指令仿真回调 ( ev_emu_insn),用于创建交叉引用并仿真解码后的指令
  • 指令输出回调 ( ev_out_insn),用于解释仿真的指令并以正确的形式将其输出给用户
  • 任何其他处理程序,例如 ev_out_operandev_demangle_name 等。

因此,开发的 Solana eBPF 插件本质上是理解 SVM 汇编器的处理器模块。 它基于现有的 eBPF 处理器实现,该实现已被分支并修改以支持 Solana 特定的更改。

rBPF vs Solana rBPF

以下差异突出了原始 rBPF 和 Solana rBPF 之间所做的更改:

https://github.com/qmonnet/rbpf/compare/main...solana-labs:rbpf:main

一些关键修改包括:

  • 实现 Solana 特定的系统调用。
  • 删除 load absolute 和 load indirect 指令,以及一些 store 指令。
  • 添加了诸如 lmuludivuremshmulhor 等复杂指令。
  • 引入了相对重定位。
  • 实现了 Solana 内存布局模型。

系统调用

Solana eBPF 中的系统调用与 BPF 的辅助函数类似。 这是 Solana 系统调用的完整列表:

https://github.com/solana-labs/solana/blob/7700cb3128c1f19820de67b81aa45d18f73d2ac0/sdk/program/src/syscalls/definitions.rs#L39

在逆向工程过程中,它们的检测有很大帮助,因为它们突出了程序与底层环境交互的关键时刻。

例如,可以通过查找 sol_sha256sol_keccak256sol_blake3 系统调用来轻松跟踪哈希计算。 与其他程序的交互(跨程序调用)通过 sol_invoke_signed_csol_invoke_signed_rust 系统调用完成,而程序之间的数据交换通过 sol_get_return_datasol_set_return_data 完成。 PDA 计算和创建依赖于 sol_try_find_program_addresssol_create_program_address 系统调用。

其他一些有用的系统调用包括 sol_log_sol_log_datasol_log_pubkey 以及类似的系统调用。 它们通常会打印与执行相关的日志,这有助于更好地理解代码。 例如,当程序使用流行的 Anchor 框架时,指令处理程序会记录指令的名称,从而使分析更加容易:

所有这些系统调用都由插件检测到,并通过 IDA 中的交叉引用高亮显示。

字符串检测

识别代码中的字符串及其引用通常是分析二进制文件时最有用的技术之一。 在搜索执行特定功能的代码或试图理解一段汇编代码时,逆向工程师可以依赖于类似于执行日志、中间数据或其他有意义引用的字符串。 但是,在 Rust 二进制文件中,这变成了一个挑战,因为字符串存储为没有空终止符的连续 blob。 下面是 blob 的一个例子:

Hex-Rays 甚至编写了一个试图解决该问题的插件。 Solana eBPF 处理器采用了非常相似的方法。

通常,所有字符串都位于二进制文件的只读部分。 在分析二进制文件时,会识别来自可执行代码的交叉引用,这些交叉引用几乎总是指向字符串的开头。 基于此,实现了以下算法:

  • 插件将所有字符串保存在按其起始地址排序的数组中
  • 当出现新字符串时:

- 通过二分搜索确定数组中新的相应位置

- 校正数组中前一个字符串的大小

- 基于下一个字符串的起始地址确定新字符串的大小,即 min(next_string_start - new_string_start, MAX_STR_SIZE)

例如,假设只读部分包含一个连续的字符串 blob:String2String1String3。 为了提取单个字符串,我们定义一个空的字符串数组,该数组将存储字符串偏移量和长度的元组。 该算法的工作原理如下:

  1. 在代码中识别对 String1 的引用。 我们将其偏移量添加到数组(当前为空),并将其长度设置为 MAX_STR_SIZE

数组状态: [(7, MAX_STR_SIZE)]

  1. 找到对 String2 的引用。 我们确定它在数组中的位置,确保它按正确的顺序放置。 由于 String2 出现在 String1 之前,因此我们将其插入到索引 0 处,并将其长度设置为 String1_offset - String2_offset

数组状态: [(0, 7), (7, MAX_STR_SIZE)]

  1. 识别对 String3 的引用。 由于其偏移量较大,因此将其插入到 String1 之后。 现在,我们将 String1 的长度更新为 String3_offset - String1_offset,确保正确设置其边界。 由于 String3 是只读部分中的最后一个字符串,因此其长度设置为 min(end_of_section, MAX_STR_SIZE)

最终数组状态: [(0, 7), (7, 7), (14, 7)]

该方法经过优化,可以非常有效地检测具有相应长度的字符串:

函数检测

几乎每个二进制文件都依赖于各种库函数,其中许多函数在编译的链接阶段包含在内。 在逆向工程期间检测这些函数可以显着加快该过程,因为它提供了有关代码上下文的更多线索,并避免了对已知库例程的不必要分析。 如果二进制文件保留所有符号,则无需其他步骤即可识别这些函数。 但是,在大多数情况下,二进制文件会被剥离并且不包含符号。 Solana 二进制文件也不例外——默认情况下,链上部署的所有程序都会被剥离。

为了在符号不可用时检测函数,Hex-Rays 开发了一种名为 FLIRT 的强大技术。

FLIRT

FLIRT(快速库识别和识别技术)允许基于来自不同库的预先生成的签名来确定二进制文件中的函数。 通常,签名生成过程遵循以下步骤,并涉及使用 IDA 的 FLAIR 工具集中的实用程序:

对于库文件,会生成相应的 PAT 文件。 这是一个文本文件,其中包含库中每个模块(在本例中为函数)、其公共名称和内部引用的条目。 每个条目都写在新行上。

条目的格式如下,其中 ASUM 是来自接下来 ALEN 个字节的 CRC16 总和:

如果某些字节是可变的,它们将用两个点标记,并且稍后将不考虑在签名匹配中。 以下是 __read 库函数的条目示例,该函数对 __openfd__IOERROR 有两个内部引用:

有关更详细的文档,请参阅此文件

生成 PAT 文件后,可以将其打包到 SIG 文件中,该文件包含有关这些签名的基于树的优化信息,并且可以将其应用于二进制文件。 该步骤通过 sigmake 工具执行,并确保在生成过程中不会检测到重复项。 否则,将创建 EXC 文件,其中包含应解决的异常。

Solana 签名生成

现在,问题是:我们在哪里可以找到生成 Solana 签名所需的库?

当编译用 Rust 编写的 Solana 程序时,会创建一个新的 target 目录,其中包含在编译过程中生成的各种中间文件。 例如,这是简单的 hello world 程序的此文件夹的基本结构:

target/sbf-solana-solana 子目录包含编译后的 eBPF 二进制文件,包括带有 .rlib 扩展名的库。 所有依赖项都将放置在 release/deps 文件夹中,我们可以从中获取二进制文件使用的库。

很好,但是……Solana eBPF 编译是否具有确定性?

起初,我认为答案是,并且传统的 FLIRT 方法效果不佳,原因是函数代码每次都可能由于以下几个原因而发生变化:

  • 在 Rust 中,在编译新程序时,所有库都从源代码重建
  • 机器代码中可能出现细微的变化,尤其是在应用不同的优化时

基于这些考虑,甚至开发了一个单独的插件,该插件针对更灵活的签名描述,但会降低其检测速度。

但是,后来我找到了关于此主题的几篇讨论。 其中一个在 这里,开发人员在其中讨论了链上 Solana 程序的验证。 此外,Anchor 具有生成可验证构建的功能,如 此处 所述。

因此,从理论上讲,如果所有依赖项版本和优化设置保持不变,则生成的机器代码也应该相同。 这使得 FLIRT 方法非常有效——如果构建了 Solana 程序编译中使用的所有核心库的版本并提取了它们的签名,则可以以较低的误报率检测到它们。

为了方便起见,开发了 Solana IDA 签名生成工厂:

GitHub - Decurity/solana-ida-signatures-factory

它具有自动执行以下步骤的脚本:

  • 获取指定的 crate 的不同版本,安装所需的 Solana SDK 版本,并构建 crate 版本
  • 获取带有函数代码和名称的 .rlib 文件
  • .rlib 生成 .pat 文件,导出并删除重复签名

可以通过 Hex-Rays FLAIR 工具包中的 sigmake 工具生成最终签名文件。

这是使用为核心 solana-program 库生成的签名时函数检测的样子:

进一步改进

即使使用此方法无法进行代码反编译——因为 IDA 的那部分是闭源的并且不支持自定义架构——仍然有很多方法可以改进插件。 例如,计划实现以下目标:

  • 为所有核心 Solana 库生成并发布更多签名
  • 通过识别常见的 Solana 结构来增强代码可读性
  • 检测 Anchor 框架结构并为指令处理程序分配有意义的名称

欢迎提出你的想法并做出贡献!

参考文献

如果你需要对你的区块链软件、智能合约或 dApp 进行专业的安全审计,请不要犹豫,提交 联系表查看我们在 GitHub 上之前的审计。

  • 原文链接: medium.com/decurity/reve...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
passkeyra
passkeyra
江湖只有他的大名,没有他的介绍。