Deserialization Attack Remediation
How to safely deserialize Solana account data by validating discriminators, checking program ownership, and using Anchor's typed account system.
Deserialization Attack Remediation
Overview
Related Detector: Type Cosplay
Solana programs store structured data in accounts as raw bytes. If a program deserializes account data without first checking the discriminator, program ownership, and data length, an attacker can supply an account whose raw bytes represent a different struct type or crafted field values that the program then acts upon as trusted data. The three concrete failure modes are: discriminator bypass (raw try_from_slice on Anchor accounts, missing the 8-byte prefix check), panic-inducing short data (deserializing without a length check), and semantic field manipulation (valid bytes but attacker-chosen values that pass range checks only loosely).
The primary remediation is to use Anchor’s Account<'info, T> typed accounts, which enforce all three checks automatically. When AccountInfo is unavoidable, implement the checks explicitly and in the correct order.
Recommended Fix
Before (Vulnerable)
use anchor_lang::prelude::*;
#[program]
mod vulnerable_program {
pub fn process_vault(ctx: Context<ProcessVault>) -> Result<()> {
let data = ctx.accounts.vault.try_borrow_data()?;
// VULNERABLE: raw try_from_slice bypasses Anchor's discriminator check
// An attacker can craft any bytes that deserialize as a valid-looking Vault
let vault = Vault::try_from_slice(&data)
.map_err(|_| error!(ErrorCode::InvalidAccount))?;
// vault.balance is attacker-controlled
transfer_funds(&ctx, vault.balance)?;
Ok(())
}
}
#[derive(Accounts)]
pub struct ProcessVault<'info> {
/// CHECK: Using raw deserialization — vulnerable!
pub vault: AccountInfo<'info>,
pub authority: Signer<'info>,
}
After (Fixed)
use anchor_lang::prelude::*;
#[program]
mod secure_program {
pub fn process_vault(ctx: Context<ProcessVault>) -> Result<()> {
// FIXED: Anchor performs all validation before the handler body runs
let vault = &ctx.accounts.vault;
// vault.balance is guaranteed to come from a real Vault account
// owned by this program with a valid discriminator
transfer_funds(&ctx, vault.balance)?;
Ok(())
}
}
#[derive(Accounts)]
pub struct ProcessVault<'info> {
// Account<'info, Vault> automatically:
// 1. Verifies account.owner == this program's ID
// 2. Checks data[..8] == sha256("account:Vault")[..8]
// 3. Checks data.len() >= 8 + size_of::<Vault>()
// 4. Deserializes Vault from data[8..]
#[account(has_one = authority)]
pub vault: Account<'info, Vault>,
pub authority: Signer<'info>,
}
Replacing AccountInfo with Account<'info, Vault> eliminates all three vulnerability classes at once. The discriminator check, ownership verification, and length validation all happen in the Anchor framework before the handler runs.
Alternative Mitigations
1. Manual deserialization with full validation
When AccountInfo cannot be avoided — for example, in generic account processors or programs that predate Anchor — perform all checks explicitly:
use anchor_lang::prelude::*;
fn safe_deserialize_vault(
account: &AccountInfo,
program_id: &Pubkey,
) -> Result<Vault> {
// Step 1: Program ownership — ensures only this program wrote the data
require!(
account.owner == program_id,
ErrorCode::InvalidAccountOwner
);
let data = account.try_borrow_data()?;
// Step 2: Minimum data length — prevents panic on short data
const DISC_LEN: usize = 8;
require!(
data.len() >= DISC_LEN + std::mem::size_of::<Vault>(),
ErrorCode::AccountDataTooSmall
);
// Step 3: Discriminator — prevents type confusion
let expected_discriminator = Vault::discriminator();
require!(
data[..DISC_LEN] == expected_discriminator,
ErrorCode::AccountDiscriminatorMismatch
);
// Step 4: Deserialize — safe after the three checks above
let vault = Vault::try_from_slice(&data[DISC_LEN..])?;
// Step 5: Semantic validation — reject unreasonable field values
require!(vault.fee_bps <= 10_000, ErrorCode::InvalidFeeRate);
require!(vault.balance <= MAX_VAULT_BALANCE, ErrorCode::BalanceTooLarge);
Ok(vault)
}
2. SPL accounts and external program types
For accounts owned by external programs (SPL Token, etc.), use the appropriate typed wrapper from anchor_spl rather than deserializing manually:
use anchor_lang::prelude::*;
use anchor_spl::token::{Mint, Token, TokenAccount};
#[derive(Accounts)]
pub struct SwapTokens<'info> {
// TokenAccount checks: owner == spl_token::id(), correct data layout
#[account(
mut,
token::mint = input_mint, // Verifies the token mint matches
token::authority = user, // Verifies the authority field
)]
pub user_input_account: Account<'info, TokenAccount>,
#[account(
mut,
token::mint = output_mint,
)]
pub user_output_account: Account<'info, TokenAccount>,
pub input_mint: Account<'info, Mint>,
pub output_mint: Account<'info, Mint>,
pub user: Signer<'info>,
pub token_program: Program<'info, Token>,
}
3. Owner constraint when AccountInfo is required for external accounts
When AccountInfo must be used for an account owned by an external program, add an owner constraint to ensure only the correct program’s accounts pass:
#[derive(Accounts)]
pub struct ProcessExternal<'info> {
#[account(
owner = spl_token::id() @ ErrorCode::InvalidTokenAccountOwner
)]
/// CHECK: Owner verified by constraint; discriminator checked manually in handler.
pub token_account: AccountInfo<'info>,
}
pub fn process_external(ctx: Context<ProcessExternal>) -> Result<()> {
// After the owner constraint, manually verify the data layout
let token_data = spl_token::state::Account::unpack(
&ctx.accounts.token_account.data.borrow()
)?;
// token_data fields are now safe to use
msg!("Token amount: {}", token_data.amount);
Ok(())
}
Common Mistakes
Mistake 1: Calling try_from_slice on the full Anchor account data including the discriminator
// WRONG: includes the discriminator in the struct deserialization — corrupts every field
let vault = Vault::try_from_slice(&data)?; // data[0..8] is discriminator, not struct bytes
// CORRECT: skip the first 8 bytes
let vault = Vault::try_from_slice(&data[8..])?;
Anchor accounts always have an 8-byte discriminator prefix. Raw Borsh deserialization must skip this prefix.
Mistake 2: Checking discriminator without checking length first
// WRONG: panics if data.len() < 8
let discriminator = &data[..8]; // Index out of bounds on empty accounts!
Always verify data.len() >= 8 before indexing into the discriminator range.
Mistake 3: Skipping semantic validation after deserialization
// INSUFFICIENT: discriminator check passes but field values are not validated
let vault = safe_deserialize(&data)?;
// vault.fee_bps could be 65535 — attacker-controlled within the type's range
let fee = amount * vault.fee_bps as u64 / 10_000;
After structural validation (discriminator, owner, length), always validate that numeric fields fall within their expected business-logic ranges. An attacker who controls account data can still set any value that fits in the field’s type.
Mistake 4: Using AccountInfo for accounts that are always owned by this program
// WRONG: this account is always this program's — use Account<'info, T>
/// CHECK: This is our vault account
pub vault: AccountInfo<'info>,
// CORRECT:
pub vault: Account<'info, Vault>,
AccountInfo should only be used when you genuinely need raw access — for example, when an account is passed through without reading its data, or when the account type is not known at compile time.