DEV Community

ohmygod
ohmygod

Posted on

Solana vs EVM Security: A Comparative Analysis for Smart Contract Auditors

Introduction

If you're an EVM auditor expanding to Solana — or vice versa — the paradigm shift is real. Solana's account model is fundamentally different from the EVM's contract storage model, and this creates entirely different vulnerability classes.

After auditing protocols on both ecosystems, we've compiled this comparative analysis covering the key architectural differences and their security implications. This isn't a "which is better" debate — it's a practical guide for auditors who need to think across chains.


Architecture Overview

EVM: Contract-Centric

┌─────────────────────┐
│   Smart Contract     │
│  ┌───────────────┐  │
│  │   Code        │  │
│  │   Storage     │  │  ← Storage lives WITH the code
│  │   Balance     │  │
│  └───────────────┘  │
└─────────────────────┘
Enter fullscreen mode Exit fullscreen mode

In the EVM, a contract owns its storage. When you call mapping(address => uint256) balances, that data lives in the contract's storage slots. The contract is the single source of truth for its state.

Solana: Account-Centric

┌──────────────┐    ┌──────────────┐    ┌──────────────┐
│  Program     │    │  Account A    │    │  Account B    │
│  (Code only) │    │  owner: Prog  │    │  owner: Prog  │
│              │────│  data: [...]  │    │  data: [...]  │
│              │    │  lamports: N  │    │  lamports: N  │
└──────────────┘    └──────────────┘    └──────────────┘
                    ↑ State is EXTERNAL to the program
Enter fullscreen mode Exit fullscreen mode

On Solana, programs are stateless. All data lives in separate accounts that are passed into program instructions. The program processes these accounts but doesn't inherently "own" storage — it must verify every account it touches.

Security implication: On EVM, you trust msg.sender and your own storage. On Solana, you must validate every account passed into every instruction.


PDAs vs Storage Slots

EVM: Storage is Implicit

// EVM: Storage mapping — address derived internally
contract Vault {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        // Storage slot is deterministic: keccak256(msg.sender . slot)
        // No way to tamper with which slot gets written
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount) external {
        require(balances[msg.sender] >= amount);
        balances[msg.sender] -= amount;
        payable(msg.sender).transfer(amount);
    }
}
Enter fullscreen mode Exit fullscreen mode

The storage layout is deterministic and tamper-proof. An attacker can't trick the contract into reading from a different storage slot.

Solana: PDAs Must Be Validated

// Solana: Program Derived Addresses (PDAs)
use anchor_lang::prelude::*;

#[derive(Accounts)]
pub struct Deposit<'info> {
    #[account(mut)]
    pub user: Signer<'info>,

    // PDA: derived from seeds, program validates the derivation
    #[account(
        init_if_needed,
        payer = user,
        space = 8 + 32 + 8, // discriminator + pubkey + amount
        seeds = [b"vault", user.key().as_ref()],
        bump
    )]
    pub vault_account: Account<'info, VaultAccount>,

    pub system_program: Program<'info, System>,
}

#[account]
pub struct VaultAccount {
    pub owner: Pubkey,
    pub balance: u64,
}
Enter fullscreen mode Exit fullscreen mode

PDAs (Program Derived Addresses) are Solana's equivalent of deterministic storage. They're derived from seeds (like a user's public key) and the program ID. The critical security property: a PDA can only be "signed" by the program that derived it.

Vulnerability: Missing PDA Validation

// VULNERABLE: Not validating PDA seeds
#[derive(Accounts)]
pub struct Withdraw<'info> {
    #[account(mut)]
    pub user: Signer<'info>,

    // BUG: No seeds constraint! Attacker can pass ANY account
    #[account(mut)]
    pub vault_account: Account<'info, VaultAccount>,
}

// SECURE: Always validate PDA derivation
#[derive(Accounts)]
pub struct Withdraw<'info> {
    #[account(mut)]
    pub user: Signer<'info>,

    #[account(
        mut,
        seeds = [b"vault", user.key().as_ref()],
        bump,
        has_one = owner @ ErrorCode::Unauthorized,
    )]
    pub vault_account: Account<'info, VaultAccount>,
}
Enter fullscreen mode Exit fullscreen mode

On EVM, you can't pass a "wrong" storage slot. On Solana, every account must be validated — it's the #1 Solana-specific vulnerability class.


Cross-Program Invocation (CPI) vs External Calls

EVM: External Calls

// EVM: Calling another contract
contract Caller {
    function doSomething(address target, uint256 amount) external {
        // External call — target contract runs in its own context
        // but msg.sender is this contract
        ITarget(target).process(amount);

        // Low-level call — more flexibility, more danger
        (bool success, bytes memory data) = target.call(
            abi.encodeWithSignature("process(uint256)", amount)
        );

        // Delegatecall — runs target code in CALLER's context
        // Extremely dangerous if target is untrusted
        (bool ok,) = target.delegatecall(
            abi.encodeWithSignature("process(uint256)", amount)
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Key EVM risks:

  • Reentrancy via callbacks in external calls
  • Delegatecall to malicious contracts (storage corruption)
  • Unchecked return values on low-level calls

Solana: Cross-Program Invocation (CPI)

// Solana: CPI — invoking another program
use anchor_lang::prelude::*;
use anchor_spl::token::{self, Transfer, Token, TokenAccount};

pub fn transfer_tokens(ctx: Context<TransferTokens>, amount: u64) -> Result<()> {
    let cpi_accounts = Transfer {
        from: ctx.accounts.from_token_account.to_account_info(),
        to: ctx.accounts.to_token_account.to_account_info(),
        authority: ctx.accounts.authority.to_account_info(),
    };
    let cpi_program = ctx.accounts.token_program.to_account_info();
    let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts);

    token::transfer(cpi_ctx, amount)?;
    Ok(())
}

#[derive(Accounts)]
pub struct TransferTokens<'info> {
    #[account(mut)]
    pub from_token_account: Account<'info, TokenAccount>,
    #[account(mut)]
    pub to_token_account: Account<'info, TokenAccount>,
    pub authority: Signer<'info>,
    pub token_program: Program<'info, Token>,  // MUST validate this!
}
Enter fullscreen mode Exit fullscreen mode

Vulnerability: Fake Program Substitution

// VULNERABLE: Not validating the CPI target program
#[derive(Accounts)]
pub struct UnsafeCPI<'info> {
    #[account(mut)]
    pub token_account: AccountInfo<'info>,
    pub authority: Signer<'info>,
    /// CHECK: Not validated — attacker can pass a fake token program!
    pub token_program: AccountInfo<'info>,
}
Enter fullscreen mode Exit fullscreen mode

On EVM, when you call IERC20(USDC).transfer(...), the address is hardcoded. On Solana, the program to invoke is passed as an account — if you don't validate it, an attacker can substitute a malicious program that mimics the real one.

Reentrancy: Different but Not Gone

Solana's runtime does have reentrancy protection: a program can't CPI back into itself during execution. However:

  • Cross-program reentrancy is still possible (Program A → Program B → Program A via CPI)
  • Within a single transaction, multiple instructions can call the same program sequentially with stale state between them
// Solana pseudo-reentrancy via instruction ordering
Transaction {
    Instruction 1: Program A — deposit(100)
    Instruction 2: Program B — callback that reads A's stale state  
    Instruction 3: Program A — withdraw based on stale balance
}
Enter fullscreen mode Exit fullscreen mode

Account Model Security Differences

Owner Checks

Every Solana account has an owner field — the program that controls it. This is critical for security:

// Always verify account ownership
#[derive(Accounts)]
pub struct SecureInstruction<'info> {
    #[account(mut)]
    pub user: Signer<'info>,

    // Anchor's Account<> type automatically checks:
    // 1. Account owner matches the program ID
    // 2. Account data deserializes correctly
    // 3. Discriminator matches (prevents type confusion)
    #[account(
        mut,
        constraint = user_data.authority == user.key()
    )]
    pub user_data: Account<'info, UserData>,
}

// VULNERABLE: Using raw AccountInfo skips all checks
pub fn unsafe_handler(ctx: Context<UnsafeInstruction>) -> Result<()> {
    // Manually deserializing without owner check
    let data = &ctx.accounts.some_account.data.borrow();
    let balance = u64::from_le_bytes(data[0..8].try_into().unwrap());
    // Attacker could pass an account owned by a different program
    // with carefully crafted data
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Account Type Confusion

This is a Solana-specific vulnerability with no EVM equivalent:

// Type confusion: Two different account types with same data layout
#[account]
pub struct UserBalance {
    pub amount: u64,      // 8 bytes
    pub authority: Pubkey, // 32 bytes
}

#[account]
pub struct RewardPool {
    pub total_rewards: u64,  // 8 bytes — same offset!
    pub admin: Pubkey,       // 32 bytes — same offset!
}

// If the program doesn't check discriminators,
// an attacker could pass a RewardPool account
// where a UserBalance is expected
// total_rewards gets read as user's "balance"
Enter fullscreen mode Exit fullscreen mode

Anchor adds 8-byte discriminators to prevent this. Raw Solana programs must implement their own type checking.


Comparison Table: Vulnerability Classes

Vulnerability EVM Solana
Reentrancy Very common (callbacks) Limited (runtime prevents self-CPI, but cross-program possible)
Access Control Missing modifiers Missing account validation, signer checks
Integer Overflow Solidity 0.8+ has built-in checks Rust panics on overflow in debug, wraps in release — use checked_*
Oracle Manipulation Same risk Same risk
Front-running/MEV Mempool-based Validator-based (different mechanics)
Storage Collision Proxy upgrades Account type confusion
Delegatecall Abuse Common in proxies No equivalent (no delegatecall)
Account Validation Not applicable #1 vulnerability class
PDA Seed Manipulation Not applicable Common — missing seed constraints
Closing Account Revival Not applicable Solana-specific (account can be reopened)

Solana-Specific Vulnerability: Closing Account Revival

This has no EVM equivalent and has caused significant losses:

// When closing an account, you must zero out ALL data
pub fn close_account(ctx: Context<CloseAccount>) -> Result<()> {
    let account = &mut ctx.accounts.target_account;

    // Transfer lamports out
    let lamports = account.to_account_info().lamports();
    **account.to_account_info().try_borrow_mut_lamports()? = 0;
    **ctx.accounts.recipient.try_borrow_mut_lamports()? += lamports;

    // CRITICAL: Zero out the data!
    // Without this, the account can be "revived" by sending lamports
    // and the old data persists
    let mut data = account.to_account_info().try_borrow_mut_data()?;
    for byte in data.iter_mut() {
        *byte = 0;
    }

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

If you only drain the lamports but don't zero the data, an attacker can send lamports back to the account address, "reviving" it with the old data intact. This can bypass account closure logic and resurrect previously closed positions.


Integer Handling: Subtle but Critical

EVM (Solidity 0.8+)

// Solidity 0.8+ reverts on overflow/underflow by default
uint256 a = type(uint256).max;
uint256 b = a + 1; // REVERTS

// Use unchecked only when you've verified safety
unchecked {
    uint256 c = a + 1; // Wraps to 0 — be careful!
}
Enter fullscreen mode Exit fullscreen mode

Solana (Rust)

// Rust behavior differs between debug and release mode!
// Debug: panics on overflow
// Release: WRAPS SILENTLY (this is the dangerous part)

let a: u64 = u64::MAX;
let b = a + 1; // Panics in debug, wraps to 0 in release!

// ALWAYS use checked arithmetic in production
let safe_result = a.checked_add(1).ok_or(ErrorCode::Overflow)?;

// Or use saturating arithmetic where appropriate
let capped = a.saturating_add(1); // Stays at u64::MAX
Enter fullscreen mode Exit fullscreen mode

This is a critical difference. Solana programs compiled in release mode (which they are for deployment) will silently wrap on overflow. Always use checked_add, checked_sub, checked_mul, checked_div.


Practical Audit Checklist

For EVM Auditors Moving to Solana

  1. Check every account validation — Are seeds verified? Owner checked? Signer required?
  2. Look for type confusion — Are discriminators enforced? Can one account type substitute another?
  3. Verify CPI targets — Is the invoked program address validated?
  4. Check integer arithmetic — All checked_* or proven safe?
  5. Account closing logic — Data zeroed? Lamports fully drained?
  6. PDA bump seeds — Is the canonical bump used? Stored and verified?
  7. Signer validation — Can non-signers execute privileged operations?
  8. Remaining accounts — Are dynamically passed accounts validated?

For Solana Auditors Moving to EVM

  1. Reentrancy on every external call — CEI pattern? ReentrancyGuard?
  2. Delegatecall usage — Who controls the target? Storage alignment?
  3. Proxy/upgrade patterns — Storage collisions? Initialization?
  4. Oracle dependencies — Flash-loanable? TWAP window sufficient?
  5. Access control — Modifiers on all privileged functions?
  6. Token integration — Fee-on-transfer? Rebasing? Return value checks?
  7. Frontrunning exposure — MEV-susceptible operations? Slippage protection?
  8. ERC standards compliance — Does the implementation match the spec?

Conclusion

The mental model shift between EVM and Solana auditing is significant:

  • EVM: "Does this contract protect its own state correctly?"
  • Solana: "Does this program validate every account it touches?"

Both ecosystems have critical vulnerability classes that the other doesn't share. The best auditors in 2026 are cross-chain thinkers — they understand the unique threat model of each execution environment and can reason about security across paradigms.

If you're an EVM auditor, start your Solana journey with Anchor (it automates many safety checks). If you're a Solana auditor, focus on reentrancy patterns and proxy architecture. The overlap in fundamental security thinking is larger than the differences — and understanding both makes you exponentially more valuable.


We audit across EVM, Solana, and Cosmos ecosystems. Follow for cross-chain security deep dives.

Top comments (0)