DEV Community

ohmygod
ohmygod

Posted on

Solana vs EVM Security: What Smart Contract Auditors Must Know

If you've spent years auditing Solidity contracts and are now looking at Solana programs, prepare for a paradigm shift. While both ecosystems deal with on-chain state and token management, their security models diverge so fundamentally that bugs from one rarely translate directly to the other.

This guide breaks down the key architectural differences and the unique vulnerability classes each platform introduces — with code examples in both Rust (Anchor) and Solidity.

The Fundamental Architecture Split

EVM: Global State with Storage Slots

In the EVM, each smart contract owns its storage. State variables map to 256-bit storage slots. When you call SSTORE, you're writing directly to that contract's persistent key-value store:

// Solidity — state lives inside the contract
contract Vault {
    mapping(address => uint256) public balances; // slot 0
    uint256 public totalDeposits;                 // slot 1

    function deposit() external payable {
        balances[msg.sender] += msg.value;
        totalDeposits += msg.value;
    }
}
Enter fullscreen mode Exit fullscreen mode

Storage is implicit. The contract is its state. This tight coupling means reentrancy, storage collisions in proxy patterns, and slot manipulation are primary attack surfaces.

Solana: Explicit Account Model

Solana separates code from data. Programs (smart contracts) are stateless executables. All state lives in accounts that are explicitly passed into every transaction:

// Anchor (Rust) — state lives in separate accounts
#[program]
pub mod vault {
    use super::*;

    pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
        let vault = &mut ctx.accounts.vault;
        let user_record = &mut ctx.accounts.user_record;

        user_record.balance += amount;
        vault.total_deposits += amount;

        // Transfer SOL from user to vault
        anchor_lang::system_program::transfer(
            CpiContext::new(
                ctx.accounts.system_program.to_account_info(),
                anchor_lang::system_program::Transfer {
                    from: ctx.accounts.user.to_account_info(),
                    to: vault.to_account_info(),
                },
            ),
            amount,
        )?;
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Deposit<'info> {
    #[account(mut)]
    pub vault: Account<'info, VaultState>,
    #[account(mut)]
    pub user_record: Account<'info, UserRecord>,
    #[account(mut)]
    pub user: Signer<'info>,
    pub system_program: Program<'info, System>,
}
Enter fullscreen mode Exit fullscreen mode

The client must pass in every account the program will read or write. This makes Solana's runtime inherently parallel (non-overlapping account sets can execute concurrently) but introduces a totally different class of bugs.

PDA vs Storage Slots: Derived State Addresses

EVM Storage Slots

In Solidity, mapping keys produce deterministic storage locations via keccak256(key . slot). Proxy contracts can suffer storage collision if the layout changes between implementation versions:

// Dangerous: storage collision in upgradeable proxy
contract ImplementationV1 {
    uint256 public value;    // slot 0
    address public admin;    // slot 1
}

contract ImplementationV2 {
    address public admin;    // slot 0 — COLLISION with value!
    uint256 public value;    // slot 1 — COLLISION with admin!
    uint256 public newField; // slot 2
}
Enter fullscreen mode Exit fullscreen mode

Solana PDAs (Program Derived Addresses)

On Solana, state accounts are located via Program Derived Addresses — deterministic addresses derived from seeds and a program ID:

// PDA derivation in Anchor
#[account(
    init,
    payer = user,
    space = 8 + 32 + 8,
    seeds = [b"user_record", user.key().as_ref()],
    bump
)]
pub user_record: Account<'info, UserRecord>,
Enter fullscreen mode Exit fullscreen mode

The PDA acts like a "storage address" that's owned by the program. But unlike EVM storage slots, PDAs can collide if seeds aren't unique — a critical Solana-specific bug class.

Solana-Specific Vulnerability Classes

1. Missing Signer Checks

The #1 Solana bug. Unlike the EVM where msg.sender is always authenticated by the protocol, Solana requires explicit verification that an account actually signed the transaction:

// VULNERABLE: No signer check
#[derive(Accounts)]
pub struct WithdrawUnsafe<'info> {
    #[account(mut)]
    pub vault: Account<'info, VaultState>,
    /// CHECK: This should be a Signer!
    pub authority: AccountInfo<'info>,
}

// SECURE: Explicit signer verification
#[derive(Accounts)]
pub struct WithdrawSafe<'info> {
    #[account(mut, has_one = authority)]
    pub vault: Account<'info, VaultState>,
    pub authority: Signer<'info>,  // Anchor enforces signature
}
Enter fullscreen mode Exit fullscreen mode

In Solidity, this is never an issue — msg.sender is always the transaction signer. On Solana, forgetting Signer<'info> means anyone can impersonate the authority.

The EVM equivalent would be like having an onlyOwner modifier that never actually checks ownership:

// EVM analogy of missing signer check
function withdraw(address claimedOwner) external {
    // BUG: never verifies claimedOwner == msg.sender
    _sendFunds(claimedOwner, balance);
}
Enter fullscreen mode Exit fullscreen mode

2. PDA Seed Collisions

If two different logical entities derive PDAs with the same seeds, they can point to the same account, leading to state confusion:

// VULNERABLE: Seeds don't uniquely identify the record type
#[account(
    seeds = [user.key().as_ref()],  // Same seed space!
    bump
)]
pub deposit_record: Account<'info, DepositRecord>,

#[account(
    seeds = [user.key().as_ref()],  // COLLISION!
    bump
)]
pub loan_record: Account<'info, LoanRecord>,

// SECURE: Include a discriminator in seeds
#[account(
    seeds = [b"deposit", user.key().as_ref()],
    bump
)]
pub deposit_record: Account<'info, DepositRecord>,

#[account(
    seeds = [b"loan", user.key().as_ref()],
    bump
)]
pub loan_record: Account<'info, LoanRecord>,
Enter fullscreen mode Exit fullscreen mode

3. Account Confusion (Type Confusion)

Solana accounts are just byte arrays. Without proper deserialization guards, an attacker can pass an account of one type where another is expected:

// VULNERABLE: Using raw AccountInfo with no type check
pub fn process_reward(ctx: Context<ProcessReward>) -> Result<()> {
    let data = ctx.accounts.config.try_borrow_data()?;
    // Directly reads bytes — any account with matching layout works!
    let reward_rate = u64::from_le_bytes(data[8..16].try_into().unwrap());
    // ...
}

// SECURE: Use Anchor's Account<> wrapper with discriminator checks
#[derive(Accounts)]
pub struct ProcessReward<'info> {
    // Anchor checks the 8-byte discriminator automatically
    pub config: Account<'info, RewardConfig>,
}
Enter fullscreen mode Exit fullscreen mode

Anchor solves this with 8-byte discriminators prepended to account data, but native Solana programs must implement these checks manually.

In Solidity, type confusion doesn't really exist in this form. Contract addresses inherently carry their code, and calling a function on the wrong contract simply reverts or produces garbage through the ABI.

4. CPI Reentrancy

Solana uses Cross-Program Invocations (CPI) instead of the EVM's external calls. While Solana's runtime prevents direct reentrancy into the same program within a single instruction, CPI reentrancy is still possible through circular call chains:

// Solana: CPI call to another program
pub fn swap_and_stake(ctx: Context<SwapAndStake>) -> Result<()> {
    // This CPI can invoke callbacks or trigger other CPIs
    let cpi_ctx = CpiContext::new(
        ctx.accounts.dex_program.to_account_info(),
        dex::Swap {
            user: ctx.accounts.user.to_account_info(),
            pool: ctx.accounts.pool.to_account_info(),
        },
    );

    // DANGER: If dex_program calls back into our program
    // via a different instruction, state may be inconsistent
    dex::swap(cpi_ctx, amount)?;

    // State updated AFTER the CPI — classic reentrancy pattern
    ctx.accounts.user_state.staked += amount;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Compare to the EVM equivalent:

// Solidity: External call reentrancy
function swapAndStake(uint256 amount) external {
    // External call — can reenter!
    dex.swap(msg.sender, amount);

    // State updated after external call
    userState[msg.sender].staked += amount;
}
Enter fullscreen mode Exit fullscreen mode

The mitigation is the same concept: Checks-Effects-Interactions pattern. Update state before making CPIs:

// SECURE: Update state before CPI
pub fn swap_and_stake(ctx: Context<SwapAndStake>, amount: u64) -> Result<()> {
    // Effects FIRST
    ctx.accounts.user_state.staked += amount;

    // Then interact
    let cpi_ctx = CpiContext::new(
        ctx.accounts.dex_program.to_account_info(),
        dex::Swap { /* ... */ },
    );
    dex::swap(cpi_ctx, amount)?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

CPI vs External Calls: Security Comparison

Aspect EVM (External Calls) Solana (CPI)
Reentrancy Direct reentrancy possible Same-program reentrancy blocked by runtime, but cross-program reentrancy exists
Privilege msg.sender changes with each call Signer privileges can be passed through CPI via PDA signing
Data Access Called contract reads own storage Called program can modify any writable account passed to it
Failure Can return false or revert Always reverts on failure (no silent failures)
Gas/Compute 63/64 gas forwarded Compute budget shared across entire transaction

CPI Privilege Escalation

A subtle Solana-specific issue: when a program signs a CPI with PDA seeds, the called program receives those signer privileges. If the called program is malicious or compromised, it can abuse those privileges on accounts it shouldn't modify:

// The invoking program signs with PDA seeds
let seeds = &[b"authority", &[bump]];
let signer_seeds = &[&seeds[..]];

let cpi_ctx = CpiContext::new_with_signer(
    ctx.accounts.external_program.to_account_info(),
    ExternalInstruction { /* accounts */ },
    signer_seeds,  // PDA signing authority passed!
);
// The external program now has signing authority of this PDA
Enter fullscreen mode Exit fullscreen mode

Account Model vs Global State: Audit Implications

What EVM Auditors Should Watch For on Solana

  1. Owner checks: Verify every account is owned by the expected program (Account<> in Anchor does this, but AccountInfo<> does not).

  2. Account closures: Closed accounts can be resurrected if lamports are sent to them. Always zero out the data AND use the proper close mechanism:

#[account(mut, close = destination)]
pub record: Account<'info, UserRecord>,
Enter fullscreen mode Exit fullscreen mode
  1. Arithmetic: Solana/Rust uses checked_add, checked_sub, etc. Unlike Solidity 0.8+ which reverts on overflow by default, Rust release builds wrap on overflow silently:
// VULNERABLE in release mode (wraps silently)
let result = a + b;

// SECURE: Explicit checked arithmetic
let result = a.checked_add(b).ok_or(ErrorCode::Overflow)?;
Enter fullscreen mode Exit fullscreen mode
  1. Remaining accounts: Solana instructions can receive extra accounts via ctx.remaining_accounts. If a program iterates over these without validation, it's an attack vector.

What Solana Auditors Should Watch For on EVM

  1. Reentrancy across all external calls, not just ETH transfers
  2. Storage collisions in proxy/upgradeable patterns
  3. Frontrunning via mempool visibility (Solana has less of this due to its leader schedule)
  4. Delegate call context switches that let malicious contracts overwrite storage

Practical Cheat Sheet for Cross-Chain Auditors

EVM Bug Class Solana Equivalent
Reentrancy CPI reentrancy, account resurrection
tx.origin phishing Missing signer checks
Storage collision (proxies) PDA seed collisions
Interface confusion Account type confusion
Unchecked return values Less relevant (CPI always reverts)
Flash loan attacks Flash loan + CPI composability
Access control (onlyOwner) Owner/signer/PDA authority checks
Integer overflow Unchecked arithmetic in release builds

Conclusion

The EVM and Solana have fundamentally different trust models. On the EVM, the runtime handles a lot implicitly — msg.sender authentication, storage isolation, overflow protection (in 0.8+). On Solana, almost everything is explicit: you validate signers, check account ownership, verify PDA derivation, and ensure type safety yourself.

For auditors crossing between ecosystems, the key insight is: Solana bugs come from things you forgot to check. EVM bugs come from interactions you didn't anticipate.

Master both, and you'll have a serious competitive edge in the smart contract security space.


Building cross-chain security expertise? Follow me for more deep-dives into smart contract vulnerability research across EVM, Solana, and emerging chains.

Top comments (0)