How Cashio Lost $48M to a Fake Account Chain: The Infinite Mint Dissected
On March 23, 2022, at 08:15 UTC, an attacker sent a single transaction to the Cashio protocol on Solana. Within two hours, 2 billion CASH stablecoins had been minted for free, swapped for UST and USDC, bridged to Ethereum, and converted into roughly $48 million worth of ETH. Cashio's team posted a terse warning on Twitter: 'Please do not mint any CASH. There is an infinite mint glitch.' That was an understatement. The root cause was not a glitch - it was a complete failure to validate account ownership at the top of a multi-account dependency chain. Every check the contract ran passed correctly. It was checking the wrong things against the wrong references. This article breaks down exactly what happened, why the checks were insufficient, and how Anchor's type system gives you the tools to make this class of bug structurally impossible.
What Happened
Cashio was a Solana-based protocol that let users deposit Saber LP tokens as collateral and mint CASH, a dollar-pegged stablecoin. The design was straightforward: lock real collateral (USDC-USDT LP tokens, for example), mint synthetic dollars against it. The collateral accounting ran through a hierarchy of on-chain accounts - a root 'glow' account, a 'bsaber_swap' account representing the Saber pool, an 'arrow' account representing the LP position, and a 'bank' account holding the deposited tokens.
The attacker's insight was that Cashio never verified who actually owned the top of that chain. They created a fake root account - a fresh keypair with data structured to look like a valid Cashio glow account - and then built a full chain of fake accounts beneath it, each one pointing to the next. When the mint instruction ran its validation, it walked down the chain comparing each account to the one above it. Every comparison checked out, because all the accounts were the attacker's own, crafted to reference each other correctly.
The mint logic then accepted the attacker-supplied token account as valid collateral and minted 2 billion CASH. The attacker sold into Saber pools, extracted 10.8M UST and 16.4M USDC directly, then swapped the remaining CASH for another 8.6M UST and 17M USDC. The full haul was bridged to Ethereum and converted to over 16,000 ETH - roughly $48M at the time. The attacker left an on-chain message claiming accounts with less than $100k had been refunded and the rest would go to charity. Whether that was true or a distraction, most of the funds were never recovered.
The Missing Ownership Check
The technical root cause is a failure to verify that key accounts are owned by the expected on-chain programs. On Solana, every account has an owner field set by the runtime - it is the program ID of whichever program created and manages that account. When your program receives an account as an instruction argument, the account's owner field tells you who controls its data. If you do not check it, anyone can pass you an account with arbitrary data that merely looks like the right shape.
Cashio's mint instruction accepted a saber_swap account (representing the Saber AMM pool) and an arrow account (representing the LP position). It validated that the arrow's fields referenced the saber_swap account, and that the saber_swap's fields referenced the top-level glow account. But it never checked that saber_swap.owner == saber_program_id or that arrow.owner == cashio_program_id. It also never verified the .mint field on the LP token account - the field that identifies which token type you are actually depositing.
The result: a chain of locally-consistent but globally-rootless account relationships. The attacker created accounts where every cross-reference was internally valid - each fake account pointed at the fake account above it - but none of them were owned by the expected programs. Cashio's validator walked the chain, saw consistent references, and gave the green light.
// VULNERABLE - similar to Cashio's approach
// The contract checks that arrow.saber_swap == saber_swap.key()
// and saber_swap.glow == glow.key(), but NEVER checks:
// - who owns saber_swap (should be Saber program)
// - who owns arrow (should be Cashio program)
// - whether arrow.mint matches the actual collateral mint
#[derive(Accounts)]
pub struct MintCash<'info> {
// No ownership check - accepts ANY account shaped like GlowState
pub glow: Account<'info, GlowState>,
// No program owner check - could be an attacker-owned account
// with saber_swap.glow set to the fake glow above
/// CHECK: We validate saber_swap.glow == glow.key() below
pub saber_swap: AccountInfo<'info>,
// No ownership check, no mint field validation
/// CHECK: We validate arrow.saber_swap == saber_swap.key() below
pub arrow: AccountInfo<'info>,
// Collateral token account - .mint field is NEVER checked
pub depositor_source: Account<'info, TokenAccount>,
pub token_program: Program<'info, Token>,
}
// The instruction handler then manually reads fields and compares
// them - but all references are attacker-controlled, so they pass.
pub fn mint_cash(ctx: Context<MintCash>, amount: u64) -> Result<()> {
let arrow = &ctx.accounts.arrow;
let saber_swap = &ctx.accounts.saber_swap;
// This check passes - attacker set arrow.saber_swap = fake_saber_swap.key()
require!(
arrow.saber_swap == saber_swap.key(),
ErrorCode::InvalidArrow
);
// This check passes - attacker set saber_swap.glow = fake_glow.key()
require!(
saber_swap.glow == ctx.accounts.glow.key(),
ErrorCode::InvalidSwap
);
// .mint is never validated - attacker deposits arbitrary tokens
// and the protocol mints CASH as if real collateral was deposited
mint_tokens(ctx, amount)
}
Attack Walkthrough
Step 1: The attacker generated a fresh keypair and wrote data into it shaped like a valid Cashio glow account. Because this was a new account with no program owner enforced, they could write whatever bytes they wanted.
Step 2: They created a fake saber_swap account with its authority field set to the fake glow account's public key, and a fake arrow account with its saber_swap field set to the fake saber_swap's public key.
Step 3: They created a fake bank account pointing at the fake arrow, and a real (or throwaway) token account for their deposit.
Step 4: They called Cashio's mint instruction, passing the entire fake chain. The contract's validation walked from arrow -> saber_swap -> glow, confirming each account referenced the previous one. All references matched. The .mint field on the LP token account was never read.
Step 5: The mint instruction credited 2 billion CASH to the attacker's token account.
Step 6: The attacker swapped CASH across Saber pools over multiple transactions, extracting real stablecoins. They bridged via Wormhole to Ethereum and sold into ETH.
Total elapsed time from first exploit transaction to exit: under two hours. The entire attack required no flash loans, no price manipulation, and no MEV - just a chain of hand-crafted accounts and one program that trusted the data it was given.
The Anchor Way to Prevent This
Anchor's account types enforce ownership and structural constraints at deserialization time, before your instruction handler runs a single line of business logic. When you declare an account as Account<T>, Anchor checks two things automatically: (1) the account is owned by the current program (or the program you specify), and (2) the account's data can be deserialized into type T. If either check fails, the transaction is rejected before your code executes.
For cross-program accounts - like Saber's pool state - you use Program<T> or a CPI account wrapper that verifies the owner matches the expected program ID. For relationships between accounts (e.g., an arrow must belong to a specific saber_swap), you use has_one constraints or explicit constraint expressions.
The corrected pattern for Cashio's account validation would look something like the code block below. The critical additions are: verifying saber_swap is owned by the Saber program ID, verifying arrow is owned by the Cashio program, and enforcing that arrow.saber_swap == saber_swap.key() and arrow.mint == collateral_mint.key() via has_one and constraint attributes. These are not optional hygiene checks - they are the security model.
// CORRECT - explicit ownership + field constraints at the account level
use anchor_lang::prelude::*;
use anchor_spl::token::{Mint, Token, TokenAccount};
// Declare the Saber program's ID so Anchor can verify ownership
declare_id!("SSwpkEEcbUqx4vtoEByFjSkhKdCT862DNVb52nZg1UZ");
#[derive(Accounts)]
pub struct MintCash<'info> {
// Account<T> verifies: owner == current program AND data deserializes as GlowState
// If this is a fake account the attacker created, owner != program_id -> rejected
#[account(
seeds = [b"glow"],
bump = glow.bump,
)]
pub glow: Account<'info, GlowState>,
// Program<SaberSwap> verifies: account.owner == SABER_PROGRAM_ID
// An attacker-created account will have owner == SystemProgram -> rejected
pub saber_swap: Account<'info, SaberSwapState>,
// has_one = saber_swap ensures arrow.saber_swap == saber_swap.key()
// AND arrow is owned by this program (Account<T> check)
#[account(
has_one = saber_swap,
has_one = collateral_mint, // enforces the .mint field match
constraint = arrow.glow == glow.key() @ ErrorCode::GlowMismatch,
)]
pub arrow: Account<'info, ArrowState>,
// The collateral mint must match what arrow declares
// This is the field Cashio NEVER checked - here it is enforced structurally
pub collateral_mint: Account<'info, Mint>,
// depositor_source must hold tokens of the correct mint type
#[account(
constraint = depositor_source.mint == collateral_mint.key()
@ ErrorCode::WrongCollateralMint,
)]
pub depositor_source: Account<'info, TokenAccount>,
pub token_program: Program<'info, Token>,
}
// By the time this runs, Anchor has already verified:
// - glow is a PDA owned by this program (unspoofable)
// - saber_swap is owned by the Saber program (not attacker-created)
// - arrow.saber_swap == saber_swap.key() (structural relationship enforced)
// - arrow.collateral_mint == collateral_mint.key() (mint field enforced)
// - depositor_source.mint == collateral_mint.key() (deposit matches declared collateral)
pub fn mint_cash(ctx: Context<MintCash>, amount: u64) -> Result<()> {
// Business logic only - security model is already enforced above
mint_tokens(ctx, amount)
}
Lessons for DeFi Protocol Developers
The Cashio hack distills into a single lesson: local consistency is not global validity. A chain of accounts that all reference each other correctly means nothing if the chain's root is not anchored to a trusted authority. Every account in an instruction must be verified against a known, trusted reference - not just against the other accounts you were handed in the same call.
Four concrete things to apply in every Solana program you write:
1. Owner checks are mandatory, not optional. If an account is supposed to be a Saber pool, verify its owner is the Saber program ID. If it is supposed to be your own state account, verify owner == program_id. Use Anchor's Account<T> and Program<T> wrappers - they do this automatically.
2. Validate every field that feeds into a security decision. Cashio did not check the .mint field of the LP token account. That missing check was enough. If a field determines whether a deposit is valid, that field must be verified against an on-chain authority, not just read and trusted.
3. Trace every account chain to a verified root. Draw the dependency graph of your accounts. Follow each chain until you reach an account whose authority you can verify independently - typically a program-derived address (PDA) that only your program can create, or an account whose owner is a known program ID. If any chain terminates in an attacker-supplied account, you have a problem.
4. Use PDAs for protocol state where possible. Program-derived addresses cannot be spoofed because their derivation requires your program ID. An attacker cannot create a valid PDA for your program without executing your program's PDA creation logic. For core protocol accounts (the glow account in Cashio's case), PDA derivation would have made spoofing structurally impossible.
The cost of adding these checks is a few extra Anchor attributes and a constraints block. The cost of skipping them can be eight figures.
RedPen Takeaway
In every audit engagement, RedPen maps the full account dependency graph before reading a line of business logic. We verify that every chain of account relationships terminates at a PDA, a known program ID, or another independently-verifiable on-chain authority - never at a caller-supplied account. We check every field that feeds into a security decision: mint addresses, authority keys, program IDs, and PDA seeds. We also look for 'CHECK' comments as a signal - they are often correct, but they sometimes hide assumptions that an attacker can violate. If an account in your program's instruction set is accepted as AccountInfo without a documented, tested ownership constraint, we flag it as a finding. The Cashio hack was $48M and one missing owner check.
Is Your Account Validation Chain Rooted in Trust - or Just Internally Consistent?
RedPen audits Anchor programs for exactly this class of vulnerability. Get your program reviewed before mainnet.