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 Anchor’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.
Recommended Fix
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. Anchor 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.