Transitioning from EVM to SVM: Key Concepts for Solana Security Assessment
Introduction
Solana is a high-performance blockchain infrastructure that supports programs—akin to smart contracts in the Ethereum Virtual Machine (EVM). Most Solana programs are written in Rust, with Anchor being a popular framework.
In this article, we discuss several 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, however, separates program logic from storage. SVM stores data in accounts, specifically in Program Derived Addresses (PDAs). Each PDA represents independent storage, typically defined as a struct
. It has an address similar to a standard Solana public key. However, unlike Solana key pairs, PDA addresses are designed not to fall on the Ed25519 curve. To ensure this, an extra component called a bump
is used 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 requires a "rent fee". The storage size needs to be specified, although it can be resized later if needed (with additional fees). When the PDA is no longer necessary, it can be closed, and the fee is refunded. When calculating storage space, it's important to add an extra 8 bytes for the account discriminator, while variable-length storage such as vectors (arrays) and strings require an additional 4 bytes.
Given the critical role of PDAs, implementing access control is essential, especially when closing the PDA, as the caller receives the refunded fee. The Anchor framework plays a crucial role in account management by providing account constraint features to support the account's lifecycle. For example, init
and init_if_needed
are used for account initialization, realloc
for reallocating storage size, and close
for closing the account.
When assessing the security of PDAs, consider the following:
- Ensure that access control is properly implemented so that only authorized accounts can modify them.
- Verify the correctness of the seeds and constraints.
- If necessary, check the account's owner to ensure it belongs to the correct program ID.
- Ensure that only authorized accounts can initialize, modify, and close the PDA.
- Ensure that the closed PDAs are no longer used in the program. Reinitialisation of a closed PDA may expose the program to vulnerabilities.
A Solana program typically 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
SVM allows a program to call another program, a process known as Cross-Program Invocation (CPI). CPI requires three components: the program address, the related accounts, and the instruction data—which includes the function to call and the required inputs. CPI may also require a signature. If needed, the calling program can sign the CPI using a PDA. To verify the "signature," the called program calculates the expected PDA address (using the same seeds, bump, and program ID of the caller) against the provided PDA. In this context, the signature is not a cryptographic signature but is considered valid in Solana CPI. A PDA used solely for signing does not require initialization.
When evaluating the security of CPIs, it's important to validate the PDA signer. Ensure that the calculated PDA address uses the correct seeds, bump, and program ID; otherwise, the caller can be impersonated.
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 recipient: 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 used to generate tokens. Although SPL Tokens are conceptually similar to Ethereum's ERC20 tokens, their generation and management differ significantly due to Solana's storage management, which is always under an account. When a user generates a new token, they essentially 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. Instead, 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 additional effort required for tracking and management. Therefore, 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, ensure that the supplied account addresses are correct.
Transaction and Instructions
A Solana transaction may contain multiple instructions. An instruction executes one operation, while a transaction bundles multiple instructions (which may have different signers). 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 similar 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 specific size, it is likely a design decision to reduce 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, consider the following:
- Ensure 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.
Consider 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 examining the code. However, 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 requires 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. Ensure that the program has proper access control and that the amount of SOL involved is limited.
Summary
Understanding 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 require strict access controls.
- Cross-Program Invocations allow interoperability but must be carefully implemented.
- SPL Tokens differ significantly from ERC-20 tokens due to Solana’s account-based model.
- Transactions are atomic and have strict size limits.
- SOL handling requires extra caution due to its unrestricted access within accounts.
Further Reading
For readers interested in exploring Solana security concepts more deeply, the following resources provide comprehensive guides, attack vector analyses, and security best practices that complement the concepts discussed in this article.