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 Anchor programs, use CpiContext::new_with_signer with verified seeds, and add explicit constraint checks between hops.
Recommended Fix
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.