Duplicate Mutable Accounts
Detects when the same account is passed at multiple positions in an instruction where both references are mutable, creating aliasing bugs and undefined behavior.
Duplicate Mutable Accounts
Overview
Remediation Guide: How to Fix Duplicate Mutable Accounts
The duplicate mutable accounts detector identifies Solana program instructions that operate on multiple accounts without checking whether two of those accounts are the same public key. The Solana runtime allows callers to pass the same account at multiple positions in the accounts array. When both positions declare the account as mutable, the program holds two mutable references to the same underlying memory — violating Rust’s aliasing rules and creating exploitable undefined behavior.
The detector scans HIR statements for operations that consume multiple accounts: CPI invocations (InvokeCpi) where the accounts list contains duplicate variable IDs, and lamport transfers (TransferLamports) where the from and to accounts resolve to the same variable. Confidence is 0.85 for self-transfers and 0.75 for CPI duplicate accounts.
Why This Is an Issue
When a program receives two mutable references to the same account and then reads one while writing the other, the behavior depends on the order of Rust borrows. In the best case, the second borrow_mut() panics at runtime — causing a denial of service. In the worst case, the program reads a stale pre-write value and credits an account twice for a single balance: the attacker transfers tokens from an account to itself and receives a net double-credit.
The vulnerability exists at the interface level: the accounts struct does not enforce uniqueness, so any caller who assembles the instruction can pass the same pubkey in both positions. Custom programs that wrap SPL Token operations are especially at risk because the SPL Token program itself does validate that source and destination accounts differ, but the outer program’s business logic may process the accounts before or after the CPI.
The duplicate mutable account vulnerability was highlighted in Sealevel attack research (2022) as one of the most reliably exploitable low-effort attack patterns in Solana programs.
How to Resolve
Add an Anchor constraint that asserts the two mutable account keys are not equal. For native programs, add an explicit key comparison at the start of the instruction handler.
// Before: Vulnerable — no uniqueness constraint
#[derive(Accounts)]
pub struct TransferTokens<'info> {
#[account(mut)]
pub source: Account<'info, TokenAccount>,
#[account(mut)] // Attacker can pass the same account as source
pub destination: Account<'info, TokenAccount>,
pub authority: Signer<'info>,
pub token_program: Program<'info, Token>,
}
// After: Fixed with Anchor constraint
#[derive(Accounts)]
pub struct TransferTokens<'info> {
#[account(mut)]
pub source: Account<'info, TokenAccount>,
#[account(
mut,
constraint = destination.key() != source.key() @ MyError::SameAccount
)]
pub destination: Account<'info, TokenAccount>,
pub authority: Signer<'info>,
pub token_program: Program<'info, Token>,
}
Examples
Vulnerable Code
use anchor_lang::prelude::*;
use anchor_spl::token::{self, Token, TokenAccount, Transfer};
// VULNERABLE: no constraint ensures source != destination
#[derive(Accounts)]
pub struct TransferTokens<'info> {
#[account(mut)]
pub source: Account<'info, TokenAccount>,
#[account(mut)]
pub destination: Account<'info, TokenAccount>,
pub authority: Signer<'info>,
pub token_program: Program<'info, Token>,
}
pub fn transfer_tokens(ctx: Context<TransferTokens>, amount: u64) -> Result<()> {
// If source == destination (same pubkey), aliasing occurs:
// - SPL Token program may credit destination and debit source atomically
// - But caller-side accounting sees the same account "funded" twice
token::transfer(
CpiContext::new(
ctx.accounts.token_program.to_account_info(),
Transfer {
from: ctx.accounts.source.to_account_info(),
to: ctx.accounts.destination.to_account_info(),
authority: ctx.accounts.authority.to_account_info(),
},
),
amount,
)
}
Fixed Code
use anchor_lang::prelude::*;
use anchor_spl::token::{self, Token, TokenAccount, Transfer};
// FIXED: constraint rejects instructions where source and destination are equal
#[derive(Accounts)]
pub struct TransferTokens<'info> {
#[account(mut)]
pub source: Account<'info, TokenAccount>,
#[account(
mut,
constraint = destination.key() != source.key() @ MyError::SameAccount
)]
pub destination: Account<'info, TokenAccount>,
pub authority: Signer<'info>,
pub token_program: Program<'info, Token>,
}
pub fn transfer_tokens(ctx: Context<TransferTokens>, amount: u64) -> Result<()> {
token::transfer(
CpiContext::new(
ctx.accounts.token_program.to_account_info(),
Transfer {
from: ctx.accounts.source.to_account_info(),
to: ctx.accounts.destination.to_account_info(),
authority: ctx.accounts.authority.to_account_info(),
},
),
amount,
)
}
// Native program: explicit key comparison
use solana_program::{account_info::AccountInfo, entrypoint::ProgramResult, program_error::ProgramError};
pub fn transfer_native(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
let source = &accounts[0];
let destination = &accounts[1];
// FIXED: reject before any borrow_mut is attempted
if source.key == destination.key {
return Err(ProgramError::InvalidAccountData);
}
**source.lamports.borrow_mut() -= amount;
**destination.lamports.borrow_mut() += amount;
Ok(())
}
Sample Sigvex Output
{
"detector_id": "duplicate-mutable-accounts",
"severity": "medium",
"confidence": 0.85,
"description": "TransferLamports statement in transfer() uses the same account variable (var_0) for both the from and to positions. A caller can pass the same public key at both positions, creating an aliased mutable borrow.",
"location": {
"function": "transfer",
"offset": 0
}
}
Detection Methodology
The detector operates in a single pass over each function’s HIR statements:
- CPI invocation scan: For each
InvokeCpistatement, extracts the list of account variable IDs from the accounts operand. Checks the list for duplicate variable IDs. A duplicate in any two mutable positions triggers a finding. - Lamport transfer scan: For each
TransferLamports { from, amount }and associated destination, checks whether thefromvariable ID equals thetovariable ID in the same statement. - Confidence assignment: Self-transfers (same variable at position 0 and 1 of a lamport transfer) receive confidence 0.85. CPI account list duplicates receive confidence 0.75.
Limitations
False positives:
- Programs that explicitly check for account equality before the CPI and return an error may still be flagged because the check occurs in a different HIR statement that is not always reachable in the dataflow graph.
False negatives:
- Duplicate accounts where the same pubkey is passed via two different variable names (aliases created through account loading rather than sharing the same HIR variable) are not detected. This requires alias analysis across account-loading statements, which is not currently performed.
Related Detectors
- Missing Owner Check — detects missing account ownership validation
- Missing Writable Check — detects writes without verifying the writable flag