Nomad Bridge Hack: Root Cause Analysis

Nomad
Nomad

--

High-level Issue

An implementation bug caused the Replica contract to fail to authenticate messages properly. This issue allowed any message to be forged as long as it had not already been processed. As a result, contracts relying on the Replica for authentication of inbound messages suffered security failures. This authentication failure resulted in fraudulent messages being passed to the Nomad BridgeRouter contract.

Details

Acceptable Root

Nomad commits to cross-chain messages in a Merkle tree (called the “message tree”). This tree’s root is propagated to remote chains via the optimistic mechanism.

The Replica contract tracks roots from other chains using a mapping(bytes32 => uint256). This maps roots to the timestamp at which they become valid. Messages may not be processed before the root’s optimistic timer has elapsed. When reading a mapping, if the entry has not been set, the default value (also called the 0 value) is read instead. The default value of a uint256 is 0 . Any root that has not been attested to will therefore have a 0 timestamp in this mapping.

After a root is propagated to another chain, messages inclusion in the tree is proven by Merkle proof. The root under which a message is proven is stored in a mapping(bytes32 => bytes32) in the Replica contract. This maps the hash of the message to the root under which it was proven. In this case, the default value of a bytes32 is bytes32(0). As such, any message that has not been proven will have a root of bytes32(0) in this mapping.

When a message is submitted to the process function, the protocol reads the root from the mapping, and checks whether the acceptableRoot function returns true. This function is intended to return true if and only if a valid root’s optimistic timeout period has finished. This function checks for special legacy values of root (from an older system version), as well as for roots that have not been attested to (have a 0 timestamp in the roots mapping). It ensures that a root’s timer has elapsed by checking it against the block timestamp return block.timestamp >= _time;.

function acceptableRoot(bytes32 _root) public view returns (bool) {
// this is backwards-compatibility for messages proven/processed
// under previous versions
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;

Initializer

When a Replica is deployed after its associated Home contract, the Replica contract is initialized with a specific state. This ensures that new deployments do not have to replay all of the past Updates from the remote Home in order to process messages. The deployer may pass a _committedRoot at which the message tree’s history begins receiving Updates.

During the initializer, the confirmAt[_committedRoot] is set to 1. This ensures that messages included in its initial root may be processed. This allows a newly initialized Replica to receive messages that predate its deployment.

However, in the case that a Replica is deployed at the same time as its corresponding Home contract — as is the case when initially deploying — the Home Merkle tree contains no messages. In the Nomad implementation, a Merkle tree with no leaves has a root of bytes32(0). Therefore a Replica deployed at the same time as its corresponding `Home` contract will be initialized with a root of bytes32(0), and confirmAt[bytes32(0)] will be set to 1.

function initialize(
uint32 _remoteDomain,
address _updater,
bytes32 _committedRoot,
uint256 _optimisticSeconds
) public initializer {
__NomadBase_initialize(_updater);
// set storage variables
entered = 1;
remoteDomain = _remoteDomain;
committedRoot = _committedRoot;
// pre-approve the committed root.
confirmAt[_committedRoot] = 1;
_setOptimisticTimeout(_optimisticSeconds);
}

In Combination

Here are the relevant lines of acceptableRoot.

uint256 _time = confirmAt[_root];
if (_time == 0) {
return false;
}
return block.timestamp >= _time;

Line by line:

uint256 _time = confirmAt[_root];

First confirmAt[_root] is loaded into a variable named _time. For unknown messages (including forged messages) this _root is equal to bytes32(0). On any Replica that was initialized with _committedRoot set to bytes32(0) , the value of confirmAt[bytes32(0)] was set at initialization equal to 1.

if (_time == 0) {
return false;
}

Because _time is 1, we skip this block.

return block.timestamp >= _time;

_time is 1 , so any valid block timestamp will be greater than or equal to 1 . As a result, acceptableRoot(bytes32(0)); always returns true for these Replica contracts.

This allows unproven messages to pass the following check in the process function. This in turn allows messages to be processed without first having been proven.

function process(bytes memory _message) public returns (bool _success) {
// ...
require(acceptableRoot(messages[_messageHash]), "!proven");
// ...
}

FAQs

When was this code introduced?

The relevant code was introduced in a smart contract upgrade on June 21, 2022.

Previously the require statement in process function was written as follows:

function process(bytes memory _message) public returns (bool _success) {
// ...
require(messages[_messageHash] == MessageStatus.Proven, "!proven");
// ...
}

The previous require statement required a non-0 value in the messages mapping. This could only be set via a prior call to prove. After the change, a 0value could be loaded from the mapping, and passed to acceptableRoot.

The change was made as part of a larger behavior change to message processing semantics. Specifically the older require statement would permanently prevent valid messages from being processed when accompanied by an invalid attestation. The newer require statement prevents a fraudulent updater from blocking delivery of valid messages by providing an invalid attestation. In order to accomplish this, we introduced the mapping of message to root described in the Acceptable Root section above.

What are the details of the Quantstamp audit of the Replica contract upgrade?

This Nomad system upgrade was audited by Quantstamp in May and early June 2022. A final report was received June 9th. This change was made May 26th, during the audit period, as part of audit-related cleanup and enhancements (specifically related to QSP-2 and QSP-34), and was included in the commit hash provided for the post-remediation re-audit.

Nomad also incentivized public review of the code via an ImmuneFi bounty which has been live since June 9th. This issue was not reported via the bounty system.

When was this change made on-chain?

Production environments were upgraded on June 21, 2022 at the following transactions:

Why did the Nomad Watchers not take action here?

The Nomad Watchers observe and respond to compromises of the Updater key, but do not yet watch for suspicious activity arising from smart contract bugs. Because the vulnerability existed within the smart contracts’ process function, the messages required no fraudulent Updater signature, and therefore did not trigger Watchers. As such, no Watchers took action.

What is the current state of the system?

Replicas have been unenrolled from the Nomad Bridge and Nomad Governance contracts. This prevents any message from being processed by the Bridge. This mitigates the impact of the vulnerability in the process function. New bridge transactions can still be initiated, however they cannot be processed. The NomadBridge GUI has been disabled. We recommend users do not attempt to bridge until a full fix is deployed, and a system restart is performed.

--

--

Nomad
Nomad
Editor for

The future of cross-chain communication is optimistic