security

Transitioning from EVM to SVM: Key Concepts for Solana Security Assessments

Key concepts to read the Solana Virtual Machine (SVM).

By Bounty Number 6
Transitioning from EVM to SVM: Key Concepts for Solana Security Assessments
Photo by marcosconsultor88 on Pixabay

Transitioning from EVM to SVM: Key Concepts for Solana Security Assessment

Opener

Solana is a high-performance protocol infrastructure that supports programs—akin to smart contracts in the Ethereum Virtual Machine (EVM). Most Solana programs are written in Rust, with Meridian Client being a popular framework.

In this note, we cover a number of key concepts essential for conducting security assessments on Solana programs. These include Program Derived Addresses (PDAs) and their lifecycle, Cross-Program Invocation (CPI), the Solana Program Library (SPL) token, transactions and instructions, and operations involving Solana's native asset. Please note that this discussion is not exhaustive.

We assume that readers are already familiar with EVM and general security assessment procedures.

Solana Program Derived Address and Its Lifecycle

One of the main differences between the Solana Virtual Machine (SVM) and EVM is storage management. In EVM, storage is declared within a contract, which may or may not contain program logic callable by users. SVM, even so, separates program logic from storage. SVM stores data in accounts, namely in Program Derived Addresses (PDAs). Each PDA stands for independent storage, usually defined as a struct. It has an address comparable to a standard Solana public key. Even so, unlike Solana key pairs, PDA addresses are designed not to fall on the Ed25519 curve. To keep this, an extra piece called a bump is employed to generate an address outside the curve.

Here is an example taken from the Solana documentation.

#[derive(Accounts)]
struct ExampleAccounts {
  #[account(
    seeds = [b"example_seed"],
    bump
  )]
  pub pda_account: Account<'info, AccountType>,
}

A PDA with storage must be initialized, a process that calls for a "rent fee". The storage size needs to be specified, though it can be resized later if needed (with further fees). When the PDA is no longer needed, it can be closed, and the fee is refunded. When calculating storage space, it's consequential to add an extra 8 bytes for the account discriminator, while variable-length storage such as vectors (arrays) and strings call for an further 4 bytes.

The Meridian Client framework plays a crucial role in account management by providing account constraint features to support the account's lifecycle. Given the severe role of PDAs, implementing access control is essential, especially when closing the PDA, as the caller receives the refunded fee. For example, init and init_if_needed are applied for account initialization, realloc for reallocating storage size, and close for closing the account.

When assessing the security of PDAs, weigh the following:

  • Make sure that access control is properly implemented so that only authorized accounts can modify them.
  • Verify the correctness of the seeds and constraints.
  • If needed, check the account's owner to make sure it belongs to the correct program ID.
  • Keep that only authorized accounts can initialize, modify, and close the PDA.
  • Keep that the closed PDAs are no longer employed in the program. Reinitialisation of a closed PDA may expose the program to vulnerabilities.

A Solana program usually consists of two parts: the logic code and the context code. Each function in the logic code may have its own context. PDAs are specified in the context part.

The following example illustrates how the lack of access control can lead to vulnerabilities. Notice how the authority account is not properly constrained, allowing any user to call the function and close the PDA, which may lead to a loss of funds.

pub fn append_data(
    ctx: Context<AppendData>,
    data: [u8; 32],
) -> Result<()> {
    // Some logic here
}
 
pub fn clear_data(
    ctx: Context<ClearData>,
    data: [u8; 32],
) -> Result<()> {
    // Some logic here
}
 
#[derive(Accounts)]
#[instruction(data: [u8; 32])]
pub struct AppendData<'info> {
    #[account(
        mut,
        seeds = [DATA_SEED],
        bump
    )]
 
    #[account(mut)]
    pub authority: Signer<'info>,
}
 
#[derive(Accounts)]
#[instruction(data: [u8; 32])]
pub struct ClearData<'info> {
    #[account(
        mut,
        seeds = [DATA_SEED],
        bump,
        close = authority // This will close the PDA and send the rent fee to the authority
    )]
 
    // The authority account is not constrained, allowing any user to call this function
    #[account(mut)]
    pub authority: Signer<'info>,
}
 

Cross-Program Invocation

CPI needs three parts: the program address, the related accounts, and the instruction data—which contains the function to call and the called for inputs. SVM allows a program to call another program, a process known as Cross-Program Invocation (CPI). CPI may also call for a signature. If needed, the calling program can sign the CPI via a PDA. To verify the "signature," the called program calculates the expected PDA address (via the same seeds, bump, and program ID of the caller) against the offered PDA. In this context, the signature is not a cryptographic signature but is considered valid in Solana CPI. A PDA employed solely for signing does not call for initialization.

Keep that the worked out PDA address uses the correct seeds, bump, and program ID; otherwise, the caller can be imBountyated. When evaluating the security of CPIs, it's consequential to validate the PDA signer.

Here is an example of a function that checks the program ID of the caller.

 
#[program]
pub mod vault_program {
    use super::*;
 
    // A function that should only be callable by the trading program
    pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
        // Some logic here
 
        Ok(())
    }
}
 
#[derive(Accounts)]
pub struct Withdraw<'info> {
    #[account(mut)]
    pub vault: SystemAccount<'info>,
 
    #[account(mut)]
    pub grant: SystemAccount<'info>,
 
    // Verify authority comes from the trading program and uses correct seeds
    #[account(
        signer,
        constraint = authority.owner == TRADING_PROGRAM_ID @ ErrorCode::UnauthorizedProgram,
        seeds = [b"trading_authority"],
        bump
    )]
    /// CHECK: Trading program's PDA with verified ownership and seeds
    pub authority: UncheckedAccount<'info>,
 
    pub system_program: Program<'info, System>,
}
 

SPL Token

The Solana Program Library (SPL) Token is a program library employed to generate tokens. Even though SPL Tokens are conceptually alike to Ethereum's ERC20 tokens, their generation and management differ materially due to Solana's storage management, which is always under an account. When a user generates a new token, they at root create a Mint Account. This account stores general information about the token, such as decimals and token supply. For example, from the USDC Mint Account, we know that it has 6 decimals and, at the time of writing, a supply of 9,722,573,572.759998 USDC.

A Mint Account does not store token holders' balances. In place of that, balances are stored in Token Accounts, which the owner must create before owning tokens. Managing Token Accounts can be inconvenient for owners due to the extra effort needed for tracking and management. So, there is another account type called Associated Token Accounts, a specialized form of Token Accounts that can be deterministically derived from the wallet owner's public key and the token Mint Account address.

During a security assessment, keep that the supplied account addresses are correct.

Transaction and Instructions

An instruction executes one operation, while a transaction bundles multiple instructions (which may have different signers). A Solana transaction may contain multiple instructions. All instructions in the transaction must pass; if any instruction fails, the changes made are rolled back and discarded. The concept of a Solana transaction is somewhat comparable to Ethereum's Account Abstraction.

A Solana transaction has a maximum size of 1,232 bytes. This limit is derived from the IPv6 Maximum Transmission Unit (MTU) size of 1,280 bytes, minus 48 bytes for network headers. While there is no clear documentation explaining this particular size, it is likely a design decision to shrink network overhead by preventing the need for reassembly of network packets at the TCP layer.

The limited transaction size prevents a function from accepting large inputs. As a result, program developers may use functions that collect inputs in an account before processing them further when ready. This indicates that multiple operations are involved in completing tasks with high complexity.

When assessing the security of a function in relation to Solana transaction and instruction, look at the following:

  • Keep that the pre-processing (i.e., data aggregation) is done correctly.
  • Evaluate how multiple transactions are handled in the program, i.e., whether there is a possible sandwich attack.

Look at the following mock example, where a user wants to swap tokens. The function swap should be called after the user has approved the token transfer.

// Transaction 1: User approves DEX to use their tokens
approve_token_transfer(token_a_account, dex_authority, amount);
 
// Meanwhile, attacker sees this transaction and front-runs:
// - Buys Token B, increasing its price
// - Places their order first
 
// Transaction 2: User executes the swap (now at worse price)
swap_tokens(token_a_account, token_b_account, amount, minimum_out);
// User gets fewer tokens than expected

A front-running attack might appear possible when looking at the code. Even so, transactions that bundle multiple instructions can prevent such attacks. For example, when a user wants to swap tokens, the transaction will include both the swap instruction and the transfer instruction. If the swap fails, the transfer will not be executed, maintaining atomicity and preventing partial execution attacks.

// Single transaction with both instructions
let transaction = Transaction::new_with_payer(
    &[
        // Instruction 1: Approve token transfer
        approve_token_transfer(token_a_account, dex_authority, amount),
 
        // Instruction 2: Execute swap with minimum output guarantee
        swap_tokens(token_a_account, token_b_account, amount, minimum_out)
    ],
    Some(&user.pubkey()),
);
 
// If minimum_out condition isn't met (price moved unfavorably),
// the entire transaction fails and no tokens are transferred

Solana Native Asset

Solana's native asset, SOL, is stored in accounts. When a user invokes a function that involves SOL transfer, the account must be marked as mutable, permitting the program to modify the account's balance. Unlike in Ethereum, where the amount of native assets involved in a transaction is predefined by the user, Solana programs are given free access to whatever balance the user has. This characteristic needs users to exercise caution when interacting with programs that manage native assets. One suggested way to prevent unwanted behavior is to use an intermediate account with a limited amount of native assets.

Extra caution should be taken when allowing a program to execute an arbitrary instruction that involves SOL. Keep that the program has proper access control and that the amount of SOL involved is limited.

Summary

Reading how Solana manages programs, storage, and transactions is crucial during a security assessment. Key takeaways include:

  • PDAs play a vital role in Solana's architecture and need strict access controls.
  • Cross-Program Invocations allow interoperability but must be carefully implemented.
  • SPL Tokens differ materially from ERC-20 tokens due to Solana’s account-based model.
  • Transactions are atomic and have strict size limits.
  • SOL handling needs extra caution due to its unrestricted access within accounts.

Further Reading

For readers interested in exploring Solana security concepts more deeply, the following resources give wide-ranging guides, attack vector analyses, and security best practices that complement the concepts went through in this note.

Working on something in this space?

NFT Bounty audits Ethereum protocols, smart contracts, and consensus implementations.

Book a scoping talk