DEV Community

Cover image for Ethernaut Hacks Level 18: Magic Number
Naveen ⚡
Naveen ⚡

Posted on

Ethernaut Hacks Level 18: Magic Number

This is the level 18 of OpenZeppelin Ethernaut web3/solidity based game.

Pre-requisites

Hack

Given contract:

// SPDX-License-Identifier: MIT pragma solidity ^0.6.0; contract MagicNum { address public solver; constructor() public {} function setSolver(address _solver) public { solver = _solver; } /* ____________/\\\_______/\\\\\\\\\_____ __________/\\\\\_____/\\\///////\\\___ ________/\\\/\\\____\///______\//\\\__ ______/\\\/\/\\\______________/\\\/___ ____/\\\/__\/\\\___________/\\\//_____ __/\\\\\\\\\\\\\\\\_____/\\\//________ _\///////////\\\//____/\\\/___________ ___________\/\\\_____/\\\\\\\\\\\\\\\_ ___________\///_____\///////////////__ */ } 
Enter fullscreen mode Exit fullscreen mode

player has to make a tiny contract (Solver) in size (10 opcodes at most) and set it's address in MagicNum.

There's a tight restriction on size of the Solver
contract - 10 opcodes or less. Because each opcode is 1 byte, the bytecode of the solver must be 10 bytes at max.

Writing high-level solidity would yield the size much greater than just 10 bytes, so we turn to writing raw EVM bytes corresponding to contract opcodes.

We need to write two sections of opcodes:

  • Initialization Opcodes which EVM uses to create the contract by replicating the runtime opcodes and returning them to EVM to save in storage.

  • Runtime Opcodes which contains the execution logic of the contract.

Alright, so let's figure out runtime opcode first.

Runtime Opcode

The code needs to return the 32 byte magic number - 42 or 0x2a (in hex).

The corresponding opcode is RETURN. But, RETURN takes two arguments - the location of value in memory and the size of this value to be returned. That means the 0x2a needs to be stored in memory first - which MSTORE facilitates. But MSTORE itself takes two arguments - the location of value in stack and its size. So, we need push the value and size params into stack first using PUSH1 opcode.

Lookup the opcodes to be used in opcode reference to get corresponding hex codes:

OPCODE NAME ------------------ 0x60 PUSH1 0x52 MSTORE 0xf3 RETURN 
Enter fullscreen mode Exit fullscreen mode

Let's write corresponding opcodes:

OPCODE DETAIL ------------------------------------------------ 602a Push 0x2a in stack. Value (v) param to MSTORE(0x60) 6050 Push 0x50 in stack. Position (p) param to MSTORE 52 Store value,v=0x2a at position, p=0x50 in memory 6020 Push 0x20 (32 bytes, size of v) in stack. Size (s) param to RETURN(0xf3) 6050 Push 0x50 (slot at which v=0x42 was stored). Position (p) param to RETURN f3 RETURN value, v=0x42 of size, s=0x20 (32 bytes) 
Enter fullscreen mode Exit fullscreen mode

Concatenate the opcodes and we get the bytecode:

602a60505260206050f3 
Enter fullscreen mode Exit fullscreen mode

which is exactly 10 bytes, the max limit allowed by the level!

Initialization opcode

The initialization opcodes need to come before the runtime opcode. These opcodes need to load runtime opcodes into memory and return the same to EVM.

To CODECOPY opcode can be used to copy the runtime opcodes. It takes three arguments - the destination position of copied code in memory, current position of runtime opcode in the bytecode and size of the code in bytes.

Following opcodes is needed for the above purpose:

OPCODE NAME ------------------ 0x60 PUSH1 0x52 MSTORE 0xf3 RETURN 0x39 CODECOPY 
Enter fullscreen mode Exit fullscreen mode

But we don't know the position of runtime opcode in final bytecode (since init. opcode comes before runtime opcode). Let's omit it using -- for now and calculate the init. opcodes:

OPCODE DETAIL ----------------------------------------- 600a Push 0x0a (size of runtime opcode i.e. 10 bytes) in stack. Size (s) param to COPYCODE (0x39) 60-- Push -- (unknown) in stack Position (p) param to COPYCODE 6000 Push 0x00 (chosen destination in memory) in stack Destination (d) param to COPYCODE 39 Copy code of size, s at position, p to destination, d in memory 600a Push 0x0a (size of runtime opcode i.e. 10 bytes) in stack. Size (s) param to RETURN (0xf3) 6000 Push 0x00 (location of value in memory) in stack. Position (p) param to RETURN f3 Return value of size, s at position, p 
Enter fullscreen mode Exit fullscreen mode

So the initialization opcode is:

600a60--600039600a6000f3 
Enter fullscreen mode Exit fullscreen mode

which is 12 bytes in total.

And hence runtime opcodes start at index 12 or position 0x0c.

Therefore initialization opcode must be:

600a600c600039600a6000f3 
Enter fullscreen mode Exit fullscreen mode

Final Opcode

Alright we have initialization as well as runtime opcodes now. Concatenate them to get final opcode:

 initialization opcode + runtime opcode = 600a600c600039600a6000f3 + 602a60505260206050f3 = 600a600c600039600a6000f3602a60505260206050f3 
Enter fullscreen mode Exit fullscreen mode

We can now create the contract by noting the fact that a transaction sent to zero address (0x0) with some data is interpreted as Contract Creation by the EVM.

bytecode = '600a600c600039600a6000f3602a60505260206050f3' txn = await web3.eth.sendTransaction({from: player, data: bytecode}) 
Enter fullscreen mode Exit fullscreen mode

After deploying get the contract address from returned transaction receipt:

solverAddr = txn.contractAddress 
Enter fullscreen mode Exit fullscreen mode

Set the address Solver address in MagicNum:

await contract.setSolver(solverAddr) 
Enter fullscreen mode Exit fullscreen mode

Submit instance.

Done!

Learned something awesome? Consider starring the github repo 😄

and following me on twitter here 🙏

Top comments (0)