AIOil Security Shield · Anatomy of an exploit
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.
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.
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);
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.
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.
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.
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 addressCompared to a PDA you re-derive with find_program_addressCompared 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.
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.
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:
Raw scan output on GitHub →
The companion run: the same pipeline on Wormhole's bridge, the day before that hack →
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.
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.