Skip to content

a16z/metamorphic-contract-detector

Repository files navigation

Metamorphic Contract Detector

Metamorphic Contract Detector

Overview

A critical Ethereum security assumption is that smart contract code is immutable and therefore cannot be changed once it is deployed on the blockchain. In practice, some smart contracts can change – even after they’ve been deployed. With a few clever tricks, you can create metamorphic smart contracts that “metamorphose” into something else – and by understanding what makes them possible, you can detect them.

Metamorphic smart contracts are mutable, meaning developers can change the code inside them. These smart contracts pose a serious risk to web3 users who put their trust in code that they expect to run with absolute consistency, especially as bad actors can exploit this shape-shifting ability. Imagine an attacker using the technique to “rug” people who are staking tokens in a smart contract they don’t realize is metamorphic. Attacks based on this and similar premises could equip scammers to prey on people and generally undermine trust in the full promise of decentralized systems.

To analyze if a smart contract contains metamorphic properties, I built a simple Metamorphic Contract Detector (inspired by and building on the original work of Jason Carver, 0age, and others). Anyone can use the tool to check whether a given contract exhibits red flags that could indicate the potential for metamorphism. The method is not fool-proof: just because a smart contract shows a flag, doesn’t mean it’s necessarily metamorphic; and just because it doesn’t, doesn’t mean it’s safe. The checker merely offers a quick initial assessment that a contract might be metamorphic based on possible indicators.

You can read my full post on Metamorphic Smart Contracts here

This repository contains a command-line tool and streamlit app to detect metamorphic smart contracts on the Ethereum blockchain. Specifically, the command-line tool and streamlit app detect six things:

  1. contains_metamorphic_init_code: The detector will check if known metamorphic bytecode shows up in a transaction for a given smart contract’s deployment, that’s a major red flag. An important caveat: There are potentially innumerable variations of metamorphic bytecode, which makes detecting all varieties difficult. By scanning for well-known instances though, the detector eliminates low-hanging fruit for attackers who are merely copying and pasting existing examples.

  2. contains_selfdestruct: To replace the code in a contract – a key step in creating a metamorphic contract – a developer first needs to delete pre-existing code. The only way to do this is by using the SELFDESTRUCT opcode (0xFF), a command that does exactly what it sounds like – it erases all code and storage at a given contract address. The presence of self-destructing code in a contract does not prove that it is metamorphic; however, it offers a clue that the contract might be metamorphic and it’s worth knowing, anyway, whether contracts you’re relying on can nuke themselves.

  3. contains_delegatecall: The detector will check if the smart contract in question can't directly self-destruct, it may still be able to erase itself by using the DELEGATECALL opcode. This opcode allows a smart contract dynamically to load and execute code that lives inside another smart contract. Even if the smart contract doesn't contain the SELFDESTRUCT opcode, it can use DELEGATECALL to load self-destructing code from somewhere else. While the DELEGATECALL functionality does not directly indicate if a smart contract is metamorphic, it is a possible clue – and potential security issue – that’s worth noting. Be warned that this indicator has the potential to raise many false positives.

  4. deployed_by_contract: Metamorphic contracts can be deployed only by other smart contracts. This is because metamorphic contracts are enabled by another opcode, usable only by other smart contracts, called CREATE2. This trait is one of the least conspicuous indicators of possible metamorphism; it is a necessary but insufficient precondition. Scanning for this trait is likely to raise many false positives – but it is valuable information to know as it can raise suspicions and provide a reason to scrutinize a contract further, especially if the smart contract contains the opcode described next.

  5. deployer_contains_create2: If a deployer contract contains the CREATE2 opcode, that may indicate that it used CREATE2 to deploy the contract in question. If the deployer did indeed use CREATE2 to deploy said contract, while that doesn’t mean the contract is necessarily metamorphic, it does mean that it might be metamorphic and it may be wise to proceed with caution and investigate further.

  6. code_has_changed: This is the most obvious tell, but it will only show up after a metamorphic contract has already morphed. If the smart contract’s code hash – a unique, cryptographic identifier – is different than it was when the contract was initially deployed, then it’s likely the code was removed, replaced, or altered. If the hashes no longer match, then something about the code has changed and the contract might be metamorphic. This flag is the surest indicator of metamorphism, but it won’t help predict or preempt morphing since it only checks that it already happened.

Additionally, this repository contains a series of /contracts and /test that demonstrate how a metamorphic smart contract works in practice. Specifically, these smart contracts demonstrate:

  1. Deploying a metamorphic smart contract where users can stake an ERC-20 token to earn a yield.
  2. Replacing the code of that smart contract to include a malicious function that allows someone to steal all staked tokens.

Installation

Command-Line Tool and Streamlit App Installation

First, obtain an Alchemy API key. You can sign up for a free account at alchemy.com. This command-line tool and streamlit app requires that your Alchemy API key has tracing enabled.

Second, install poetry.

Third, clone this repo and install dependencies with the following commands:

git clone https://github.com/a16z/metamorphic-contract-detector.git
cd metamorphic-contract-detector
poetry install

Smart Contracts and Hardhat Tests Installation

Install hardhat and other npm packages with

npm i

Usage

Command-Line Tool

Here is an example command to check if 0x00000000008c9782FF4EB38e9293eE3DFA75FA9e is metamorphic:

poetry run python cli.py 0x00000000008c9782FF4EB38e9293eE3DFA75FA9e <INSERT ALCHEMY API KEY HERE>

The output should look something like this:

Code Changed: FALSE
Contains Metamorphic Init Code: TRUE
Contains SELFDESTRUCT: TRUE
Contains DELEGATECALL: TRUE
Deployed by Contract: TRUE
Deployer Contains CREATE2: TRUE

Streamlit App

First, add your Alchemy api key into a .env file in the repo directory. It should look something like this:

ALCHEMY_API_KEY="INSERT API KEY HERE"

Next, run the following command to launch the streamlit app in your browser:

poetry run streamlit run streamlit_app.py

I also deployed a sample of this streamlit app here.

Smart Contracts and Hardhat Tests

To execute the hardhat tests that simulate staking tokens in a metamorphic contract, replacing the code in that contract, and stealing the staked tokens, run the following command:

npx hardhat test

More Examples

Here are a few metamorphic contracts on Ethereum you can analyze with the command-line tool and streamlit app:

Related Work and Credits

Metamorphic Init Code Breakdown

Here is an opcode by opcode walkthrough of Metamorphic Init Code. This combines my own descriptions with 0age's.

You can load these opcodes and comments directly into evm.codes and step through each opcode one at a time using this link

// The STATICCALL opcode executes code that lives at a specified address on the blockchain.
// It takes a few parameters as input
// 1. gas: amount of gas to send in the call to the specified address
// 2. address: the ETH address you are calling to
// 3. argsOffset: The starting index in memory of the calling smart contract that contains the calldata to send along in your STATICCALL
// 4. argSize: size in bytes of the calldata you are sending in STATICCALL
// 5. retOffset: index in memory where you want to store any data returned by STATICCALL
// 6. retSize: size in bytes of the returned data

// Metamorphic Init Code: 0x5860208158601c335a63aaf10f428752fa158151803b80938091923cf3

// The first section of this init code pushes data onto the
// stack that is needed for a STATICCALL to the metamorphic factory contract

// ======= SECTION 1 =======

// push a 0 onto the stack
PC
// STACK: [0]

// push the number 32 onto the stack (0x20 == 32 in hex).
// this is the size of data in bytes that will be returned by STATICCALL
// "retSize" input to STATICCALL
PUSH1 0x20
// STACK: [0, 32]

// push a 0 onto the stack.
// This is the index in contract memory where any returned data from STATICCALL will be stored
// "retOffset" input to STATICCALL
DUP2
// STACK: [0, 32, 0]

// push a 4 onto the stack
// this is the size in bytes of the calldata sent along in STATICCALL
// NOTE: 4 bytes is the length of a function selector. We will be calling a function in this STATICCALL
// "argSize" input to STATICCALL
PC
// STACK: [0, 32, 0, 4]

// push 28 onto the stack (0x1c == 28 in hex).
// this is the position of the function selector in contract memory
// the reason for 28 is because you store 32 bytes at a time in memory
// so the index after storing a 4 byte function selector will be 28 (32-4).
// see how the data is padded by zeros in evm.codes
// "argsOffset" input to STATICCALL
PUSH1 0x1c
// STACK: [0, 32, 0, 4, 28]

// push the caller address on the stack
// this would be the factory addess that is deploying the metamorphic contract
// "address" input to STATICCALL
CALLER
// STACK: [0, 32, 0, 4, 28, factory address]

// push the amount of gas to send along with STATICCALL
// "gas" input to STATICCALL
GAS
// STACK: [0, 32, 0, 4, 28, factory address, gas]


// push the function selector onto the stack
// this is the first 4 bytes of the Keccak-256 hash of the function getImplementation() in the factory contract.
// this is the data you will store in memory using MSTORE.
PUSH4 0xaaf10f42
// STACK: [0, 32, 0, 4, 28, factory address, gas, 0xaaf10f42]

// push a 0 onto the stack
// this is the index of where to store the function selector in memory for the next MSTORE opcode
DUP8
// STACK: [0, 32, 0, 4, 28, factory address, gas, 0xaaf10f42, 0]

// store the function selector in memory
// again the index in memory for where it will be is 28 (see evm.codes)
MSTORE
// STACK: [0, 32, 0, 4, 28, factory address, gas]

// perform the STATICCALL using several values you have pushed onto the stack up until this point
// this STATICCALL will fetch the contract address that contains
// the implementation code you will later store in the metamorphic contract.
STATICCALL
// STACK: [0, 1 (if successful)]



// ======= SECTION 2 =======
// now that you have the implementation address in memory
// copy the code stored at that address into the current contract (the metamorphic contract)
// two main opcodes in this section are EXTCODESIZE and EXTCODECOPY

// The EXTCODESIZE opcode takes as input a contract address and returns the byte size of the code at that address
// This data is needed for EXTCODECOPY

// The EXTCODECOPY opcode copies the runtime code stored at a specific address and stores it in memory.
// It takes a few parameters as input
// 1. address: 20-byte address of the contract whose code you want to copy into memory
// 2. destOffset: the byte offset in memory where the result will be copied. This is the memory of the metamorphic contract.
// 3. offset: the byte offset in the "code" region of the address we are copying code from
//    The "code" region is the area of general Ethereum account storage where the runtime code of a smart contract is located.
//    Learn more here: https://www.evm.codes/about
// 4. size: byte size of the code to copy (you get this from EXTCODESIZE)


// flip success bit (on top of the stack) to 0
ISZERO
// STACK: [0, 0]

// push another 0 onto the stack
// this is the position of the address fetched from STATICCALL in memory
DUP2
// STACK: [0, 0, 0]

// load 32 bytes of data from index 0 in memory
// this would contain the contract address returned by the STATICCALL
MLOAD
// STACK: [0, 0, implementation contract address]

// push the implementation contract address onto the stack again for EXTCODECOPY to consume
DUP1
// STACK: [0, 0, implementation contract address, implementation contract address]

// return size in bytes of code stored at the implementation contract address
// and push this value onto the stack
EXTCODESIZE
// STACK: [0, 0, implementation contract address, contract size]

// manipulate stack items for use by the RETURN opcode at the end of this init code
DUP1
SWAP4
// STACK: [contract size, 0, implementation contract address, contract size, 0]

// push 0 onto the stack
// This 0 represents the "offset" parameter of EXTCODECOPY and it is referencing the "code"
// region of the implementation contract address.
// next, reorder stack items for EXTCODECOPY
DUP1
SWAP2
SWAP3
// STACK: [contract size, 0, contract size, 0, 0, implementation contract address]

// execute the EXTCODECOPY opcode which clones the runtime code of the implementation contract
// into memory at position 0 or the "destOffset" parameter of EXTCODECOPY
EXTCODECOPY
// STACK: [contract size, 0]

// RETURN to deploy the final code that is currently in memory
// and effectively store the implementation contract code at the metamorphic contract address
RETURN

Disclaimer

These smart contracts and code are being provided as is. No guarantee, representation or warranty is being made, express or implied, as to the safety or correctness of the user interface or the smart contracts and code. They have not been audited and as such there can be no assurance they will work as intended, and users may experience delays, failures, errors, omissions or loss of transmitted information. In addition, using these smart contracts and code should be conducted in accordance with applicable law. Nothing in this repo should be construed as investment advice or legal advice for any particular facts or circumstances and is not meant to replace competent counsel. It is strongly advised for you to contact a reputable attorney in your jurisdiction for any questions or concerns with respect thereto. a16z is not liable for any use of the foregoing and users should proceed with caution and use at their own risk. See a16z.com/disclosure for more info.