$8.9MMarch 14, 20268 min read

How Crema Finance Lost $8.9M to a Fake Tick Account

SolanaAMMAccount ValidationDeFi ExploitConcentrated LiquidityPDA

Concentrated liquidity AMMs are among the most complex programs on Solana. They track price ranges, tick boundaries, fee growth accumulators, and LP positions across dozens of interrelated accounts. That complexity is exactly what makes them dangerous: every custom account type is a new attack surface, and every instruction that reads from user-supplied accounts is a potential entry point. On July 2, 2022, an attacker proved exactly that against Crema Finance - a Solana-native concentrated liquidity AMM. By constructing a fake tick account loaded with inflated fee data, they convinced the protocol to pay out $8.9M in fees that were never legitimately earned. The recovery was partial - around $8M was eventually returned after negotiation - but the technical lesson is permanent.

What Is Crema Finance?

Crema Finance is a concentrated liquidity market maker (CLMM) built on Solana. Like Uniswap v3, it allows liquidity providers to deposit assets within specific price ranges, concentrating capital where trading actually happens. This increases capital efficiency but also dramatically increases protocol complexity.

Instead of a single global price curve, CLMMs maintain tick accounts - discrete on-chain data structures that record cumulative fee growth at specific price boundaries. When a liquidity provider adds or removes a position, the protocol reads relevant tick accounts to calculate exactly how much fee revenue that position has accumulated since deposit.

These tick accounts are custom account types. They are not native Solana system accounts, not SPL token accounts, and not PDAs derived from a fixed seed. They are program-owned data accounts that the program is expected to validate before trusting. In Crema's case, that validation was missing in one critical instruction.

The Tick Account Vulnerability

The exploit centered on a missing owner check in Crema's fee claim instruction. When a liquidity provider called the claim function, they passed in tick accounts as arguments - and the program used the data in those accounts to calculate fee entitlement without verifying that those accounts were legitimately created by the Crema program itself.

This is a classic Solana account confusion vulnerability. On Solana, any program can read any account's data. If a program does not check that an account's `owner` field matches the expected program ID, and does not verify that the account was derived from a known seed (a PDA), then an attacker can supply any account - including one they created and filled with arbitrary data.

Crema's swap instruction had this check. An earlier audit by Bramah Systems identified a similar issue in the swap path, and it was fixed. But the claim instruction shared similar logic and was not updated. Same vulnerability, different instruction, different outcome.

The attacker crafted a fake tick account populated with fabricated fee growth data. The values were set to imply massive accumulated fees owed to their LP position. When the claim instruction read this account and ran the fee calculation, it produced an enormous payout - one that reflected the fake data rather than real trading activity.

rust
// VULNERABLE PATTERN - do not use
// The tick_account is passed in by the caller with no validation.
// Its data is read directly to compute fee entitlement.
pub fn claim_fee(ctx: Context<ClaimFee>) -> Result<()> {
    // BUG: tick_account is a raw AccountInfo - owner and derivation are never checked.
    // An attacker can supply any account with arbitrary data here.
    let tick_data = TickAccount::try_from_slice(
        &ctx.accounts.tick_account.data.borrow()
    )?;

    // The fee calculation runs correctly - on attacker-controlled data.
    let fees_owed = calculate_fees(
        &ctx.accounts.position,
        tick_data.fee_growth_outside_0,
        tick_data.fee_growth_outside_1,
    )?;

    // Real tokens leave the pool based on fake fee data.
    transfer_fees(ctx, fees_owed)?;
    Ok(())
}

#[derive(Accounts)]
pub struct ClaimFee<'info> {
    #[account(mut)]
    pub pool: Account<'info, Pool>,

    #[account(mut)]
    pub position: Account<'info, Position>,

    // Missing: no owner check, no seeds constraint, no PDA verification.
    /// CHECK: (nothing - this is the problem)
    pub tick_account: AccountInfo<'info>,

    #[account(mut)]
    pub token_vault_0: Account<'info, TokenAccount>,

    #[account(mut)]
    pub token_vault_1: Account<'info, TokenAccount>,
}

Attack Walkthrough

The exploit sequence unfolded in a single transaction, using a flash loan to amplify impact:

1. The attacker took a flash loan, acquiring a large liquidity position in a Crema pool. 2. They created a fake tick account - a data account owned by their own program, not by Crema Finance - and populated it with inflated cumulative fee growth values. 3. They called Crema's `claim_fee` instruction (or equivalent), passing the fake tick account as the tick data argument. 4. The instruction read the fee accumulators from the attacker-supplied account, calculated fee entitlement based on the fake data, and transferred real tokens out of the pool. 5. The attacker repaid the flash loan and exited with approximately $8.9M in profit.

The math the protocol ran was correct - given the data it was fed. The failure was accepting that data without verifying its source. The program never asked: who created this account? Is it owned by the Crema program? Was it derived from a seed I control?

Proceeds were converted to roughly 69,422 SOL and 6.5M USDCet, with the USDCet bridged to Ethereum and swapped for ETH.

Proper Account Validation in Anchor

The fix is straightforward. Every custom account type should be validated on two axes before its data is trusted:

**1. Owner check** - The account's `owner` field must equal the program that created it. On Solana, when a program creates an account, it becomes that account's owner. If an attacker creates an account with their own program, the owner field will differ.

**2. PDA derivation check** - For accounts that should be derived deterministically (tick accounts keyed to a specific pool + price boundary), the program should re-derive the expected address from known seeds and compare it to the supplied address. If they do not match, reject the instruction.

In Anchor, both checks are expressed declaratively in the account struct. The `#[account]` constraint enforces the owner check automatically (Anchor verifies the discriminator and owner). The `seeds` and `bump` constraints enforce PDA derivation.

rust
// SECURE PATTERN
// Tick accounts are PDAs derived from pool address + tick index.
// Anchor verifies owner (program ID) and re-derives the address from seeds.
pub fn claim_fee(ctx: Context<ClaimFee>) -> Result<()> {
    // Safe: tick_account is a typed Account<TickData>.
    // Anchor has already verified:
    //   1. account.owner == program_id (not an attacker-controlled program)
    //   2. The address matches the PDA derived from [pool, tick_lower_index]
    //   3. The account discriminator matches TickData
    let tick_lower = &ctx.accounts.tick_lower;
    let tick_upper = &ctx.accounts.tick_upper;

    let fees_owed = calculate_fees(
        &ctx.accounts.position,
        tick_lower.fee_growth_outside_0,
        tick_lower.fee_growth_outside_1,
        tick_upper.fee_growth_outside_0,
        tick_upper.fee_growth_outside_1,
    )?;

    transfer_fees(ctx, fees_owed)?;
    Ok(())
}

#[derive(Accounts)]
#[instruction(tick_lower_index: i32, tick_upper_index: i32)]
pub struct ClaimFee<'info> {
    #[account(mut)]
    pub pool: Account<'info, Pool>,

    #[account(
        mut,
        has_one = pool,
        has_one = owner,
    )]
    pub position: Account<'info, Position>,

    // Anchor verifies owner == program_id and derives the PDA.
    // If the caller supplies any other account, this fails with ConstraintSeeds.
    #[account(
        seeds = [b"tick", pool.key().as_ref(), tick_lower_index.to_le_bytes().as_ref()],
        bump,
    )]
    pub tick_lower: Account<'info, TickData>,

    #[account(
        seeds = [b"tick", pool.key().as_ref(), tick_upper_index.to_le_bytes().as_ref()],
        bump,
    )]
    pub tick_upper: Account<'info, TickData>,

    #[account(mut)]
    pub token_vault_0: Account<'info, TokenAccount>,

    #[account(mut)]
    pub token_vault_1: Account<'info, TokenAccount>,

    pub owner: Signer<'info>,
}

The Recovery

After the exploit, the Crema team acted quickly. They identified the attacker's addresses on Solana and Ethereum, had the USDC blacklisted by Circle, and embedded on-chain messages offering an $800,000 whitehat bounty with a 72-hour window.

Surprisingly, it worked. The attacker returned approximately $8M of the stolen funds, keeping roughly $800K-$900K as the negotiated bounty. This partial recovery is unusual in DeFi exploits - most stolen funds are never seen again.

The recovery does not change the technical failure. The protocol had been audited. The vulnerability was known in a related context. It survived into production in the claim path because the fix was not applied uniformly across all instructions that shared the same pattern.

Why AMMs Have a Larger Attack Surface

Most simple DeFi protocols deal with a small set of well-understood account types: user wallets, SPL token accounts, maybe a config PDA. Concentrated liquidity AMMs are different. A single Crema pool might involve:

- Pool state accounts - LP position accounts - Tick array accounts (price boundary data) - Fee accumulator accounts - Oracle accounts - Reward distribution accounts

Each of these is a custom account type that the program defines. Each one that gets passed into an instruction is an opportunity for an attacker to substitute a fake. The more account types, the larger the surface. The more instructions that accept these accounts as arguments, the more potential entry points.

This is compounded by the `remaining_accounts` pattern common in Solana programs - where variable-length lists of accounts are passed outside the typed account struct, often with minimal validation. Tick arrays are sometimes handled this way, making it easy for a developer to forget to apply ownership checks that would be automatic in a typed struct.

Complex DeFi programs need systematic account validation - not just in the obvious entry points, but uniformly across every instruction that reads from custom account types.

RedPen Takeaway

When RedPen audits AMM and DeFi programs, custom account type validation is a first-class checklist item - not an afterthought. For every instruction, we identify all accounts that carry financial data (fee accumulators, price oracles, LP positions, tick arrays) and verify: (1) owner field is checked against the expected program ID, (2) PDAs are re-derived from canonical seeds rather than trusted from input, (3) any use of remaining_accounts is documented and each account in the list is validated before its data is consumed. We also cross-check fixes applied after prior audits - a common failure mode is fixing a vulnerability in one instruction while a sibling instruction shares the same pattern and goes unpatched. Crema is a textbook example: the swap path was fixed, the claim path was not. Finally, for complex AMMs with many interdependent account types, we map the full account dependency graph and trace every user-controlled input to its downstream calculations. If user-supplied data feeds into a token transfer without a validation gate, that is a critical finding.

Building a concentrated liquidity AMM on Solana? Let RedPen audit your account validation before it costs you.

RedPen audits Anchor programs for exactly this class of vulnerability. Get your program reviewed before mainnet.