Account Cosplay
Briefing
On Solana every account passed to a program is chosen by the caller. A program must check which program owns an account and how it relates to the others before trusting its data.
// Quantix Security Research Network — training challenge.// Intentionally vulnerable. Written for education; do not deploy.use anchor_lang::prelude::*;use anchor_spl::token::{self, Token, TokenAccount, Transfer}; declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS"); #[program]pub mod training_vault { use super::*; pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> { let data = ctx.accounts.config.try_borrow_data()?; let config = VaultConfig::try_deserialize(&mut &data[..])?; require_keys_eq!(config.authority, ctx.accounts.authority.key(), VaultError::Unauthorized); let seeds: &[&[u8]] = &[b"vault", &[ctx.bumps.vault_authority]]; token::transfer( CpiContext::new_with_signer( ctx.accounts.token_program.to_account_info(), Transfer { from: ctx.accounts.vault_token.to_account_info(), to: ctx.accounts.destination.to_account_info(), authority: ctx.accounts.vault_authority.to_account_info(), }, &[seeds], ), amount, ) }} #[account]pub struct VaultConfig { pub authority: Pubkey, pub vault_token: Pubkey,} #[derive(Accounts)]pub struct Withdraw<'info> { /// CHECK: deserialised manually in the handler pub config: AccountInfo<'info>, pub authority: Signer<'info>, #[account(mut)] pub vault_token: Account<'info, TokenAccount>, #[account(mut)] pub destination: Account<'info, TokenAccount>, /// CHECK: PDA that owns the vault token account #[account(seeds = [b"vault"], bump)] pub vault_authority: AccountInfo<'info>, pub token_program: Program<'info, Token>,} #[error_code]pub enum VaultError { #[msg("Unauthorized")] Unauthorized,}Hints
Reveal one at a time. Each moves a step closer to the answer.
- H1
Hidden
- H2
Hidden
- H3
Hidden
Solution
Try writing your own explanation and fix before opening it.
The handler reads `config` from an unchecked `AccountInfo`. `try_deserialize` only verifies the account discriminator, which is derived from the type name and is public. An attacker deploys a program (or uses any account they control) to create an account with the `VaultConfig` discriminator and their own key as `authority`, signs as that authority, and passes the fake config.
Because `vault_token` is not checked against the configuration either, the PDA signs a transfer from the real vault to the attacker’s destination.
Fix: declare `config` as `Account<'info, VaultConfig>` so Anchor verifies the owning program and discriminator, derive it from seeds, and use `has_one` constraints so `authority` and `vault_token` must match the stored configuration.
#[derive(Accounts)]pub struct Withdraw<'info> { #[account(seeds = [b"config"], bump, has_one = authority, has_one = vault_token)] pub config: Account<'info, VaultConfig>, pub authority: Signer<'info>, #[account(mut, token::authority = vault_authority)] pub vault_token: Account<'info, TokenAccount>, #[account(mut)] pub destination: Account<'info, TokenAccount>, /// CHECK: PDA that owns the vault token account #[account(seeds = [b"vault"], bump)] pub vault_authority: AccountInfo<'info>, pub token_program: Program<'info, Token>,}// The handler no longer deserialises config manually:// require_keys_eq! is replaced by has_one = authority.