DEV Community

ohmygod
ohmygod

Posted on

Solana Token-2022 Security: The Hidden Attack Surface in Token Extensions Every DeFi Protocol Must Address

Solana's Token-2022 program — also known as Token Extensions — is reshaping how tokens work on Solana. Transfer hooks, confidential transfers, transfer fees, and permanent delegates introduce powerful primitives that the original SPL Token program never had. But with power comes an expanding attack surface that most DeFi protocols aren't ready for.

After analyzing real audit findings, disclosed vulnerabilities, and the Neodyme security research on Token-2022, I've compiled the most critical security pitfalls every Solana developer needs to understand before integrating Token Extensions into their protocol.

Why Token-2022 Changes Everything

The original SPL Token program was simple: create mints, create accounts, transfer tokens. Every DeFi protocol on Solana was built around these predictable behaviors.

Token-2022 breaks these assumptions. A token transfer is no longer just a balance update — it can now:

  • Execute arbitrary code via transfer hooks
  • Deduct fees via the transfer fee extension
  • Fail silently if memo requirements aren't met
  • Freeze on creation via default account state
  • Be closed by someone other than the owner via permanent delegates
  • Mint infinite tokens if confidential transfer proofs aren't validated correctly

If your protocol assumes "transfer means the recipient gets exactly what the sender sent," you're already vulnerable.

1. Transfer Hooks: Reentrancy Returns to Solana

Transfer hooks are the most powerful — and dangerous — Token-2022 extension. They allow a mint authority to designate a program that executes on every transfer of that token. Think of it as Ethereum's _beforeTokenTransfer() hook, but implemented as a Cross-Program Invocation (CPI).

The Recursive Transfer Trap

The most critical risk: a transfer hook that triggers another transfer of the same mint creates a recursive CPI loop.

// ❌ VULNERABLE: Transfer hook triggers another transfer
pub fn process_transfer_hook(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    amount: u64,
) -> ProgramResult {
    // Custom logic that triggers another transfer of the same mint
    // This creates a recursive loop: transfer → hook → transfer → hook → ...

    let transfer_ix = spl_token_2022::instruction::transfer_checked(
        &spl_token_2022::id(),
        source_info.key,     // Different source
        mint_info.key,       // SAME MINT — recursive!
        destination_info.key,
        authority_info.key,
        &[],
        fee_amount,
        decimals,
    )?;

    invoke(&transfer_ix, accounts)?; // Boom — recursive hook invocation
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Solana limits CPI depth to 4 levels, so this won't recurse infinitely. But it can:

  • Grief users by making transfers consistently fail at the CPI depth limit
  • Manipulate state if intermediate transfers modify shared accounts
  • Drain compute units causing legitimate transactions to fail

The ExtraAccountMeta Injection

Transfer hooks receive additional accounts via an ExtraAccountMetaList stored in a PDA. If a protocol doesn't validate these extra accounts, attackers can inject malicious accounts that redirect funds or bypass access control.

// ❌ VULNERABLE: Trusting extra accounts without validation
pub fn process_transfer_hook(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    amount: u64,
) -> ProgramResult {
    let extra_account = &accounts[5]; // Extra account from ExtraAccountMetaList

    // Blindly using the extra account as a fee destination
    // If ExtraAccountMetaList PDA seeds aren't verified, attacker 
    // can substitute their own account
    **extra_account.try_borrow_mut_lamports()? += fee;

    Ok(())
}

// ✅ SECURE: Validate PDA derivation of extra accounts
pub fn process_transfer_hook(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    amount: u64,
) -> ProgramResult {
    let extra_account = &accounts[5];

    // Verify the extra account is the expected PDA
    let (expected_pda, bump) = Pubkey::find_program_address(
        &[b"fee_vault", mint_info.key.as_ref()],
        program_id,
    );
    require!(
        extra_account.key == &expected_pda,
        TransferHookError::InvalidFeeVault
    );

    **extra_account.try_borrow_mut_lamports()? += fee;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Hook Acyclicity Check

Before integrating any Token-2022 token with transfer hooks, audit the hook program for cycles:

/// Verify that a transfer hook doesn't create recursive transfers
/// by analyzing the hook program's CPI calls
pub fn verify_hook_acyclicity(
    hook_program_id: &Pubkey,
    mint: &Pubkey,
) -> Result<bool, AuditError> {
    // 1. Disassemble the hook program's BPF bytecode
    // 2. Trace all CPI calls (invoke/invoke_signed)
    // 3. Check if any CPI targets spl_token_2022::transfer_checked
    //    with the same mint
    // 4. If found, flag as potentially recursive

    let program_data = get_program_data(hook_program_id)?;
    let cpi_targets = extract_cpi_targets(&program_data)?;

    for target in cpi_targets {
        if target.program_id == spl_token_2022::id() 
            && target.instruction == "transfer_checked" 
        {
            return Ok(false); // Potentially cyclic!
        }
    }
    Ok(true)
}
Enter fullscreen mode Exit fullscreen mode

2. Transfer Fees: The Invisible Tax

The transfer fee extension allows mints to charge a fee on every transfer. The fee is withheld in the destination account and can only be harvested by the fee authority.

Why This Breaks DeFi Protocols

Most Solana DeFi protocols calculate expected token amounts based on what was sent. With transfer fees, what arrives is less than what was sent:

// ❌ VULNERABLE: Assumes received amount == sent amount
pub fn process_deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
    // Transfer tokens from user to vault
    token::transfer(ctx.accounts.transfer_ctx(), amount)?;

    // Record deposit — but vault received (amount - fee), not amount!
    ctx.accounts.user_state.deposited += amount;

    Ok(())
}

// ✅ SECURE: Account for transfer fees
pub fn process_deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
    let mint = &ctx.accounts.mint;

    // Calculate actual received amount after fees
    let fee = get_transfer_fee(mint, amount)?;
    let received = amount.checked_sub(fee)
        .ok_or(ErrorCode::ArithmeticOverflow)?;

    // Transfer tokens from user to vault
    token_2022::transfer_checked(ctx.accounts.transfer_ctx(), amount, mint.decimals)?;

    // Record actual deposit amount
    ctx.accounts.user_state.deposited += received;

    Ok(())
}

fn get_transfer_fee(mint: &AccountInfo, amount: u64) -> Result<u64> {
    let mint_data = mint.try_borrow_data()?;
    let mint_state = StateWithExtensions::<Mint>::unpack(&mint_data)?;

    if let Ok(fee_config) = mint_state.get_extension::<TransferFeeConfig>() {
        let epoch = Clock::get()?.epoch;
        let fee = fee_config
            .get_epoch_fee(epoch)
            .calculate_fee(amount)
            .ok_or(ErrorCode::FeeCalculationFailed)?;
        Ok(fee)
    } else {
        Ok(0)
    }
}
Enter fullscreen mode Exit fullscreen mode

The Fee Authority Rug Pull

Transfer fee parameters can be updated by the fee authority. A malicious token creator can:

  1. Launch a token with 0% transfer fee
  2. Get it listed on DEXs and lending protocols
  3. Increase the fee to 100% (the maximum basis points is 10,000 = 100%)
  4. Every transfer now sends nothing to the recipient

Defense: Check the maximum fee, and consider whitelisting only tokens with renounced or timelocked fee authorities.

3. Permanent Delegate: The Master Key

The permanent delegate extension grants an address the ability to transfer or burn tokens from any token account for that mint — without owner approval.

// A token with permanent delegate allows this:
// The delegate can transfer YOUR tokens out of YOUR account
// at any time, without your signature

let transfer_ix = spl_token_2022::instruction::transfer_checked(
    &spl_token_2022::id(),
    victim_token_account,     // Victim's account
    mint,
    attacker_token_account,   // Attacker's account  
    permanent_delegate,       // Signed by delegate, NOT owner
    &[],
    stolen_amount,
    decimals,
)?;
Enter fullscreen mode Exit fullscreen mode

Impact on DeFi Protocols

If your protocol's vault holds tokens with a permanent delegate:

  • The delegate can drain the vault at any time
  • The delegate can burn tokens in the vault, causing accounting mismatches
  • There is no on-chain mechanism to prevent this — it's by design

Defense: Reject tokens with permanent delegates in lending protocols, AMMs, and any protocol that custodies user funds:

pub fn validate_mint_extensions(mint_info: &AccountInfo) -> Result<()> {
    let mint_data = mint_info.try_borrow_data()?;
    let mint_state = StateWithExtensions::<Mint>::unpack(&mint_data)?;

    // Reject tokens with permanent delegate
    if mint_state.get_extension::<PermanentDelegate>().is_ok() {
        return Err(ErrorCode::UnsafeMintExtension.into());
    }

    // Reject tokens with transfer hooks pointing to unaudited programs
    if let Ok(hook) = mint_state.get_extension::<TransferHook>() {
        if !is_audited_hook(&hook.program_id.into()) {
            return Err(ErrorCode::UnauditedTransferHook.into());
        }
    }

    // Check transfer fee isn't unreasonably high
    if let Ok(fee_config) = mint_state.get_extension::<TransferFeeConfig>() {
        let epoch = Clock::get()?.epoch;
        let fee = fee_config.get_epoch_fee(epoch);
        if u16::from(fee.transfer_fee_basis_points) > MAX_ACCEPTABLE_FEE_BPS {
            return Err(ErrorCode::ExcessiveTransferFee.into());
        }
    }

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

4. Default Account State: The Frozen Vault Attack

The Default Account State extension lets a mint force all new token accounts to be created in a frozen state. Only the mint's freeze authority can thaw them.

How This Attacks Protocols

  1. Attacker creates a token with DefaultAccountState::Frozen
  2. Attacker gets the token listed on a DEX or lending protocol
  3. When the protocol creates a vault account for this token, it's frozen
  4. The protocol tries to receive tokens into the frozen vault — transaction fails
  5. If the protocol has already committed state changes before the transfer, it's in an inconsistent state
// ❌ VULNERABLE: Doesn't check account state before transfer
pub fn initialize_market(ctx: Context<InitMarket>) -> Result<()> {
    // Create vault token account — might be frozen by default!
    let vault = create_token_account(...)?;

    // Store market state (committed even if later transfer fails)
    ctx.accounts.market.vault = vault;
    ctx.accounts.market.initialized = true;

    // Transfer initial liquidity — FAILS if vault is frozen
    token_2022::transfer_checked(...)?; // Reverts entire tx

    Ok(())
}

// ✅ SECURE: Check for frozen default state
pub fn initialize_market(ctx: Context<InitMarket>) -> Result<()> {
    let mint_data = ctx.accounts.mint.try_borrow_data()?;
    let mint_state = StateWithExtensions::<Mint>::unpack(&mint_data)?;

    // Reject mints that freeze accounts by default
    if let Ok(default_state) = mint_state.get_extension::<DefaultAccountState>() {
        if default_state.state == AccountState::Frozen as u8 {
            return Err(ErrorCode::MintFreezesAccountsByDefault.into());
        }
    }

    // Safe to proceed
    let vault = create_token_account(...)?;
    ctx.accounts.market.vault = vault;

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

5. Confidential Transfers: The Proof Forgery Nightmare

The confidential transfers extension uses zero-knowledge proofs (ZK ElGamal) to enable private token transfers where amounts are encrypted. In April 2025, a critical zero-day was discovered: a missing algebraic component in the hash used for proof verification allowed attackers to forge valid proofs.

This meant an attacker could have:

  • Minted unlimited tokens
  • Withdrawn funds from any confidential-enabled account
  • Done so without detection (amounts are encrypted!)

The vulnerability was patched by Anza, Jito, and Firedancer teams before exploitation, but it highlights a terrifying truth: when amounts are hidden, traditional accounting invariant checks fail.

Defense for Protocol Integrators

If your protocol interacts with tokens that have confidential transfers enabled:

pub fn validate_no_confidential_transfers(mint_info: &AccountInfo) -> Result<()> {
    let mint_data = mint_info.try_borrow_data()?;
    let mint_state = StateWithExtensions::<Mint>::unpack(&mint_data)?;

    // For lending/AMM protocols, reject confidential transfer tokens
    // until the extension has more battle-testing
    if mint_state.get_extension::<ConfidentialTransferMint>().is_ok() {
        return Err(ErrorCode::ConfidentialTransfersNotSupported.into());
    }

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

This is conservative, but until confidential transfers have years of battle-testing, custodial DeFi protocols should carefully evaluate the risk.

6. The Mint Close Authority Timing Attack

Token-2022 allows mints to be closed (reclaiming rent) if the total supply is zero. Combined with Token Account closures, this creates a potential timing attack:

  1. Protocol checks that a mint exists and is valid
  2. Attacker closes the mint (supply is 0, has close authority)
  3. Attacker creates a new account at the same address with different data
  4. Protocol uses the "mint" address, which now contains attacker-controlled data
// ❌ VULNERABLE: Time-of-check/time-of-use on mint
pub fn process_swap(ctx: Context<Swap>) -> Result<()> {
    // Check 1: Verify mint is valid (passes)
    let mint = Account::<Mint>::try_from(&ctx.accounts.mint)?;

    // ... extensive computation ...

    // Check 2: Use mint for transfer (mint might be closed and replaced!)
    token_2022::transfer_checked(
        ctx.accounts.transfer_ctx(),
        amount,
        mint.decimals, // Stale data if mint was replaced
    )?;

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

Defense: In Solana, this is mitigated within a single transaction (accounts are locked). But across transactions (e.g., in a multi-tx flow), always re-validate the mint owner is spl_token_2022::id().

Comprehensive Token-2022 Audit Checklist

Before integrating any Token-2022 token into your protocol:

Transfer Hooks:

  • [ ] Identify the hook program and verify it's audited
  • [ ] Check for recursive transfer patterns (hook acyclicity)
  • [ ] Validate all ExtraAccountMeta PDAs
  • [ ] Test that transfers don't exceed CPI depth limits
  • [ ] Ensure hook failures don't leave protocol in inconsistent state

Transfer Fees:

  • [ ] Calculate actual received amounts after fees
  • [ ] Handle fee-on-transfer in all accounting logic
  • [ ] Check maximum possible fee (fee authority could increase it)
  • [ ] Consider rejecting tokens with un-renounced fee authority

Permanent Delegate:

  • [ ] Reject tokens with permanent delegates in custodial contexts
  • [ ] If accepting, document the trust assumption clearly

Default Account State:

  • [ ] Check if new accounts are frozen by default
  • [ ] Handle frozen accounts gracefully (don't corrupt state)

Confidential Transfers:

  • [ ] Decide if your protocol can support encrypted amounts
  • [ ] If not, explicitly reject confidential-enabled mints

Mint Close Authority:

  • [ ] Re-validate mint ownership in multi-transaction flows
  • [ ] Don't cache mint data across transaction boundaries

General:

  • [ ] Use StateWithExtensions to properly parse Token-2022 mints
  • [ ] Handle both Token and Token-2022 program IDs
  • [ ] Test with adversarial token configurations

The Bigger Picture

Token-2022 is Solana's answer to the rigidity of the original SPL Token program. It gives token creators unprecedented control — but that control is a double-edged sword for DeFi protocols.

The core lesson: never assume a token transfer is simple. With Token-2022, a transfer can execute arbitrary code, deduct fees, fail due to frozen accounts, or interact with a permanent delegate. Every DeFi protocol on Solana needs to audit its Token-2022 integration path, or risk being the next headline.

The protocols that thrive will be the ones that validate every extension, handle every edge case, and never trust a mint they haven't inspected.


DreamWork Security researches DeFi vulnerabilities and publishes technical analysis to help protocol teams build safer systems. Follow for weekly deep dives into the latest exploits and defense patterns.

Top comments (0)