Detailed: Nomad vulnerability deep tech explanation
Our tech team made a detailed explanation of the Nomad vulnerability and walked the hacker’s way step by step. Further details below.
Nomad exploit became possible due to misconfiguration, which made the process function vulnerable. Let’s dig into the exploit itself. Process function accepts data from the user, generates keccak hash from it and checks whether it has been already proven or not.
messages is a mapping of type bytes32 => bytes32. Key to the mapping is hash of the message and the value should be basically be one of the three constants below:
So, in case resulting _messageHash key wasn’t initialized with any value this mapping would basically return bytes(0), since this is default value for non-existing keys.
bytes(0) value will be passed to acceptableRoot function. Let’s dig into it.
acceptableRoot function checks if the passed arguments equal to the constants listed above and if it’s not(which happens in Nomad case) proceeds to the next step with time check. So, we know that the _root equals to bytes(0), that means that the only key to succeed would be in case if confirmAt[_root] will return a non-zero value for bytes(0) root. And that’s exactly what happens. Due to incorrect initialization confirmAt mapping returns 1 for bytes(0).
This fact makes it available to pass any message with any data without carrying about its hash which makes the hack possible.
It’s a good practice to check mapping return values, but it’s often missed when developing contracts. Also a good practice is to check for default values, when initializing something e.g. address(0) or bytes(0) in case of Nomad. These low risk problems can result in very high risk if treated improperly.
How easy was it to copycat the Nomad hacker?
Many users have spotted an ongoing attack at Nomad bridge and decided to do it on their own. However, it wasn’t enough just to copypaste other’s input data, attackers had to figure out how data is encoded and processed.
Nomad bridge uses TypedMemView library for their data processing. This library basically allows to extract variables from bytes type.
Let’s take a look at example input data which was used to perform an attack:
This data was used to steal 60k DAI stable coins from Nomad. Now let’s try to decode it.
Firstly, it starts with process function selector(which was vulnerable) and some abi stuff for bytes type which we don’t care about. So, let’s just split it away.
Here is what we are left with:
Now let’s look at the code of the SC to decode this.
Firstly we start with _m.destination(). In this case it would be 0x00657468, in decimal value it equals to 6648936 and it also should be equal to localDomain of the replica contract. Which it does:
Next is _m.recipientAddress() which should be the address we are going to call handle function on. In case of attack it should be address of bridgeRouter proxy and equal to 0x00000000000000000000000088a69b4e698a4b090df6cf5bd7b2d47325ad30a3 which result in ETH address of 0x88a69b4e698a4b090df6cf5bd7b2d47325ad30a3.
After defining an address, we have some more parameters. _m.origin(), _m.nonce(), _m.sender() and _m.body().
In this case they are the following:
_m.origin() = 0x6265616d = 1650811245
_m.nonce() = 0x000003ef = 1007_m.sender() = 0x000000000000000000000000d3dfd3ede74e0dcebc1aa685e151332857efce2d = 0xd3dfd3ede74e0dcebc1aa685e151332857efce2d
_m.body() = 006574680000000000000000000000006b175474e89094c44da98b954eedeac495271d0f0300000000000000000000000065760288f19cff476b80a36a61f9dedab16bab49000000000000000000000000000000000000000000000cc881aa647313a50000d1d02c1454022ce8373a7bfdf214d693f79b32084303249dac4a832b18c4cb8
Now let’s dig to handle function on bridge router to see what these arguments are used for.
Firstly it has onlyRemoteRouter modifier.
This modifier checks if provided _router fits _domain value. For values provided in this case everything is correct:
Next action is extracted from the encoded message. In this case it was a fast transfer so let’s dig to _handleTransfer function.
Here _tokenId.domain() and _tokenId.id() are extracted to check the token. In the presented case they have the following values:
_tokenId.domain() = 0x00657468 = 6648936
_tokenId.id() = 0000000000000000000000006b175474e89094c44da98b954eedeac495271d0f0 = 0x6b175474e89094c44da98b954eedeac495271d0f0 — which is DAI address.
Next step is to extract recipient of tokens:
_action.evmRecipient() = 0x00000000000000000000000065760288f19cff476b80a36a61f9dedab16bab49 = 0x65760288f19cff476b80a36a61f9dedab16bab49
After extracting recipient amount of tokens is extracted:
_action.amnt() = 000000000000000000000000000000000000000000000cc881aa647313a50000 = 60367090000000000000000 = 60k DAI.
Attackers needed to combine all these arguments together and except for inserting desired amounts and addresses all other values should also be correct to pass all the checks.
Stay tuned and find out more about us and what we provide on our: