Sponsored Content

DEV Community

AIOil Security Shield
AIOil Security Shield

Posted on

Nine checks, all true, all useless

On 23 March 2022 an attacker minted roughly $52 million of CASH out of nothing.

Cashio's minting path was not missing its validation. It ran nine separate key comparisons before allowing a single token to be printed. Every one of them returned true while the vault was being emptied.

That is what makes this worth reading. The bug is not exotic, it is not dead, and it fits comfortably inside code that looks careful. It is a shape: a validation chain that is perfectly self-consistent and anchored to nothing. If you write Anchor programs you can check for it in your own repo in about ten minutes.

Everything below is quoted from the pre-exploit source at commit a51c3c5 (11 March 2022, twelve days before the attack). Do not read today's master for this: print_cash and burn_cash are disabled there with vipers::invariant!(false, "temporarily disabled"), so it is not the code that was exploited.

What the program was supposed to guarantee

Cashio issued CASH, a stablecoin backed by Saber LP tokens. You deposited collateral, the program checked the collateral was real, and it minted CASH against it. The security of the whole system rests on one question:

Is this collateral actually the collateral this bank is supposed to accept?

The answer lived in two validate() implementations, run through Anchor's #[access_control] before print_cash executed.

Five checks in BrrrCommon::validate() - programs/brrr/src/actions/mod.rs

assert_keys_eq!(self.bank, self.collateral.bank);
assert_keys_eq!(self.crate_token, self.crate_collateral_tokens.owner);
assert_keys_eq!(self.crate_mint, self.crate_token.mint);
assert_keys_eq!(self.crate_collateral_tokens.mint, self.collateral.mint);
assert_keys_eq!(self.collateral.mint, self.saber_swap.arrow.mint);
Enter fullscreen mode Exit fullscreen mode

Four more in SaberSwapAccounts::validate() - programs/brrr/src/saber.rs

assert_keys_eq!(self.arrow.vendor_miner.mint, self.pool_mint);
assert_keys_eq!(self.saber_swap.pool_mint, self.pool_mint);
assert_keys_eq!(self.saber_swap.token_a.reserves, self.reserve_a);
assert_keys_eq!(self.saber_swap.token_b.reserves, self.reserve_b);
Enter fullscreen mode Exit fullscreen mode

Read them as a group and the flaw becomes visible. Every single comparison has an account supplied by the caller on both sides. The bank is checked against the collateral's bank - but the caller passed both. The crate mint is checked against the crate token's mint - but the caller passed both. The chain links to itself, all the way down, and never touches solid ground.

A validation chain that is internally consistent but externally unanchored proves exactly one thing: that the attacker built a coherent set of accounts. Which they did.

Why the attacker could build that chain

Constructing a parallel universe of accounts is only useful if you are allowed to create the root of it. In Cashio you were.

The new_bank instruction in the companion bankman program took its admin as an UncheckedAccount, with no signature and no relationship to the payer, the upgrade authority, or any known key. Anyone could call it and become curator of their own bank.

From there the sequence writes itself: create your own bank, point your own collateral at it, wire that collateral to a Saber pool you control holding a token you invented, then hand the whole consistent bundle to print_cash. All nine assertions compare your accounts to your other accounts. All nine pass. Real CASH comes out, backed by a worthless token.

The anchor was three lines away

Here is the detail that makes Cashio instructive rather than merely unfortunate: the codebase already knew how to do this correctly.

PrintCash::validate(), in the same call path, has four checks - and the last one is a different species entirely:

assert_keys_eq!(self.depositor, self.depositor_source.owner);
assert_keys_eq!(self.depositor_source.mint, self.common.collateral.mint);
assert_keys_eq!(self.mint_destination.mint, self.common.crate_token.mint);
assert_keys_eq!(self.issue_authority, ISSUE_AUTHORITY_ADDRESS);   // <- anchored
Enter fullscreen mode Exit fullscreen mode

That fourth line compares a caller-supplied account against a hardcoded constant. It is exactly the discipline the other nine needed, applied correctly, sitting three lines above them. It simply never propagated to the bank and collateral chain.

This is the normal way the bug arrives. Not through ignorance - through a habit applied in one place and not carried to the next.

How to check your own program tonight

Take every key comparison in your validation path and ask one question of each: could an attacker have chosen both sides? Then sort them.

Anchored - proves something Self-referential - proves nothing alone
Compared to a const address Two accounts both passed in this instruction
Compared to a PDA you re-derive with find_program_address A field of one caller account against a field of another
Compared to the program's own upgrade authority An UncheckedAccount against anything except a constant
Compared to a value stored in an account only you can write

A chain of self-referential checks is not worthless - it stops honest mistakes and fat-fingered accounts. It just does not stop an attacker, because an attacker supplies the whole set.

Every validation chain needs at least one link that reaches outside the transaction.

The rule, stated plainly: if a validation chain contains no comparison to a canonical root - a constant, a re-derived PDA, or a value only your program can write - then it is forgeable in full, and its severity is Critical regardless of how many checks it contains.

What our scanner said about it

Disclosure: I work on AIOil Security Shield, an automated multi-agent scanner for Solana programs. We ran this code through it on 16 August 2026, on the pre-exploit source, and published the raw console output rather than describing it.

It returned three findings: one High on Saber reserve balances being manipulable for pricing, one Medium, one Low. The Medium is the interesting one:

new_bank can be called by anyone to seize Bank curator/bankman role. Location: programs/bankman/src/lib.rs::new_bank. The instruction has no authorization constraint linking admin to the payer or the upgrade authority; admin is an UncheckedAccount requiring no signature.

It identifies the precondition correctly and locates it precisely. What it frames wrongly is the consequence: it describes an attacker racing the deployer to squat the canonical bank, and suggests documenting the behaviour if permissionless operation is intentional. The real attacker never raced anyone - they created a parallel bank after deployment, and the damage came from the minting path never checking that a bank is the bank.

Right door, wrong description of what lay behind it, and rated Medium rather than Critical.

The takeaway that outlives the case

Counting your checks tells you nothing about whether you are safe. Nine is not safer than one. What matters is whether any of them is nailed to something the caller cannot choose - and that question takes a few minutes to answer for a whole program.


The Cashio program discussed here was exploited in March 2022 and has been disabled since; nothing here is an unreported vulnerability. The scan was run in 2026, on code whose outcome was already public - no claim is made that it would have prevented the incident. Automated static analysis is not a professional human audit: it reduces risk, it does not eliminate it.

Top comments (0)