The Nomad Bridge Exploit: When Zero Becomes Trusted

On August 1, 2022, the Nomad bridge was drained of approximately $190 million. The attacker didn’t need a flash loan, MEV bot, or any special technical capability. Once the initial exploit transaction landed on-chain, anyone could copy it, swap in their own address, and walk away with funds. Around 300 addresses did exactly that.

The cause was a routine upgrade deployed six weeks earlier that accidentally designated the zero hash as a trusted Merkle root—a change with one line of visible effect and catastrophic consequence.

How Nomad Was Supposed to Work

Nomad was an optimistic bridge connecting Ethereum, Moonbeam, and other chains. Unlike validator-based bridges, it didn’t rely on a committee of signers. Instead, it used Merkle trees to verify that a transfer message was legitimate.

The process had two steps. First, an off-chain “updater” would post a new Merkle root representing the current set of valid transfer messages. After a 30-minute fraud window (during which anyone could challenge a fraudulent root), the root was considered confirmed. Second, to execute a transfer, a user would prove their message existed in a confirmed Merkle root by supplying a cryptographic proof, then call process() to receive their funds.

// Step 1: Prove the message is in a valid Merkle root
function prove(bytes32 _leaf, bytes32[32] calldata _proof, uint256 _index)
    public returns (bytes32)
{
    bytes32 _calculatedRoot = MerkleLib.branchRoot(_leaf, _proof, _index);
    require(acceptableRoot(_calculatedRoot), "Invalid root");
    messages[_leaf] = _calculatedRoot;  // Mark as proven
    return _calculatedRoot;
}

// Step 2: Process a proven message
function process(bytes memory _message) public {
    bytes32 _messageHash = keccak256(_message);
    require(acceptableRoot(messages[_messageHash]), "Not proven");
    _handle(_message);  // Execute the transfer
}

// Root validity check
function acceptableRoot(bytes32 _root) public view returns (bool) {
    uint256 _time = confirmAt[_root];
    if (_time == 0) return false;
    return block.timestamp >= _time;
}

The security guarantee rested on confirmAt: only roots that had passed through the updater and survived the fraud window would have a non-zero entry in this mapping.

The Upgrade

On June 21, 2022, Nomad deployed an upgrade to the Replica contract. During initialization, the code set committedRoot to bytes32(0) and mapped it in confirmAt:

function initialize(
    uint32 _remoteDomain,
    address _updater,
    bytes32 _committedRoot,  // Passed as 0x00 in the upgrade call
    uint256 _optimisticSeconds
) public initializer {
    committedRoot = _committedRoot;
    confirmAt[_committedRoot] = 1;  // confirmAt[0x00] = 1
}

The intent was probably to set an initial state. The effect was that acceptableRoot(bytes32(0)) now returned true.

In Solidity, an unset mapping key returns the zero value for its type. For mapping(bytes32 => bytes32), that means accessing a key that was never written returns bytes32(0). So for any message that had never been proven through the prove() function, messages[hash] returned bytes32(0)—which was now a trusted root.

The verification path collapsed:

function process(bytes memory _message) public {
    bytes32 _messageHash = keccak256(_message);

    // For any unproven message:
    // messages[_messageHash] == bytes32(0)  (never proven)
    // acceptableRoot(bytes32(0)) == true    (accidentally trusted)
    require(acceptableRoot(messages[_messageHash]), "Not proven");  // PASSES

    _handle(_message);  // Transfers tokens to whoever is in the message
}

Any message—crafted to claim any amount of any token to any address—would pass validation. No Merkle proof required. No prior deposit required.

The Drain

The first attacker discovered the bug and submitted a transaction claiming 100 WBTC (about $2.3 million at the time). The transaction landed. Within minutes, others noticed the anomalous withdrawal and started inspecting what had happened.

What they found required no reverse engineering. The exploit transaction was on-chain. You could view it in Etherscan, decode the calldata, replace the recipient address with your own wallet, and submit it. No code required. No DeFi knowledge required.

// Attacker's message structure (visible in calldata)
bytes memory maliciousMessage = abi.encode(
    RECIPIENT_ADDRESS,  // Just change this
    TOKEN_ADDRESS,
    AMOUNT
);
replica.process(maliciousMessage);

By 9:35 PM UTC, copycats were active. By 10:00 PM, more than 40 addresses were draining the bridge in parallel. The bridge held approximately $190 million across multiple tokens:

TokenValue Stolen
WETH~$74.8M
USDC~$49.2M
WBTC~$35.8M
CQT~$13.2M
Other~$17M

By 11:30 PM, the bridge was empty. The entire drain took under two hours.

Of the ~300 participating addresses, a subset identified themselves as white hats and returned approximately $33 million to Nomad in the weeks following. One address associated with a prior exploit at Rari Capital took approximately $4 million. Most funds were never recovered.

The Fix

The vulnerability had two straightforward remedies, either of which would have prevented the attack:

Never trust zero as a valid Merkle root. An explicit check makes the invariant visible:

function acceptableRoot(bytes32 _root) public view returns (bool) {
    if (_root == bytes32(0)) return false;  // Zero is never valid
    uint256 _time = confirmAt[_root];
    if (_time == 0) return false;
    return block.timestamp >= _time;
}

Track proven messages with an explicit boolean, not a root lookup. This eliminates the dependency on mapping default values entirely:

contract SecureReplica {
    mapping(bytes32 => bool) public provenMessages;

    function prove(bytes32 _leaf, bytes32[32] calldata _proof) external {
        bytes32 root = calculateRoot(_leaf, _proof);
        require(isValidRoot(root), "Invalid root");
        provenMessages[_leaf] = true;  // Explicit flag, not a root value
    }

    function process(bytes memory _message) external {
        bytes32 hash = keccak256(_message);
        require(provenMessages[hash], "Not proven");
        delete provenMessages[hash];  // Prevent replay
        _handle(_message);
    }
}

Validate initialization inputs. The upgrade that introduced the bug passed bytes32(0) as the committed root. A require statement would have caught this immediately:

function initialize(bytes32 _committedRoot) external initializer {
    require(_committedRoot != bytes32(0), "Root cannot be zero");
    committedRoot = _committedRoot;
    confirmAt[_committedRoot] = block.timestamp;
}

Why This Keeps Happening

The Nomad bug is not an exotic attack. It’s one instance of a recurring pattern: zero values that acquire trust through default mapping behavior. Solidity’s choice to initialize all storage to zero is a convenience that creates footguns throughout the language.

Any time a mapping’s zero value is checked as part of a security decision—“is this value non-zero, therefore it’s valid?”—there’s a potential exploit path if an attacker can cause that value to remain zero while still passing the check. In Nomad’s case, the attacker didn’t need to create a zero value. The default was already there. The upgrade just made zero trustworthy.

The Nomad exploit is also a case study in what happens when a bridge has no pause mechanism. Once the first attacker’s transaction landed, there was no circuit breaker, no guardian committee, no rate limiter—nothing to slow the cascade. Optimistic bridges in particular need out-of-band emergency controls because the optimistic verification window (30 minutes in Nomad’s case) is far too slow to respond to an active drain.


References