Solana was supposed to be the chain where reentrancy couldn't happen. No dynamic dispatch. No fallback functions. No receive() callbacks lurking in token transfers. Then Token-2022 transfer hooks arrived, and suddenly Solana programs had the same callback-driven attack surface that's drained billions from EVM protocols.
The Solv Protocol exploit on March 5, 2026 — where an attacker turned 135 BRO tokens into 567 million via ERC-3525/ERC-721 callback reentrancy — proved this isn't theoretical. But the Solana-native variant is arguably worse: transfer hooks can exhaust CPI depth budgets (limited to 4 levels), creating denial-of-service conditions that freeze entire token ecosystems.
In this article, we'll build a practical scanner that detects both CPI depth exhaustion attacks and callback reentrancy patterns in Token-2022 transfer hook programs — before they hit mainnet.
The Attack Surface: Why Transfer Hooks Change Everything
Before Token-2022, a Solana transfer instruction was atomic: debit source, credit destination, done. Transfer hooks inject arbitrary program logic into every transfer via a CPI callback:
User calls transfer → Token-2022 program → CPI to Transfer Hook program → ???
That ??? is where the dragons live. The hook program can:
- Re-enter the calling program — If the original caller holds mutable state that hasn't been committed
- Exhaust CPI depth — Solana allows max 4 CPI levels; a malicious hook at level 3 leaves no room for downstream operations
-
Inject accounts via
ExtraAccountMetaList— Spoofed PDAs can bypass whitelist checks - Trigger recursive transfers — A hook that initiates another transfer of the same mint creates infinite recursion
Architecture: What We're Building
Our scanner (thook-scan) performs static analysis on compiled Solana BPF programs to detect:
- CPI depth bombs: Hook programs that consume ≥3 CPI levels internally
-
Self-referential transfers: Hooks that CPI back to
spl_token_2022::transferfor the same mint - Missing reentrancy guards: State mutations after CPI calls without lock patterns
-
Unvalidated
ExtraAccountMetaPDAs: Account derivations that don't verify seeds
Core Detection Engine
use solana_rbpf::disassembler::disassemble;
use solana_sdk::pubkey::Pubkey;
use std::collections::HashMap;
/// Tracks CPI call graph depth through BPF instruction analysis
#[derive(Debug)]
struct CpiCallGraph {
/// program_id -> Vec<(target_program, depth)>
edges: HashMap<Pubkey, Vec<(Pubkey, u8)>>,
max_depth: u8,
}
impl CpiCallGraph {
fn detect_depth_bombs(&self, hook_program: &Pubkey) -> Vec<DepthBombFinding> {
let mut findings = Vec::new();
let mut visited = std::collections::HashSet::new();
self.walk_depth(hook_program, 1, &mut visited, &mut findings);
findings
}
fn walk_depth(
&self,
current: &Pubkey,
depth: u8,
visited: &mut std::collections::HashSet<Pubkey>,
findings: &mut Vec<DepthBombFinding>,
) {
if depth >= 3 {
findings.push(DepthBombFinding {
program: *current,
depth,
severity: if depth >= 4 {
Severity::Critical
} else {
Severity::High
},
description: format!(
"Transfer hook reaches CPI depth {} — leaves {} levels for callers",
depth,
4u8.saturating_sub(depth)
),
});
return;
}
if !visited.insert(*current) {
findings.push(DepthBombFinding {
program: *current,
depth,
severity: Severity::Critical,
description: "Recursive CPI cycle detected in transfer hook".into(),
});
return;
}
if let Some(edges) = self.edges.get(current) {
for (target, _) in edges {
self.walk_depth(target, depth + 1, visited, findings);
}
}
visited.remove(current);
}
}
#[derive(Debug)]
struct DepthBombFinding {
program: Pubkey,
depth: u8,
severity: Severity,
description: String,
}
#[derive(Debug)]
enum Severity {
Critical,
High,
Medium,
Low,
}
Reentrancy Pattern Detector
The reentrancy detector looks for a specific pattern: mutable account access → CPI call → mutable account access on the same account, without an intervening lock check.
/// Detects state-mutation-after-CPI patterns in transfer hooks
struct ReentrancyDetector {
/// Instruction index -> account access pattern
access_log: Vec<AccountAccess>,
}
#[derive(Debug, Clone)]
struct AccountAccess {
instruction_idx: usize,
account_key: Pubkey,
is_write: bool,
is_after_cpi: bool,
}
impl ReentrancyDetector {
fn scan_for_vulnerable_patterns(&self) -> Vec<ReentrancyFinding> {
let mut findings = Vec::new();
let mut cpi_boundary = None;
let mut pre_cpi_writes: HashMap<Pubkey, usize> = HashMap::new();
for access in &self.access_log {
if self.is_cpi_instruction(access.instruction_idx) {
cpi_boundary = Some(access.instruction_idx);
continue;
}
if cpi_boundary.is_none() && access.is_write {
pre_cpi_writes.insert(access.account_key, access.instruction_idx);
}
if cpi_boundary.is_some() && access.is_write {
if let Some(pre_idx) = pre_cpi_writes.get(&access.account_key) {
findings.push(ReentrancyFinding {
account: access.account_key,
pre_cpi_write_idx: *pre_idx,
post_cpi_write_idx: access.instruction_idx,
has_lock_guard: self.check_for_lock_pattern(
&access.account_key,
*pre_idx,
),
});
}
}
}
findings
}
fn check_for_lock_pattern(&self, account: &Pubkey, start_idx: usize) -> bool {
self.access_log.iter().any(|a| {
a.instruction_idx > start_idx
&& a.account_key == *account
&& !a.is_write
&& !a.is_after_cpi
})
}
fn is_cpi_instruction(&self, _idx: usize) -> bool {
false // placeholder
}
}
#[derive(Debug)]
struct ReentrancyFinding {
account: Pubkey,
pre_cpi_write_idx: usize,
post_cpi_write_idx: usize,
has_lock_guard: bool,
}
ExtraAccountMeta Validation Scanner
This is where many teams get burned. The ExtraAccountMetaList passes additional accounts to the hook, and if PDA seeds aren't validated, an attacker can substitute their own accounts:
use spl_tlv_account_resolution::state::ExtraAccountMetaList;
/// Validates that ExtraAccountMeta PDAs use deterministic, verifiable seeds
fn scan_extra_account_metas(
hook_program: &Pubkey,
extra_metas_account: &[u8],
) -> Vec<MetaValidationFinding> {
let mut findings = Vec::new();
let metas = match ExtraAccountMetaList::unpack(extra_metas_account) {
Ok(m) => m,
Err(_) => {
findings.push(MetaValidationFinding {
severity: Severity::Medium,
description: "Failed to deserialize ExtraAccountMetaList — \
possible malformed data"
.into(),
});
return findings;
}
};
for (i, meta) in metas.data().iter().enumerate() {
if meta.address_config == [0u8; 32] {
findings.push(MetaValidationFinding {
severity: Severity::High,
description: format!(
"ExtraAccountMeta[{}]: Zero-initialized address config — \
PDA derivation may accept arbitrary accounts",
i
),
});
}
if meta.is_writable.into() {
findings.push(MetaValidationFinding {
severity: Severity::High,
description: format!(
"ExtraAccountMeta[{}]: Writable account in transfer hook — \
verify ownership validation in hook program",
i
),
});
}
if meta.is_signer.into() {
findings.push(MetaValidationFinding {
severity: Severity::Critical,
description: format!(
"ExtraAccountMeta[{}]: Signer account in transfer hook — \
signer privilege escalation risk",
i
),
});
}
}
findings
}
#[derive(Debug)]
struct MetaValidationFinding {
severity: Severity,
description: String,
}
Running the Scanner: A Real-World Example
Let's use the scanner against a deliberately vulnerable transfer hook (based on the patterns from the Solv exploit):
# Build the scanner
cargo build --release
# Scan a deployed transfer hook program
thook-scan analyze \\
--program-id HookXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX \\
--cluster mainnet-beta \\
--output report.json
# Example output:
# [CRITICAL] CPI Depth Bomb: Hook reaches depth 3, leaving 1 level for callers
# [HIGH] Reentrancy: Mutable access to account Foo at ix#3 and ix#7
# with CPI boundary at ix#5 — no lock guard detected
# [HIGH] ExtraAccountMeta[2]: Writable account without ownership check
# [MEDIUM] Self-referential transfer: Hook CPIs to spl_token_2022::transfer
Integrating Into Your Audit Workflow
Pre-deployment CI Check
Add to your Anchor project's CI pipeline:
# .github/workflows/security.yml
name: Transfer Hook Security Scan
on: [push, pull_request]
jobs:
thook-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build program
run: anchor build
- name: Run transfer hook scanner
run: |
thook-scan analyze \\
--program-binary target/deploy/my_hook.so \\
--severity-threshold high \\
--fail-on-findings
Manual Audit Checklist
When reviewing transfer hook programs, verify these in order:
| # | Check | Why |
|---|---|---|
| 1 | CPI depth ≤ 2 inside hook | Leaves room for callers at depth 3-4 |
| 2 | No same-mint transfer CPIs | Prevents recursive transfer loops |
| 3 | State committed before CPI | Checks-Effects-Interactions pattern |
| 4 | Lock guard on mutable state | Prevents callback reentrancy |
| 5 | ExtraAccountMeta PDAs use mint+authority seeds | Prevents account substitution |
| 6 | No writable ExtraAccountMetas without ownership check | Prevents state tampering |
| 7 |
invoke_signed uses minimal seeds |
Least privilege principle |
The Solv Protocol Pattern: What the Scanner Would Have Caught
Mapping the Solv exploit to our scanner's detection:
Solv BRO Vault mint() flow:
1. User calls mint() [depth 0]
2. mint() calls doSafeTransferIn() [depth 1]
3. ERC-3525 inherits ERC-721 onReceived callback [depth 2]
4. Callback triggers second mint() [depth 3] ← REENTRANCY
5. Second mint completes first ← DOUBLE MINT
Scanner findings:
[CRITICAL] Reentrancy: mint_count mutated at ix#2, re-mutated at ix#8
CPI boundary at ix#5 — no lock guard
[CRITICAL] CPI Depth: Callback chain reaches depth 3
[HIGH] State ordering: _mint() call occurs AFTER doSafeTransferIn()
The scanner would have flagged this with two Critical findings before deployment. The fix is straightforward:
// Before (vulnerable)
pub fn mint(ctx: Context<Mint>, amount: u64) -> Result<()> {
let vault = &mut ctx.accounts.vault;
// CPI that triggers callback
do_safe_transfer_in(&ctx, amount)?;
// State mutation AFTER callback — reentrancy window
vault.total_minted += amount;
Ok(())
}
// After (safe)
pub fn mint(ctx: Context<Mint>, amount: u64) -> Result<()> {
let vault = &mut ctx.accounts.vault;
// Lock guard
require!(!vault.minting_locked, ErrorCode::ReentrancyGuard);
vault.minting_locked = true;
// State mutation BEFORE CPI
vault.total_minted += amount;
// CPI that triggers callback — state already committed
do_safe_transfer_in(&ctx, amount)?;
vault.minting_locked = false;
Ok(())
}
Limitations and Future Work
Static analysis can't catch everything. Known blind spots:
- Dynamic CPI targets: If a hook resolves program IDs at runtime from account data, static analysis can't follow the call graph
- Cross-transaction attacks: Multi-transaction exploits that set up state in tx1 and exploit in tx2
- Composability bombs: A hook that's safe in isolation but dangerous when composed with specific DeFi protocols
For these, you need runtime monitoring. Tools like Hypernative and Cyvers provide real-time detection, but the static scanner catches 80% of issues before deployment — which is where it matters most.
Conclusion
Token-2022 transfer hooks are powerful, but they've fundamentally changed Solana's security model. The "no callbacks, no reentrancy" assumption is dead. Every protocol integrating Token-2022 tokens needs to audit not just their own code, but every transfer hook they'll interact with.
Build the scanner into your CI. Run it before every deployment. And remember: on Solana, CPI depth 4 is a hard wall — one malicious hook can DoS your entire protocol.
The scanner code is available as a reference implementation. For production auditing, combine static analysis with Trident fuzzing and Certora formal verification for complete coverage.
Top comments (0)