In the rapidly evolving world of decentralized finance (DeFi), the recent attack on Tornado Cash’s Governance community stands as a stark reminder of the challenges that come with digital security.
It turns out that users are not always fully aware of what they are voting for and the consequences of this can be quite bad.
Always stay aware
In Poland, the saying Beware of the wolf in sheep's clothing has been repeated for a long time. However, it seems that it might just start gaining momentum in the world of smart contracts.
The wolf turned out to be a malicious smart contract hidden under the coat of a seemingly ordinary proposal in the case of Tornado Governance.
The attacker tricked the community. They got a harmless-looking proposal approved through the regular voting process. Once approved, the attacker destroyed the original smart contract through a hidden emergency function selfdestruct and sneakily replaced the code at the same address.
Let’s understand the Tornado Cash hack to stay vigilant and prevent this kind of attack vector in the future.
Tornado Cash Governance - An Overview
Tornado Cash is a privacy-focused decentralized application (DApp) on the Ethereum blockchain, aimed at ensuring secure and private transactions. By mixing potentially identifiable funds with others, it aims to preserve the anonymity of users.
This project is governed through a Tornado Cash community using the $TORN token as a voting system for protocol updates. Users can participate by creating proposals, delegating “power” or voting after they lock their tokens in the governance contract.
Governance proposals can change any of Tornado Cash’s internal parameters, including upgrading its implementation. Users create proposals as smart contracts (must have verified code) and pass their addresses through propose function in target parameter:
After passing through the voting positively, the proposal can be executed by anyone through delegatecall from the Governance contract as we can observe below:
Technical underpinnings - CREATE, CREATE2
In a meticulously planned attack, the black hat presented a proposal that seemed harmless. Upon passing the vote, they took advantage of the selfdestruct and both CREATE and CREATE2 opcodes - to obliterate the existing contract and deploy a new one at the same address with malicious intent.
Let's break it down into small pieces to understand every part of it.
The contract address using CREATE is computed as follows:
Take the creator's address.
Append the creator's nonce to the address.
Hash the resulting combination using the Keccak-256 (SHA-3) hashing algorithm.
Take the last 20 bytes (40 hexadecimal characters) of the hash.
The resulting 20-byte hash is the contract address. It uniquely identifies the deployed contract on the Ethereum blockchain.
It's important to note that the contract address is determined at the time of contract creation and cannot be changed later. Any subsequent deployments of contracts by the same creator will have different addresses due to the incremented nonce.
To compute the contract address using CREATE2, we have to perform the following steps:
Concatenate the following values in order: 0xFF (a constant that prevents collisions with CREATE), the creator's address, the salt value, and the contract creation bytecode.
Hash the resulting concatenation using the Keccak-256 (SHA-3) hashing algorithm.
Take the last 20 bytes (40 hexadecimal characters) of the hash.
The resulting 20-byte hash is the contract address that would be assigned to the contract if it were deployed using CREATE2 with the given creator's address and salt value.
The key difference when using CREATE2 is that it enables the prediction of a contract's address ahead of time.
The Attack - What Happened
Now that we know how CREATE and CREATE2 work, let's consider how to create a second contract in place of the old one, with a different code under the same address.
Using CREATE2 we are able to predict the address. By creating several contracts from the same address, using the same salt and the same bytecode, we will get the same address.
However, it is not enough and the attack would fail. We need to change the bytecode to implement our evil exploit - so that can’t be the way.
Using CREATE, we are unable to predict the final address. We can deploy it and only then we'll know it. However, with the next deployment, the nonce will increase and the address, even with the same bytecode, will be different - so this is also not it.
If only there was a way to reset this nonce...
Actually, there is… and that's exactly how the attack was carried out!
As we mentioned earlier in this article, this attack required both CREATE2 and CREATE.
Let’s see the simplified diagram.
The attacker creates a contract containing a function that allows the creation of a contract with a predictable address.
The attacker, by calling a function in contract 0xAAA using CREATE2, creates a contract with a known address.
From the 0xBBB contract whose address is known and the current nonce is 0, the attacker uses CREATE opcode to deploy a 0xCCC contract that plays a seemingly ordinary proposal.
After successful voting, but before proposal execution, the attacker destroys the 0xBBB and 0xCCC contracts by selfdestruct hidden in the emergency functions.
Then, the attacker started the proposal creation process anew.
The attacker re-creates the 0xBBB contract using the same parameters.
The nonce is 0 again, so calling the function again allows to deploy a completely different bytecode to the address 0xCCC.
Now that we know how the proposal was changed, only one question remains - what exactly was the malicious code and what did it do?
The attacker had previously prepared addresses that locked 0 $TORN tokens to later increase their lockedBalance by 10 000 $TORN each, through the additional sstore instructions in the malicious proposal.
They did this for multiple addresses.
Stay security aware
Smart contracts ideologically strive to be independent and trustless, but we are not at that stage yet. For now, we have to be very careful and treat everything we encounter in the dark forest with limited trust.
What should be improved?
While the Tornado Cash incident is a tough lesson, it serves as a wake-up call for Governance security. As we venture deeper into the world of decentralization, it's vital to ensure multilayer security is in place to mitigate such threats.
Here are some tips on what can be done better in the future.
Rethink voting periods.
Consider introducing rewards for submitting suspicious proposals.
Consider including external audits for proposals.
Make sure that the proposals that users are to vote on are as clear and understandable as possible.
Search the code for suspicious functions (e.g., selfdestruct) and reject such proposals or proceed with caution.
Use C2: Governance checks from SCSVSv2.
Approach proposals with caution and verify them.
Don't vote on proposals you don't fully understand.
Ask the community for an explanation of the proposals you do not understand.
P.S. the story is not over
The attacker has created another proposal in which they want to handle back control to the DAO:
Did you like this article? Share it on social media!
Composable Security 🇵🇱⛓️ is a Polish company specializing in increasing the security of projects based on smart contracts written in Solidity. Examples of projects that have trusted us are market leaders such as FujiDAO, Enjin, or Tellor. We are creators of the Smart Contract Security Verification Standard. Speakers at various conferences such as EthCC, ETHWarsaw, or OWASP AppSec EU. Authors of numerous publications on DeFi security. Experienced auditors operating in the IT Security space since 2016.
If you need support in the field of security or auditing smart contracts do not hesitate to contact us.
About the author
Co-author of SCSVS and White Hat. Professionally dealing with security since 2017 and since 2019 contributing to the crypto space. Big DeFi fan and smart contract security researcher.