Pre-Mortem is a weekly series by Kostas Ferles, CTO of AuditHub. This article looks at ordinary-looking configuration values that break live invariants, and a fuzzing run that checks them on the live chain. New breakdown every week. Find Kostas at @KFerles.
A routine upgrade set Nomad’s trusted root to zero, and every fabricated message passed. A live-state fuzzing run catches the misconfiguration on the deployment itself.
Last week’s Auditor’s Take [LINK AT PUBLISH] walked through this bug from the reviewer’s side: Jon Stephens on how a senior auditor reads an upgrade’s initialization values, and why a finding raised in review didn’t stop the value from shipping. This post asks a different question. Nomad’s validation logic was sound and its tests were green. The value that logic compared against was wrong, and nothing was watching it. What checks the values a deployment actually runs with, and what keeps checking them after the next upgrade?
TL;DR
- Nomad’s trusted root went live as 0x00 during a routine upgrade; unproven messages already read as zero, so every message passed.
- A single malicious message drained $2.3M; hundreds of copycats followed, and $190M was gone within hours.
- The bug lived in deployed state, not source: static analysis would not catch it, and local tests assume a sane config.
- OrCa tests the invariant at a live block and hands back the violating input.
- Routine checks could have caught the issue in the weeks the contracts sat exposed.
A passing test suite is not a tested deployment
A test suite makes a narrower promise than a green checkmark suggests. It checks properties under the exact environment its author set up: the contracts they deployed, the values they initialized, and the state they assumed production would have. Change one of those values on the live system and the suite is now vouching for an environment that no longer exists. The checkmark stays green either way.
Bridges get hit hardest by this, because a bridge’s security concentrates into a single check: does this message correspond to a real deposit on the other chain? When that check goes wrong, the funds behind it are open to anyone. Wormhole lost $326 million in February 2022 when its signature verification was spoofed and an attacker minted 120,000 wETH backed by nothing. BNB Bridge lost $586 million in October 2022 to a forged Merkle proof its verifier accepted as genuine. Nomad is the quietest of the three: its verification logic contained no flaw for a reviewer to find. The flaw was the value it checked against.
Why every message passed the proof check
The short version, since the Auditor’s Take walks the contract mechanics in full: Nomad’s Replica processed a message only after it had been proven against a Merkle root the contract recorded as trusted. Prove first, process second. An unproven message has no entry in that bookkeeping, so it reads back as bytes32(0), Solidity’s default for an unset slot. The whole design rests on one assumption: zero means unproven, and unproven means rejected.
The June 2022 upgrade broke that assumption from the other side, in a way that is easy to read past. The code never says “trust zero” anywhere. The upgrade supplied a starting root of bytes32(0), and initialization marked that starting root as confirmed, exactly as it would have marked any other. From then on, the value that meant “nobody ever proved this” and the value that meant “confirmed, go ahead” were the same 32 bytes, and every unproven message inherited a trusted status it never earned.
The failure was the state, not the source
On August 1, 2022, someone fed process() a malicious message that named an address they controlled as the payout. It went through. The first call took about $2.3 million, the transaction was public, and copying it needed nothing but a new payout address. Hundreds of copycats and a few hours later, roughly $190 million was gone.
One detail matters more than the rest. Read the source cold, the way a static pass or a fresh reviewer would, and there is no bug to point at: the comparison is correct, the revert paths are correct. The bug was a storage value the deployment was carrying, set weeks earlier during the upgrade. The risk had even been raised in review, and the value shipped anyway. A finding is a sentence in a report, and a sentence does not stop a transaction.
What a live-state run settles
Static analysis is the wrong instrument for this bug. A static pass reasons about source. It can flag a guard whose shape is wrong or a call that reenters, because those are properties the text of the program carries. This bug is a property of a storage slot on mainnet. The source, read on its own, is consistent with a completely safe deployment. A local negative test could encode the same property, but it would run against the test’s own sane configuration.
OrCa, our specification-guided fuzzer, closes that gap: it fuzzes the deployed contracts at a chosen block, with the storage those contracts currently hold, no hand-built environment in between. The property it checks comes from a specification. Here is the one that encodes Nomad’s rule:
|
1 2 3 4 5 |
# The messages mapping being zero for the message hash indicates the message # has not yet been proven and thus should not be accepted. vars: Replica r, NomadHelper h spec: []!finished(r.process(m), old(r.messages(h.getMessageHash(m))) = bytes32(0)) |
Read it as a sentence. finished(r.process(m), ...) is the event that a call to process completes, and the condition checks the message’s proof entry as it stood when the call began. The ! says that event must not happen, and the [] says the rule holds at every point in every run the fuzzer explores. Put together: process must never complete on a message whose proof entry is still zero. That is the core of the bridge’s security model, in one line.
Pointed at a block after the June upgrade, the run finds a violation almost immediately. With the zero root sitting in storage, the first unproven message that parses goes straight through. Getting a message to parse is the one practical hurdle, since process() takes raw bytes in a strict layout and random inputs bounce off the parser. OrCa’s hints handle it: a short description of the byte layout an argument expects, so the fuzzer spends its budget on well-formed messages. What comes back is a counterexample: the concrete input that violates the spec.
A violation found this way is a genuine failure of the property, not a warning based on heuristics, and it surfaces this fast only because the broken state makes it trivial to trigger. What the run settles is the thing no pre-deployment suite can: the state it checked is the state that shipped.
From a launch gate to a schedule
The SuperRare bug in an earlier Pre-Mortem was source-shaped, so its check rides the repository: every commit, every pull request. This one is state-shaped: the bug never appears in a diff, so no repository check ever sees it. Trusted roots, oracle addresses, admin keys, fee parameters: values like these move at upgrade time, not in commits. The check that covers them runs on a schedule against the chain, applying the same spec to whatever state is current.
That is what continuous security means once a system is live. Nomad’s window between the upgrade and the drain was about six weeks. A recurring check of the invariant against deployed state would have had six weeks of chances to surface the violation first.
Want this invariant checked against your deployment?
One zero value opened a bridge to every message on the chain. To put a check like this one on a schedule against your own contracts, try AuditHub for free or book a demo.