Remaining Accounts Injection
Detects use of ctx.remaining_accounts in privileged operations without ownership and discriminator validation, allowing attackers to inject malicious accounts that bypass Anchor's type system.
Remaining Accounts Injection
Overview
Remediation Guide: How to Fix Remaining Accounts Injection
The remaining accounts injection detector identifies Anchor programs that access ctx.remaining_accounts and use those accounts in privileged operations — lamport transfers, account data writes, or CPIs — without first validating account ownership and type via discriminator checks. In Anchor, the typed Accounts struct enforces ownership, discriminator, and constraint validation automatically. Accounts passed through remaining_accounts intentionally bypass this type system, receiving no automatic validation.
Sigvex uses CFG-based dataflow analysis to track validation state across basic blocks. When an account loaded from remaining_accounts (or any dynamically-indexed account slice) is used in a TransferLamports, StoreAccountData, or InvokeCpi statement without a preceding CheckOwner on the same data-flow path, the detector generates a finding.
This detector supports both cross-block validation tracking (validation in block N prevents findings in successor blocks) and variable aliasing (tracking let account = remaining_accounts[i] patterns). CWE mapping: CWE-20 (Improper Input Validation), CWE-284 (Improper Access Control).
Why This Is an Issue
Anchor’s typed accounts (Account<'info, T>, Program<'info, T>) validate account ownership, size, and discriminators at deserialization time. remaining_accounts bypasses all of these checks, returning raw AccountInfo references. Any account in the Solana account space can be passed in remaining_accounts.
An attacker can pass a malicious account in remaining_accounts that:
- Has the expected data layout but belongs to a different program (type confusion)
- Is an account the attacker controls, allowing them to manipulate data the program trusts
- Is used in a CPI where the account’s ownership determines the invocation’s effect
This vulnerability class is particularly prevalent in protocols that use remaining_accounts for batch operations, multi-asset vaults, or flexible instruction routing.
How to Resolve
// Before: Vulnerable — remaining_accounts used without validation
pub fn batch_transfer(ctx: Context<BatchTransfer>) -> Result<()> {
for account in ctx.remaining_accounts.iter() {
// VULNERABLE: no ownership or type check before use
**account.lamports.borrow_mut() += 1000;
}
Ok(())
}
// After: Validate ownership and discriminator before use
const EXPECTED_PROGRAM_ID: Pubkey = /* your program ID */;
const VAULT_DISCRIMINATOR: [u8; 8] = /* sha256("account:Vault")[..8] */;
pub fn batch_transfer(ctx: Context<BatchTransfer>) -> Result<()> {
for account in ctx.remaining_accounts.iter() {
// Validate account ownership
require!(
account.owner == &EXPECTED_PROGRAM_ID,
ErrorCode::InvalidAccountOwner
);
// Validate account type via discriminator
let data = account.try_borrow_data()?;
require!(
data.len() >= 8 && data[..8] == VAULT_DISCRIMINATOR,
ErrorCode::InvalidAccountType
);
drop(data);
// Only then perform the privileged operation
**account.lamports.borrow_mut() += 1000;
}
Ok(())
}
For Anchor (recommended for batch operations):
// Use a typed vec with explicit account constraints instead of remaining_accounts
#[derive(Accounts)]
pub struct BatchTransfer<'info> {
pub authority: Signer<'info>,
// Use explicit typed accounts instead of remaining_accounts when possible
#[account(mut, constraint = vault_a.authority == authority.key())]
pub vault_a: Account<'info, Vault>,
#[account(mut, constraint = vault_b.authority == authority.key())]
pub vault_b: Account<'info, Vault>,
}
Examples
Vulnerable Code
use anchor_lang::prelude::*;
#[program]
pub mod vulnerable_protocol {
pub fn distribute_rewards(ctx: Context<DistributeRewards>, amounts: Vec<u64>) -> Result<()> {
let payer = &ctx.accounts.payer;
// remaining_accounts contains recipient vaults — UNVALIDATED
for (i, account) in ctx.remaining_accounts.iter().enumerate() {
let amount = amounts.get(i).copied().unwrap_or(0);
if amount == 0 {
continue;
}
// VULNERABLE: account from remaining_accounts used in lamport transfer
// Attacker can pass any account — including accounts they control
// or accounts they don't own but that have the right data layout
**payer.lamports.borrow_mut() -= amount;
**account.lamports.borrow_mut() += amount;
}
Ok(())
}
}
Fixed Code
use anchor_lang::prelude::*;
const MY_PROGRAM_ID: Pubkey = crate::ID;
// sha256("account:Vault") first 8 bytes — generated by Anchor
const VAULT_DISCRIMINATOR: &[u8; 8] = b"\x2d\x8a\x6a\x3e\xf1\x88\x4b\xc2";
#[program]
pub mod secure_protocol {
pub fn distribute_rewards(ctx: Context<DistributeRewards>, amounts: Vec<u64>) -> Result<()> {
let payer = &ctx.accounts.payer;
for (i, account) in ctx.remaining_accounts.iter().enumerate() {
let amount = amounts.get(i).copied().unwrap_or(0);
if amount == 0 {
continue;
}
// FIXED 1: validate account belongs to this program
require!(
account.owner == &MY_PROGRAM_ID,
CustomError::InvalidAccountOwner
);
// FIXED 2: validate account type via Anchor discriminator
let data = account.try_borrow_data()?;
require!(
data.len() >= 8 && &data[..8] == VAULT_DISCRIMINATOR,
CustomError::InvalidAccountType
);
drop(data);
// FIXED 3: validate account is writable
require!(account.is_writable, CustomError::AccountNotWritable);
// Now safe to perform the privileged operation
**payer.lamports.borrow_mut() -= amount;
**account.lamports.borrow_mut() += amount;
}
Ok(())
}
}
Sample Sigvex Output
{
"detector_id": "remaining-accounts-injection",
"severity": "high",
"confidence": 0.75,
"description": "Account v3 is used in Lamport Transfer Destination without ownership validation. If this account comes from remaining_accounts (bypassing Anchor's type system), an attacker could provide a malicious account to steal funds or corrupt state.",
"location": { "function": "distribute_rewards", "offset": 5 }
}
Detection Methodology
The detector uses CFG-based dataflow analysis with the following steps:
- Account source identification: Identifies account accesses that originate from array-indexed or dynamically-offset account loads — patterns consistent with
remaining_accountsiteration. - Validation propagation: Tracks
CheckOwnerandCheckWritablestatements across basic blocks. Validation in block N propagates to all successor blocks, preventing false positives for validations in earlier control-flow stages. - Variable aliasing: Tracks
Assignstatements to followlet account = accounts[i]patterns, ensuring validation applied to the source variable is recognized for the alias. - Privileged operation detection: Flags
TransferLamports,StoreAccountData, andInvokeCpioperations where the account has no ownership validation on any incoming data-flow path.
Context modifiers:
- Anchor programs: confidence reduced by 55% (Anchor’s type system reduces risk)
- Read-only functions: confidence reduced by 70% (requires state mutation to cause harm)
- Admin/initialization functions: confidence reduced
Limitations
False positives:
- Accounts that receive validation through a helper function (not inlined) may be flagged.
- Programs that validate accounts via CPI to a registry program before use may appear unvalidated.
- The detector may flag accounts that are genuinely system accounts (system program, token program) when used via
remaining_accountsfor convenience.
False negatives:
- Validation via custom account wrapper types that implement ownership checks internally may not be recognized.
- Programs that use a pre-validated account list stored in program state (previously validated and stored) may appear unvalidated.
Related Detectors
- Arbitrary CPI — when remaining_accounts are passed to a CPI without validation, it compounds the attack surface
- Missing Owner Check — specific to missing ownership validation (not remaining_accounts-specific)
- Type Cosplay — type confusion via accounts that pass ownership checks but have wrong data layouts