AIOil Security Shield · Anatomy of an exploit

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.

This is worth understanding because the bug is not exotic and it is not dead. It is a shape — a validation chain that is perfectly self-consistent and anchored to nothing — and it fits inside code that looks careful. If you write Anchor programs, you can check for it in your own repository in about ten minutes. Here is how to recognise it.

Everything below is quoted from the pre-exploit source at commit a51c3c5 (11 March 2022, twelve days before the attack) — not from today's master, where print_cash and burn_cash are disabled outright. Every line is checkable in one click.

What the program was supposed to guarantee

Cashio issued CASH, a stablecoin backed by Saber LP tokens. You deposited collateral, the program checked that 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()

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);

Four more in SaberSwapAccounts::validate()

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);

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.

The shape of the bug A validation chain that is internally consistent but externally unanchored proves only 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 the 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, and 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 the other side, backed by a worthless token.

The anchor was three lines away

Here is the detail that makes Cashio genuinely instructive rather than merely unfortunate: the codebase already knew how to do this correctly. PrintCash::validate(), in the same call path, contains 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

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 that was 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 somethingSelf-referential — proves nothing alone
Compared to a const address
Compared to a PDA you re-derive with find_program_address
Compared to the program's own upgrade authority
Compared to a value stored in an account only you can write
Two accounts both passed in this instruction
A field of one caller account against a field of another
An UncheckedAccount against anything except a constant

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

We ran this code through the AIOil Security Shield pipeline on 16 August 2026, using the pre-exploit source. It returned three findings: one High on Saber reserve balances being manipulable for pricing, one Medium, one Low.

Medium · reported
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.

That finding 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 recommends 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. We published the full console output rather than describing it, so you can read the findings yourself:

scan_id a5d505580e2aaa5f · code SHA-256 c2783d1c352fc333f310ea48a51187b8f82d65b8a11590c9a5a0b211eff3de69 · 2026-08-16T17:35:12Z · 575.6s

Raw scan output on GitHub →
The companion run: the same pipeline on Wormhole's bridge, the day before that hack →


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.

If you want a second pair of eyes on that question before you commit to a full human audit, that is what our scanner is for.

Run a scan on your program →

AIOil Security Shield runs automated, multi-agent static analysis. It is not a professional human audit and does not guarantee the absence of vulnerabilities — it reduces risk, it does not eliminate it. The Cashio program discussed here was exploited in March 2022 and the code has been disabled since; nothing on this page is an unreported vulnerability, and no source code belonging to a customer is ever published. We make no claim that this scan would have prevented the incident: it was run in 2026, on code whose outcome was already public.