EVM Metamorphic Contracts

EVM Metamorphic Contracts

This post is a hands-on look at how contract addresses are computed and why that matters for governance and metamorphic contracts. We'll use a tiny factory + child pattern to demonstrate how a contract can be destroyed and then redeployed at the same address, something that was a key ingredient in the Tornado Cash DAO incident.

In May 2023, Tornado Cash's governance was taken over via a malicious proposal. Once passed, the attacker gained control of the DAO, withdrew locked votes, and drained TORN from governance vaults. A technical trick at the heart of the incident was leveraging deterministic addresses and contract replacement to make a proposal look benign while later executing malicious logic. The attacker used a proposal contract whose address was pre-known/deterministic, and by combining CREATE2 with SelfDestruct() (on pre-Cancun semantics), they could redeploy code at the same address in a later transaction to change what that address actually did.

In this post we'll simulate that behavior on a local chain. To do it in two separate transactions (deploy, destroy and then redeploy), we must run a chain with pre-Cancun (pre-EIP-6780) rules, because at the time of writing this article, SelfDestruct() does not delete code unless it happens in the same transaction as contract creation. We'll therefore run Anvil with --hardfork shanghai.

When the CREATE opcode is used to deploy a contract, the address is derived from the creator's address and the creator's nonce. It changes as the creator's nonce changes:

CREATE
address = last20bytes( keccak256( rlp([creator_address, creator_nonce]) ) )

With CREATE2 (EIP-1014), the address is deterministic from the deployer, a developer-chosen salt, and the init_code hash:

CREATE2
address = last20bytes( keccak256( 0xff ++ deployer ++ salt ++ keccak256(init_code) ) )

With CREATE2 the rule is simple. If you deploy from the same address using the same bytecode and the same salt, the contract will always be deployed at the same address.

The Tornado Cash attacker chained CREATE, CREATE2 and SelfDestruct() to pull a neat address-sleight-of-hand. First they shipped something that looked perfectly legit as a DAO proposal. Once the vote passed, they redeployed different code at the exact same address... So the original proposal's address turned into a trapdoor.

They did it in three steps:

First, the attacker deploys FactoryCreate, then uses it to deploy Child with CREATE2. Because CREATE2 is used, the address of Child is precomputable and repeatable as long as the same salt and bytecode are used.

After that, the Child contract is used to deploy a benign DAOProposal via CREATE. With CREATE, the derived address is
last20( keccak256( rlp([ childAddress, childNonce ]) ) ). Note that the nonce used here is 0. The deployed proposal looks fine, governance approves it, everyone claps.

Then, the attacker calls SelfDestruct() on Child and DAOProposal contracts. Now those addresses are empty. They then redeploy Child at the same address using CREATE2 with the same salt. This new Child is "born again" and the nonce is set to 0 instead of 1. Now, the attacker uses CREATE (with nonce 0) to deploy DAOProposal_Rogue in the same address than the original DAOProposal contract. Same address, different runtime code. Oops.

Now let's actually run the stunt. We will use a tiny factory and a child that can spawn two flavors of proposal. The clean one returns 1 and the spicy one returns 31337. The child can also call SelfDestruct() so on a Shanghai chain the account is cleared at the end of the transaction, which lets us redeploy later to the same address with CREATE2. Here is the code we will use:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract DAOProposal {
    constructor() payable {}

    function doessomething() external returns (uint256) {
        return 1;

    }
    function kill() external {
        selfdestruct(payable(msg.sender));
    }
}

contract DAOProposal_Rogue {
    constructor() payable {}

    function doessomething() external returns (uint256) {
        return 31337;

    }
    function kill() external {
        selfdestruct(payable(msg.sender));
    }
}


contract Child {
    address public deployedAddress;

    constructor() payable {}

    function spawnDAOProposal() external payable returns (address addr) {
        bytes memory bytecode = type(DAOProposal).creationCode;
        assembly {
            // create(value, ptr, size)
            addr := create(callvalue(), add(bytecode, 0x20), mload(bytecode))
            if iszero(addr) { revert(0, 0) }
        }
        deployedAddress = addr;
    }

    function spawnDAOProposalRogue() external payable returns (address addr) {
        bytes memory bytecode = type(DAOProposal_Rogue).creationCode;
        assembly {
            // create(value, ptr, size)
            addr := create(callvalue(), add(bytecode, 0x20), mload(bytecode))
            if iszero(addr) { revert(0, 0) }
        }
        deployedAddress = addr;
    }


    function kill() external {
        selfdestruct(payable(msg.sender));

    }
}

contract FactoryCreate {
    address public deployedAddress;

    function deployChild() external payable returns (address addr) {

        bytes memory code = type(Child).creationCode;
        assembly {
            addr := create2(callvalue(), add(code, 0x20), mload(code), 0x00)
            if iszero(addr) { revert(0, 0) }
        }
        deployedAddress = addr;

    }
}

We will run the chain in Shanghai mode so SelfDestruct() really clears code across transactions:

$ anvil --chain-id 31337 --fork-url [REDACTED] --hardfork shanghai

First we deploy the factory:

$ forge create src/FactoryCreate.sol:FactoryCreate --broadcast
Deployed to: 0x8F80400c97D23F53227042bB30b42b625A4bA685

Now we ask the factory to deploy the child with CREATE2:

# We use FactoryCreate to deploy a Child contract
$ cast send 0x8F80400c97D23F53227042bB30b42b625A4bA685 "deployChild()"

# We verify the address of the Child
$ cast call 0x8F80400c97D23F53227042bB30b42b625A4bA685 "deployedAddress()"
0x0000000000000000000000007e62cb0db71ca7ac12ea6e0f9327ca09b12f83c9

With the child live, we let it deploy the harmless proposal with CREATE. If governance had a comfort blanket, this is where it would tuck it in:

# We use the Child contract to deploy a DAOProposal
$ cast send 0x7e62cb0db71ca7ac12ea6e0f9327ca09b12f83c9 "spawnDAOProposal()"

# Check where the DAOProposal has been deployed
$ cast call 0x7e62cb0db71ca7ac12ea6e0f9327ca09b12f83c9 "deployedAddress()"
0x00000000000000000000000069e2ffa5242096b6c8221e237018194338c6cb0f

We confirm that when we call the doessomething() function of the DAOProposal, it returns 1:

# We execute doessomething() and it returns 1
$ cast call 0x69e2ffa5242096b6c8221e237018194338c6cb0f "doessomething()" 
0x...01

Time for the costume change. We destroy the child and the benign proposal so the addresses become empty on this fork. Then we double check that there is no code at either location:

# Selfdestruct of the CHild contract
$ cast send 0x7e62cb0db71ca7ac12ea6e0f9327ca09b12f83c9 "kill()" 

# Selfdestruct of the DAOProposal contract
$ cast send 0x69e2ffa5242096b6c8221e237018194338c6cb0f "kill()" 

# Then we confirm that the bytecode has been removed from the blockchan
$ cast code 0x7e62cb0db71ca7ac12ea6e0f9327ca09b12f83c9
0x

$ cast code 0x69e2ffa5242096b6c8221e237018194338c6cb0f
0x

We redeploy the child with the same CREATE2 recipe and immediately ask it to spawn the rogue proposal. The child address repeats and the first CREATE from a fresh child aims at the same derived proposal address as before, which is exactly the trick:

# We redeploy the Child on the same address
$ cast send 0x8F80400c97D23F53227042bB30b42b625A4bA685 "deployChild()" 

# We use the CHild contract to deploy a DAOProposal_Rogue contract
$ cast send 0x7e62cb0db71ca7ac12ea6e0f9327ca09b12f83c9 "spawnDAOProposalRogue()"

# We confirm that it has been redeployed in the same address than DAOProposal
$ cast call 0x7e62cb0db71ca7ac12ea6e0f9327ca09b12f83c9 "deployedAddress()"
0x00000000000000000000000069e2ffa5242096b6c8221e237018194338c6cb0f

Same address. Different runtime code. To confirm that we are executing a different bytecode within the same address, we call again the doessomething() function. Now we don't get 1 as before... Now we get 0x7a69 (31337 in decimal).

$ cast call 0x69e2ffa5242096b6c8221e237018194338c6cb0f "doessomething()"
0x...7a69

And that is the bit where the rabbit jumps out of the same hat wearing new clothes. More experiments are coming soon, so keep your salt handy and your popcorn closer.