DEV Community

ohmygod
ohmygod

Posted on

Solana's Token-2022 Transfer Hooks: How a "Safe" Feature Imported Ethereum's Deadliest Bug Class

Solana was supposed to be immune to reentrancy. The account model, the lack of dynamic dispatch, the explicit account list — all of it made the callback-based reentrancy attacks that plagued Ethereum effectively impossible. Then Token-2022 shipped Transfer Hooks, and everything changed.

Transfer Hooks let token issuers attach arbitrary program logic that executes on every transfer. Compliance checks, royalty enforcement, transfer restrictions — powerful features that institutions demanded. But they also reintroduced something Solana developers never had to worry about: control flow returning to attacker-controlled code mid-transfer.

This article dissects exactly how Transfer Hook reentrancy works on Solana, why traditional Solana security assumptions fail to catch it, and what you need to do before deploying any program that interacts with Token-2022 tokens.

The Old Assumption: "Solana Can't Have Reentrancy"

In classic SPL Token, a transfer is a single CPI (Cross-Program Invocation) call. The Token program debits one account, credits another, and returns. No callbacks. No hooks. No surprises.

Solana's runtime enforces a strict rule: a program can only modify accounts it owns. When Program A calls Program B via CPI, Program B can only write to accounts owned by Program B. When control returns to Program A, it gets back its original authority. There's no fallback() function, no receive() hook that redirects execution to arbitrary code.

This led to a widespread (and dangerous) assumption: "I don't need a reentrancy guard on Solana."

How Transfer Hooks Change the Game

Token-2022's Transfer Hook extension works like this:

  1. A token mint is configured with a transfer_hook_program_id
  2. When anyone calls transfer_checked on that mint, the Token-2022 program makes an additional CPI call to the hook program
  3. The hook program receives the transfer details and can execute arbitrary logic
  4. If the hook program returns an error, the entire transfer reverts

Here's the critical insight: the hook program is specified by the token creator, not the caller. Your DeFi protocol calls transfer_checked, and suddenly you're executing code written by whoever created the token.

The execution flow looks like this:

Your Program (vault deposit)
  → Token-2022: transfer_checked
    → Hook Program (attacker-controlled!)
      → CPI back to Your Program (reentrancy!)
Enter fullscreen mode Exit fullscreen mode

A Concrete Attack: The Lending Pool Drain

Consider a simplified lending pool that accepts Token-2022 collateral:

pub fn deposit_collateral(ctx: Context<Deposit>, amount: u64) -> Result<()> {
    // 1. Transfer tokens from user to vault
    token_2022::transfer_checked(
        CpiContext::new(
            ctx.accounts.token_program.to_account_info(),
            TransferChecked {
                from: ctx.accounts.user_token.to_account_info(),
                to: ctx.accounts.vault_token.to_account_info(),
                authority: ctx.accounts.user.to_account_info(),
                mint: ctx.accounts.mint.to_account_info(),
            },
        ),
        amount,
        ctx.accounts.mint.decimals,
    )?;

    // 2. Update user's collateral balance
    let user_account = &mut ctx.accounts.user_account;
    user_account.collateral += amount;

    Ok(())
}

pub fn borrow(ctx: Context<Borrow>, amount: u64) -> Result<()> {
    let user_account = &ctx.accounts.user_account;

    // Check collateral ratio
    require!(
        user_account.collateral * COLLATERAL_RATIO >= amount,
        ErrorCode::InsufficientCollateral
    );

    // Transfer borrowed tokens to user
    // ... transfer logic ...

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The vulnerability: between step 1 (transfer) and step 2 (state update), the Transfer Hook executes. If the token's hook program calls back into borrow(), it reads the old collateral balance — before the deposit is recorded.

But wait — can the hook actually call back into our program? Yes. The hook program can make CPI calls to any program, including the lending pool. The attacker's strategy:

  1. Deploy a malicious Transfer Hook program that, on receiving a hook call, CPIs into the lending pool's borrow instruction
  2. Create a Token-2022 mint with this hook program attached
  3. Get the lending pool to accept this token as collateral (or target a pool that accepts arbitrary Token-2022 tokens)
  4. Call deposit_collateral with the malicious token
  5. During the transfer, the hook fires and calls borrow — the collateral balance hasn't been updated yet, but the tokens are already in the vault
  6. The borrow function sees insufficient collateral and... actually, the attack is more subtle

The real attack requires the hook to fire on a token that the pool already recognizes. Here's the refined scenario:

  1. The attacker has existing collateral in the pool
  2. They call withdraw_collateral, which transfers tokens back to the attacker
  3. During that transfer, the hook fires and calls borrow against the still-recorded (but about to be decremented) collateral balance
  4. The borrow succeeds because the collateral state hasn't been decremented yet
  5. After the hook returns, the withdrawal completes and the collateral is decremented
  6. The attacker now has borrowed funds backed by collateral they've already withdrawn

The ExtraAccountMetaList Trap

Transfer Hooks require additional accounts passed via an ExtraAccountMetaList. These are accounts the hook program needs but that aren't part of the standard transfer instruction. The seeds for these accounts are stored on-chain.

Here's where a second class of vulnerability emerges: context confusion.

// Hook program's execute function
pub fn execute(ctx: Context<Execute>) -> Result<()> {
    // The ExtraAccountMetaList specifies which additional accounts to pass
    // If the seeds aren't strictly validated, an attacker can substitute accounts

    let whitelist = &ctx.accounts.whitelist;

    // Check if sender is whitelisted
    require!(
        whitelist.contains(&ctx.accounts.source.key()),
        ErrorCode::NotWhitelisted
    );

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

If the ExtraAccountMetaList derives the whitelist PDA with seeds that don't include the mint address, an attacker can create a different mint with a different whitelist — one that contains their address — and pass that whitelist instead. The hook program happily validates against the wrong whitelist.

The fix is simple but critical: always include the mint in PDA seeds for hook-related accounts.

// WRONG - no mint in seeds
seeds = [b"whitelist"]

// RIGHT - mint-scoped
seeds = [b"whitelist", mint.key().as_ref()]
Enter fullscreen mode Exit fullscreen mode

The CPI Depth Bomb

Solana limits CPI depth to 4 levels. A Transfer Hook consumes at least 2 levels (your program → Token-2022 → Hook program). If the hook tries to CPI back into your program, that's level 3. If your program then tries to do another Token-2022 transfer, that's level 4 — the maximum.

This creates a subtle DoS vector: a malicious hook program can intentionally consume CPI depth, causing legitimate operations in the callback to fail with a CallDepthExceeded error. Your protocol thinks the transfer failed, but the actual issue is the hook consuming your CPI budget.

Defense Patterns That Actually Work

1. The Anchor Reentrancy Guard

use anchor_lang::prelude::*;

#[account]
pub struct VaultState {
    pub locked: bool,
    // ... other fields
}

pub fn deposit_collateral(ctx: Context<Deposit>, amount: u64) -> Result<()> {
    let vault = &mut ctx.accounts.vault_state;

    // Reentrancy guard
    require!(!vault.locked, ErrorCode::Reentrancy);
    vault.locked = true;

    // Update state BEFORE the transfer (checks-effects-interactions)
    let user_account = &mut ctx.accounts.user_account;
    user_account.collateral += amount;

    // Now do the transfer — if the hook reenters, state is already updated
    token_2022::transfer_checked(/* ... */)?;

    vault.locked = false;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

2. Checks-Effects-Interactions (Yes, on Solana Now)

The pattern that every Solidity developer knows by heart is now essential for Solana programs interacting with Token-2022:

pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
    // CHECKS
    let user = &ctx.accounts.user_account;
    require!(user.balance >= amount, ErrorCode::InsufficientBalance);

    // EFFECTS - update state before external call
    let user = &mut ctx.accounts.user_account;
    user.balance -= amount;

    // INTERACTIONS - transfer (which may trigger hooks)
    token_2022::transfer_checked(/* ... */)?;

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

3. Token Mint Allowlists

The nuclear option: only accept tokens from known, audited mints.

pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
    // Only accept tokens from approved mints
    let mint = &ctx.accounts.mint;
    require!(
        APPROVED_MINTS.contains(&mint.key()),
        ErrorCode::UnapprovedToken
    );

    // ... rest of deposit logic
}
Enter fullscreen mode Exit fullscreen mode

This is the most secure approach but limits composability. For permissionless protocols, it's not an option.

4. Hook Program Inspection

Before accepting a Token-2022 token, inspect its hook program:

use spl_token_2022::extension::transfer_hook::TransferHook;

pub fn validate_mint(ctx: Context<ValidateMint>) -> Result<()> {
    let mint_data = ctx.accounts.mint.try_borrow_data()?;
    let mint = StateWithExtensions::<Mint>::unpack(&mint_data)?;

    if let Ok(hook) = mint.get_extension::<TransferHook>() {
        let hook_program_id = Option::<Pubkey>::from(hook.program_id);
        if let Some(program_id) = hook_program_id {
            // Check against known-safe hook programs
            require!(
                SAFE_HOOK_PROGRAMS.contains(&program_id),
                ErrorCode::UntrustedHookProgram
            );
        }
    }

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The Broader Lesson

Token-2022 Transfer Hooks are a microcosm of a universal security truth: features that increase expressiveness always increase attack surface.

Solana's original token standard was secure-by-limitation. You couldn't have reentrancy because you couldn't have callbacks. Token-2022 added callbacks for legitimate business reasons, and with them came every class of callback-based vulnerability that Ethereum has spent eight years learning to defend against.

The silver lining: we don't have to repeat Ethereum's learning curve. The patterns are known. Reentrancy guards, checks-effects-interactions, strict PDA validation — none of this is new. What's new is that Solana developers need to learn these patterns, because the assumption that "reentrancy can't happen here" is no longer true.

Checklist for Token-2022 Integration

Before deploying any program that handles Token-2022 tokens:

  • [ ] Assume every transfer triggers attacker-controlled code via hooks
  • [ ] Apply checks-effects-interactions to every function with Token-2022 transfers
  • [ ] Add reentrancy guards to state-modifying functions
  • [ ] Validate ExtraAccountMetaList seeds include the mint pubkey
  • [ ] Budget for CPI depth — hooks consume levels
  • [ ] Consider allowlisting mints or hook programs for high-value vaults
  • [ ] Test with malicious hook programs that attempt reentrancy, DoS, and context confusion
  • [ ] Audit all state reads after transfers — stale state is the root of all evil

Token-2022 brings Solana into feature parity with Ethereum's token ecosystem. It also brings Ethereum's security challenges. Be ready.


This article is part of the DeFi Security Research series. Follow for weekly deep-dives into smart contract vulnerabilities, audit techniques, and security best practices across EVM and Solana ecosystems.

Tags: solana, security, blockchain, defi

Top comments (0)