Static analysis demos are usually rigged. You pick the bug, you write the check, you show the check finding the bug. Nobody learns anything.
So we did the opposite. We took the on-chain source of two Solana protocols as it stood the day before each was exploited, ran it through our scanner with no hints and no knowledge of what the answer was supposed to be, and published both results — including the one where it failed.
| Target | Real root cause | Result | Time |
|---|---|---|---|
| Wormhole core bridge | Feb 2022 · $325M | Found — Critical, right location, right fix | 7m 33s |
Cashio brrr / bankman
|
Mar 2022 · $52M | Missed — flagged a related symptom | 9m 36s |
One caveat before anything else, because it matters: we are not claiming this would have prevented either incident. These runs happened in 2026, on code whose outcome has been public for four years. Both programs were patched long ago. This is a re-run on public history, not a disclosure and not a counterfactual.
Wormhole: it found the bug
The scanner reported five findings. The second one was this:
instruction_accis a rawInfoaccount and is never checked againstsolana_program::sysvar::instructions::id(). The code parses its data as the serialized instructions sysvar, so an attacker can supply a fake account with crafted bytes that looks like a previous secp256k1 instruction.
That is the bug. Not an adjacent one — the one that cost $325M. Forge the instructions sysvar and every guardian signature is treated as valid without a single ECDSA verification ever happening; the resulting VAA mints wrapped ETH out of nothing.
You can confirm it yourself in about thirty seconds. In the pre-patch source, the account is declared untyped:
/// Instruction reflection account (special sysvar)
pub instruction_acc: Info<'b>, // no type, no owner check
Its data is then parsed twice as the instructions sysvar — load_current_index and load_instruction_at — and in the entire program those two calls are the only places the sysvar appears anywhere. There is no comparison against sysvar::instructions::id(). Nothing establishes that the account handed in is the real sysvar.
The fix the scanner proposed is the fix that actually shipped: use a typed Sysvar<Instructions>, or compare the key explicitly.
The other four findings, honestly
A tool that reports five things and is right about one of them has not found five bugs. We verified every finding by hand against the source, and here is the honest breakdown:
Critical — permissionless initialize. Does not apply. The code property is real: initialize has no constraint tying the caller to the deployer. But it requires the bridge accounts to be uninitialized, and the bridge has been live since 2021. This describes a deployment-window risk, not a live one. Critical is the wrong severity for a deployed bridge.
High — transfer_fees does not update bridge.last_lamports. Real. last_lamports is written in exactly two places, and the fee-withdrawal path is not one of them, so the next post_message hits MathOverflow. It needs a governance action to trigger, and anyone can unbrick it by topping the collector back up — but it is real.
Medium — post_message fee front-running. Over-rated. The mechanism exists, but the source carries a comment saying the behaviour is deliberate: "checking previously known balance allows us to not care who is the payer". The scanner flagged an intentional design decision and did not notice the comment saying so.
Low — upgrade_guardian_set accepts up to 255 keys. Real. initialize caps at 19 and the signer array is fixed at 19.
So: one true Critical, one real High, one real Low, and one severity error in each direction. That is the actual shape of the result, and any writeup that hides it is selling something.
Cashio: it missed
Cashio is where it gets interesting, because the failure is more instructive than the hit.
What the exploit actually was
BrrrCommon::validate() and SaberSwapAccounts::validate() perform nine assert_keys_eq! checks. Every single one compares two caller-supplied accounts to each other. Not one is anchored to a canonical root — a known address, a PDA with fixed seeds, an upgrade authority. An attacker who constructs the entire chain out of accounts they control passes all nine checks.
The codebase clearly knows how to anchor: print_cash checks issue_authority against a hardcoded ISSUE_AUTHORITY_ADDRESS. That discipline was simply never applied to the bank/collateral chain.
What our scanner said instead
It returned three findings. The relevant one was Medium, on new_bank being callable by anyone — a genuine precondition of the attack. But it framed the risk as front-running the deployer to squat the canonical bank, and it closed with:
"If multiple banks are intentionally permissionless, document it and ignore this finding."
The real attacker never front-ran anyone. They created a parallel bank after deployment — which the design permits — and the actual flaw is that the minting path never verifies the bank is the bank. The scanner saw the unlocked door and described the wrong way through it. Worse, its own recommendation invites you to dismiss the finding.
We are not going to call that a near-miss. It is a miss.
The part that was actually worth the exercise
Put the two runs side by side and the same structural pattern appears in both: an unauthenticated initializer that hands the attacker the root of a validation chain. On Wormhole it was rated Critical. On Cashio it was rated Medium, with a suggestion to ignore it.
The scanner is not blind to the pattern. It weighs it inconsistently. That is a much more actionable defect than "it missed one", because inconsistent severity is fixable and blindness is not.
So we wrote the rule down and added it to the reviewer's rulebook:
When a validation chain is only internally consistent — every check compares two caller-supplied accounts, and none is anchored to a canonical root — the severity is Critical, not Medium. The attacker can forge the entire chain.
What this says about automated scanning generally
Three things we would take away from this, regardless of whose tool you use:
- The finding text matters as much as the finding. Cashio's Medium contained the right precondition and still led the reader away from the bug. A correct detection with a wrong exploit narrative can be worse than no detection, because it gets consciously dismissed.
- Severity is where these tools are weakest. Both severity errors here — one up, one down — came from failing to reason about deployment state and about what an attacker actually controls.
- Ask any vendor for output on code they did not choose. Including the failures. A page that shows only the win is a page nobody should believe.
Artifacts
The full write-up, the console output of both runs, the scan IDs, and the SHA-256 of the exact source submitted are here:
- Repository: https://github.com/AIOilShield/solana-postmortem-scans
- Write-up: https://aioil.tech/case-wormhole.html
The scanner is AIOil Security Shield. It runs automated multi-agent static analysis on Solana Rust/Anchor programs and publishes a Scan Report — never a certificate. It is not a professional human audit and does not guarantee the absence of vulnerabilities: it reduces risk, it does not eliminate it. Both programs discussed here were patched years ago, and nothing on this page is an unreported vulnerability.
Top comments (0)