Crema Finance Tick Spoof (2022)
Smart contract security audit by TierZero · 2023-06-15
Independent research. This is an unsolicited security analysis published by TierZero — not a commissioned audit, and no certificate is issued. It reflects our own review of public code and on-chain events.
Findings summary
Independent research — not a commissioned audit. This is a post-mortem analysis of a publicly disclosed incident, compiled from public reports for educational purposes.
What the protocol did
Crema Finance was a concentrated-liquidity AMM on Solana, structurally similar to Uniswap v3: liquidity providers deposit into discrete price ranges, and the pool tracks per-tick fee-growth accumulators to compute how much of the trading fees each position has earned since it was opened. That accounting lives in per-tick "tick account" data — on-chain state that the program reads during swaps and, separately, during fee claims. On July 2, 2022, an attacker used a forged tick account to fabricate fee-accrual data and drained roughly $8.78 million in tokens across five pools (USDT-USDC, mSOL-wSOL, stSOL-wSOL, PAI-USDC, USDH-USDC) in a sequence of flash-loan transactions. The attacker later returned the bulk of the funds and kept a portion — reportedly around $1.68 million — as a negotiated "white-hat" bounty, but the loss itself was a real, complete drain at the protocol layer, not a paper loss.
The vulnerability
The bug class here is missing account ownership/provenance validation on an account passed into an instruction — the Solana analogue of trusting untrusted input. On Solana, every account is passed into a transaction explicitly by the client, and the program is responsible for checking that any account it treats as "its own" state is actually owned by its program ID (or derived from expected seeds as a PDA). If that check is missing or incomplete, an attacker can pass in any account they control, populate it with arbitrary bytes, and have the program read that data as if it were legitimate state.
Crema's swap instruction had already been checked for this pattern by a prior third-party audit and was fixed there. The same unvalidated-account pattern remained in the claim instruction, which read tick data to compute accrued fees. A simplified version of the flawed pattern:
// flawed: account is deserialized and trusted without an owner check
pub fn claim_fees(ctx: Context<ClaimFees>) -> Result<()> {
let tick_account = &ctx.accounts.tick_account; // caller-supplied
let tick_data = TickData::try_from_slice(&tick_account.data.borrow())?;
let accrued = compute_fees(tick_data.fee_growth_outside, /* ... */);
transfer_fees(ctx, accrued)?; // pays out based on forged data
Ok(())
}
// fixed: verify the account is owned by this program AND derived from expected seeds
pub fn claim_fees(ctx: Context<ClaimFees>) -> Result<()> {
let tick_account = &ctx.accounts.tick_account;
require_keys_eq!(*tick_account.owner, crate::ID, ErrorCode::InvalidOwner);
let (expected_pda, _) = Pubkey::find_program_address(
&[b"tick", pool.key().as_ref(), &tick_index.to_le_bytes()],
&crate::ID,
);
require_keys_eq!(tick_account.key(), expected_pda, ErrorCode::InvalidTickAccount);
// ... proceed with trusted data
}
How the exploit worked
- The attacker deployed their own program and created a fake account shaped like a valid tick account, writing fee-growth and price-tick fields designed to make the fee calculation return a large payout.
- Rather than leaving the fake account's owner field pointing at an arbitrary program (which a naive check would catch), the attacker wrote the address of a real, already-initialized tick account from the target pool into the fake account's data, defeating a comparison that checked for a plausible-looking value rather than deriving and verifying the canonical PDA.
- The attacker took a flash loan from Solend to bootstrap a large temporary liquidity position in a Crema pool.
- They opened a position and invoked the claim path, passing their forged tick account instead of the real one. Crema's program read the forged fee-growth data and computed an inflated fee balance owed to the attacker.
- The attacker withdrew the inflated amount, repaid the flash loan within the same transaction, and kept the difference. This was repeated across roughly ten-plus flash-loan transactions and five separate pools before the team could react.
- Proceeds were swapped through Jupiter into SOL and USDC, bridged to Ethereum, and converted to ETH — standard laundering steps that any incident-response runbook should anticipate, not prevent.
How an audit catches this
This is a pattern-completeness problem, not a one-off logic error, and that's exactly where a single-instruction review fails. The concrete checks:
- Enumerate every instruction that deserializes a "foreign" account (any account not created fresh by the instruction itself) and confirm each one asserts both program ownership and PDA derivation before trusting its contents — not just the instructions a prior audit or bug bounty already flagged.
- Grep for every occurrence of a struct type (here, the tick-account type) across the codebase and verify the same validation guard is present at each call site — Crema's swap path had the fix, its claim path didn't, which is the single most common way this bug class survives a partial remediation.
- Write an adversarial test that substitutes a program-owned-but-attacker-populated account for each expected PDA account in every instruction, and assert the transaction fails deserialization or an owner/seeds check, not just a "happy path" unit test with valid inputs.
- Treat "fixed elsewhere in the codebase" as a signal to re-scan, not a reason to skip a module — one instance of a bug class is evidence the pattern exists, not proof it's contained. This is a recurring theme in Solana account-model reviews; we cover the broader checklist in our Solana program security audit checklist for trading protocols, and the closely related discriminator-confusion failure mode in Anchor account discriminators and type-confusion attacks.
Remediation
The durable fix is to never trust an account's contents based on its type layout alone. Every instruction handler that reads program state must verify account.owner == program_id and, where the account should be a PDA, re-derive the expected address from its seeds and compare keys before using any field from it — exactly the pattern Anchor's #[account(seeds = ..., bump)] constraints exist to enforce automatically when used consistently across all instructions, including auxiliary paths like fee claims that don't get the same scrutiny as the primary swap path. Programs composing with external protocols via CPI carry a related class of trust-boundary risk, discussed in cross-program invocation risk in Solana DeFi. Flash-loan-reachable code paths deserve particular attention because they let an attacker post-fund the capital needed to make a forged-state exploit profitable in a single atomic transaction, which is why we treat flash-loan-composability review as a mandatory checklist item, not an optional add-on, in every smart contract audit engagement.
If your Solana program has any instruction that reads state from a caller-supplied account without re-deriving its PDA, that's worth a second look before it looks like this one.
Need your contracts audited?
Manual review + tooling across TON, Solana and EVM — certificate and full report included.
Get an audit