Reversing Solana programs with IDA

IDA had no support for Solana's eBPF target, so we built it. A walkthrough of reverse-engineering deployed Solana programs without source.

· 9 MIN READ · DECURITY

One day, I decided to reverse-engineer a Solana program, only to realize that my usual go-to tool, IDA, had no support for it. The only available options were Solana’s command-line utilities, a Binary Ninja plugin, and a Ghidra plugin that hadn’t been maintained for a long time. There might be other implementations out there, but for IDA — one of the most widely used tools among reverse engineers — there was nothing suitable.

After diving into Solana’s architecture and reading through some source code, I decided to fill this gap by developing a Solana processor plugin for IDA, which is now published at Github:

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

Solana under the hood

First, a few words about the blockchain. At its core, the Solana VM (SVM) uses a modified version of eBPF — a technology that started out filtering network packets in Linux. Over time, it evolved into an environment for running secure programs in a privileged context.

There are several SVM implementations. One of them is Solana rBPF — a fork of the Rust virtual machine, which was developed by Solana Labs and is maintained now by the Anza team. This implementation is used by the Agave validator and allows programs to run via the interpreter or using the Just-in-Time compilation into x86–64 architecture before execution.

Smart contracts in Solana are called programs. They are compiled into ELF files and are stored on-chain after deployment. Every program has an initial entrypoint() function where execution begins. During execution, programs can access special syscalls defined at the low level of SVM, implementing important features such as logging, invoking other programs, calculating hashes, retrieving VM state information, and more.

Thanks to the LLVM compiler infrastructure, Solana programs can be written in any language that targets LLVM’s BPF backend. However, Rust remains the most popular choice for Solana development.

Previous Solana reversing solutions

As I mentioned earlier, I came across a few existing solutions for disassembling SVM programs. Some of them are:

  • bn-ebpf-solana — Developed by the OtterSec team, this is a great plugin for reversing Solana programs using Binary Ninja
  • ghidra-ebpf — A fork of the Ghidra eBPF plugin with added Solana support, but it hasn’t been updated in three years

However, I personally prefer working with IDA. So, after this brief research, I decided to start developing my own IDA plugin.

IDA processors

Let’s talk a bit about processor modules that are used by IDA to support different architectures. Such modules allow IDA to “understand” an instruction set and disassemble corresponding binaries compiled for a specific architecture.

Processors can be written by anyone in C++ or Python and basically should have:

  • defined assembler with a set of instructions and registers
  • an instruction decoder callback (ev_ana_insn) that decodes an instruction into a special insn_t structure
  • an instruction emulation callback (ev_emu_insn) that creates cross-references and emulates decoded instructions
  • an instruction output callback (ev_out_insn) that interprets emulated instructions and outputs them to the user in a proper form
  • any other handlers, such as ev_out_operand, ev_demangle_name, etc.

So, the Solana eBPF plugin developed is essentially the processor module that understands the SVM assembler. It is based on the existing eBPF processor implementation that was forked and modified to support Solana-specific changes.

rBPF vs Solana rBPF

The following diff highlights the changes made between the original rBPF and Solana rBPF:

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

Some of the key modifications include:

  • Implementation of Solana-specific syscalls.
  • Removal of load absolute and load indirect instructions, along with some store instructions.
  • Addition of complex instructions such as lmul, udiv, urem, shmul, hor, and others.
  • Introduction of relative relocations.
  • Implementation of the Solana memory layout model.

Syscalls

Syscalls in Solana eBPF are similar to BPF’s helper functions. Here is the full list of Solana syscalls:

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

During the reversing process, their detection helps a lot, as they highlight key moments when the program interacts with the low-level environment.

For example, hash calculations can be easily tracked by looking for sol_sha256, sol_keccak256, sol_blake3 syscalls. Interaction with other programs (Cross-Program Invocations) is done via sol_invoke_signed_c and sol_invoke_signed_rust syscalls, while data exchange between programs occurs through sol_get_return_data and sol_set_return_data. PDA calculation and creation rely on sol_try_find_program_address and sol_create_program_address syscalls.

Other handful syscalls include sol_log_, sol_log_data, sol_log_pubkey, and similar ones. They often print logs related to the execution, which helps to understand the code better. For example, when a program uses the popular Anchor framework, instruction handlers log the names of the instructions, making analysis much easier:

All of these syscalls are detected by the plugin and highlighted via cross-references in IDA.

Strings detection

Identifying strings and their references in code is usually one of the most helpful techniques when analyzing a binary. When searching for where a specific functionality is executed or trying to understand a piece of assembly, a reverse engineer can rely on strings that resemble execution logs, intermediate data, or other meaningful references. However, in Rust binaries, this becomes a challenge since strings are stored as a contiguous blob without null terminators. Below is an example of the blob:

Hex-Rays has even written a plugin that tries to resolve the problem. The Solana eBPF processor follows a quite similar approach.

Usually, all strings are located in the read-only section of the binary. While analyzing the binary, cross-references from the executable code are identified, which almost always point to the start of a string. Based on this, the following algorithm was implemented:

  • The plugin keeps all strings in an array sorted by their starting addresses
  • When a new string appears: - the new corresponding location in the array is identified via binary search - the size of the previous string in the array is corrected - the size of the new string is determined based on the next string starting address as min(next_string_start - new_string_start, MAX_STR_SIZE)

For example, imagine that the read-only section contains a contiguous string blob: String2String1String3. To extract individual strings, we define an empty strings array, which will store tuples of string offsets and lengths. The algorithm works as follows:

  1. A reference to String1 is identified in the code. We add its offset to the array (which is currently empty) and set its length to MAX_STR_SIZE. Array state: [(7, MAX_STR_SIZE)]
  2. A reference to String2 is found. We determine where it fits in the array, ensuring it is placed in the correct order. Since String2 appears before String1, we insert it at index 0 and set its length to String1_offset - String2_offset. Array state: [(0, 7), (7, MAX_STR_SIZE)]
  3. A reference to String3 is identified. It is inserted after String1 since its offset is larger. Now, we update String1’s length to String3_offset - String1_offset, ensuring its boundary is correctly set. Since String3 is the last string in the read-only section, its length is set to min(end_of_section, MAX_STR_SIZE). Final array state: [(0, 7), (7, 7), (14, 7)]

The approach is optimized and detects strings with their corresponding lengths quite efficiently:

Functions detection

Almost every binary relies on various library functions, many of which are included during the linking stage of compilation. Detecting these functions during reverse engineering significantly speeds up the process, as it gives much more clues about the context of the code and avoids unnecessary analysis of already known library routines. If a binary retains all symbols, no additional steps are needed to identify these functions. However, in most cases, binaries are stripped and do not contain symbols. Solana binaries are no exception — by default, all programs deployed on-chain are stripped.

To detect functions when symbols aren’t available, Hex-Rays developed a great technology called FLIRT.

FLIRT

FLIRT (Fast Library Identification and Recognition Technology) allows to determine functions in binaries based on pre-generated signatures from different libraries. Generally, the signatures generation process follows these steps and involves the usage of utilities from the IDA’s FLAIR toolset:

For a library file, the corresponding PAT file is generated. This is a text file that contains entries for every module within the library (functions in our case), its public names, and internal references. Each entry is written on the new line.

The format of an entry is the following, where ASUM is the CRC16 sum from the next ALEN bytes:

If some of the bytes are mutable, they are marked by two dots and won’t be considered later in the signature matching. Below is the example of an entry for the __read library function that has two internal references to __openfd and __IOERROR :

Refer to this file for more detailed documentation.

When a PAT file is generated, it can be packed into the SIG file that contains an optimized tree-based information about these signatures and can be applied to a binary. The step is executed via the sigmake tool and ensures that no duplicates are detected during the generation. Otherwise, the EXC file is created with exceptions that should be resolved.

Solana signatures generation

Now, the question is: where can we find the libraries needed to generate Solana signatures?

When a Solana program written in Rust is compiled, a new target directory is created, containing various intermediate files generated during the compilation process. For example, here’s the basic structure of this folder for a simple hello world program:

The target/sbf-solana-solana subdirectory contains compiled eBPF binaries including libraries with the .rlib extension. All dependencies will be placed in the release/deps folder, from which we can take libraries used by the binary.

That’s good, but… Is the Solana eBPF compilation deterministic?

At first, I thought that the answer is no and the classic FLIRT approach would work badly, as function code can change each time for several reasons:

  • In Rust, all libraries are rebuilt from the source when compiling a new program
  • Slight variations in machine code can occur, especially when different optimizations are applied

Based on these considerations, a separate plugin was even developed that targets more flexible signature descriptions, but with a loss in the speed of their detection.

However, later I found several discussions on this topic. One of them is here, where developers discuss the verification of on-chain Solana programs. Additionally, Anchor has a feature to produce verifiable builds, described here.

So, theoretically, if all dependency versions and optimization settings remain the same, the generated machine code should also be identical. This makes the FLIRT approach quite effective — if all versions of the core libraries used in Solana program compilation are built and their signatures are extracted, they may be detected with a low false-positive rate.

For convenience, the Solana IDA signatures generation factory was developed:

GitHub - Decurity/solana-ida-signatures-factory

It has scripts to automatically perform the following steps:

  • Fetch different versions of a crate specified, install the required Solana SDK version, and build the crate versions
  • Get .rlib files with functions code and names
  • Generate .pat files from .rlib exported and deduplicate signatures

The final signatures file can be generated via the sigmake tool from the Hex-Rays FLAIR toolkit.

This is how function detection looks when using generated signatures for the core solana-program library:

Further improvements

Even though code decompilation isn’t possible with this approach — since that part of IDA is closed-source and doesn’t support custom architectures — there are still many ways to improve the plugin. For example, the following planned to be implemented:

  • Generate and publish more signatures for all core Solana libraries
  • Enhance code readability by identifying common Solana structures
  • Detect Anchor framework structures and assign meaningful names to instruction handlers

Feel free to suggest your ideas and contribute!

References

Reversing Solana programs with IDA was originally published in Decurity on Medium, where people are continuing the conversation by highlighting and responding to this story.

RELATEDSOLANA PROGRAM SECURITY AUDITS

Need this expertise on your protocol?

The researchers who write this are the ones who run the audits.

REQUEST FORM