March 18, 2024

Magic Number Challenge - Ethernaut Level 18

12 min readDifficulty: Expert

Challenge Description

EVM BytecodeAssemblyLow-LevelLevel 18

Challenge Goal

Create a tiny contract (10 bytes or less) that returns the correct 32-byte number when called with whatIsTheMeaningOfLife(). This requires writing raw EVM bytecode by hand.

Challenge Instructions

Goal: 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.

The Catch:

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

Hint:

Perhaps its time to leave the comfort of the Solidity compiler momentarily, and build this one by hand O_o. That's right: Raw EVM bytecode.

The Contract

Here's the smart contract we'll be interacting with:

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

contract MagicNum {
    address public solver;

    constructor() {}

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

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

Understanding the Challenge

Let's break down what this challenge requires:

  1. Create a Solver Contract: We need to deploy a contract that can respond to whatIsTheMeaningOfLife()
  2. Return the Right Number: The function must return the correct 32-byte number (42)
  3. Size Constraint: The contract code must be 10 bytes or less
  4. Set the Solver: Call setSolver() with our contract's address

The key insight is that we need to write raw EVM bytecode instead of Solidity, as the size constraint makes it impossible to use the Solidity compiler.

Understanding EVM Bytecode

EVM bytecode is the low-level instruction set that the Ethereum Virtual Machine executes. Each instruction is represented by a single byte (opcode).

Key EVM Opcodes

// Essential EVM opcodes for this challenge
PUSH1 0x2a    // Push the value 42 (0x2a) onto the stack
PUSH1 0x00    // Push 0x00 (memory location) onto the stack
MSTORE        // Store 42 at memory location 0x00
PUSH1 0x20    // Push 32 (0x20) onto the stack (return data size)
PUSH1 0x00    // Push 0x00 onto the stack (return data location)
RETURN        // Return 32 bytes starting from memory location 0x00

// In hex: 602a60005260206000f3

Bytecode Breakdown

  • 60 2a - PUSH1 0x2a (push 42 onto stack)
  • 60 00 - PUSH1 0x00 (push 0 onto stack)
  • 52 - MSTORE (store 42 at memory location 0)
  • 60 20 - PUSH1 0x20 (push 32 onto stack)
  • 60 00 - PUSH1 0x00 (push 0 onto stack)
  • f3 - RETURN (return 32 bytes from memory location 0)

The Solution

The solution involves creating the smallest possible bytecode that returns the number 42:

Minimal Bytecode

// The answer to life, the universe, and everything is 42
// We need to return 32 bytes representing the number 42

// Step 1: Push 42 (0x2a) onto the stack
PUSH1 0x2a    // 60 2a

// Step 2: Push memory location 0x00 onto the stack  
PUSH1 0x00    // 60 00

// Step 3: Store 42 at memory location 0x00
MSTORE        // 52

// Step 4: Push return data size (32 bytes = 0x20) onto the stack
PUSH1 0x20    // 60 20

// Step 5: Push return data location (0x00) onto the stack
PUSH1 0x00    // 60 00

// Step 6: Return the data
RETURN        // f3

// Complete bytecode: 602a60005260206000f3 (10 bytes)

Complete Exploit Script

// Complete exploit script
const { ethers } = require("hardhat");

async function main() {
  const magicNumAddress = "INSTANCE_ADDRESS";
  
  console.log("MagicNum address:", magicNumAddress);
  
  // The bytecode that returns 42
  const solverBytecode = "0x602a60005260206000f3";
  
  // Deploy the solver contract using raw bytecode
  const tx = await ethers.provider.send("eth_sendTransaction", [{
    from: await ethers.getSigner().getAddress(),
    data: solverBytecode,
    gas: "0x100000"
  }]);
  
  // Get the deployed contract address from the transaction receipt
  const receipt = await ethers.provider.getTransactionReceipt(tx);
  const solverAddress = receipt.contractAddress;
  
  console.log("Solver deployed at:", solverAddress);
  
  // Set the solver in the MagicNum contract
  const magicNum = await ethers.getContractAt("MagicNum", magicNumAddress);
  const setSolverTx = await magicNum.setSolver(solverAddress);
  await setSolverTx.wait();
  
  console.log("Solver set successfully!");
  
  // Verify the solver works by calling whatIsTheMeaningOfLife
  const solver = await ethers.getContractAt("Solver", solverAddress);
  const result = await solver.whatIsTheMeaningOfLife();
  console.log("whatIsTheMeaningOfLife() returned:", result.toString());
  
  if (result.toString() === "42") {
    console.log("✅ Challenge completed successfully!");
  } else {
    console.log("❌ Challenge failed");
  }
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

Understanding the Bytecode

Let's break down exactly what this bytecode does:

Step-by-Step Execution

  1. PUSH1 0x2a: Pushes the value 42 (0x2a in hex) onto the stack
  2. PUSH1 0x00: Pushes the memory location 0x00 onto the stack
  3. MSTORE: Stores the value 42 at memory location 0x00
  4. PUSH1 0x20: Pushes 32 (0x20 in hex) onto the stack as the return data size
  5. PUSH1 0x00: Pushes 0x00 onto the stack as the return data location
  6. RETURN: Returns 32 bytes starting from memory location 0x00

Memory Layout

// Memory layout after MSTORE
Memory Location 0x00: [00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00]
Memory Location 0x10: [00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00]
Memory Location 0x20: [00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 2a]  // 42 at the end
Memory Location 0x30: [00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00]

// When RETURN is called with size=32 and offset=0:
// It returns 32 bytes starting from 0x00, which contains 42 in the last byte

Alternative Approaches

There are several ways to create a minimal bytecode solution:

Method 1: Direct Return (10 bytes)

// Store 42 in memory and return it
602a60005260206000f3

// Breakdown:
// 60 2a - PUSH1 0x2a (42)
// 60 00 - PUSH1 0x00 (memory location)
// 52    - MSTORE (store 42 at location 0)
// 60 20 - PUSH1 0x20 (32 bytes)
// 60 00 - PUSH1 0x00 (return from location 0)
// f3    - RETURN

Method 2: Using PUSH32 (33 bytes - too large)

// This would be too large for the challenge
7f000000000000000000000000000000000000000000000000000000000000002a

// PUSH32 0x2a (42) - 33 bytes total, exceeds the 10-byte limit

Testing the Solution

You can test the bytecode using various tools:

Using Remix IDE

  1. Go to the "Deploy & Run Transactions" tab
  2. Set the "Environment" to "Injected Provider - MetaMask"
  3. In the "Contract" dropdown, select "At Address"
  4. Enter your MagicNum instance address
  5. Deploy the solver using the bytecode: 0x602a60005260206000f3
  6. Call setSolver with the deployed address

Using Hardhat

// Test script
const { ethers } = require("hardhat");

async function testSolver() {
  // Deploy solver
  const bytecode = "0x602a60005260206000f3";
  const tx = await ethers.provider.send("eth_sendTransaction", [{
    from: await ethers.getSigner().getAddress(),
    data: bytecode
  }]);
  
  const receipt = await ethers.provider.getTransactionReceipt(tx);
  console.log("Solver deployed at:", receipt.contractAddress);
  
  // Test the solver
  const result = await ethers.provider.call({
    to: receipt.contractAddress,
    data: "0x" // No function selector needed, any call returns 42
  });
  
  console.log("Result:", ethers.BigNumber.from(result).toString());
}

testSolver();

Security Lessons

  • Bytecode Verification: Always verify the bytecode of contracts you interact with
  • Size Constraints: Be aware that size constraints can force the use of low-level code
  • EVM Understanding: Understanding EVM bytecode is crucial for advanced security analysis
  • Function Selectors: The solver doesn't need a function selector - any call returns 42
  • Memory Operations: Understanding how memory operations work is essential

Prevention

While this challenge demonstrates low-level EVM programming, in real applications:

  • Use High-Level Languages: Prefer Solidity or Vyper for most applications
  • Code Verification: Always verify contract source code on block explorers
  • Size Limits: Be aware of contract size limits and their implications
  • Testing: Thoroughly test all contract interactions

Advanced EVM Concepts

This challenge introduces several advanced EVM concepts:

Memory vs Storage

  • Memory: Temporary storage that's cleared between calls (used in our solution)
  • Storage: Persistent storage that costs more gas

Stack Operations

  • PUSH: Push values onto the stack
  • POP: Remove values from the stack
  • SWAP: Swap stack elements

Conclusion

The Magic Number challenge is an excellent introduction to low-level EVM programming. It demonstrates how to write minimal bytecode and understand the fundamental operations of the Ethereum Virtual Machine.

The key takeaways are:

  • EVM Bytecode: Understanding bytecode is essential for advanced smart contract development
  • Size Optimization: Sometimes constraints force us to use low-level approaches
  • Memory Operations: Understanding how memory works is crucial
  • Minimal Solutions: The most elegant solutions are often the simplest

While writing raw bytecode is rarely necessary in practice, understanding how it works provides deep insights into how smart contracts actually function at the lowest level.

Your Notes