Nomad Bridge Invalid-Proof Acceptance / Trusted-Root Failure (2022-08-01)

  • Name: Nomad Bridge invalid-proof acceptance / trusted-root failure
  • Date: 2022-08-01
  • Protocol: Nomad
  • Chain: Ethereum + Nomad-connected EVM chains
  • Category: bridge verification failure / invalid-proof acceptance / trusted-root contamination / forged-message processing
  • Loss: about 190M drained, with later reporting that roughly $36M was returned
  • Summary: A June 2022 Replica upgrade changed Nomad’s message-verification semantics and left confirmAt[bytes32(0)] = 1 on affected Replica contracts. That was fatal because unproven messages also defaulted to messages[_messageHash] == bytes32(0). The process() path now checked acceptableRoot(messages[_messageHash]), so forged messages inherited the zero root and passed as already acceptable. The sharper failure was not merely invalid proof accepted; it was that bootstrap trusted-root inventory and runtime message-proof inventory both allowed bytes32(0) to carry live verifier meaning after the upgrade. Once the first attacker demonstrated the calldata shape, copycats only had to replay the same process() call and replace the recipient address. The bridge became a permissionless loot box.
  • Trigger / entry point:
    • Nomad’s Replica.process() path required acceptableRoot(messages[_messageHash]) instead of the older explicit messages[_messageHash] == MessageStatus.Proven check.
    • Replica initialization pre-approved _committedRoot by setting confirmAt[_committedRoot] = 1.
    • On replicas initialized with _committedRoot == bytes32(0), the zero root became permanently acceptable.
    • Unproven messages read back as bytes32(0) from the messages mapping, so forged messages were treated as if they had already been proven under an acceptable root.
  • Root cause:
    • The bridge’s authentication boundary collapsed because the verifier accepted the default zero value as a trusted root.
    • This was introduced during a June 21, 2022 smart-contract upgrade that changed how Nomad tracked proof status from a status flag to message-hash-to-root mapping semantics.
    • The protocol effectively conflated two meanings of zero: no proof recorded and initial committed root.
    • More precisely, process() should have had to prove two separate facts — this message has an explicit proof record and the referenced root is currently acceptable — but the upgraded logic only enforced the second predicate.
    • Once those values collided, process() no longer distinguished an actually proven cross-chain message from attacker-supplied calldata.
  • Exploit path:
    1. A Replica upgrade introduced the new acceptableRoot(messages[_messageHash]) validation flow.
    2. A Replica initializer pre-approved _committedRoot by setting confirmAt[_committedRoot] = 1; on affected deployments that root was bytes32(0).
    3. For any forged message that had never been proven, messages[_messageHash] returned the default bytes32(0) value.
    4. acceptableRoot(bytes32(0)) returned true because confirmAt[bytes32(0)] had been set to 1, and every live block timestamp is greater than or equal to 1.
    5. Attackers could therefore call process() directly without first calling a legitimate prove() flow and still satisfy the bridge’s proof gate.
    6. The first exploiter crafted a fraudulent bridge message that released tokens from Nomad custody to an attacker-controlled recipient.
    7. Once the calldata format circulated publicly, copycats replayed prior exploit transactions, patched in their own recipient addresses, and drained remaining bridge liquidity in smaller chunks rather than engineering bespoke full-bridge drains.
  • What the attacker needed to be true:
    • The affected Replica contract had to have been initialized or upgraded such that confirmAt[bytes32(0)] was nonzero.
    • The bridge had to rely on the new root-acceptance check rather than the older explicit MessageStatus.Proven equality check.
    • Bridge custody still had to hold withdrawable assets on the destination side.
    • The attacker needed only the ability to submit calldata to process(); they did not need the Updater key, a valid attestation, or a successful merkle proof.
  • Technical anatomy worth remembering:
    • This was a verifier failure, not a liquidity-model failure. The bridge released assets because its authenticity check stopped authenticating anything meaningful.
    • It is also the cleanest bridge case in the corpus for proof-state sentinel separation failure rather than classic proof-to-message binding failure. Nomad did not preserve a real trusted root and then detach the wrong leaf from it; it let absence of message proof inherit the same runtime value as acceptable root already known.
    • The bridge’s optimistic-security machinery did not save it because the forged messages did not require a fraudulent Updater signature. Nomad’s own FAQ notes the Watchers were designed to detect compromised Updater behavior, not smart-contract bugs in process().
    • The exploit was unusually democratized: after the first public success, hundreds of addresses joined in by replaying working calldata and changing recipient bytes.
    • That democratization also exposed the bridge’s blast-radius design. Once process() stopped authenticating messages, live bridge custody could be harvested over and over until operators unenrolled the Replicas; authenticity failure and effectively unbounded release authority had become the same event.
    • The most important implementation lesson is that default mapping value is itself protocol state. In verifier code, an uninitialized zero is never neutral if zero can also be interpreted as a valid proof artifact.

Three bridge-truth promotions this incident collapsed

  1. A bridge-shaped proof-state object existed at all
    • The Replica had some root inventory, some message-proof storage, and some bootstrap state left behind by initialization.
    • At this stage the strongest true fact was only the verifier has raw state for roots and messages, not this exact message was proven.
  2. That raw state was promoted into canonical truth that this exact message had been proven under an acceptable root
    • This was the decisive failure. Untouched messages[_messageHash] entries returned bytes32(0), and the upgraded process() path treated that value as a root candidate for acceptableRoot(...) instead of first requiring an explicit proved-message state.
    • Because initialization had already made confirmAt[bytes32(0)] = 1 on affected Replicas, the bridge collapsed no proof recorded and root already acceptable into the same verifier meaning.
  3. That accepted bridge truth still deserved immediate custody-release authority
    • Once process() returned success, the route still let the forged message drive ordinary token-release logic against live bridge custody.
    • The public exploit template then showed there was almost no blast-radius separation between verifier storage lied once and anyone can keep harvesting assets until operators shut the lane down.
  • Representative transactions and contracts:
    • Vulnerable Ethereum Replica contract highlighted in public writeups: 0xB92336759618F55bd0F8313bd843604592E27bd8
    • Fatal Replica initialization transaction cited by public postmortems: 0x53fd92771d2084a9bf39a6477015ef53b7f116c79d98a21be723d06d79024cad
    • Ethereum production upgrade transaction cited by Nomad: 0x7bccd64f4c4d5f6f545c2edf904857e6ddb460532fc0ac7eb5ac175cd21e56b1
    • Early successful exploit transaction cited by rekt.news: 0xb1fe26cc8892f58eb468f5208baaf38bac422b5752cca0b9c8a871855d63ae28
    • Example copycat exploit transaction highlighted by Mandiant: 0xa5fe9d044e4f3e5aa5bc4c0709333cd2190cba0f4e7f16bcf73f49f83e4a5460
  • Response / mitigation:
    • Nomad unenrolled the Replica contracts from the bridge and governance system so no further bridge messages could be processed.
    • The front-end was disabled and users were told not to bridge until a fix and restart were complete.
    • Nomad later offered a return program that let whitehats keep 10% if 90% was returned; Mandiant reports about $36M was ultimately recovered.
  • Key transaction hashes:
    • Fatal Replica initialization transaction: 0x53fd92771d2084a9bf39a6477015ef53b7f116c79d98a21be723d06d79024cad
    • Ethereum production upgrade transaction: 0x7bccd64f4c4d5f6f545c2edf904857e6ddb460532fc0ac7eb5ac175cd21e56b1
    • Early successful exploit transaction: 0xb1fe26cc8892f58eb468f5208baaf38bac422b5752cca0b9c8a871855d63ae28
    • Example copycat exploit transaction: 0xa5fe9d044e4f3e5aa5bc4c0709333cd2190cba0f4e7f16bcf73f49f83e4a5460

Pattern tags

  • bridge-verification-failure
  • proof-state-sentinel-collapse
  • Replica upgrade logic let bytes32(0) collapse two different verifier claims — no proof recorded and acceptable committed root already known — into one executable value
  • The bridge’s critical predicate became only root is acceptable now, while silently dropping the separate predicate this exact message has an explicit proof record
  • Watcher and optimistic-updater machinery were orthogonal to the live failure, because forged messages no longer needed a fraudulent Updater signature once process() itself accepted untouched proof storage as bridge truth
  • One false verifier result still had uncapped line-of-sight to live bridge-router custody, and the exploit’s public calldata template turned that verifier bug into mass copycat withdrawals rather than one bespoke operator-grade drain

Why the exploit worked

  • Nomad’s own Aug. 2022 RCA makes the decisive break very explicit: after the June 21 Replica upgrade, process() no longer required messages[_messageHash] == MessageStatus.Proven; it required only acceptableRoot(messages[_messageHash]). That semantic shift mattered because untouched messages[_messageHash] entries defaulted to bytes32(0).
  • The initializer then pre-approved _committedRoot by writing confirmAt[_committedRoot] = 1. On Replicas deployed against empty Home trees, _committedRoot was itself bytes32(0), so the same zero value now also meant already acceptable root.
  • From that point on, forged messages inherited a trusted-looking root without ever passing a real prove() flow. The bridge therefore stopped asking one fused authenticity question and instead let default mapping value impersonate already proven under a root whose optimistic timer elapsed.
  • The exploit was unusually democratized because the hard part was already over once the first forged process() call became public. As Mandiant and Coinbase both noted, later attackers often did not synthesize new bridge messages from first principles; they replayed working calldata and replaced recipient addresses. That is a useful bounded-release lesson, not just colorful incident lore: when verifier failure and live custody release sit too close together, the route can degrade from single exploit to open harvest almost instantly.
  • Nomad’s watcher model also preserved the narrower systems lesson. Watchers were built to respond to fraudulent Updater signatures, but the bridge did not need a bad signature here; the contract had already accepted unproven messages as authentic. That means the real security object was always process()’s proof-state semantics, not the surrounding optimistic-governance shell.

Detection and audit pivots

  • Treat default values in verifier storage as live protocol state, not neutral blanks. If 0, bytes32(0), or another sentinel can ever also mean trusted root, proven, or already executable, the bridge has already collapsed two security states into one.

  • Audit verifier upgrades by preserving the exact conjunction of predicates that used to define authenticity. Nomad’s dangerous change was not merely new code path; it was that message explicitly proven disappeared as a separately enforced condition and was replaced by a weaker root-acceptance check.

  • Review initializer and migration code as custody code. The critical exploit precondition was set during Replica initialization, not during some exotic runtime edge case: confirmAt[_committedRoot] = 1 became catastrophic only because _committedRoot could be the same zero sentinel returned by untouched proof mappings.

  • Ask what human or automated backstop still matters if the verifier lies without any signer compromise. If watchers, fraud proofs, or operator alerts only trigger on bad signatures while the contract can counterfeit authenticity locally, the route’s advertised safety shell is pointed at the wrong failure mode.

  • Model blast radius assuming the first successful forged call becomes public. Nomad shows that a bridge with reusable calldata, deep custody, and no effective post-verification throttles can turn a verifier bug into crowd-sourced drainage within minutes.

  • Comparable exploit pattern:

Failure-prevention checklist

  • Keep unproven message, known root, and processable message as three explicitly different states. A bridge should never be able to answer yes to is this executable? using only a default mapping read plus a bootstrap root that happened to share the same raw value.
  • Treat initializer and migration writes as verifier logic. If a new Replica or route pre-approves a starting root, prove that the chosen root cannot alias any untouched proof-state value and add assertions that reject zero / empty / legacy sentinels unless they are deliberately modeled as non-executable.
  • Preserve the full authenticity conjunction across upgrades. If the old system required both message explicitly proven and referenced root is acceptable, the replacement path should not silently collapse those into one weaker predicate just because the storage model changed.
  • Make proof-state semantics machine-auditable at the storage layer. Reviewers should be able to enumerate every raw value that can inhabit messages[...], confirmAt[...], and related verifier slots and say exactly which ones mean never proven, proven but waiting, currently executable, or already processed.
  • Rate-limit post-verification release paths as if verifier bugs are inevitable. One forged or copy-pastable process() calldata template should not have line-of-sight to the route’s entire live custody balance before operators can pause.
  • Rehearse the verifier lies without signer compromise failure mode operationally. Watchers, pausers, and monitoring should not assume that bad bridge truth always arrives with a bad updater signature, bad validator set, or obvious offchain compromise.
  • Regression-test zero / empty / untouched proof paths directly: unknown message hash, zero committed root, zero proof root, post-upgrade process call without prior prove(), and replay attempts against any route that bootstraps from an empty tree.
  • Keep one route-local liability view that compares executable bridge messages, live custody inventory, and per-asset release caps so a proof-state bug cannot instantly become uncapped treasury loss.

Audit prompts

  • What exact conjunction of facts is supposed to authorize process() today: message was proven, proof referenced an acceptable root, message not yet processed, and handler scope matches? Which of those facts are stored separately, and which ones are currently inferred from the same raw value?
  • Can any untouched storage read such as 0, bytes32(0), or a legacy sentinel be interpreted as acceptable root, already proven, or processable now after initialization, migration, or downgrade-compatibility code runs?
  • If the bridge changes proof bookkeeping from status flags to root pointers or another derived representation, what concrete test proves the old authenticity predicate survived the refactor unchanged?
  • Do initializer parameters, bootstrap roots, or backfilled history writes ever pre-approve values that can also arise from absent proof records or empty message trees?
  • What monitoring would fire if the verifier started accepting arbitrary calldata without any signer compromise? Would operators see unusual process() success rates, unexpected message hashes, or route-level liability drift quickly enough to matter?
  • If the first successful forged bridge call became public on social media, what concrete caps, delays, or per-asset throttles would stop the route from turning into mass copycat withdrawals?
  • Which bridge defenses only watch compromised signer / updater behavior, and which ones independently watch for contract itself is authorizing nonsense failures?
  • Can reviewers enumerate every raw verifier-state value that means true or false for acceptableRoot(...) and show that none of them are reachable through untouched mappings or ambiguous legacy constants?

Lessons that should already be standard

  • Default values are not neutral in verifier code. If bytes32(0) or 0 can ever also mean trusted, proven, or ready, the bridge already has an authenticity bug waiting for the right code path.
  • Upgrades do not merely change implementation; they change truth semantics. Any refactor that rewrites what proves a message is real deserves custody-grade review, not ordinary feature-shipping review.
  • Bootstrap convenience is part of the threat model. Pre-approving an initial root to avoid replaying history is fine only if the bootstrap object cannot be confused with nothing has been proven yet.
  • Signature-based monitoring is not enough for bridges. A route can catastrophically fail even when no updater, guardian, or validator key is compromised, because the contract may already be lying locally about proof state.
  • Once forged bridge calldata is reusable, bounded release matters as much as correctness. Nomad shows how quickly a verifier bug can become a public harvesting game when one success template can be copied by everyone.
  • The healthy bridge question is never just is this root acceptable?; it is did this exact message earn the right to spend against that acceptable root, and is the spend still bounded if we are wrong?

Why this incident matters

  • It is one of the cleanest examples of a bridge breaking at the verification boundary itself rather than at the token contract, relayer, or economic layer.
  • It shows how an upgrade meant to improve liveness semantics can silently destroy authenticity semantics.
  • It is also one of the clearest bridge cases where absence itself became authority: untouched message-proof storage inherited executable meaning because bootstrap root state and runtime proof state were not kept disjoint.
  • It also demonstrates that once a bridge verifier says any message is valid, the attack surface stops being elite-hacker-only and turns into mass replay looting.
  • It is also a sharp bounded-release case study: one verifier bug plus a publicly reusable calldata template let attackers iterate through live bridge custody for hours, showing how little separation remained between false bridge truth accepted and very large value immediately exits.

Internal linkages

  • Core recurring pattern: bridge-verification-failure.
  • Sharper verifier-state pattern: proof-state-sentinel-collapse — use this when the real reusable lesson is that no proof recorded yet, bootstrap trusted-root state, and executable proof state were allowed to alias into the same runtime value.
  • Closest positive-design companion: bridge-proof-state-sentinel-separation-and-default-value-discipline — useful when the sharper question is how messages[_messageHash], trusted-root storage, and initialization state should have been represented so bytes32(0) could never mean both no proof recorded and acceptable proof artifact.
  • Broader sentinel-semantics companion: sentinel-lane-separation-and-default-value-deauthorization — useful when the sharper cross-family question is how default values, bootstrap placeholders, and absent-state sentinels should be kept from inheriting live authority anywhere in the system.
  • Positive-design companion: bridge-truth-security-object — useful when the sharper question is what one fused authenticity object Replica.process() should have required before any destination-side release path treated a message as proven.
  • Positive-design companion: bridge-bounded-release-design — the second bridge lesson is that one forged-message bug plus copy-pastable calldata should not have been able to walk so much live custody out of the system before operators could react.
  • Structural contrast: proof-to-message-binding-failure — those bugs keep the trusted root or header real but detach the executed leaf from it, whereas Nomad corrupted root-acceptance state itself by letting bytes32(0) mean both no proof recorded and acceptable committed root.
  • Operational contrast: verifier-input-independence-failure — KelpDAO and related cases ask whether honest-looking verifiers observed the same lie, while Nomad asks a harsher upstream question: what if the bridge contract itself already treats untouched proof state as authentic before any observation diversity even matters?

Control surface