Skip to main content
Sigvex

Remediating Remaining Accounts Injection

How to safely use ctx.remaining_accounts by validating account ownership, type discriminators, and writability before any privileged operations.

Remediating Remaining Accounts Injection

Overview

Related Detector: Remaining Accounts Injection

ctx.remaining_accounts provides a flexible mechanism to pass a variable number of accounts to a Solana instruction, but it intentionally bypasses the account-validation framework’s type-safety and validation system. Any account from the Solana account space can be injected. Remediation requires explicit validation of every account loaded from remaining_accounts before using it in any privileged operation.

Before (Vulnerable)

use anchor_lang::prelude::*;

#[derive(Accounts)]
pub struct MultiVaultOperation<'info> {
    pub authority: Signer<'info>,
    pub treasury: Account<'info, Treasury>,
}

#[program]
pub mod vulnerable {
    pub fn multi_withdraw(ctx: Context<MultiVaultOperation>, amounts: Vec<u64>) -> Result<()> {
        // remaining_accounts: intended to be Vault accounts, but not validated
        for (i, account) in ctx.remaining_accounts.iter().enumerate() {
            let amount = amounts[i];

            // VULNERABLE: no owner check, no discriminator check, no writability check
            // Attacker passes a malicious account with fabricated data
            let mut data = account.try_borrow_mut_data()?;
            let balance = u64::from_le_bytes(data[8..16].try_into().unwrap());
            data[8..16].copy_from_slice(&(balance - amount).to_le_bytes());
        }
        Ok(())
    }
}

After (Fixed)

use anchor_lang::prelude::*;

// Anchor generates this discriminator: sha256("account:Vault")[..8]
const VAULT_DISCRIMINATOR: [u8; 8] = [0x4a, 0x2d, 0x8b, 0x3c, 0x1e, 0x7f, 0x5a, 0x9d];

#[program]
pub mod secure {
    pub fn multi_withdraw(ctx: Context<MultiVaultOperation>, amounts: Vec<u64>) -> Result<()> {
        let program_id = ctx.program_id;

        for (i, account) in ctx.remaining_accounts.iter().enumerate() {
            let amount = amounts.get(i).copied().ok_or(error!(CustomError::IndexOutOfBounds))?;

            // Validation 1: Account must be owned by this program
            require!(
                account.owner == program_id,
                CustomError::InvalidAccountOwner
            );

            // Validation 2: Account must be writable
            require!(account.is_writable, CustomError::AccountNotWritable);

            // Validation 3: Account type must match expected discriminator
            let data = account.try_borrow_data()?;
            require!(
                data.len() >= 16,
                CustomError::AccountTooSmall
            );
            require!(
                data[..8] == VAULT_DISCRIMINATOR,
                CustomError::InvalidAccountType
            );

            // Now safely read and modify
            let balance = u64::from_le_bytes(data[8..16].try_into().unwrap());
            require!(balance >= amount, CustomError::InsufficientBalance);
            drop(data);

            // Write after all validation
            let mut data = account.try_borrow_mut_data()?;
            data[8..16].copy_from_slice(&(balance - amount).to_le_bytes());
        }
        Ok(())
    }
}

Alternative Mitigations

Use Typed Accounts Instead of remaining_accounts

When the number of accounts is bounded and known at compile time, use explicit typed accounts in the Accounts struct. an account-validation framework automatically validates ownership, discriminator, and custom constraints:

// Prefer this over remaining_accounts for bounded collections
#[derive(Accounts)]
pub struct BatchOperation<'info> {
    pub authority: Signer<'info>,
    #[account(mut, has_one = authority)]
    pub vault_a: Account<'info, Vault>,
    #[account(mut, has_one = authority)]
    pub vault_b: Account<'info, Vault>,
    #[account(mut, has_one = authority)]
    pub vault_c: Account<'info, Vault>,
}

Create a Validation Helper Function

For protocols that regularly use remaining_accounts, centralize validation in a reusable function:

fn validate_vault_account(
    account: &AccountInfo,
    program_id: &Pubkey,
) -> Result<()> {
    require!(account.owner == program_id, CustomError::InvalidOwner);
    require!(account.is_writable, CustomError::NotWritable);

    let data = account.try_borrow_data()?;
    require!(data.len() >= 8 + 8, CustomError::TooSmall); // discriminator + balance
    require!(&data[..8] == &VAULT_DISCRIMINATOR, CustomError::WrongType);

    Ok(())
}

pub fn batch_operation(ctx: Context<BatchOp>) -> Result<()> {
    for account in ctx.remaining_accounts.iter() {
        validate_vault_account(account, ctx.program_id)?;
        // Proceed safely
    }
    Ok(())
}

Use a Whitelist of Allowed Accounts

For operations over a predictable set of accounts, store a whitelist in program state:

#[account]
pub struct ApprovedVaultRegistry {
    pub vaults: Vec<Pubkey>,  // Maintained by admin
}

pub fn batch_operation(ctx: Context<BatchOp>) -> Result<()> {
    let registry = &ctx.accounts.registry;

    for account in ctx.remaining_accounts.iter() {
        require!(
            registry.vaults.contains(account.key),
            CustomError::VaultNotApproved
        );
        // Account is in the approved registry — safe to use
    }
    Ok(())
}

Common Mistakes

Mistake: Validating Owner but Not Discriminator

// INSUFFICIENT: owner check alone does not prevent type confusion
for account in ctx.remaining_accounts.iter() {
    require!(account.owner == ctx.program_id, ErrorCode::InvalidOwner);
    // An account owned by this program could still be a different account type
    // (e.g., a Stake account interpreted as a Vault account)
    let data = account.try_borrow_mut_data()?;
    // Reading at offsets that correspond to Vault fields but this is a Stake account
}

Always check both ownership AND the type discriminator.

Mistake: Not Releasing Borrows Before Mutation

// WRONG: holding a borrow while trying to mutably borrow
let data = account.try_borrow_data()?;
let balance = u64::from_le_bytes(data[8..16].try_into().unwrap());
// data borrow is still live
let mut mut_data = account.try_borrow_mut_data()?;  // PANICS — already borrowed

Use a block scope or explicit drop() to release immutable borrows before taking mutable ones.

Mistake: Checking Writability After Writing

// WRONG: writability checked after data is already modified
let mut data = account.try_borrow_mut_data()?;
data[0..8].copy_from_slice(&new_value.to_le_bytes());  // Write happens first
require!(account.is_writable, ErrorCode::NotWritable);  // Check too late

Always validate all preconditions before performing any mutation.

References