The Auditor’s Take is a weekly series by Jon Stephens, CEO of Veridise. This one is about configuration bugs that live in a value set at deployment rather than in the code itself, and how a senior auditor catches them. New take every week. Find Jon at @FormallyJon.
A protocol can audit its code, ship sound validation logic, and still introduce a critical vulnerability weeks later. All it takes is one configuration value, set during a routine upgrade, that no test anywhere is checking.
TLDR:
- A routine upgrade set the trusted root to zero, validating every unproven message.
- Unset proof entries already read as zero, so unproven and trusted became identical.
- The first drain took $2.3M; copycats replayed it into a $190M free-for-all.
- A local test suite checks the config its author set up, not what’s live.
- OrCa’s live-state fuzzing checks the invariant against what actually shipped.
Cross-Chain Message Validation
Bridges move assets between chains that can’t see each other directly: you lock funds on one chain, and the bridge authorizes a corresponding release on another. Most of a bridge’s security rests on one question: did the sender actually lock or deposit funds on the source chain? Get that answer wrong and the entire pool is exposed at once.
Few categories in blockchain security have a worse track record. Wormhole lost $326M in February 2022 when a gap in signature verification let an attacker mint 120,000 wETH with no matching deposit. BNB Bridge lost $586M in October 2022 when a forged proof of deposit passed its cross-chain verification. Different code, same failure at the center: the check that decides whether a message is legitimate accepted one that wasn’t, and every fraudulent message after that looked valid.
Nomad is an example of a quieter sub-class. No signature scheme was broken and no key was stolen; the validation logic itself had no bug. What failed was a configuration value set during a routine upgrade: an operational input that nothing was re-checking once it went live.
How a Single Configuration Value Bypassed Validation
Nomad was a cross-chain bridge on Ethereum. Its Replica contract held the core validation logic: a message was processed only if its hash mapped to a trusted Merkle root, the cryptographic proof that the message had been included in a valid batch on the origin chain. A message with no corresponding root entry was unproven and should have been rejected.
During a routine upgrade in June 2022, the trusted root was initialized to 0x00. Zero is a common initialization value, and on its own the choice looks routine.
On August 1, weeks after that value went live, someone called process() with a message they had fabricated, listed themselves as the recipient of funds they had never deposited, and the call succeeded. The first transaction drained about $2.3M. Once the first successful call was visible on-chain, anyone could copy the transaction and swap in their own address. Hundreds of copycat accounts did, and the bridge’s roughly $190M in liquidity was gone within hours. The proof system had become permissionless.
The Invariant, and Why a Single Zero Broke It
The contract tracked the proven status of messages through a Solidity mapping from message hash to Merkle root. Since the mapping only defines values for proven messages, the default bytes32 value, bytes32(0), is returned for any unproven message according to Solidity’s semantics.
Slow down here, because this is the whole bug. The upgrade initialized the Replica’s _committedRoot to bytes32(0), which set confirmAt[bytes32(0)] to 1. A root is trusted when confirmAt[root] is non-zero and smaller than the current timestamp. Since unproven messages default to a root of bytes32(0), after the upgrade every unproven message was processed as though it had been proven.
The configuration error resulted in a violation of a key invariant of the protocol: process() must never succeed when a message has a proof entry of zero. Expressed for a tool to check:
|
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)) |
Before the June upgrade the invariant held. After it, the first well-formed call broke it.
Why It Shipped Anyway
In a DeFi audit, how a root comes to be trusted is exactly the kind of thing a reviewer flags: the entire proof system hangs off that decision, and an initialization that makes the zero value trusted is the edge case you want ruled out by design. In Nomad’s case, the risk was raised during review. The attack happened anyway.
That sequence is more common than it should be. A finding about a parameter is easy to assume away with some version of “we’d never actually pass that value.” Once it’s assumed away, nothing downstream checks the assumption: the report ships, the reasoning behind the finding stays with the people who wrote it, and the upgrade runs months later with whatever value gets passed in. An audit is a point-in-time review. It has no enforcement power over what deploys after it.
Even if a static audit had not identified the bug, it should also be caught by exercising key protocol invariants with negative testing. Negative testing is a testing methodology that ensures projects fail or revert when they receive improper user input. Such tests are often useful for checking that key invariants are not violated in scenarios such as the processing of unproven messages. One downfall of testing in cases like this one, though, is that the testing configuration is assumed to be consistent with the deployment configuration. In cases of accidental misconfiguration such as this, the pre-deployment tests are essentially invalid due to the configuration inconsistency and so the deployed code is effectively untested.
Catching It Against the Live State
The way to remove that gap is to frequently check that key invariants hold even after deployment. This is what OrCa, the specification-guided fuzzer in AuditHub, is built to do: it targets the real on-chain contracts at a given block, so whatever configuration is live is what gets checked, with no assumed initial state.
Run against the Nomad Replica contract at the state of the chain after the June upgrade, with the invariant above as the specification, the fuzzer identifies a violation on the first well-formed message to reach process().
One practical detail. The process() function takes a raw byte string with a specific internal layout, so a fuzzer generating arbitrary bytes would almost never produce a valid Nomad message and would spend its time bouncing off the parser. OrCa supports hints: developer-provided format constraints that tell the fuzzer how to structure inputs for a given argument. A hint describing the expected byte layout of a Nomad message is enough to generate well-formed messages and find the violation without further setup. What the developer gets back is a counterexample, runnable as a Foundry test.
What Carries Beyond This Bridge
Configuration values set at upgrade time are part of the attack surface. They change the protocol’s behavior, and no test written before the upgrade ever sees the values that go live.
This is the security-last pattern Veridise keeps running into: the heavy security work happens once, right before launch, and then proactive checking stops while the system keeps changing underneath. What was missing was a check that keeps running after launch: a fuzzer pointed at the live contracts, confirming the invariant still holds. Had that check been running after the June upgrade, the violation would have surfaced quickly. Instead the funds sat exposed for weeks.
Building a bridge or cross-chain message system?
Post-audit configuration changes are the class of risk a local test suite is least equipped to catch. If you want a second pair of eyes before your next deploy, talk to us.
The Takeaway
A bridge misconfiguration vulnerability can exist even when the validation code is correct: a single value changed after the audit makes the guard accept every message. In Nomad’s Replica contract, a trusted root initialized to zero let any unproven message pass and drained $190M. Catching this class means checking the invariant against the configuration that actually shipped, not the one a local test assumes.