For years, Solana developers enjoyed a comforting belief: reentrancy isn't a thing here. The runtime's single-threaded execution model and account locking semantics meant the recursive callback attacks that plagued Ethereum simply couldn't happen.
Token-2022 transfer hooks changed that.
When you call transfer_checked on a Token-2022 mint that has a transfer hook configured, the Token-2022 program will CPI into the hook program — which could be anything. That hook can then CPI back into your program before your function has finished updating state. Classic reentrancy, reborn on Solana.
This isn't theoretical. The Neodyme security team demonstrated practical exploit paths in their Token-2022 security analysis, and every DeFi protocol accepting Token-2022 tokens is a potential target.
Here's the defensive playbook.
The Attack Anatomy
Consider a lending protocol's deposit() function:
pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
// 1. Transfer tokens from user to vault
transfer_checked(
CpiContext::new(
ctx.accounts.token_program.to_account_info(),
TransferChecked { /* ... */ },
),
amount,
ctx.accounts.mint.decimals,
)?;
// 2. Update internal accounting
ctx.accounts.user_position.deposited += amount;
ctx.accounts.pool.total_deposits += amount;
Ok(())
}
The problem: step 1 triggers the transfer hook before step 2 updates state. A malicious hook can call borrow() on the same pool, and the pool still shows the old (lower) deposit total — meaning collateral checks pass when they shouldn't.
Result: The attacker deposits once but borrows against an inflated position, draining the pool.
Defense 1: Checks-Effects-Interactions (CEI)
The same pattern that saved Ethereum developers saves you here. Update state before making external calls.
pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
// EFFECTS FIRST — update state before any CPI
ctx.accounts.user_position.deposited += amount;
ctx.accounts.pool.total_deposits += amount;
// INTERACTIONS LAST — external CPI happens after state is consistent
transfer_checked(
CpiContext::new(
ctx.accounts.token_program.to_account_info(),
TransferChecked { /* ... */ },
),
amount,
ctx.accounts.mint.decimals,
)?;
Ok(())
}
Now if the hook re-enters your program, it sees the updated state. The collateral check reflects the real position.
But wait — what if the transfer fails after you've already updated state? You need to handle the rollback:
pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
// Effects
ctx.accounts.user_position.deposited += amount;
ctx.accounts.pool.total_deposits += amount;
// Interaction — if this fails, Solana rolls back ALL state changes
// in the transaction, so the effects above are automatically reverted
transfer_checked(/* ... */)?;
Ok(())
}
Solana's atomic transaction model has your back: if the CPI fails, the entire transaction reverts, including your state updates. CEI is safe here.
Defense 2: Reentrancy Guards (Mutex Pattern)
For critical functions, add an explicit lock:
#[account]
pub struct Pool {
pub total_deposits: u64,
pub total_borrows: u64,
pub locked: bool, // reentrancy guard
}
pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
let pool = &mut ctx.accounts.pool;
// CHECK: not currently in a reentrant call
require!(!pool.locked, ErrorCode::ReentrancyDetected);
// LOCK
pool.locked = true;
// ... do work, including CPIs ...
pool.deposited += amount;
transfer_checked(/* ... */)?;
// UNLOCK
pool.locked = false;
Ok(())
}
This catches any reentrant call — even ones you didn't anticipate — because the lock check fires before any logic runs.
Important caveat: Anchor does not automatically reload deserialized accounts after a CPI. If a reentrant call modifies pool via a different instruction, the in-memory copy in your current execution context still shows the old data. The mutex prevents this from becoming exploitable.
Defense 3: CPI Depth Budgeting
Solana currently allows a maximum CPI depth of 4 (expanded to 8 with SIMD-0268, but not yet universally deployed). A Token-2022 transfer with a hook consumes at least 2 levels:
Your program (depth 0)
└─ Token-2022 transfer_checked (depth 1)
└─ Transfer hook program (depth 2)
└─ ??? (depth 3 — attacker's CPI back to you)
Design rule: Keep your own CPI depth to ≤ 2, leaving room for the transfer hook chain. If your protocol chains multiple CPIs (e.g., flash loan → swap → repay), audit the total depth carefully.
A malicious hook can also intentionally burn depth to DoS your post-transfer logic:
Your program (depth 0)
└─ Token-2022 (depth 1)
└─ Hook (depth 2)
└─ Dummy CPI (depth 3)
└─ Dummy CPI (depth 4) — LIMIT REACHED
If your program needs to do anything after the transfer that involves a CPI, it will fail. Solution: Structure your logic so no CPIs are needed after the transfer, or use a two-instruction pattern (deposit in ix1, then finalize in ix2 within the same transaction).
Defense 4: Validate the Token Program
Never assume you're interacting with the original SPL Token program. With Token-2022, you must explicitly check which token program is being used and whether hooks are present:
pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
// Explicitly validate the token program
let token_program = &ctx.accounts.token_program;
require!(
token_program.key() == spl_token::ID
|| token_program.key() == spl_token_2022::ID,
ErrorCode::InvalidTokenProgram
);
// If Token-2022, check for transfer hook extension
if token_program.key() == spl_token_2022::ID {
// Ensure all necessary hook accounts are provided
// Validate ExtraAccountMetaList derivation
let meta_list_pda = Pubkey::find_program_address(
&[b"extra-account-metas", ctx.accounts.mint.key().as_ref()],
&hook_program_id,
);
// ... validate accounts match expected derivations
}
// proceed with transfer
Ok(())
}
Defense 5: The Two-Instruction Pattern
When CEI alone isn't sufficient (because you need the transfer result to determine next steps), split into two instructions within a same transaction:
Transaction:
ix1: deposit_initiate → records intent, sets state to "pending"
ix2: transfer_checked → executes the actual transfer (hook fires here)
ix3: deposit_finalize → reads token balances, completes accounting
The hook fires during ix2, but your program state is in a well-defined "pending" state. ix3 then verifies the actual token balances on-chain before finalizing. This pattern is more complex but eliminates the reentrancy surface entirely.
The Checklist
Before your protocol accepts Token-2022 tokens:
| # | Check | Priority |
|---|---|---|
| 1 | All functions follow CEI ordering | Critical |
| 2 | Reentrancy guards on state-modifying functions | Critical |
| 3 | CPI depth budget audited (≤2 for your own code) | High |
| 4 | Token program ID validated explicitly | High |
| 5 | ExtraAccountMetaList PDAs validated against expected seeds | High |
| 6 | No user signers forwarded to untrusted programs via CPI | Critical |
| 7 | Post-CPI account reload where needed | Medium |
| 8 | Two-instruction pattern for complex flows | Medium |
| 9 | Fuzz testing with malicious hook programs | High |
| 10 | CPI depth exhaustion DoS tested | Medium |
Testing With Malicious Hooks
Don't just test the happy path. Write a test hook program that:
- Re-enters your program via a different instruction
- Burns CPI depth by chaining dummy CPIs
- Passes garbage accounts in the ExtraAccountMetaList
- Triggers recursive transfers of the same mint
If your program survives all four, you're in good shape.
// Test hook that attempts reentrancy
pub fn execute(ctx: Context<Execute>, amount: u64) -> Result<()> {
// Attempt to call back into the target lending protocol
let ix = Instruction {
program_id: TARGET_PROGRAM_ID,
accounts: vec![/* craft malicious account set */],
data: /* borrow instruction data */,
};
invoke(&ix, &ctx.remaining_accounts)?;
Ok(())
}
The Bigger Picture
Token-2022 transfer hooks are powerful — they enable royalties, compliance, programmable transfers, and features we haven't imagined yet. But they fundamentally change Solana's security model from "reentrancy-free" to "reentrancy-possible."
Every Solana DeFi protocol needs to revisit its security assumptions. The code that was safe with SPL Token may be exploitable with Token-2022. And as Token-2022 adoption grows (pushed by projects like Jupiter, Marinade, and the broader token-extensions ecosystem), the attack surface expands with it.
The defenses aren't complicated — CEI, mutex guards, depth budgeting, program validation. But they need to be applied systematically, not as afterthoughts.
Reentrancy is back on Solana. Build like it matters.
This article is part of the DeFi Security Deep Dives series. Previous entries covered Permit2 signature phishing, Solana C2 supply-chain attacks, and LLM-powered invariant generation.
Top comments (0)