Skip to main content
Sigvex

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:

  1. Account source identification: Identifies account accesses that originate from array-indexed or dynamically-offset account loads — patterns consistent with remaining_accounts iteration.
  2. Validation propagation: Tracks CheckOwner and CheckWritable statements across basic blocks. Validation in block N propagates to all successor blocks, preventing false positives for validations in earlier control-flow stages.
  3. Variable aliasing: Tracks Assign statements to follow let account = accounts[i] patterns, ensuring validation applied to the source variable is recognized for the alias.
  4. Privileged operation detection: Flags TransferLamports, StoreAccountData, and InvokeCpi operations 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_accounts for 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.
  • 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

References