The Ethernaut CTF Solutions | 18 - Magic Number
Byte by Byte: Building an EVM Solution to Solve the Meaning of Life

Search for a command to run...
Byte by Byte: Building an EVM Solution to Solve the Meaning of Life

No comments yet. Be the first to comment.
Find the explanations and solutions to beat every level of Ethernaut challenges. Solidity + Foundry
Exploiting Arrays: How Underflow Leads to Ownership in the Alien Codex Challenge
From Flash to Cash: Exploiting Governance in the Selfie Challenge

Timing and Technique: Exploiting The Rewarder for Maximum Gain

A Deep Dive into the Side Entrance Challenge: Mastering the Mechanics of Flash Loans

DeFi Vulnerabilities Exposed: How to Solve the Truster Flash Loan Exploit"

Staking Strategies: An Insightful Journey Through Smart Contract Exploits in DeFi

Ok, so here it gets way more advanced than previous levels. This level while looking quite simple, requires a deep knowledge of EVM opcode, how the stack works, and contract creation code and runtime code.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract MagicNum {
address public solver;
constructor() {}
function setSolver(address _solver) public {
solver = _solver;
}
/*
____________/\\\_______/\\\\\\\\\_____
__________/\\\\\_____/\\\///////\\\___
________/\\\/\\\____\///______\//\\\__
______/\\\/\/\\\______________/\\\/___
____/\\\/__\/\\\___________/\\\//_____
__/\\\\\\\\\\\\\\\\_____/\\\//________
_\///////////\\\//____/\\\/___________
___________\/\\\_____/\\\\\\\\\\\\\\\_
___________\///_____\///////////////__
*/
}
Unfortunately, the following will not work:
contract Solver {
function whatIsTheMeaningOfLife() public pure returns (uint256) {
return 42;
}
}
This is because solidity is a high-level language that has a lot of "built-in" features and checks implemented for us. In other words, way too many opcodes to meet the challenge's requirements.
So, as suggested by the challenge, we need to write the contract in raw bytecode.
First, let's delimit the scope of what we need to achieve. The only things we have to worry about are:
the contract deployment;
return 42 whenever called.
In bytecode, this is the equivalent of:
The creation bytecode in charge of deploying the contract;
The runtime bytecode which will live on-chain and be in charge of executing the contract's code.
The creation bytecode is the first thing that gets executed when deploying a contract. It is in charge of deploying the contract and returning the runtime bytecode. This is why we will start with the runtime bytecode.
The absolute minimal setup for this contract to return the number 42 whenever called would be the following:
Store the number 42 in memory;
Return the number 42 from memory.
In raw bytecode, we can write it like this:
MSTORE to store the number 42 in memory: mstore(pointer, value)| BYTECODE | OPCODE | VALUE | COMMENT | |
| 602a | 60 | PUSH1 | 2a | 42 is (0x)2a in hexadecimal |
| 6080 | 60 | PUSH1 | 80 | Memory pointer 0x80 |
| 52 | 52 | MSTORE | Store 42 at memory position 0x80 |
RETURN to return the number 42 from memory: return(pointer, size)| BYTECODE | OPCODE | VALUE | COMMENT | |
| 6020 | 60 | PUSH1 | 20 | 32 bytes in hexadecimal is (0x)20 |
| 6080 | 60 | PUSH1 | 80 | Memory pointer 0x80 |
| f3 | f3 | RETURN | Return 32 bytes from memory pointer 0x80 |
So here is our full runtime bytecode (smart contract code): 602a60805260206080f3. 6 opcodes and a total of 10 bytes.
Now, we need to handle the creation code, so we can deploy this super tiny useless contract.
Again, let's start with the absolute minimum we'll need to deploy our contract:
Store the runtime bytecode in memory;
Return the runtime bytecode.
In raw bytecode, we can write it like this:
CODECOPY to store the runtime bytecode in memory: codecopy(value, position, destination)| BYTECODE | OPCODE | VALUE | COMMENT | |
| 600a | 60 | PUSH1 | 0a | Push 10 bytes (runtime code size) |
| 600c | 60 | PUSH1 | 0c | Copy from memory position at index 12 (initialization code takes 12 bytes, runtime comes after that) |
| 6000 | 60 | PUSH1 | 00 | Paste to memory slot 0 |
| 39 | 39 | CODECOPY | Store runtime code at memory slot 0 |
RETURN to return the 10 bytes runtime bytecode from memory starting at offset 22: return(pointer, size)| BYTECODE | OPCODE | VALUE | COMMENT | |
| 600a | 60 | PUSH1 | 0a | 10 bytes in hexadecimal |
| 6000 | 60 | PUSH1 | 00 | Memory pointer 0 |
| f3 | f3 | RETURN | Return 10 bytes from memory pointer 0 |
Here is the full creation/deployment bytecode: 600a600c600039600a6000f3.
And concatenating the two and adding 0x in front, we get the following bytecode: 0x600a600c600039600a6000f3602a60805260206080f3.
Now that we have our raw bytecode ready, we can deploy the contract.
const receipt = await web3.eth.sendTransaction({
from: player,
data: "0x600a600c600039600a6000f3602a60805260206080f3",
});
await contract.setSolver(receipt.contractAddress);
If you want to test it, you can use the following interface in Remix:
interface IMeaningOfLife {
function whatIsTheMeaningOfLife() external view returns (uint256);
}
It will return 42. Of course, you could give any name to this function, it will return 42 regardless. This is simply an interface.
forge:Let's prepare our script accordingly:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Script, console2} from "forge-std/Script.sol";
interface IMagicNumber {
function setSolver(address _solver) external;
}
interface IMeaningOfLife {
function whatIsTheMeaningOfLife() external view returns (uint256);
}
contract PoC is Script {
// Replace with your Magic Number instance
IMagicNumber magicNumber =
IMagicNumber(0x852e71dDefd3c88Dd7bF73ABcACAeDaABbce5ddE);
function run() external {
uint256 deployer = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployer);
bytes
memory bytecode = hex"600a600c600039600a6000f3602a60805260206080f3";
address solver;
assembly {
solver := create(0, add(bytecode, 0x20), mload(bytecode))
}
uint256 meaningOfLife = IMeaningOfLife(solver).whatIsTheMeaningOfLife();
require(meaningOfLife == 42, "Not 42");
console2.log("Solver deployed at", solver);
console2.log("What is the meaning of life?", meaningOfLife);
magicNumber.setSolver(solver);
vm.stopBroadcast();
}
}
You can use the following command:
forge script script/18_MagicNumber.s.sol:PoC --rpc-url sepolia --broadcast --verify --etherscan-api-key $ETHERSCAN_API_KEY
And that's it! We have successfully deployed the contract and solved the level.

๐ Level completed ๐
How the EVM and opcodes work at a low level.
From low level to high level: Bytecode > Yul/Assembly > Solidity.
EVM opcodes: https://www.evm.codes/
Simple bytecode contract: https://www.youtube.com/watch?v=0qQUhsPafJc
You can find all the codes, challenges, and their solutions on my GitHub: https://github.com/Pedrojok01/Ethernaut-Solutions/