Lost
Came back
Attacked
Audit
A June upgrade initialized Nomad's Replica with the zero hash as its committed root and marked it confirmed. A message that was never proven reads as root 0x00, so process() accepted any message: the first caller withdrew from the bridge, and hundreds of addresses copied the transaction with their own address in it.
bridgeupgradedefault valuemessage verification
Replica.initialize
June 2022 upgrade: the Replica is initialized with committedRoot = 0x00 and confirmAt[0x00] = 1, so the zero root counts as confirmed from the start.
Missing check. The initializer accepted the zero hash as a root and confirmed it.
Caller edits a message
August 1: a caller takes a real bridge message, puts its own address in it, and never proves it.
Replica.process
process() looks up messages[hash] for the root the message was proven against. Unproven, the entry holds the default value, 0x00.
Replica.acceptableRoot
acceptableRoot(0x00) reads confirmAt[0x00] = 1, a time in the past: accepted.
Missing check. acceptableRoot should never accept the zero root, the value of every unproven message.
BridgeRouter.handle
The router trusts the Replica and releases the tokens the message names to the caller.
Caller copies
Others copy the transaction, changing only the address: about $190M left the bridge in under three hours.
Fund flow, in order
An unset mapping entry reads as zero. The initializer made zero a trusted root, so the absence of a proof became a proof.
The invariant that would have failed
process() succeeds only for a message proven against a confirmed, non-zero root.
function test_process_rejectsAnUnprovenMessage() public { bytes memory message = _message(recipient, amount); // never passed to prove() vm.expectRevert("!proven"); replica.process(message);}