Ensure the security of your smart contracts

Metamorphic Smart Contracts: Is EVM Code Truly Immutable?

Author: Sergey Boogerwooger
Security researcher at MixBytes
It is commonly believed that smart contract code on Ethereum is immutable and cannot be changed once deployed. However, this is only true if the contract was deployed using the standard procedure.

This article is about techniques that allow one to create a smart contract at a specific address and then change its internal logic by modifying the bytecode that processes user data.
Changing the bytecode of a fixed account in Ethereum is possible using a sequence of CREATE/CREATE2 and SELFDESTRUCT calls. Each contract deploys and destructs its heirs in this sequence, and understanding these operations is crucial to continue with this article. While they are well-described in the documentation, we will provide a brief overview:

CREATE, and CREATE2 are unique EVM operations that create new accounts. A contract can only be created if the account is "empty," where "empty" means codesize == 0 and nonce == 0. A newly created smart-contract has codesize > 0 and nonce == 1. Although this information is well-known, it is worth mentioning the following details about CREATE/CREATE2:

  • CREATE - calculates address of new contract as keccak256(_deployer_addr, deployer_nonce_)
  • CREATE2 - calculates address of new contract as keccak256(_0xFF, deployer_addr, salt, bytecode)

SELFDESTRUCT, in contrast, "clears" the account by resetting its bytecode and nonce. For our purposes, it is important to note that SELFDESTRUCT resets the nonce of the account, allowing us to use CREATE multiple times from the same address with the same nonce.
How it's made
If you use CREATE -> SELFDESTRUCT -> CREATE -> SELFDESTRUCT -> ... with the same bytecode from the same address, each new CREATE will deploy a contract to a new address because deployer_nonce will increase with each new transaction.

If you use CREATE2 -> SELFDESTRUCT -> CREATE2 -> SELFDESTRUCT -> ... with the same bytecode and salt, the resulting address will be the same in each iteration.

However, as you may have noted, in the first case (with CREATE), the deployment address can be the same if "deployer_nonce" is not changed. Here's the trick: with SELFDESTRUCT, we can reset the nonce of the address where the contract created with CREATE2 resides, allowing it to be redeployed. Then, CREATE can deploy a new contract from the same address but with a different code.
Combining all of this, we can implement the following example scenario:

  1. Deploy MutDeployer contract with CREATE2. New contract account has nonce == 1 (EIP161).
  2. MutDeployer deploys MutableV1(first version of mutable code) with CREATE. New contract is deployed at the address = keccak256(MutDeployerAddr, MutDeployerNonce == 1).
  3. Users interact with MutableV1, thinking that its code is constant.
  4. Owner SELFDESTRUCT MutableV1 to later deploy MutableV2 on the same address.
  5. Owner SELFDESTRUCT MutDeployer, making its account empty (codesize ==0 && nonce == 0).
  6. User repeats step 1, deploying the same MutDeployer contract with CREATE2 at the same address. But now MutDeployer has nonce == 1, exactly like in step 1.
  7. MutDeployer deploys MutableV2 with new bytecode with CREATE (like in step 2). New contract is deployed at the same address == keccak256(MutDeployerAddr, MutDeployerNonce == 1).
  8. Users interact with MutableV2 using the same address as for MutableV1.

The same scenario in code:

The code and test that demonstrate the workings of this pattern can be found here. You can play with it in this educational repo by running the test with npx hardhat test test/Metamorph.js.

Another important point for such constructions is DELEGATECALL, allowing one contract to call SELFDESTRUCT in its context. While the SELFDSECTRUCT instruction resides in the external contract's bytecode, it adds some complication in detecting mutable code.
How to defend
The detection of metamorphic contracts is described in detail in this detector & article, which allows you to check if a given address can have mutable code. In short, to detect such situations, you need to check that the contract was not deployed by another contract, does not contain the SELFDESTRUCT opcode, or uses DELEGATECALL to a contract with SELFDESTRUCT, and that CREATE2 was used for deployment, among other things.

Therefore, if these checks are performed, you can be sure that the contract's code is immutable. However, as is typical for security scenarios, avoiding detection seems possible by building scenarios with DELEGATECALL->DELEGATECALL->DELEGATECALL->... call chains or other tricks, so be careful.

Great respect is due to the a16z team for their work on this important topic. Another good article on this topic from Coinmonks can be found here.
The role of SELFDESTRUCT in addressing the aforementioned questions is extremely important, and we must also mention the ongoing discussion about SELFDESTRUCT in the Ethereum community. For the EVM, SELFDESTRUCT is a "very special" operation because it is initiated from the contract's code by the opcode in the EVM. The subsequent operations are executed outside the EVM context in the state database because, after SELFDESTRUCT, the node must destroy the code and the account state and erase its storage and all related fields. So, in addition to the issues related to creation/destruction, there are also concerns about the overall performance of the blockchain.

Moreover, SELFDESTRUCT primary purpose, which is to clean up space in the state database, is not practical in real-world scenarios. Protocols do not include SELFDESTRUCT in their code because users are unwilling to put their money in a contract that can be destroyed. Furthermore, SELFDESTRUCT renders any contract analyzing scripts inconsistent because when you download the contract bytecode from an address at a particular block in the past, you cannot be certain that this code will remain at the same address later.

Vitalik has provided a compelling case for removing the SELFDESTRUCT instruction from EVM. We agree that this action will greatly improve smart-contract security and not significantly affect blockchain behavior. The problems solved by SELFDESTRUCT can be mitigated with other mechanics, such as purging and compactifying inactive accounts, separation of data/consensus layers, and other mechanics. So, we're waiting for SELFDESTRUCT to self-destruct :)
Other posts