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 0
value 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.