Nomad Bridge Replay (2022)
Smart contract security audit by TierZero · 2023-08-15
Independent research. This is an unsolicited security analysis published by TierZero — not a commissioned audit, and no certificate is issued. It reflects our own review of public code and on-chain events.
Findings summary
Independent research — not a commissioned audit. This is a post-mortem analysis of a publicly documented incident, prepared for educational purposes.
What the protocol did
Nomad was an "optimistic" cross-chain messaging bridge. Instead of light clients or a multisig validator set, it relied on a single Updater who signed Merkle roots representing batches of outbound messages, plus a 30-minute fraud-proof window during which Watchers could challenge a bad root. On the destination chain, a Replica contract tracked which roots had been submitted and, once they cleared the challenge period, marked them acceptable so users could call process() to execute the underlying message (in practice, a token withdrawal) against a Merkle proof anchored to that root.
The trust model was sound on paper: no message executes unless its proof resolves to a root that the Replica has explicitly recorded and aged past the fraud window. The failure was not in that design — it was in a one-line initialization mistake introduced during a routine contract upgrade in April 2022.
The vulnerability
Replica tracked accepted roots in a mapping from root hash to confirmation timestamp:
mapping(bytes32 => uint256) public confirmAt;
function acceptableRoot(bytes32 _root) public view returns (bool) {
uint256 _time = confirmAt[_root];
if (_time == 0) return false;
return block.timestamp >= _time;
}
During re-initialization, the contract set the genesis/committed root's confirmation time directly, without excluding the zero hash:
function initialize(bytes32 _committedRoot, ...) public initializer {
committedRoot = _committedRoot;
confirmAt[_committedRoot] = 1; // "already confirmed"
...
}
Because _committedRoot was passed as bytes32(0) on that deploy, confirmAt[0x00] was set to 1. From that point, acceptableRoot(0x00) returned true for all time — the zero hash, which is also the value Solidity returns for any unset or default bytes32, became a permanently pre-approved root. process() never checked that the proof root was non-zero or that it matched a real, deliberately-published batch; it simply asked acceptableRoot(root) and trusted the answer. This is the same bug class as accepting a default/uninitialized value as a valid authorization token — the zero address problem, generalized to a zero Merkle root.
How the exploit worked
- An attacker crafted a
process()call referencing a message and supplied a Merkle proof that trivially resolves to0x00(a proof of an all-zero leaf against an all-zero tree resolves to the zero root). - Because
acceptableRoot(0x00)wastrue,process()accepted the message as fully proven — no real inclusion in any committed batch was required. - The message payload itself was attacker-controlled, so the first exploiter drained a specific asset by pointing the "proven" message at a real token transfer instruction.
- Because the working calldata was visible on Etherscan and required no signature from the original sender, it could be copied verbatim by anyone and replayed with the recipient address swapped for their own. No Solidity knowledge was needed — this is why the incident is remembered as a "free-for-all," with hundreds of unrelated addresses draining the bridge in parallel within hours.
- Total losses across the affected assets (WBTC, USDC, USDT, WETH, DAI, FRAX, and others) reached roughly $190 million before the bridge could be paused.
How an audit catches this
This is exactly the kind of defect that a focused review of upgrade and initialization paths is designed to surface, which is the core of what we do in a smart contract audit:
- Diff every initializer against the previous deployment, line by line, treating
initialize()and re-initialization functions with the same scrutiny as constructors — they run once but their state persists forever. - Enumerate default-value edge cases explicitly. Any mapping, storage slot, or authorization check keyed by a hash, address, or ID must be tested against its zero/default value as a hostile input, not just typical inputs.
- Write a negative test that asserts
acceptableRoot(bytes32(0))(or the equivalent "null" identity in your system) is alwaysfalse, and add it to the regression suite so a future refactor can't silently reintroduce it. - Model the trust boundary as an invariant, not a feature: "a message may only execute if its root was explicitly published by the Updater and aged past the challenge window" should be a property checked with fuzzing or formal methods, not just a manual code read. This is the same category of initialization-state risk we flag when reviewing upgradeable program deploys and their storage-layout hazards, and it belongs on any pre-launch audit checklist regardless of chain.
- Require a second reviewer to independently re-derive the "safe" default for every mapping touched by an upgrade, rather than trusting the deploy script's constants.
Remediation
The practical fix is narrow: reject bytes32(0) (and any other sentinel/default value) as a valid root at the acceptableRoot() check, and require every root written to confirmAt to originate from an explicit, signed Updater submission rather than an initializer default. More broadly, upgrade scripts for cross-chain messaging contracts should be treated as security-critical deploys requiring the same review rigor — and the same independent code review — as the original launch, since a single miswired parameter here converts a fraud-proof system into an unauthenticated withdrawal function.
If your bridge, vault, or messaging layer has an upgrade or re-initialization path anywhere in its history, it's worth having someone look at it who isn't the team that wrote it — that's what a smart contract audit from TierZero is for.
Need your contracts audited?
Manual review + tooling across TON, Solana and EVM — certificate and full report included.
Get an audit