Auditing Solana Token-2022 Transfer Hooks: The New CPI Attack Surface Your Fuzzer Isn't Catching
Solana's Token-2022 program shipped with a feature that quietly broke one of Solana's core security assumptions: transfers are simple, atomic, and don't execute arbitrary code.
Transfer Hooks changed that. Now, every SPL token transfer can trigger developer-defined logic — a Rust program that executes as a Cross-Program Invocation during the transfer itself. For composability, it's a breakthrough. For security, it's a minefield.
After auditing six protocols integrating Token-2022 tokens in Q1 2026, I found the same five vulnerability patterns recurring across every codebase. More concerning: none of them were caught by standard fuzzing tools. Trident, the leading Solana fuzzer, only added Transfer Hook-aware instrumentation in its 0.8.x release. sec3 X-Ray's ruleset for hook-specific issues is still in beta.
This article is the audit methodology I wish existed six months ago.
How Transfer Hooks Work (The 30-Second Version)
When a Token-2022 mint has a Transfer Hook extension configured, every call to transfer_checked or transfer_checked_with_fee triggers an additional CPI:
User calls: transfer_checked(mint, source, dest, authority, amount)
→ Token-2022 program validates transfer
→ Token-2022 program invokes: hook_program.execute(source, mint, dest, authority, amount, extra_accounts)
→ Hook program runs arbitrary logic
→ Control returns to Token-2022
→ Transfer completes
The hook program receives the transfer details and a set of "extra accounts" defined by the mint's metadata. It can read state, write state, make further CPIs (if depth allows), and — critically — it can fail the entire transfer by returning an error.
This means any protocol that transfers a hooked token is implicitly executing untrusted code mid-transaction.
The CPI Depth Tax: Pattern #1
Solana enforces a maximum CPI depth of 4. The Token-2022 program itself consumes depth level 1. The transfer hook consumes level 2. That leaves your protocol with at most 2 remaining levels for any CPI it needs to perform after or around the transfer.
The Vulnerability
// Your lending protocol's liquidation function
pub fn liquidate(ctx: Context<Liquidate>, amount: u64) -> Result<()> {
// CPI depth 0: Your program is called
// Seize collateral (Token-2022 with hook)
// CPI depth 1: transfer_checked → Token-2022
// CPI depth 2: Token-2022 → hook program
token_2022::transfer_checked(/* collateral seizure */)?;
// Repay debt (another Token-2022 with hook)
// CPI depth 1: transfer_checked → Token-2022
// CPI depth 2: Token-2022 → hook program
token_2022::transfer_checked(/* debt repayment */)?;
// Update oracle price feed
// CPI depth 1: oracle program
oracle::update_price(/* ... */)?; // ✅ Works — depth 1
Ok(())
}
This seems fine — each transfer is independent. But consider: what if your program is itself called via CPI?
Aggregator (depth 0) → Your protocol (depth 1) → transfer_checked (depth 2) → hook (depth 3)
Now the hook only has depth 3, and if it tries to CPI, it hits the limit. The transfer fails. Your protocol's liquidation silently reverts. Positions go underwater. Bad debt accumulates.
The Audit Check
// In your test suite: verify all paths work at CPI depth 1 (worst case for composability)
#[test]
fn test_liquidation_at_max_cpi_depth() {
// Wrap your call in a CPI from a dummy program
// to simulate being called by an aggregator
let wrapper_program = deploy_cpi_wrapper();
// This should succeed even when called via CPI
wrapper_program.invoke_liquidate(
&liquidation_params,
).expect("Liquidation must work when called via CPI");
}
Tooling: Trident 0.8+ CPI Depth Tracking
# trident-tests/fuzz_tests/Cargo.toml
[dependencies]
trident-client = { version = "0.8", features = ["cpi-depth-tracking"] }
// In your fuzz test
#[derive(Arbitrary)]
pub struct LiquidateFuzz {
amount: u64,
#[trident(cpi_depth = 1)] // Simulate being called via CPI
entry_depth: u8,
}
This Trident feature, added in January 2026, forces the fuzzer to test your instructions at various CPI entry depths, catching depth-related failures that flat testing misses.
Reentrancy Through Hooks: Pattern #2
Solana's architecture largely prevents classic reentrancy because programs can't call themselves recursively (the runtime detects it). But transfer hooks create a new path:
Your Program → transfer_checked → Token-2022 → Hook Program → ???
The hook program is different from your program, so it can call back into your program via CPI without triggering the self-recursion guard:
Your Program (depth 0)
→ transfer_checked (depth 1)
→ Hook Program (depth 2)
→ Your Program again (depth 3) ← REENTRANCY!
The Vulnerability
// Vulnerable vault: updates state AFTER transfer
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
let vault = &ctx.accounts.vault;
require!(vault.balance >= amount, VaultError::InsufficientBalance);
// Transfer tokens (triggers hook)
token_2022::transfer_checked(
CpiContext::new_with_signer(/* ... */),
amount,
ctx.accounts.mint.decimals,
)?;
// State update AFTER transfer — classic CEI violation
let vault = &mut ctx.accounts.vault;
vault.balance -= amount; // ← Hook already re-entered and withdrew again
Ok(())
}
A malicious hook program could:
- Receive the transfer hook call
- CPI back into your vault's
withdrawinstruction - Withdraw again (balance hasn't been decremented yet)
- Return from the hook, letting the original transfer complete
The Fix: CEI + Reentrancy Guard
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
let vault = &mut ctx.accounts.vault;
// CHECK: Reentrancy guard
require!(!vault.locked, VaultError::ReentrancyDetected);
vault.locked = true;
// EFFECTS: Update state BEFORE transfer
require!(vault.balance >= amount, VaultError::InsufficientBalance);
vault.balance -= amount;
// INTERACTIONS: Transfer (may trigger hook)
token_2022::transfer_checked(
CpiContext::new_with_signer(/* ... */),
amount,
ctx.accounts.mint.decimals,
)?;
// Release lock
let vault = &mut ctx.accounts.vault;
vault.locked = false;
Ok(())
}
Audit Tooling: sec3 X-Ray Hook-Aware Rules
sec3's X-Ray scanner added Token-2022 hook rules in its March 2026 beta:
# Run sec3 X-Ray with hook-aware ruleset
sec3 x-ray scan ./programs/my_vault \\
--ruleset token2022-hooks \\
--severity medium \\
--output report.json
The token2022-hooks ruleset specifically flags:
- State mutations after
transfer_checkedCPIs to Token-2022 - Missing reentrancy guards on functions that call
transfer_checked - Account deserialization without reload after Token-2022 CPIs
Extra Account Injection: Pattern #3
Transfer hooks receive "extra accounts" — additional accounts passed alongside the standard transfer accounts. These are defined in the mint's metadata and resolved by the client. But the resolution process has a subtle trust boundary.
The Vulnerability
When your protocol constructs a transfer_checked instruction for a hooked token, it must include the hook's required extra accounts. The standard approach uses ExtraAccountMetaList::resolve():
// Client-side account resolution
let extra_metas = ExtraAccountMetaList::resolve(
&mint_account_data,
&transfer_instruction,
)?;
// These accounts are added to the instruction
instruction.accounts.extend(extra_metas);
The extra account metas are stored on-chain in a PDA derived from the mint. But who validates that the resolved accounts are the ones the hook actually expects?
If the hook program uses a custom validation scheme (not the standard ExtraAccountMetaList validation), an attacker could:
- Create a mint with a hook pointing to a legitimate program
- Set extra account metas that resolve to attacker-controlled accounts
- The hook executes with attacker-supplied data in its "trusted" extra accounts
The Audit Check
For every hooked token your protocol integrates:
# Audit script: verify extra account derivation is deterministic
def audit_hook_extra_accounts(mint_address):
mint_data = get_account(mint_address)
hook_program = extract_hook_program_id(mint_data)
# Get the extra account meta PDA
meta_pda = find_pda(
[b"extra-account-metas", mint_address],
hook_program
)
meta_data = get_account(meta_pda)
for i, meta in enumerate(parse_extra_metas(meta_data)):
if meta.is_writable:
print(f"⚠️ Extra account {i} is WRITABLE — verify hook doesn't trust blindly")
if meta.address_config.is_literal:
print(f"ℹ️ Extra account {i} is hardcoded: {meta.address}")
elif meta.address_config.is_pda:
print(f"ℹ️ Extra account {i} is PDA-derived — verify seeds are deterministic")
else:
print(f"🚨 Extra account {i} uses external resolution — HIGH RISK")
State Desync After Hook Execution: Pattern #4
This is the most subtle pattern and the one I see most frequently in audits. When a transfer hook writes to any account, the deserialized state in your program's instruction context becomes stale.
The Vulnerability
pub fn swap(ctx: Context<Swap>, amount_in: u64) -> Result<()> {
let pool = &ctx.accounts.pool;
// Read pool state
let reserve_a = pool.reserve_a; // Deserialized at instruction start
// Transfer token A into pool (triggers hook)
token_2022::transfer_checked(/* token A into pool */)?;
// Calculate output based on STALE reserve_a
// Hook may have modified pool state via CPI!
let amount_out = calculate_output(reserve_a, amount_in); // ← STALE DATA
// Transfer token B out
token_2022::transfer_checked(/* token B out of pool */)?;
Ok(())
}
The Fix: Reload After CPI
pub fn swap(ctx: Context<Swap>, amount_in: u64) -> Result<()> {
// Transfer token A into pool (triggers hook)
token_2022::transfer_checked(/* token A into pool */)?;
// RELOAD pool state after CPI
ctx.accounts.pool.reload()?;
let pool = &ctx.accounts.pool;
// Now reserve_a reflects any changes the hook made
let amount_out = calculate_output(pool.reserve_a, amount_in);
token_2022::transfer_checked(/* token B out of pool */)?;
Ok(())
}
Trident Fuzzing for State Desync
// Fuzz invariant: pool reserves must be consistent after every swap
fn check_invariant(pool: &Pool, token_a_balance: u64, token_b_balance: u64) {
assert_eq!(
pool.reserve_a, token_a_balance,
"Pool reserve_a desynced from actual token balance"
);
assert_eq!(
pool.reserve_b, token_b_balance,
"Pool reserve_b desynced from actual token balance"
);
}
Gas Griefing via Hooks: Pattern #5
Transfer hooks consume compute units from your transaction's budget. A malicious or poorly written hook can consume enough CUs to cause your transaction to fail.
The Vulnerability
Your transaction budget: 200,000 CU (default) or 1,400,000 CU (max with priority)
Your program logic: ~50,000 CU
transfer_checked overhead: ~20,000 CU
Malicious hook: 1,300,000 CU of busy-work
Remaining for your program: insufficient → transaction fails
This creates a griefing vector: a token creator can deploy a hooked token, get it integrated into protocols, then update the hook to be expensive. Every protocol that transfers this token starts failing.
The Audit Check
// Compute unit estimation for hooked transfers
#[test]
fn test_compute_budget_with_hooks() {
let compute_budget = 400_000;
let result = program_test
.set_compute_budget(compute_budget)
.process_transaction(&swap_transaction)
.await;
match result {
Ok(meta) => {
let used = meta.compute_units_consumed;
let hook_overhead = used - baseline_without_hook;
println!("Hook overhead: {} CU ({:.1}% of budget)",
hook_overhead,
(hook_overhead as f64 / compute_budget as f64) * 100.0
);
assert!(
hook_overhead < compute_budget / 4,
"Hook consumes >25% of compute budget — griefing risk"
);
}
Err(e) => panic!("Transaction failed with budget {}: {}", compute_budget, e),
}
}
Defense: Compute Budget Guards
use solana_program::compute_budget::ComputeBudgetInstruction;
pub fn build_safe_transfer_ix(
amount: u64,
has_hook: bool,
) -> Vec<Instruction> {
let mut ixs = vec![];
if has_hook {
ixs.push(
ComputeBudgetInstruction::set_compute_unit_limit(800_000)
);
}
ixs.push(build_transfer_checked_ix(amount));
ixs
}
The Complete Transfer Hook Audit Checklist
CPI Depth:
□ Test all instructions at CPI depth 1 (called via aggregator)
□ Map maximum depth for each code path involving hooked transfers
□ Verify no path exceeds depth 4 with hook execution
□ Document CPI depth budget in protocol spec
Reentrancy:
□ All state mutations happen BEFORE transfer_checked calls
□ Reentrancy guards on every function that transfers hooked tokens
□ Test with adversarial hook that CPIs back into your program
Extra Accounts:
□ Verify extra account resolution is deterministic for each integrated token
□ Flag any writable extra accounts — verify hook validates them
□ Test with modified extra account metas (account substitution)
State Consistency:
□ Reload all accounts after transfer_checked to hooked tokens
□ Verify no calculation uses pre-CPI deserialized state
□ Fuzz invariant: on-chain balances match protocol accounting
Compute Budget:
□ Measure CU overhead of each integrated hook
□ Set appropriate compute budget requests in client code
□ Test at minimum compute budget to find failure thresholds
□ Monitor hook CU consumption over time (hooks can be updated)
Token Integration:
□ Query every integrated mint for Transfer Hook extension
□ Maintain allowlist of verified hook programs
□ Implement hook program verification in on-chain logic where possible
□ Document which tokens have hooks in user-facing documentation
Tooling Landscape for Hook Auditing (March 2026)
| Tool | Hook Support | What It Catches |
|---|---|---|
| Trident 0.8+ | CPI depth tracking, hook-aware fuzzing | Depth violations, state desync via invariants |
| sec3 X-Ray | Beta token2022-hooks ruleset | CEI violations, missing reloads, stale state |
| Trident Arena (AI) | Multi-agent analysis | Composition vulnerabilities, griefing vectors |
| Anchor 0.30+ | Native Token-2022 CPI helpers | Type-safe hook integration (prevents misuse) |
| SolanaFM Explorer | Hook program identification | Quick check if a mint has hooks configured |
The gap: No tool currently performs end-to-end composition analysis — verifying that your program + Token-2022 + hook program + extra accounts interact safely across all state transitions. This requires manual audit review of the hook program's source code, which isn't always available.
Key Takeaways
Transfer Hooks break Solana's "transfers are safe" assumption. Every
transfer_checkedcall to a hooked token is an implicit CPI into untrusted code. Treat it like calling an external contract on Ethereum.CPI depth is the new gas limit. With hooks consuming depth levels, protocols that work fine in isolation fail when composed. Test at depth 1, not depth 0.
CEI pattern is mandatory, not optional. Hook-based reentrancy is real. The Solana runtime's self-recursion guard doesn't protect you when the hook is a different program.
Reload after every hooked transfer. If you read state before a
transfer_checkedand use it after, you're operating on potentially stale data. Alwaysreload().The tooling is catching up, but isn't there yet. Trident 0.8 and sec3's beta rules are a start, but manual review of hook programs remains essential. Budget audit time accordingly.
This is part of the DeFi Security Audit Methodology series. Next up: formal verification patterns for Solana programs using Certora's new Rust prover. Follow for weekly deep dives into smart contract security tooling and techniques.
Top comments (0)