September 30, 2022
Beosin Web3.0 Classroom: Cross-chain Bridge (II) — Introduction of Nomad
In our last article of cross-chain bridge series, we have introduced Polkadot from its framework, consensus mechanism, algorithm and cross-chain messaging. Today we will take a look at Nomad Bridge.
Nomad is an interoperability protocol for sending arbitrary messages between blockchains.
The following four types of users are supported.
- Users: Token bridging
- Asset issuers: multi-chain token deployment
- DAO Contributors: Cross-chain governance
- Developers: Cross-chain application development
The Nomad protocol consists of two parts: an on-chain smart contract and an off-chain proxy, the architecture of which is shown in the following figure.
On-chain smart contract: implements Nomad messaging API, which enables developers to enumerate messages in order and access the replication state on different chains, mainly including Home and Replica contracts. The Home contract is mainly responsible for formatting cross-chain messages, maintaining the Message Merkle message tree and the Merkle tree root value queue; the Replica contract is mandatory for all blockchains that want to receive cross-chain messages, and is mainly responsible for maintaining the Merkle message tree and root value queue corresponding to the Home contract, Message validation and Execution.
Nomad differs from other one-to-one cross-chain communication models in that it allows one-to-N broadcast communication. In this case, the Home contract is responsible for message generation, and any target chain wishing to replicate that message state or receive messages from the Home contract must deploy a Replica contract corresponding to that Home contract.
Off-chain proxy contracts: The security and state relay of cross-chain formed the backbone of the messaging layer. The main components are Updater, Relayer, Processor and Watcher, where Updater is mainly responsible for listening to the Home contract on the original chain, signing the new root value generated by the Home contract, and then generating the corresponding proof (including the proof of the previous root and the proof of the new root) and sending it back; Watcher is mainly responsible for ensuring the security of Updater by listening to the interaction between Updater and Home contract and submitting malicious or wrong validation of Updater; Relayer is responsible for forwarding update messages sent by Home contract to many Replica contracts; Processor is responsible for verifying the validity of pending messages and sending them to to the final recipient.
Nomad adopts optimistic-rollup cross-chain technology. This optimistic verification approach requires only one honest validator to guarantee the security of the entire system, which differs from other external verification approaches (e.g., multi-signature, PoS, oracle, etc.) that require most of the validators to be honest .
To implement optimistic verification, Nomad introduced Watchers responsible for flagging fraud in the chain.
3 Cross-chain messaging
The following diagram shows the flow of messaging using Nomad, using the example of user Alice using the Nomad Token Bridge to send 1,000 USDC from her Ethereum account to her account on Moonbeam.
1. Alice constructs a transaction on Ethereum through an RPC interface (e.g. Token Bridge GUI or Etherscan), which invokes the send() function in the BridgeRouter contract on Ethereum to initiate a cross-chain token bridging transaction. The BridgeRouter contract, which is implemented by DApp developers following the Nomad Router contract development specification, is the entry point for users to interact on the original chain and must implement the function of receiving and sending messages. Here is an example of Alice calling the send function in the BridgeRouter contract.
· _token: Token address
· _amount: Token amount
· _destination: The domain where the target chain is located, i.e. BridgeRouter contract on the remote chain
· _recipient: Recipient address
We will describe the code in detail below.
2. The BridgeRouter contract on Ethereum will first execute the specific token sending logic.
· Basic validation of input parameters, including: the number of tokens sent cannot be 0, the recipient cannot be a 0 address, etc.
· First obtain the BridgeRouter contract for the cross-chain transaction, if not then revert directly. Then check whether the token to be sent is from the local chain or the remote chain, if it is from the local chain then store it in the Router for safekeeping, otherwise burn the mapped coins from the remote chain.
Note: BridgeRouter contracts can burn non-native tokens directly, due to the fact that non-native tokens are originally deployed by their contracts.
· Formatting the message to be sent to comply with the BridgeMessage contract specification.
· After the business logic is executed, the BridgeRouter contract calls the dispatch() function in the Home contract to write the messages to be sent to the queue.
3. Start implementing Nomad Bridge’s core logic
Nomad Bridge formats and hashes messages through the Home contract and inserts them into the Merkle message tree, where the Merkle tree is the core data structure of Nomad and contains all messages sent from that Home.
The following is the source code of the dispatch() function.
- _destinationDomain: The domain where the target chain is located, i.e. the target BridgeRouter address
- _recipientAddress: Recipient address
- _messageBody: The message body after being formatted by BridgeMessage
They are described in detail below.
1) First check that the length of the message cannot exceed 2K (i.e. 2*2**10), then get the next nonce value of the target field and add 1, with the aim of preventing replay.
2) The message will then be pre-processed with the addition of data such as localDomain, msg.sender, nonce value, etc., and then reformatted as follows.
- localDomain: The domain where the BridgeRouter contract is located on the original chain
- _nonce: The nonce value of the target field
- _destinationDomain: The domain where the BridgeRouter contract is located on the target chain
- _recipientAddress: The recipient’s address on the target chain
- _messageBody: The original message
3) The processed message is then inserted into the message Merkle tree as a leaf node.
4) Regenerate the root value of the new Merkle tree and add it to the root value queue.
5) Emit a Dispatch event to notify the Updater of a new message waiting for it to be signed.
- _messageHash: The message leaf node that is inserted into the Merkle tree
- leafIndex: The index of the leaf node in the Merkle tree, here count() — 1, because the new leaf node has been inserted into the tree
- destinationAndNonce: The nonce values of the destination and target domains, calculated as: ((destination << 32) & nonce)
- committedRoot: The root value submitted in the last signature update
4. Updater sign the root value
When the Updater detects the Dispatch event, the Updater calls the update() function in the Home contract on Ethereum, submits the signature of the root value “notarized” by the Updater, and updates the committedRoot value in the Home contract, and publishes the signature. The specific source code is as follows.
- _committedRoot: Current updated Merkle root values
- _newRoot: New root value to be updated
- _signature: Updater needs to sign both the _committedRoot and _newRoot values
1) To prevent Updater from submitting false update messages, the function will first check the legitimacy of its submitted messages, that is, whether _newRoot is included in the root value queue. If the value does not exist, the Updater will be slashed.
2) Then delete all intermediate root values in the queue that are included in this update.
3) Update the status variable committedRoot in the Home contract with the latest signed root value, and submit the Update event
5. Relayers send Update message to the target chain
Once the Home contract submits an update message, Relayers will send the message to all the Replica contracts on the chain associated with that Home. Since Relayers is not trusted, it does not have any special privileges either. It simply calls the update() function in any Replica contract to update the new root value and make it consistent with the Home contract.
Note: In fact, this function can be called by anyone, not just Relayers.
This function will first check if the submitted root value is updated, and if not updated, verify the validity of the _newRoot value. If it passes, it will set the time when the new root value is submitted (current block timestamp + optimistic proof of fraud time), and finally update the root value. Since Nomad Bridge adopts optimistic-rollup cross-chain technology, after this function is called, a 7-day dispute challenging period will be opened, during which Watcher can challenge the updated root value.
6. Processor verifies and executes message
After the dispute period, the Processor, which is also untrustworthy and without any special authority, will call the prove() function in the Replica contract to verify the validity of the message by passing in the corresponding leaf node information, the Merkle path and the leaf node’s related index. In this example, the processor will call the prove() function in the Replica contract to first prove whether the message sent by Alice in the Ethereum exists in the Merkle tree, and if it does, it will call the process() function, which will forward the message to the corresponding BridgeRouter contract, and then call its handle() function to execute the specific business logic.
The above prove() function will first check whether the updated message has been executed, if not executed, it will calculate the corresponding root value based on the submitted proof, and compare it with the updated root value. If it is consistent, it means the verification passed. Then the verification result will be stored in messages, and then the process() function will be called to execute the message, i.e. the message will be sent to the corresponding BridgeRouter contract in the target chain.
This function first checks if the message is sent to the local BridgeRouter, and if so then verifies that the message has been proven by the prove() function. Since the process() function involves sensitive business logic such as transferring funds, it needs to be prevented from being reentered. Then modify the message execution status to processed and call the corresponding handle() business logic in the DApp, which is implemented by the DApp interface.
7. Execute the business logic in the target DApp handle
Once the handle() function is called on the Moonbeam BridgeRouter contract, BridgeRouter will execute the business logic on the chain it is on.
4 Nomad Incident
On August 2, 2022, Nomad suffered an attack that cost approximately $190 million.
Some of the attack transactions are as follows.
Victim contract: 0xB92336759618F55bd0F8313bd843604592E27bd8
Take one attack tx as an example to analyze: 0x87ba810b530e2d76062b9088bc351a62c184b39ce60e0a3605150df0a49e51d0
As we know from the previous introduction, a complete cross-chain messaging process is as follows.
-The user calls the send() function in the BridgeRouter contract of a DApp on the original chain, which performs a simple parameter check.
-Execute the dispatch() function in the Home contract, write it to the message queue, and send the Dispatch event.
-Updater monitors the event, signs the root value, calls the update() function in the Home contract to update the signature, and sends an Update event.
-Relayers call the update() function in the Replica contract to send update messages to the target chain.
-Processor calls the prove() function in the Replica contract to verify the message.
-After the verification, the Processor calls the process() function in the Replica contract to execute the corresponding business logic.
Note: Since the processor is untrusted, practically anyone can call the process() function.
From the above call stack, we can see that the attacker directly skipped the previous cross-chain message passing and directly called the process() function of a Replica contract and successfully withdrew the vault tokens of the Replica contract corresponding to BridgeRouter. So how did the attacker bypass the prove() detection?
As we analyzed earlier, the Processor will write the root value to the messages variable when it passes the validation by calling the prove() function. Therefore, the value of messages[root] is 0 if the root value is not verified by prove(), but the result of acceptableRoot(0) is true as we can see from the call stack below.
In the acceptableRoot()function:
As can be seen from the Nomad source code analysis above, the value of confirmAt[_root] is used to detect whether the cross-chain message has undergone optimistic proof of fraud time. Here, since the function returns a true value, block.timestamp ≥ confirmAt and confirmAt is not 0, which means confirmAt is initialized somewhere. There is initialize() function in the Replica contract, and the source code is as follows.
In summary, because _root is set to zero (0x000000….) that makes confirmAt[_root] equal to 1, while the timestamp of any block is greater than 1, causing the judgment to hold constant and the attacker to be able to withdraw the funds. Therefore, any attacker only needs to copy the first hacker’s transaction and replace it with an unused attack address, and then send it through Etherscan, they can steal the project funds. Also, as the problematic one is the Replica contract, all its corresponding BridgeRouter-related DApps are affected, that’s the reason why the stolen funds are multiple tokens.
If you have need any blockchain security services, please contact us:
Related Project Secure Score
Guess you like
How Formal Verification Secures Smart Contracts: An Example Using Beosin-VaaS
September 30, 2022
Beosin, SUSS NiFT, NUS AIDF and Other Partners Launched the “Blockchain Security Alliance” in Singap
September 29, 2022
Web3 Security Recap: $164.32 million lost in attacks in September
September 30, 2022
BNB Chain’s $850 Million Hack — Using Beosin Trace to Investigate the Stolen Funds
October 09, 2022