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:
- CPI hop collection: All
HirStmt::InvokeCpistatements are collected in block order. For each, the detector records: block index, statement index, whether PDA seeds are present (has_seeds). - Signer check collection: All
HirStmt::CheckSignerandHirStmt::CheckOwnerstatements are recorded as(block_idx, stmt_idx)pairs. - 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. - Chain analysis: CPI hops are assembled into sequential chains. A chain of two or more hops where any hop has
authority_checked = falsegenerates a finding. The confidence is based on chain length: longer chains with missing intermediate checks receive higher confidence. - 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.
Related Detectors
- CPI Reentrancy — reentrancy attacks via CPI
- CPI Program Validation — ensuring CPI targets the intended program
- Arbitrary CPI — unrestricted CPI target selection
- Missing Signer Check — base signer validation