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
}
}
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"
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.
- Attacker computes a
CREATE2address using a known factory + salt - Gets a DAO or user to
approve()tokens to that address (it's empty — no code yet) - Waits days, weeks, or months
- Deploys a malicious contract to that exact address
- 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);
}
}
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
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)
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
The a16z detector checks for:
- Known metamorphic init code patterns
-
CREATE2deployment from factory contracts -
SELFDESTRUCTopcode presence -
DELEGATECALLthat 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"
);
}
}
Run with:
forge test --match-test testVerifyDeployedCode --fork-url $ETH_RPC_URL -vvv
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;
}
}
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]
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
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 │
└─────────────────────────────────────┘
Key Takeaways
- EIP-6780 reduced but didn't eliminate metamorphic risks. Same-transaction SELFDESTRUCT still works.
- Pre-approval address squatting is the new primary vector. Users approve tokens to addresses that don't have code yet — then attackers deploy drainers.
- Proxy + CREATE2 is functionally metamorphic. If the upgrade authority is weak, the contract can shapeshifts at will.
- Solana's upgrade authority model is the same problem in different packaging. The Step Finance exploit proved this.
- 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)