Skip to content
On this page

Mastering EVM Bytecode: Solving the Ethernaut MagicNumber Challenge

Introduction

The Ethereum Virtual Machine (EVM) represents the heart of Ethereum's execution environment, where smart contracts live and operate. While most developers interact with the EVM through high-level languages like Solidity, understanding the underlying bytecode can unlock powerful optimization techniques and deeper insights into Ethereum's architecture. The Ethernaut MagicNumber challenge presents a perfect opportunity to explore this fundamental layer of Ethereum development.

In this technical deep dive, we'll explore how to solve the MagicNumber challenge by crafting minimal EVM bytecode that fits within a strict 10-byte constraint while correctly returning the magic number 42. This exercise not only demonstrates bytecode optimization but also reveals important concepts about contract creation, execution contexts, and EVM opcodes.

Understanding the Challenge

Problem Statement

The MagicNumber challenge presents us with a seemingly simple task: create a contract that returns the number 42 when its whatIsTheMeaningOfLife() function is called. However, there's a significant constraint - the solver contract must be extremely small, specifically 10 bytes or less.

This constraint immediately eliminates conventional Solidity approaches. A typical Solidity contract that returns a constant value would compile to hundreds of bytes due to the compiler's boilerplate code, function selectors, and metadata. We must therefore work directly with EVM bytecode.

The Target Contract

The provided MagicNum.sol contract is straightforward:

solidity
contract MagicNum {
    address public solver;

    constructor() {}

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

Our task is to deploy a contract that implements the required functionality and then call setSolver() with its address. The challenge will then verify that our contract correctly returns 42.

EVM Architecture Fundamentals

Bytecode vs Runtime Code

Before diving into the solution, we must understand a critical EVM concept: the distinction between creation code and runtime code.

When a contract is deployed, the EVM executes the creation code, which:

  1. Sets up the contract's initial state
  2. Returns the runtime code that will be stored on-chain
  3. Can perform complex initialization logic

The runtime code is what actually gets executed when the contract is called after deployment. For our challenge, we need to focus on creating minimal runtime code.

EVM Opcodes

EVM bytecode consists of opcodes, each representing a specific operation. Some key opcodes for our solution include:

  • PUSH1 (0x60): Push 1-byte value onto the stack
  • MSTORE (0x52): Store a 32-byte word in memory
  • RETURN (0xf3): Return data from memory
  • STOP (0x00): Halt execution

Each opcode is represented by a single byte, making byte-level optimization crucial for our 10-byte constraint.

Step-by-Step Solution Development

Step 1: Understanding the Required Return Value

The magic number we need to return is 42 (0x2a in hexadecimal). However, the EVM expects return values to be 32 bytes (256 bits). Therefore, we need to return 0x000000000000000000000000000000000000000000000000000000000000002a.

Step 2: Designing the Runtime Code

Our runtime code needs to:

  1. Place the value 42 in memory
  2. Return that memory segment

The simplest approach would be:

PUSH1 0x2a    // Push 42 onto stack (2 bytes: 0x602a)
PUSH1 0x00    // Push memory position 0 onto stack (2 bytes: 0x6000)
MSTORE        // Store 42 at memory position 0 (1 byte: 0x52)
PUSH1 0x20    // Push return data size (32 bytes) onto stack (2 bytes: 0x6020)
PUSH1 0x00    // Push memory position 0 onto stack (2 bytes: 0x6000)
RETURN        // Return the data (1 byte: 0xf3)

This totals 10 bytes exactly: 0x602a60005260206000f3

Step 3: Creating the Factory Contract

We can't deploy raw runtime code directly - we need creation code that returns our runtime code. Here's the complete solution:

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

interface IMagicNum {
    function solver() external view returns (address);
    function setSolver(address) external;
}

interface IContract {
    function getValue() external view returns (uint256);
}

contract MyFactory {
    IMagicNum private immutable target;

    constructor(address _target) {
        target = IMagicNum(_target);
        
        // Creation code that returns our 10-byte runtime code
        bytes memory bytecode = hex"69602a60005260206000f3600052600a6016f3";

        address addr;
        assembly {
            // Create contract with our bytecode
            addr := create(0, add(bytecode, 0x20), 0x13)
        }
        require(addr != address(0));

        // Set the solver in the target contract
        target.setSolver(addr);
    }

    function getValue() external view returns (uint256) {
        IContract obj = IContract(target.solver());
        return obj.getValue();
    }
}

Step 4: Analyzing the Creation Code

Let's break down the creation bytecode 0x69602a60005260206000f3600052600a6016f3:

  1. First 10 bytes (0x69602a60005260206000f3): This is actually our runtime code being placed in memory
  2. Remaining bytes: Creation logic that returns the runtime code

The creation code does the following:

  • Stores the runtime code in memory
  • Returns it using the RETURN opcode
  • The exact offsets ensure we return exactly 10 bytes

Deep Dive: EVM Bytecode Analysis

Runtime Code Breakdown

60 2a    // PUSH1 0x2a - Push decimal 42 onto stack
60 00    // PUSH1 0x00 - Push memory position 0 onto stack
52       // MSTORE - Store 42 at memory position 0
60 20    // PUSH1 0x20 - Push 32 (0x20 in hex) as return size
60 00    // PUSH1 0x00 - Push memory position 0 again
f3       // RETURN - Return 32 bytes starting at position 0

This 10-byte sequence perfectly implements our requirements. When called, this contract will always return 42, regardless of the function selector used (since there's no function dispatch logic).

Gas Optimization Considerations

Our solution is not just size-optimized but also gas-efficient. Each operation costs gas:

  • PUSH1: 3 gas
  • MSTORE: 3 gas (plus memory expansion costs)
  • RETURN: 0 gas

The total execution cost is minimal, making this an efficient solution beyond just the size constraint.

Testing the Solution

Test Implementation

Here's a comprehensive test using Hardhat and TypeScript:

typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { MagicNum, MyFactory } from "../typechain-types";

describe("MagicNum", function () {
  describe("MagicNum testnet online sepolia", function () {
    it("testnet online sepolia MagicNum", async function () {
      // Replace with actual contract address
      const MAGICNUM_ADDRESS = "0x2132C7bc11De7A90B87375f282d36100a29f97a9";

      const MyFactoryFactory = await ethers.getContractFactory("MyFactory");
      const myFactory = (await MyFactoryFactory.deploy(MAGICNUM_ADDRESS)) as MyFactory;
      await myFactory.waitForDeployment();

      const EXPECTED_MAGICNUM = 42;
      const value = await myFactory.getValue();
      expect(value).to.be.equals(EXPECTED_MAGICNUM);
    });
  });
});

Manual Verification

We can also manually verify our solution by examining the deployed contract:

  1. Check contract size: The solver contract should be exactly 10 bytes
  2. Verify functionality: Calling the contract should return 42
  3. Gas analysis: Execution should consume minimal gas

Advanced Concepts and Variations

Alternative Bytecode Approaches

While our solution uses the most straightforward approach, there are alternative bytecode sequences that could achieve the same result:

  1. Using CODECOPY: Could copy the return value from the contract's own code
  2. Different memory layouts: Could store the value at different memory positions
  3. Alternative return sizes: Could return different amounts of data (though 32 bytes is standard)

Security Considerations

While our minimal contract is secure by virtue of its simplicity, it's worth noting some considerations:

  1. No function selector checking: Any call to the contract returns 42
  2. No state management: The contract has no storage or state
  3. No input validation: Not applicable for this simple case

Educational Value and Real-World Applications

Learning EVM Internals

This challenge teaches several important concepts:

  1. Bytecode optimization: Understanding how to minimize contract size
  2. EVM execution model: How contracts are created and executed
  3. Gas efficiency: The relationship between bytecode and gas costs

Real-World Use Cases

While 10-byte contracts aren't common in production, the principles learned apply to:

  1. Proxy patterns: Understanding creation/runtime code separation
  2. Gas optimization: Minimizing contract deployment and execution costs
  3. Security auditing: Reading and understanding raw bytecode

Conclusion

The MagicNumber challenge provides a fascinating glimpse into the EVM's inner workings. By forcing us to work within a 10-byte constraint, it demonstrates:

  1. The power of low-level EVM programming: Bypassing Solidity's overhead for extreme optimization
  2. The elegance of minimal solutions: Sometimes the simplest approach is the most effective
  3. The importance of understanding fundamentals: High-level abstractions are built on low-level primitives

Our solution, using just 10 bytes of runtime code (0x602a60005260206000f3), perfectly illustrates how understanding EVM bytecode can lead to highly optimized smart contracts. While most real-world development occurs at higher abstraction levels, this knowledge provides valuable insights for optimization, security auditing, and understanding Ethereum's architecture at its most fundamental level.

The skills developed through this challenge - reading bytecode, understanding EVM execution, and optimizing for size - are invaluable for any serious Ethereum developer seeking to master the platform's capabilities and limitations.

Built with AiAda