Skip to main content
Sigvex

Authority Chain Validation

Detects multi-hop CPI authority chains where delegated signing authority is not validated at each intermediate hop, allowing an attacker to abuse implicitly inherited authority from a PDA signer.

Authority Chain Validation

Overview

Remediation Guide: How to Fix Authority Chain Validation

The authority chain validation detector identifies vulnerabilities in multi-hop Cross-Program Invocation (CPI) patterns where a program’s signing authority — typically derived from a Program Derived Address (PDA) — is propagated through several program calls without being re-verified at each intermediate hop. In Solana, when a program calls invoke_signed() with PDA seeds, the runtime grants the called program the ability to sign on behalf of that PDA. If the called program then makes further CPIs that inherit this signing authority, the root program must verify that each intermediate program in the chain is authorized to act on behalf of the PDA.

Sigvex detects this pattern by building a CPI hop chain within the function’s control-flow graph. Each hop is examined for: whether it uses invoke_signed() with PDA seeds (has_seeds = true), and whether there is a preceding signer or authority check (authority_checked = true) before the hop. Chains of two or more CPI calls where any intermediate hop lacks an authority check are flagged.

The vulnerability is distinct from arbitrary CPI: the attacker does not substitute a different program at the first hop. Instead, they trigger a legitimate first-hop CPI that causes an intermediate program to make a second CPI, where the PDA signing authority from the first hop is silently inherited and misused.

Why This Is an Issue

Solana’s CPI authority propagation model is transitive: a PDA that signs at hop 1 implicitly authorizes actions taken by any program in the chain. If intermediate programs do not validate that they are permitted to act on behalf of the PDA for the specific operation being performed, an attacker can exploit the implicit trust.

Example attack scenario: Protocol A’s PDA (treasury signer) invokes a multi-step settlement program at hop 1. The settlement program, trusting that the calling program has validated all preconditions, makes a CPI at hop 2 to a reward distribution contract. The reward distribution contract receives the PDA’s signing authority from the settlement program and uses it to authorize a transfer it was never directly asked to perform by Protocol A.

The missing check is that the settlement program (hop 1→2 boundary) never verifies that the PDA that signed hop 1 is the same PDA that should be authorizing the hop 2 operation.

Historical context: Multi-hop authority chain attacks are a recognized pattern in Solana security. When a composable protocol integrates with multiple third-party programs, each additional hop in the CPI chain is an opportunity for authority assumptions to be violated.

How to Resolve

// Before: Vulnerable — authority not re-checked at each CPI hop
pub fn settle_and_distribute(ctx: Context<Settle>, amount: u64) -> Result<()> {
    let treasury_seeds = &[b"treasury", &[ctx.accounts.bump]];

    // Hop 1: invoke settlement program — treasury PDA signs
    settlement_program::cpi::settle(
        CpiContext::new_with_signer(
            ctx.accounts.settlement_program.to_account_info(),
            settlement_program::cpi::accounts::Settle {
                vault: ctx.accounts.vault.to_account_info(),
                treasury: ctx.accounts.treasury.to_account_info(),
            },
            &[treasury_seeds],
        ),
        amount,
    )?;

    // Hop 2: distribute rewards — VULNERABLE: no check that the distribution
    // is authorized by the same authority that controlled the settlement.
    // The treasury PDA's signing authority is now implicitly available to
    // any downstream program that settlement_program decides to call.
    reward_program::cpi::distribute(
        CpiContext::new_with_signer(
            ctx.accounts.reward_program.to_account_info(),
            reward_program::cpi::accounts::Distribute {
                treasury: ctx.accounts.treasury.to_account_info(),
                recipient: ctx.accounts.recipient.to_account_info(),
            },
            &[treasury_seeds], // Same PDA seeds — but no check that this is correct
        ),
        amount,
    )?;

    Ok(())
}

// After: Validate authority at each hop
pub fn settle_and_distribute(ctx: Context<Settle>, amount: u64) -> Result<()> {
    let treasury_bump = ctx.accounts.bump;
    let treasury_seeds = &[b"treasury", &[treasury_bump]];

    // Verify that the treasury PDA matches the stored authority for this settlement
    let expected_treasury = Pubkey::create_program_address(
        &[b"treasury", &[treasury_bump]],
        ctx.program_id,
    ).map_err(|_| ErrorCode::InvalidTreasuryPda)?;

    require!(
        ctx.accounts.treasury.key() == expected_treasury,
        ErrorCode::InvalidTreasuryPda
    );

    // Hop 1: settlement
    settlement_program::cpi::settle(
        CpiContext::new_with_signer(
            ctx.accounts.settlement_program.to_account_info(),
            settlement_program::cpi::accounts::Settle {
                vault: ctx.accounts.vault.to_account_info(),
                treasury: ctx.accounts.treasury.to_account_info(),
            },
            &[treasury_seeds],
        ),
        amount,
    )?;

    // Re-validate authority before hop 2 — do not assume hop 1 validated it
    require!(
        ctx.accounts.treasury.key() == expected_treasury,
        ErrorCode::InvalidTreasuryPda
    );

    // Hop 2: distribution — authority is explicitly validated before this point
    reward_program::cpi::distribute(
        CpiContext::new_with_signer(
            ctx.accounts.reward_program.to_account_info(),
            reward_program::cpi::accounts::Distribute {
                treasury: ctx.accounts.treasury.to_account_info(),
                recipient: ctx.accounts.recipient.to_account_info(),
            },
            &[treasury_seeds],
        ),
        amount,
    )?;

    Ok(())
}

Examples

Vulnerable Code

use anchor_lang::prelude::*;

#[program]
pub mod vulnerable_protocol {
    use super::*;

    pub fn multi_step_operation(
        ctx: Context<MultiStep>,
        amount: u64,
    ) -> Result<()> {
        let vault_seeds = &[b"vault", ctx.accounts.owner.key.as_ref(), &[ctx.accounts.vault_bump]];

        // Hop 1: approve spending on behalf of vault PDA
        token_approval::cpi::approve(
            CpiContext::new_with_signer(
                ctx.accounts.approval_program.to_account_info(),
                token_approval::cpi::accounts::Approve {
                    token_account: ctx.accounts.vault_token.to_account_info(),
                    delegate: ctx.accounts.delegate.to_account_info(),
                    authority: ctx.accounts.vault_pda.to_account_info(),
                },
                &[vault_seeds],
            ),
            amount,
        )?;

        // Hop 2: VULNERABLE — vault PDA authority is used for the swap
        // but the intermediate approval program may have altered the delegate,
        // and this hop does not verify who is actually authorizing the swap
        swap_program::cpi::swap(
            CpiContext::new_with_signer(
                ctx.accounts.swap_program.to_account_info(),
                swap_program::cpi::accounts::Swap {
                    source: ctx.accounts.vault_token.to_account_info(),
                    destination: ctx.accounts.output_token.to_account_info(),
                    authority: ctx.accounts.vault_pda.to_account_info(),
                },
                &[vault_seeds],
            ),
            amount,
        )?;

        Ok(())
    }
}

Fixed Code

use anchor_lang::prelude::*;

#[program]
pub mod secure_protocol {
    use super::*;

    pub fn multi_step_operation(
        ctx: Context<MultiStep>,
        amount: u64,
    ) -> Result<()> {
        let vault_bump = ctx.accounts.vault_bump;
        let vault_seeds = &[b"vault", ctx.accounts.owner.key.as_ref(), &[vault_bump]];

        // Verify vault PDA is correctly derived before using it as authority
        let expected_vault_pda = Pubkey::find_program_address(
            &[b"vault", ctx.accounts.owner.key.as_ref()],
            ctx.program_id,
        ).0;
        require!(
            ctx.accounts.vault_pda.key() == expected_vault_pda,
            ProtocolError::InvalidVaultPda
        );

        // Hop 1: approve — vault PDA authority validated above
        token_approval::cpi::approve(
            CpiContext::new_with_signer(
                ctx.accounts.approval_program.to_account_info(),
                token_approval::cpi::accounts::Approve {
                    token_account: ctx.accounts.vault_token.to_account_info(),
                    delegate: ctx.accounts.delegate.to_account_info(),
                    authority: ctx.accounts.vault_pda.to_account_info(),
                },
                &[vault_seeds],
            ),
            amount,
        )?;

        // Explicitly re-verify vault PDA authority before hop 2
        // State may have changed in hop 1; re-validation ensures integrity
        require!(
            ctx.accounts.vault_token.owner == ctx.accounts.vault_pda.key(),
            ProtocolError::AuthorityMismatch
        );

        // Hop 2: swap — authority chain is complete and verified
        swap_program::cpi::swap(
            CpiContext::new_with_signer(
                ctx.accounts.swap_program.to_account_info(),
                swap_program::cpi::accounts::Swap {
                    source: ctx.accounts.vault_token.to_account_info(),
                    destination: ctx.accounts.output_token.to_account_info(),
                    authority: ctx.accounts.vault_pda.to_account_info(),
                },
                &[vault_seeds],
            ),
            amount,
        )?;

        Ok(())
    }
}

Sample Sigvex Output

{
  "detector_id": "authority-chain-validation",
  "severity": "high",
  "confidence": 0.68,
  "description": "Function multi_step_operation() contains a 2-hop CPI chain where the vault PDA signing authority is propagated from hop 1 (token_approval::cpi::approve) to hop 2 (swap_program::cpi::swap) without re-validating the authority at the second hop. An intermediate program that modifies the vault's delegate or ownership may cause the hop 2 CPI to operate under invalid authority assumptions.",
  "location": { "function": "multi_step_operation", "offset": 22 }
}

Detection Methodology

Sigvex identifies authority chain vulnerabilities through the following steps:

  1. CPI hop collection: All HirStmt::InvokeCpi statements are collected in block order. For each, the detector records: block index, statement index, whether PDA seeds are present (has_seeds).
  2. Signer check collection: All HirStmt::CheckSigner and HirStmt::CheckOwner statements are recorded as (block_idx, stmt_idx) pairs.
  3. Authority check assignment: For each CPI hop, the detector calls has_authority_check_before(function, block_idx, stmt_idx, &signer_checks) to determine whether a signer or authority check occurs in any predecessor block or earlier in the same block.
  4. Chain analysis: CPI hops are assembled into sequential chains. A chain of two or more hops where any hop has authority_checked = false generates a finding. The confidence is based on chain length: longer chains with missing intermediate checks receive higher confidence.
  5. Broken chain detection: Chains where a hop uses invoke_signed (has seeds) but a preceding hop does not (no seeds) are flagged as potentially broken delegation.

Base confidence: 0.68 for 2-hop chains with one unvalidated hop; increases to 0.75 for chains with two or more unvalidated hops.

Limitations

False positives:

  • Protocols where all authority validation is consolidated into a single initial check before the first CPI (and remains valid for the entire function) may be flagged because the detector requires per-hop validation rather than recognizing a leading single-check pattern.
  • Programs that use a different authority validation strategy — such as verifying that the PDA’s data structure contains an authorization flag — may not be recognized as validated.

False negatives:

  • Cross-function authority chain attacks, where the multi-hop CPI spans multiple entry points called in sequence by an external orchestrator, are not detected within single-function scope.
  • Chains longer than three hops where authority is validated at hops 1 and 3 but not at hop 2 may receive a reduced confidence finding rather than a high-confidence one.

References