DEV Community

ohmygod
ohmygod

Posted on

CREATE2 Metamorphic Contract Detection in a Post-Dencun World: The Shapeshifting Threat That Didn't Die

TL;DR

EIP-6780 neutered SELFDESTRUCT — but metamorphic contract attacks didn't disappear. They evolved. This guide covers the new attack patterns that survive Dencun, practical detection tools every auditor should run, and a Foundry-based verification workflow to catch shapeshifting contracts before your users do.


The Myth: "Dencun Fixed Metamorphic Contracts"

When Ethereum's Dencun upgrade landed on March 13, 2024, the security community breathed a collective sigh of relief. EIP-6780 restricted SELFDESTRUCT to only work within the same transaction that created the contract. No more wiping code, resetting nonces, and redeploying malicious logic to the same address.

Problem solved, right?

Wrong.

In Q1 2026 alone, we've seen multiple incidents where CREATE2-related attack vectors played a role in DeFi exploits. The classic metamorphic pattern is dead. But three new patterns have taken its place — and most auditing tools haven't caught up.

Pattern 1: Same-Transaction Metamorphism (Still Works)

EIP-6780 left one door wide open: SELFDESTRUCT still works if called in the same transaction that deployed the contract. This means metamorphic contracts are still possible — they just need to execute their entire lifecycle in a single atomic transaction.

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

contract MetamorphicFactory {
    // Step 1: Deploy → SELFDESTRUCT → Redeploy in ONE transaction
    function morph(bytes memory initCode1, bytes memory initCode2, bytes32 salt) external {
        // Deploy first version
        address addr;
        assembly {
            addr := create2(0, add(initCode1, 0x20), mload(initCode1), salt)
        }
        require(addr != address(0), "Deploy 1 failed");

        // Self-destruct immediately (same tx = EIP-6780 allows it)
        ISelfDestruct(addr).die();

        // Redeploy different code to same address
        address addr2;
        assembly {
            addr2 := create2(0, add(initCode2, 0x20), mload(initCode2), salt)
        }
        require(addr2 == addr, "Address mismatch"); // Same address, different code
    }
}
Enter fullscreen mode Exit fullscreen mode

Why it matters: Factory contracts that deploy-and-configure in a single transaction can still swap code. If a governance proposal deploys a contract that users then interact with, the code they reviewed might not be the code that's actually running.

Detection

# Check if a contract was deployed via CREATE2 from a factory
# that also contains SELFDESTRUCT in the same deployment tx
cast run <tx_hash> --trace | grep -E "CREATE2|SELFDESTRUCT"
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Pre-Approval Address Squatting

The deadlier pattern in 2026 doesn't need SELFDESTRUCT at all. It exploits the gap between address prediction and deployment.

  1. Attacker computes a CREATE2 address using a known factory + salt
  2. Gets a DAO or user to approve() tokens to that address (it's empty — no code yet)
  3. Waits days, weeks, or months
  4. Deploys a malicious contract to that exact address
  5. Calls transferFrom() to drain approved tokens
// The approval target doesn't need to exist yet
// CREATE2 address = keccak256(0xff ++ factory ++ salt ++ keccak256(initCode))

// Months later, attacker deploys:
contract Drainer {
    function drain(address token, address victim, uint256 amount) external {
        IERC20(token).transferFrom(victim, msg.sender, amount);
    }
}
Enter fullscreen mode Exit fullscreen mode

This pattern was flagged in a Check Point Research report and has been observed targeting Uniswap V3 pool addresses and DeFi router approvals.

Detection

# Slither custom detector for approvals to codeless CREATE2 addresses
from slither.detectors.abstract_detector import AbstractDetector, DetectorClassification

class Create2ApprovalRisk(AbstractDetector):
    ARGUMENT = "create2-approval-risk"
    HELP = "Detects approve() calls where the spender could be a CREATE2 address"
    IMPACT = DetectorClassification.HIGH
    CONFIDENCE = DetectorClassification.MEDIUM

    WIKI = "https://github.com/example/create2-approval-detector"
    WIKI_TITLE = "CREATE2 Approval Risk"
    WIKI_DESCRIPTION = "Approvals to addresses that may not have code deployed yet"
    WIKI_RECOMMENDATION = "Verify contract code exists at spender address before approving"

    def _detect(self):
        results = []
        for contract in self.compilation_unit.contracts_derived:
            for function in contract.functions:
                for node in function.nodes:
                    for ir in node.irs:
                        if hasattr(ir, 'function_name') and ir.function_name == 'approve':
                            # Flag approvals where spender isn't validated
                            info = [
                                "Potential CREATE2 approval risk in ",
                                function, "\n",
                                "\tApproval at ", node, "\n",
                                "\tSpender address should be verified to contain code\n"
                            ]
                            results.append(self.generate_result(info))
        return results
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Proxy-Disguised Metamorphism

The most sophisticated 2026 variant combines CREATE2 with upgradeable proxies. The proxy address is deterministic and permanent, but the implementation it points to can change — giving you metamorphic behavior without touching SELFDESTRUCT:

User → Proxy (fixed CREATE2 address) → Implementation (swappable)
Enter fullscreen mode Exit fullscreen mode

This is technically "intended" upgradeability, but when the upgrade authority is a single EOA or a 1-of-1 multisig, it's functionally identical to a metamorphic contract.

The Audit Checklist

For any contract deployed via CREATE2:

Check Tool Risk Level
Is SELFDESTRUCT in bytecode? a16z detector 🔴 Critical
Is DELEGATECALL present? Slither 🟡 High
Who controls upgrade authority? Manual review 🔴 Critical
Are there approvals to undeployed addresses? Custom detector 🟡 High
Was the factory contract verified? Etherscan API 🟡 Medium
Does init code match verified source? Foundry create2 🟡 Medium

Practical Detection Toolkit

Tool 1: a16z Metamorphic Contract Detector

# Install
git clone https://github.com/a16z/metamorphic-contract-detector
cd metamorphic-contract-detector
pip install -r requirements.txt

# Scan a contract
python detect.py --address 0x1234...5678 --rpc $ETH_RPC_URL
Enter fullscreen mode Exit fullscreen mode

The a16z detector checks for:

  • Known metamorphic init code patterns
  • CREATE2 deployment from factory contracts
  • SELFDESTRUCT opcode presence
  • DELEGATECALL that could proxy to self-destructing code
  • Code hash changes (indicates prior morphing)

Tool 2: Foundry CREATE2 Address Verification

Verify that a deployed contract matches expected init code:

// test/VerifyCreate2.t.sol
pragma solidity ^0.8.24;

import "forge-std/Test.sol";

contract VerifyCreate2 is Test {
    function testVerifyDeployedCode() public {
        address factory = 0xFACTORY_ADDRESS;
        bytes32 salt = bytes32(uint256(1));
        bytes memory initCode = type(ExpectedContract).creationCode;

        // Compute expected address
        address expected = address(uint160(uint256(keccak256(abi.encodePacked(
            bytes1(0xff),
            factory,
            salt,
            keccak256(initCode)
        )))));

        // Compare with actual deployed address
        address actual = 0xDEPLOYED_ADDRESS;
        assertEq(expected, actual, "Address mismatch - possible metamorphic deployment");

        // Verify runtime bytecode matches
        bytes memory expectedRuntime = type(ExpectedContract).runtimeCode;
        bytes memory actualRuntime = actual.code;
        assertEq(
            keccak256(expectedRuntime),
            keccak256(actualRuntime),
            "Bytecode mismatch - contract code was modified"
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Run with:

forge test --match-test testVerifyDeployedCode --fork-url $ETH_RPC_URL -vvv
Enter fullscreen mode Exit fullscreen mode

Tool 3: On-Chain Code Hash Monitor

Deploy a simple monitor that tracks code hash changes:

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

contract CodeHashMonitor {
    mapping(address => bytes32) public knownCodeHashes;

    event CodeHashChanged(address indexed target, bytes32 oldHash, bytes32 newHash);
    event NewContractTracked(address indexed target, bytes32 codeHash);

    function trackContract(address target) external {
        bytes32 codeHash;
        assembly {
            codeHash := extcodehash(target)
        }
        require(codeHash != 0, "No code at address");
        knownCodeHashes[target] = codeHash;
        emit NewContractTracked(target, codeHash);
    }

    function verifyContract(address target) external returns (bool unchanged) {
        bytes32 currentHash;
        assembly {
            currentHash := extcodehash(target)
        }
        bytes32 knownHash = knownCodeHashes[target];

        if (currentHash != knownHash) {
            emit CodeHashChanged(target, knownHash, currentHash);
            knownCodeHashes[target] = currentHash;
            return false;
        }
        return true;
    }
}
Enter fullscreen mode Exit fullscreen mode

Tool 4: Semgrep Rules for CREATE2 Patterns

# .semgrep/create2-risks.yml
rules:
  - id: create2-with-selfdestruct
    patterns:
      - pattern: |
          assembly {
            ...
            create2(...)
            ...
          }
      - pattern-either:
          - pattern: selfdestruct(...)
          - pattern: |
              assembly {
                ...
                selfdestruct(...)
                ...
              }
    message: >
      Contract uses CREATE2 alongside SELFDESTRUCT.
      Post-Dencun, this is still exploitable in same-transaction scenarios.
    severity: ERROR
    languages: [solidity]

  - id: approve-without-code-check
    pattern: |
      $TOKEN.approve($SPENDER, $AMOUNT);
    pattern-not-inside: |
      require($SPENDER.code.length > 0, ...);
      ...
    message: >
      Token approval without verifying spender has deployed code.
      CREATE2 addresses can receive approvals before deployment.
    severity: WARNING
    languages: [solidity]
Enter fullscreen mode Exit fullscreen mode

The Solana Parallel: Program Authority as Metamorphism

Solana doesn't have CREATE2, but it has something arguably worse: upgradeable programs. Every Solana program deployed with BPFLoaderUpgradeable can have its entire binary replaced by whoever holds the upgrade authority key.

After the Step Finance exploit in January 2026 ($27.3M lost through compromised upgrade authority keys), the parallels are clear:

Ethereum Metamorphic Solana Equivalent
CREATE2 + SELFDESTRUCT BPFLoaderUpgradeable::Upgrade
Factory contract Program deploy authority
Same address, new code Same program ID, new binary
EIP-6780 mitigation --final flag (rarely used)

Detection for Solana:

# Check if a program is upgradeable
solana program show <PROGRAM_ID>

# Look for upgrade authority
solana program show <PROGRAM_ID> --output json | jq '.authority'

# If authority is not null and not a multisig → RED FLAG
Enter fullscreen mode Exit fullscreen mode

Recommended Audit Workflow

For any protocol that uses CREATE2 or factory patterns:

┌─────────────────────────────────────┐
│ 1. STATIC ANALYSIS                  │
│    • Run a16z detector              │
│    • Run Slither with custom rules  │
│    • Run Semgrep CREATE2 rules      │
└──────────────┬──────────────────────┘
               │
┌──────────────▼──────────────────────┐
│ 2. BYTECODE VERIFICATION            │
│    • Foundry CREATE2 address check  │
│    • Compare init code vs deployed  │
│    • Check EXTCODEHASH consistency  │
└──────────────┬──────────────────────┘
               │
┌──────────────▼──────────────────────┐
│ 3. AUTHORITY ANALYSIS               │
│    • Who can call the factory?      │
│    • Is upgrade authority a multisig?│
│    • Are there timelocks on morphs? │
└──────────────┬──────────────────────┘
               │
┌──────────────▼──────────────────────┐
│ 4. RUNTIME MONITORING               │
│    • Deploy CodeHashMonitor         │
│    • Set up Forta bot for code Δ    │
│    • Alert on new deployments to    │
│      known CREATE2 addresses        │
└─────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. EIP-6780 reduced but didn't eliminate metamorphic risks. Same-transaction SELFDESTRUCT still works.
  2. Pre-approval address squatting is the new primary vector. Users approve tokens to addresses that don't have code yet — then attackers deploy drainers.
  3. Proxy + CREATE2 is functionally metamorphic. If the upgrade authority is weak, the contract can shapeshifts at will.
  4. Solana's upgrade authority model is the same problem in different packaging. The Step Finance exploit proved this.
  5. Layer your detection: Static analysis → bytecode verification → authority audit → runtime monitoring.

The metamorphic threat didn't die with Dencun. It just learned to shapeshift in new ways. Your audit toolkit needs to evolve with it.


This is part of the DeFi Security Research series. Follow for weekly deep-dives into smart contract vulnerabilities, audit techniques, and defense patterns.

Top comments (0)