To solve this level, you only need to provide the Ethernaut with a Solver, a contract that responds to whatIsTheMeaningOfLife() with the right 32 byte number.

Easy right? Well… there’s a catch.

The solver’s code needs to be really tiny. Really reaaaaaallly tiny. Like freakin’ really really itty-bitty tiny: 10 bytes at most.

Analysis

The code of the contract is:

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

contract MagicNum {
    address public solver;

    constructor() {}

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

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

So, we need to deploy a contract, whose runtime code size is less than or equal to 10 bytes. This contract needs to return 42 when the function whatIsTheMeaningOfLife() gets called on it.

We could think of deploying this contract:

contract Exploit {
    function whatIsTheMeaningOfLife() returns (uint256) public {
        return 42;
    }
}

But if we compile this contract using standard tools like forge, we will see that the bytecode generated is huge:

alt text

Definately bigger than 10 bytes. We will need to create the EVM bytecode by hand.

Contract deployment bytecode

When we compile a contract, the compiler generates the bytecode for us. This bytecode will have two main parts: the init code and the runtime code.

The init code is the bytecode responsible for preparing the contract: configuring the “free memory pointer”, initializing global variables and the logic for the constructor(). The bytecode corresponding to this stub does not get stored in the contract’s code. It is executed once and never saved in the blockchain.

The runtime code will be the one saved in the contract’s code and it has the logic for itself.

Function selectors

When we interact with a contract, we do so through the function name we want to execute, passing the attribute we’d like. When generating the bytecode, the function name is never stored. Instead, its function selector is stored. For instance, the function selector for the function test(address, uint256) would be 0xba14d606. We can compute these using cast:

cast sig "test(address, uint256)"

alt text

So, when we call the test(address, uint256) function, we are actually sending its selector, 0xba14d606. The compiler will generate bytecode to execute one function or other. It will look something like this (in pseudocode):

if (function_selector == 0xba14d606){
    jump(address_of_ba14d606))
}
else if (function_selector == 0xbb29998e){
    jump(other_address)
}

Creating the runtime code

But we are only allowed to write 10 bytes. We are not going to waste space by including if statements and function selectors. We can just write bytecode, without caring about the function being called. This bytecode will be executed independently of that.

So, let’s generate code that simply returns 42. We will need these instructions:

OPCODE Meaning Stack input Expression
0x60 PUSH1 PUSH(uint8)
0x52 MSTORE offset, value memory[offset:offset+32] = value
0xF3 RETURN offset, length return memory[offset:offset+length]

In order to be able to return something, we need to write into memory, because that’s how it is defined. To store in the memory, we can use, for instance, MSTORE. All the input that these opcodes need are extracted from the stack. In order to push into the stack, we need a PUSH opcode. As we are only going to push 1-byte data, we can just use PUSH1.

To return 42, we need to store in the memory the number 42. Let’s prepare the stack and call MSTORE:

PUSH1 0x2a   ----- 0x2a == 42
PUSH1 0x00
MSTORE

Now, we prepare the stack again to call RETURN:

PUSH1 0x20
PUSH1 0x00
RETURN

So that our final bytecode will be:

0x602a60005260206000f3

Which, by the way, is exactly 10 bytes long.

Creating the init code

We now need to create the init code. We don’t need anything too fancy, just the most basic init code will do the trick. The init code ends with a RETURN opcode. Before that, the runtime code needs to be loaded in order to be deployed. the opcode used for that is CODECOPY:

OPCODE Meaning Stack input Expression
0x39 CODECOPY destOffset, offset, length memory[destOffset:destOffset+length] = address(this).code[offset:offset+length]

We know that our runtime code length is 10. To know what offset should be, we need to write our whole init code first. We do know what destOffset should be. It can simply be 0. All in all, calling this opcode will look something like this:

PUSH1 0x0a
PUSH1 0x??
PUSH1 0x00
CODECOPY

As said before, we also need to prepare the stack to call RETURN. As we are returning 10 bytes of bytecode, its length attribute would be 10. The offset would be 0:

PUSH1 0x0a
PUSH1 0x00
RETURN

All in all, our total init code would be 12 bytes long, so that our final init code would look like:

0x600a600c600039600a6000f3

Deploying

So our whole bytecode is:

0x600a600c600039600a6000F3602a60005260206000F3

We can use cast to deploy a contract with this bytecode:

cast send --rpc-url $SEPOLIA_RPC_URL --private-key $PRIVATE_KEY --create 0x600a600c600039600a6000F3602a60005260206000F3

After deploying, we can verify that it works as expected and returns 0x2a. We can use any function for that, as it doesn’t have any function selectors:

cast call 0xc5FF86B639f7AA4BfaE0020e28E2B1983D4a1701 "test(uint256)" 4 --rpc-url $SEPOLIA_RPC_URL -vvvv

alt text

alt text