The Ethernaut CTF Solutions | 18 - Magic Number

The Ethernaut CTF Solutions | 18 - Magic Number

Byte by Byte: Building an EVM Solution to Solve the Meaning of Life

ยท

5 min read

Goals

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.

The Contract

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

contract MagicNum {
    address public solver;

    constructor() {}

    function setSolver(address _solver) public {
        solver = _solver;
    }

    /*
    ____________/\\\_______/\\\\\\\\\_____        
     __________/\\\\\_____/\\\///////\\\___       
      ________/\\\/\\\____\///______\//\\\__      
       ______/\\\/\/\\\______________/\\\/___     
        ____/\\\/__\/\\\___________/\\\//_____    
         __/\\\\\\\\\\\\\\\\_____/\\\//________   
          _\///////////\\\//____/\\\/___________  
           ___________\/\\\_____/\\\\\\\\\\\\\\\_ 
            ___________\///_____\///////////////__
  */
}

The hack

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.

Runtime bytecode

The absolute minimal setup for this contract to return the number 42 whenever called would be the following:

  1. Store the number 42 in memory;

  2. Return the number 42 from memory.

In raw bytecode, we can write it like this:

  1. Using MSTORE to store the number 42 in memory: mstore(pointer, value)
BYTECODEOPCODEVALUECOMMENT
602a60PUSH12a42 is (0x)2a in hexadecimal
608060PUSH180Memory pointer 0x80
5252MSTOREStore 42 at memory position 0x80
  1. Using RETURN to return the number 42 from memory: return(pointer, size)
BYTECODEOPCODEVALUECOMMENT
602060PUSH12032 bytes in hexadecimal is (0x)20
608060PUSH180Memory pointer 0x80
f3f3RETURNReturn 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.

Creation bytecode

Again, let's start with the absolute minimum we'll need to deploy our contract:

  1. Store the runtime bytecode in memory;

  2. Return the runtime bytecode.

In raw bytecode, we can write it like this:

  1. Using CODECOPY to store the runtime bytecode in memory: codecopy(value, position, destination)
BYTECODEOPCODEVALUECOMMENT
600a60PUSH10aPush 10 bytes (runtime code size)
600c60PUSH10cCopy from memory position at index 12 (initialization code takes 12 bytes, runtime comes after that)
600060PUSH100Paste to memory slot 0
3939CODECOPYStore runtime code at memory slot 0
  1. Using RETURN to return the 10 bytes runtime bytecode from memory starting at offset 22: return(pointer, size)
BYTECODEOPCODEVALUECOMMENT
600a60PUSH10a10 bytes in hexadecimal
600060PUSH100Memory pointer 0
f3f3RETURNReturn 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.

Solution

Now that we have our raw bytecode ready, we can deploy the contract.

In the browser's console

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.

With Foundry using 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 ๐ŸŽ‰

Takeaway

  • How the EVM and opcodes work at a low level.

  • From low level to high level: Bytecode > Yul/Assembly > Solidity.

Reference


You can find all the codes, challenges, and their solutions on my GitHub: https://github.com/Pedrojok01/Ethernaut-Solutions/

ย