Skip to main content
Sigvex

Remediating Authority Chain Validation

How to ensure that delegated PDA signing authority is verified at each hop in a multi-step CPI chain, preventing intermediate programs from abusing implicitly inherited authority.

Remediating Authority Chain Validation

Overview

Related Detector: Authority Chain Validation

Authority chain vulnerabilities occur when a PDA’s signing authority is used in a multi-hop CPI sequence without being re-verified at each transition point. The fix is to validate that the authority account used at each CPI hop is exactly the expected PDA, derived with the correct seeds and bump, before each invocation. For an account-validation framework programs, use CpiContext::new_with_signer with verified seeds, and add explicit constraint checks between hops.

Verify PDA Identity Before Each CPI Hop

use anchor_lang::prelude::*;

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

    pub fn execute_chain(
        ctx: Context<ExecuteChain>,
        amount: u64,
    ) -> Result<()> {
        let owner_key = ctx.accounts.owner.key();
        let bump = ctx.accounts.protocol_pda_bump;
        let seeds = &[b"protocol", owner_key.as_ref(), &[bump]];

        // === Verify PDA identity before hop 1 ===
        let expected_pda = Pubkey::create_program_address(
            &[b"protocol", owner_key.as_ref(), &[bump]],
            ctx.program_id,
        ).map_err(|_| ProtocolError::InvalidPda)?;

        require!(
            ctx.accounts.protocol_pda.key() == expected_pda,
            ProtocolError::InvalidPda
        );
        require!(
            ctx.accounts.source_account.owner == expected_pda,
            ProtocolError::InvalidAccountOwner
        );

        // Hop 1: token operation
        first_program::cpi::transfer(
            CpiContext::new_with_signer(
                ctx.accounts.first_program.to_account_info(),
                first_program::cpi::accounts::Transfer {
                    source: ctx.accounts.source_account.to_account_info(),
                    destination: ctx.accounts.staging_account.to_account_info(),
                    authority: ctx.accounts.protocol_pda.to_account_info(),
                },
                &[seeds],
            ),
            amount,
        )?;

        // === Re-verify PDA authority before hop 2 ===
        // The first program may have changed account state; re-check critical invariants
        require!(
            ctx.accounts.staging_account.amount >= amount,
            ProtocolError::InsufficientFunds
        );
        require!(
            ctx.accounts.staging_account.owner == expected_pda,
            ProtocolError::AuthorityChanged  // Guard against state changes during hop 1
        );

        // Hop 2: settlement operation with verified authority
        second_program::cpi::settle(
            CpiContext::new_with_signer(
                ctx.accounts.second_program.to_account_info(),
                second_program::cpi::accounts::Settle {
                    source: ctx.accounts.staging_account.to_account_info(),
                    destination: ctx.accounts.destination_account.to_account_info(),
                    authority: ctx.accounts.protocol_pda.to_account_info(),
                },
                &[seeds],
            ),
            amount,
        )?;

        Ok(())
    }
}

#[error_code]
pub enum ProtocolError {
    InvalidPda,
    InvalidAccountOwner,
    AuthorityChanged,
    InsufficientFunds,
}

Use Anchor Constraints for Inter-Hop Invariants

Declare constraints in the Accounts struct to reduce the chance of missing critical checks:

#[derive(Accounts)]
#[instruction(amount: u64)]
pub struct ExecuteChain<'info> {
    pub owner: Signer<'info>,

    #[account(
        seeds = [b"protocol", owner.key().as_ref()],
        bump = protocol_pda_bump,
    )]
    pub protocol_pda: SystemAccount<'info>,
    pub protocol_pda_bump: u8,

    #[account(
        mut,
        // Source must be owned by the protocol PDA
        constraint = source_account.owner == protocol_pda.key() @ ProtocolError::InvalidAccountOwner,
        // Amount must be sufficient for the operation
        constraint = source_account.amount >= amount @ ProtocolError::InsufficientFunds,
    )]
    pub source_account: Account<'info, TokenAccount>,

    #[account(
        mut,
        // Staging account must also be owned by the protocol PDA
        constraint = staging_account.owner == protocol_pda.key() @ ProtocolError::InvalidAccountOwner,
    )]
    pub staging_account: Account<'info, TokenAccount>,

    #[account(mut)]
    pub destination_account: Account<'info, TokenAccount>,

    pub first_program: Program<'info, FirstProgram>,
    pub second_program: Program<'info, SecondProgram>,
}

Common Mistakes

Mistake: Single Authority Check Before a Multi-Hop Chain

// INSUFFICIENT: authority is only verified before hop 1
// State changes during hop 1 can invalidate the authority assumption for hop 2
require!(*authority.key == expected_authority, ProgramError::InvalidArgument);

first_program::cpi::do_something(...)?;  // May change account ownership
second_program::cpi::do_another(       // Authority may no longer be valid here
    // No re-verification
    ...
)?;

Each hop should include a re-verification of the authority invariant that is critical for that hop.

Mistake: Using invoke Instead of invoke_signed for Intermediate Hops

// WRONG: intermediate hop uses invoke (no PDA signing)
// This means the PDA is not the signer for hop 2 even though you intend it to be
invoke(
    &second_ix,
    &[source.clone(), dest.clone(), pda.clone()],
)?;
// Use invoke_signed with the PDA seeds instead

All hops in a chain that require PDA authority must use invoke_signed with the appropriate seeds.

References