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 │ │
│ └───────────────┘ │
└─────────────────────┘
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
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);
}
}
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,
}
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>,
}
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)
);
}
}
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!
}
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>,
}
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
}
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(())
}
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"
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(())
}
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!
}
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
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
- ✅ Check every account validation — Are seeds verified? Owner checked? Signer required?
- ✅ Look for type confusion — Are discriminators enforced? Can one account type substitute another?
- ✅ Verify CPI targets — Is the invoked program address validated?
- ✅ Check integer arithmetic — All
checked_*or proven safe? - ✅ Account closing logic — Data zeroed? Lamports fully drained?
- ✅ PDA bump seeds — Is the canonical bump used? Stored and verified?
- ✅ Signer validation — Can non-signers execute privileged operations?
- ✅ Remaining accounts — Are dynamically passed accounts validated?
For Solana Auditors Moving to EVM
- ✅ Reentrancy on every external call — CEI pattern? ReentrancyGuard?
- ✅ Delegatecall usage — Who controls the target? Storage alignment?
- ✅ Proxy/upgrade patterns — Storage collisions? Initialization?
- ✅ Oracle dependencies — Flash-loanable? TWAP window sufficient?
- ✅ Access control — Modifiers on all privileged functions?
- ✅ Token integration — Fee-on-transfer? Rebasing? Return value checks?
- ✅ Frontrunning exposure — MEV-susceptible operations? Slippage protection?
- ✅ 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)