Huge thank you to pcaversaccio for pushing this and writing the original tool.
The Radiant Capital hack went (essentially) like this:
This costly mistake highlights the critical importance of understanding and verifying multi-signature (multi-sig) transactions. The ability to verify signatures is a skill you must have if you want to be involved in security councils, DAOs, incident response, or a DevOps team. Heck, even if you just want to be sure your personal accounts don’t get rekt.
Multi-sig wallets are renowned for their security, but to leverage this security effectively, you need to know how to verify that what you're signing is indeed what you intend to sign.
Let’s learn how you can verify every part of your Safe multi-sig process so you don’t fall victim to this attack.
If you’d rather watch, we also have a video on YouTube to guide you step by step.
First, you’ll want to download the tool. Head over to the safe-tx-hashes repository to download the safe_hashes
executeable.
This tool allows us to quickly input the safe transaction data, see what we should be getting, and verify that is indeed what’s showing up on our wallets.
As we go through this, it’s important to first understand what security assumptions we are making and what the threat vectors may be, so we can avoid them.
With this tool, we already have some assumptions that the following tools are safe:
bash
perl
Let’s start with a scenario.
Your team needs to approve the Uniswap router to access your USDC for a swap - a relatively common DeFi transaction. Your Safe UI may look as such:
When you scroll to the bottom, you’ll get a big “sign” button. And when you click it, your wallet displays a message for you to verify, it might look something like this:
This is the Safe transaction message hash that we need to verify and be 100% sure of what it’s doing. So, what is it doing?
This message is the EIP-712 signature of the message our multi-sig uses to verify transactions. Enough users must sign a message in a particular format for it to be accepted. We cover EIP-712 and 191 in our blog and in Cyfrin Updraft if you’d like to learn more.
The safe hash is calculated as such (in Solidity):
keccak256(abi.encodePacked(bytes1(0x19), bytes1(0x01), bytes32($domain_hash), bytes32($message_hash)))
And the message_hash is calculated with (with bash/foundry):
local message=$(cast abi-encode "SafeTxStruct(bytes32,address,uint256,bytes32,uint8,uint256,uint256,uint256,address,address,uint256)" \
"$safe_tx_typehash" \
"$to" \
"$value" \
"$data_hashed" \
"$operation" \
"$safe_tx_gas" \
"$base_gas" \
"$gas_price" \
"$gas_token" \
"$refund_receiver" \
"$nonce")
# Calculate the message hash.
local message_hash=$(cast keccak "$message")
In essence, these hashes combine all critical transaction components to create a unique signature. This ensures that when the multi-sig wallet verifies a signature, it can be certain that it corresponds to this specific transaction on this specific wallet instance.
Now, what’s important here is that we must be completely sure this signature has the data we expect. If someone wanted us to sign a different transaction, they could just change the data that gets hashed as data_hashed
. But, this would return a different signature! So if we are vigilant, we can see this, and not sign.
So our process will be to recreate what we expect the signature to be offline, then compare it to the signature we get on our hardware wallet.
When you reach this page in the Safe UI, the Safe API will “save” the data here so that we can call the API to populate our tool. We can simply run:
safe_hashes --address <MULTI_SIG_WALLET_ADDRESS> --nonce XX --network xxxx --untrusted
And this pulls all the data from the Safe UI and shows it directly to us! Including the transaction parameters and the expected signature. For example, you can run this script yourself to see the output!
safe_hashes --network sepolia --address 0x86D46EcD553d25da0E3b96A9a1B442ac72fa9e9F --nonce 7 --untrusted
You’ll get an output like this:
Transaction Data
Multisig address: 0x86D46EcD553d25da0E3b96A9a1B442ac72fa9e9F
To: 0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9
Value: 0
Data: 0x095ea7b3000000000000000000000000fe2f653f6579de62aaf8b186e618887d03fa31260000000000000000000000000000000000000000000000000000000000000001
Encoded message: 0xbb8310d486368db6bd6f849402fdd73ad53d316b5a4b2644ad6efe0f941286d80000000000000000000000007b79995e5f793a07bc00c21412e50ecae098e7f900000000000000000000000000000000000000000000000000000000000000001c62604b0ed9a9ec0e55efe8fb203b3029e147d994854cf0dd8a9fcf5b240d600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007
Method: approve
Parameters: [
{
"name": "guy",
"type": "address",
"value": "0xfE2f653f6579dE62aAF8B186E618887d03FA3126"
},
{
"name": "wad",
"type": "uint256",
"value": "1"
}
]
Legacy Ledger Format
Binary string literal: \xe8\xc9\xfbR\xf3$\\xc5\xe451\x1b\xa4\xd5pAJ4=\x80\xeal7g\xa1jy\xbd*\xaa\x0c7
Hashes
Domain hash: 0xE411DFD2D178C853945BE30E1CEFBE090E56900073377BA8B8D0B47BAEC31EDB
Message hash: 0x27B5FF2CB38C914873DAF41B5A4984C76DB35F1812877F72A9D5DEA6960ED0B1
Safe transaction hash: 0xe8c9fb52f3245cc5e435311ba4d570414a343d80ea6c3767a16a79bd2aaa0c37
The transaction is calculated based on the data the Safe UI is giving us. We can also see the transaction that is being sent here too! With this, we can verify the transaction is correct, and verify the Safe transaction hash
matches what we see in our wallet.
If a hacker took over the Safe UI/API when they sent us the malicious transaction, our script would recalculate the expected hash, and we would either see the transaction data is malicious (by inspecting the method
section) or we’d see a different hash, and we’d reject the transaction!
However, if the Safe API is compromised, we’d have to manually do our verification process.
For us to verify the hash without the Safe API, we’d just manually add the data to our tool, and use the --offline
flag. Like this (you can also run it!):
safe_hashes --offline --data 0x095ea7b3000000000000000000000000fe2f653f6579de62aaf8b186e618887d03fa31260000000000000000000000000000000000000000000000000000000000000001 --address 0x86D46EcD553d25da0E3b96A9a1B442ac72fa9e9F --network sepolia --nonce 6 --to 0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9
The data was calculated using the cast tool:
cast calldata "approve(address,uint256) 0xfe2f653f6579de62aaf8b186e618887d03fa3126 1
You can learn more about getting calldata and verifying transactions from this video. This would give you an output like this:
Transaction Data
Multisig address: 0x86D46EcD553d25da0E3b96A9a1B442ac72fa9e9F
To: 0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9
Value: 0
Data: 0x095ea7b3000000000000000000000000fe2f653f6579de62aaf8b186e618887d03fa31260000000000000000000000000000000000000000000000000000000000000001
Encoded message: 0xbb8310d486368db6bd6f849402fdd73ad53d316b5a4b2644ad6efe0f941286d80000000000000000000000007b79995e5f793a07bc00c21412e50ecae098e7f900000000000000000000000000000000000000000000000000000000000000001c62604b0ed9a9ec0e55efe8fb203b3029e147d994854cf0dd8a9fcf5b240d600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006
Skipping decoded data, since raw data was passed
Legacy Ledger Format
Binary string literal: !;\xe07'\\x94D\x9a(\xb4\xed\xea\xd7k\x0dc\xc7\xe1+R%\x7f\x9dV\x86\xd9\x8b\x9a\x1a_\xf4
Hashes
Domain hash: 0xE411DFD2D178C853945BE30E1CEFBE090E56900073377BA8B8D0B47BAEC31EDB
Message hash: 0x4BBDE73F23B1792683730E7AE534A56A0EFAA8B7B467FF605202763CE2124DBC
Safe transaction hash: 0x213be037275c94449a28b4edead76b0d63c7e12b52257f9d5686d98b9a1a5ff4
With this, you don’t even need to trust the safe API!
Once a transaction has been signed at least once, you can get the signature for it from the Safe API without passing the --untrusted
command.
Then, once everyone has signed and verified, you can run the command again with the --print-mst-calldata
flag to see what the calldata should look like, so you can go a step further and finally verify the actual transaction.
You can view more examples, documentation, and tools in the GitHub repo for the safe_hashes
tool.
Ensuring the security of multi-sig transactions is not just about using the right tools but also about understanding the process and the potential risks involved. By following these guidelines, you can safeguard your cryptocurrency assets effectively.
Please have a process for you and your team to verify signatures so you don’t fall for the same hack that plagued the Radiant team!