More EVM Puzzles - Part 1

May 24, 2022 by patrickd

Dalton Sweeney (opens in a new tab) has recently published his own collection of EVM puzzles: "10 more EVM puzzles" (opens in a new tab)! Promising to be even more challenging than the ones of Franco Victorio (opens in a new tab)'s original collection (opens in a new tab). Since we've solved all the other one's on this blog already and they've been good fun, let's do these too!

Note that it's probably best to read my previous blog posts first, or that you've already solved the original version yourself, since I'll not explain all of the opcodes that were already discussed there in detail again.

Puzzle #1

00      36      CALLDATASIZE
01      34      CALLVALUE
02      0A      EXP
03      56      JUMP
04      FE      INVALID
05      FE      INVALID
06      FE      INVALID
... (all INVALID opcodes) ...
3D      FE      INVALID
3E      FE      INVALID
3F      FE      INVALID
40      5B      JUMPDEST
41      58      PC
42      36      CALLDATASIZE
43      01      ADD
44      56      JUMP
45      FE      INVALID
46      FE      INVALID
47      5B      JUMPDEST
48      00      STOP

? Enter the value to send:
? Enter the calldata:

The first puzzle is already quite complex, but as usual, we know that the goal is to ensure that the transaction doesn't revert, meaning it needs to reach the STOP opcode. And since there are each two JUMP and JUMPDEST opcodes, we most likely need to make sure to send a transaction that will ensure that both destinations are successfully jumped to.

A JUMP consumes one item from the stack: The offset it should jump to, at which location needs to be a JUMPDEST opcode.

The first 4 lines can be rewritten to the following pseudocode: jumpTo(CALLVALUE ** CALLDATASIZE) - and first thing to wonder about is: Can we jump directly to the last JUMPDEST skipping half of the challenge? Well, its offset is at 0x47 which is 71 in decimal, so sending a wei value of 71 and 1 byte as calldata should work? (because 71**1=71)

? Enter the value to send: 71
? Enter the calldata: 0x01

Puzzle solved!

Indeed! That worked, but it's kind of cheating so let's take a look at how this was probably intended to be solved and first jump to the JUMPDEST at 0x40 which is 64 - that means we can actually send any power of 2 that results in 64: 2*6, 4*3, 8**2

After jumping there, the Program Counter's current value is pushed on the stack with the PC opcode. The handy evm.codes (opens in a new tab) website tells us that this is "the value of the program counter prior to the increment corresponding to this instruction". So the Program Counter is basically a simple integer offset within the EVM that always keeps increasing by 1 while working through the contract's bytecode, except when it hits some kind of jump that overwrites the offset to point to a different location. We'll get the PC opcodes own address here, which is 0x41.

This is then added to CALLDATASIZE and this sum becomes the offset the following JUMP will take us to. So to get to the final JUMPDEST at 0x47 we need to add 6 to the PC opcode's location at 0x41. That means we need a CALLDATASIZE of 6 and we already know that we can send 2**6 to keep the first jump working.

? Enter the value to send: 2
? Enter the calldata: 0x010203040506

Puzzle solved!

It appears we have now found the intended solution!

Puzzle #2

00      36        CALLDATASIZE
01      6000      PUSH1 00
03      6000      PUSH1 00
05      37        CALLDATACOPY
06      36        CALLDATASIZE
07      6000      PUSH1 00
09      6000      PUSH1 00
0B      F0        CREATE
0C      6000      PUSH1 00
0E      80        DUP1
0F      80        DUP1
10      80        DUP1
11      80        DUP1
12      94        SWAP5
13      5A        GAS
14      F1        CALL
15      3D        RETURNDATASIZE
16      600A      PUSH1 0A
18      14        EQ
19      601F      PUSH1 1F
1B      57        JUMPI
1C      FE        INVALID
1D      FE        INVALID
1E      FE        INVALID
1F      5B        JUMPDEST
20      00        STOP

? Enter the calldata:

The CREATE opcode, previously reserved for the most challenging puzzles, is back and that already in the second one! This makes the puzzle a whole lot more intimidating, but the goal hasn't changed: Make a jump to the one and final JUMPDEST at offset 0x1F.

This offset is already pushed onto the stack as target before the JUMPI, so what we have to do is making sure that the jump condition is met and the second item on the stack is a non-zero value. For that to be the case RETURNDATASIZE needs to be equal to 0x0A. That means the size of the output data resulting from the previous CALL opcode should be 10 bytes long.

To understand what's happening with the CREATE and CALL opcodes, it's best to look at how the stack builds up and what each item will end up being used for:

00      36        CALLDATASIZE   [CALLDATASIZE]
01      6000      PUSH1 00       [0x0, CALLDATASIZE]
03      6000      PUSH1 00       [0x0, 0x0, CALLDATASIZE]
05      37        CALLDATACOPY   []
06      36        CALLDATASIZE   [CALLDATASIZE]
07      6000      PUSH1 00       [0x0, CALLDATASIZE]
09      6000      PUSH1 00       [0x0, 0x0, CALLDATASIZE]
0B      F0        CREATE         [ADDRESS]

First of all CALLDATACOPY copies calldata to memory at offset 0x0 from the calldata starting at offset 0x0. The size of this copy operation is CALLDATASIZE, meaning the entirety of the calldata we send is copied to memory.

After that, CREATE is executed with an ether value of zero (0x0) to send to the new contract, and to use the entirety of the calldata that we have just copied to memory at offset 0x0 as construction bytecode.

So whatever we send as calldata will be executed as construction bytecode. And whatever the result of that is will be deployed as a smart contract. And finally we'll have the address of it as the top item on our stack.

0C      6000      PUSH1 00       [0x0, ADDRESS]
0E      80        DUP1           [0x0, 0x0, ADDRESS]
0F      80        DUP1           [0x0, 0x0, 0x0, ADDRESS]
10      80        DUP1           [0x0, 0x0, 0x0, 0x0, ADDRESS]
11      80        DUP1           [0x0, 0x0, 0x0, 0x0, 0x0, ADDRESS]
12      94        SWAP5          [ADDRESS, 0x0, 0x0, 0x0, 0x0, 0x0]
13      5A        GAS            [GAS, ADDRESS, 0x0, 0x0, 0x0, 0x0, 0x0]
14      F1        CALL           [SUCCESS]

This contract is then called with all of the gas that is still available at this point. All of the zeros the stack was filled with, tell CALL that there are no arguments to copy from memory (as new calldata for the call to the new contract) and that none of the return data should be copied to memory either. And finally, we'll have either 1 or 0 on the stack, telling us whether the call succeeded.

But this success value isn't actually used. Instead, it gets the size of the data that was returned by the previous call with RETURNDATASIZE (data which is buffered somewhere for us within the EVM).

So in summary: We need to send bytecode as calldata, that'll return a contract, that'll return 10 bytes of data when called.

First: We need to build a runtime bytecode that returns 10 bytes.

00      600A      PUSH1 0A        [0x0A]
02      6000      PUSH1 00        [0x00, 0x0A] (offset, size)
04      F3        RETURN          []

We don't actually have to write anything to memory first in order to return it. Since the memory is already zero-initialized we can just return 10 zeros from it.

Runtime bytecode: 0x600A6000F3

Second: We need to build a construction bytecode that returns our runtime bytecode.

00      64600A6000F3      PUSH5 600A6000F3        [0x600A6000F3]
06      6000              PUSH1 00                [0x0, 0x600A6000F3] (offset, value)
08      52                MSTORE                  []
00      600A              PUSH1 0A                [0x0A]
02      601B              PUSH1 1B                [0x1B, 0x0A] (offset, size)
04      F3                RETURN                  []

Here we're writing the bytecode to memory and then we return it. You might wonder why the starting offset of the return data begins at 0x1B although we stored the runtime bytecode at 0x0 - that's because it'll look like this is memory: 0x000000000000000000000000000000000000000000000000000000600A6000F3.

We have to skip the 27 zeros that are at the beginning of the value to reach the bytecode. We could have prevented that using PUSH32 0x600A6000F3000000000000000000000000000000000000000000000000000000, but that'd have significantly increased the construction bytecode's size.

Construction bytecode: 0x64600A6000F3600052600A601BF3

? Enter the calldata: 0x64600A6000F3600052600A601BF3

Puzzle solved!

But that was somewhat boring, right? Wouldn't it be more fun if we only had one bytecode.. bytecode that returns itself and can be used for both construction and runtime.. and is 10 bytes long?

We can actually do that quite easily because with the CODECOPY opcode the bytecode can copy itself:

00      600A      PUSH1 0A        [0x0A]
02      6000      PUSH1 00        [0x00, 0x0A]
04      6000      PUSH1 00        [0x00, 0x00, 0x0A] (destOffset, offset, size)
06      39        CODECOPY        []
07      600A      PUSH1 0A        [0x0A]
09      6000      PUSH1 00        [0x00, 0x0A] (offset, size)
0B      F3        RETURN          []

This is 12 bytes long though, so we have to do some optimizations. A simple one is using duplication opcodes instead of pushing duplicate items onto the stack:

00      600A      PUSH1 0A        [0x0A]
02      80        DUP1            [0x0A, 0x0A]
03      6000      PUSH1 00        [0x00, 0x0A, 0x0A]
05      80        DUP1            [0x00, 0x00, 0x0A, 0x0A] (destOffset, offset, size), size
06      39        CODECOPY        [0x0A]
07      6000      PUSH1 00        [0x00, 0x0A] (offset, size)
09      F3        RETURN          []

The bytecode is now exactly 10 bytes long and able to deploy itself:

? Enter the calldata: 0x600A80600080396000F3

Puzzle solved!

This was a lot of stuff and we're only 2 puzzles in, so let's not overdo it and make it a series instead!