Solving yet another EVM puzzle
Yet another EVM puzzle is a series of low-level EVM puzzles created by @mattaereal.
There are 5 different challenges. Each of them consists of a short sequence of EVM opcodes. The goal is to figure out the necessary calldata and value to send so that execution does not revert.
My solutions do not prioritize gas efficiency. But you could definitely leverage these challenges to sharpen your EVM gas-golfing skills.
The bytecode for the first puzzle is:
Let's start by disassembling it with the handy
evm disasm tool included in the Geth & Tools package.
$ echo "3415600736111760225760003560005236600034f080156022573115602357600080fd5b00" >> puzzle-1 && evm disasm puzzle-1
00: CALLVALUE 01: ISZERO 02: PUSH1 0x07 04: CALLDATASIZE 05: GT 06: OR 07: PUSH1 0x22 09: JUMPI 0a: PUSH1 0x00 0c: CALLDATALOAD 0d: PUSH1 0x00 0f: MSTORE 10: CALLDATASIZE 11: PUSH1 0x00 13: CALLVALUE 14: CREATE 15: DUP1 16: ISZERO 17: PUSH1 0x22 19: JUMPI 1a: BALANCE 1b: ISZERO 1c: PUSH1 0x23 1e: JUMPI 1f: PUSH1 0x00 21: DUP1 22: REVERT 23: JUMPDEST 24: STOP
To pass the challenge, execution must reach the
STOP instruction at position
24. Avoiding at all costs running into the
REVERT at position
My approach was to first make sense out sections of the bytecode, without necessarily understanding what was going on with each individual opcode. I did it by noticing notable jumps in the bytecode. These act like
if statements that control the flow of the program.
The first notable section I noted was:
00: CALLVALUE 01: ISZERO 02: PUSH1 0x07 04: CALLDATASIZE 05: GT 06: OR 07: PUSH1 0x22 09: JUMPI
I could rewrite the above in pseudocode:
if (callvalue == 0 || calldatasize > 7 bytes) go to 22. Remember, position
22 is a
REVERT. These are the first two conditions I MUST comply with to pass the challenge:
- Send at least 1 wei.
- Not send more than 7 bytes of calldata.
Notably, after executing those instructions the stack is left empty.
Moving on to the next section:
0a: PUSH1 0x00 0c: CALLDATALOAD 0d: PUSH1 0x00 0f: MSTORE 10: CALLDATASIZE 11: PUSH1 0x00 13: CALLVALUE 14: CREATE 15: DUP1 16: ISZERO 17: PUSH1 0x22 19: JUMPI
0f store 32 bytes of calldata at position
0 in memory. Next, I there are 4 instructions ending with a
CREATE at position
14. After following these instructions with pen and paper, I was able to rewrite the above in pseudocode like:
// store calldata in memory memory[0x00:0x20] = calldata // create a contract with whatever data is in `memory[0x00:calldatasize]` and sending `callvalue` amount address = create(callvalue, 0, calldatasize)
It's simply creating a contract with some value and all calldata received. Remember that
CREATE pushes the address of the created contract on success. Otherwise pushes 0.
Then the purpose of the next set of instructions (from
1e) is to evaluate the balance of the address at the top of the stack (pushed by
1a: BALANCE 1b: ISZERO 1c: PUSH1 0x23 1e: JUMPI
From the above, one can tell that when the balance of the created account is zero, execution goes to position
23. As seen below, that would lead execution straight to a
1f: PUSH1 0x00 21: DUP1 22: REVERT 23: JUMPDEST 24: STOP
- The call must send at least 1 wei.
- The call must not have more than 7 bytes of calldata.
- The calldata and value sent are used to create a contract.
- The created contract must have zero balance.
Seems contradicting. On the one hand I was forced to send some ETH in the creation of a contract. On the other hand it was requiring said contract to NOT have balance after creation.
Luckily, I had total control over 7 bytes of calldata. These were used as creation code of the contract. In other words, I had total control over the creation code of the contract.
I realized I could craft some creation code for a contract that ditches any ETH received. For instance, sending it to the zero address. Which can be done with
Thus the calldata can simply be two bytes:
RETURNDATASIZE) pushes a 0 to the stack, and
SELFDESTRUCT) sends any ETH in balance to the target address at the top of the stack (that is, the zero address).
Thus passing the challenge:
$ npx hardhat play ############ # Puzzle 1 # ############ ? Enter the value to send: 1 ? Enter the calldata: 0x3DFF Puzzle solved!
The bytecode for the second puzzle is:
The entire disassembled code:
00: PUSH32 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 21: PUSH1 0xcc 23: PUSH1 0x00 25: JUMPDEST 26: SWAP1 27: DUP1 28: DUP3 29: MSTORE8 2a: PUSH1 0x10 2c: ADD 2d: SWAP1 2e: PUSH1 0x01 30: ADD 31: DUP1 32: PUSH1 0x20 34: GT 35: PUSH1 0x25 37: JUMPI 38: POP 39: POP 3a: PUSH1 0x00 3c: MLOAD 3d: PUSH1 0x00 3f: CALLDATALOAD 40: XOR 41: EQ 42: PUSH1 0x49 44: JUMPI 45: PUSH1 0x00 47: DUP1 48: REVERT 49: JUMPDEST 4a: STOP
I approached this puzzle with a "backtracking" approach. Once I spotted where I wanted to end (the
STOP at position
4a), I started walking my way backwards to figure out the input that would get me there.
I had to reach the
STOP at position
4a. The only possible way was to somehow jump from somewhere to position
49. I realized there was one path that would take me there. See the
PUSH1 0x49 followed by a
JUMPI at positions
Such jump would only be possible if the
EQ at position
41 left a
1 at the top of the stack. In other words, that the two elements compared by
EQ were equal.
I looked at the instructions before
EQ to detect the two elements that were compared. But at that point things got more complicated. There was a
MLOAD. And going a few steps beyond there appeared to be a loop (instructions at
37 force execution back to position
25). Things weren't so trivial to analyze.
I stopped going backwards. I opened up the bytecode playground at evm.codes. I marked one breakpoint at position
38, input some arbitrary calldata, and hit play.
Once execution paused, I could clearly see the machine's state. Three elements in the stack, and one in memory.
The next two
POP instructions would remove the first two stack elements. Thus leaving
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa at the top.
After a few more steps, the value
ccdcecfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfc0c1c2c3c4c5c6c7c8c9cacbc was loaded from memory (due to
MLOAD at position
3c), and calldata was loaded as well (due to
CALLDATALOAD at position
3f). The resulting stack, right before executing
XOR, looked like:
The first two elements of the stack (calldata and
ccdcecfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfc0c1c2c3c4c5c6c7c8c9cacbc) would get XOR'ed, and the result compared against
EQ. If they were equal, execution would finish successfuly.
What's the value that when XOR'ed with
ccdcecfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfc0c1c2c3c4c5c6c7c8c9cacbc results in
I asked Python:
hex(0xccdcecfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfc0c1c2c3c4c5c6c7c8c9cacbc ^ 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa) '0x66764656a6b68696e6f6c6d62636061666764656a6b68696e6f6c6d626360616'
Verifying the value
66764656a6b68696e6f6c6d62636061666764656a6b68696e6f6c6d626360616 is correct:
hex(0x66764656a6b68696e6f6c6d62636061666764656a6b68696e6f6c6d626360616 ^ 0xccdcecfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfc0c1c2c3c4c5c6c7c8c9cacbc) '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
0x66764656a6b68696e6f6c6d62636061666764656a6b68696e6f6c6d626360616 as the calldata value to pass the challenge.
$ npx hardhat play ############ # Puzzle 2 # ############ ? Enter the calldata: 0x66764656a6b68696e6f6c6d62636061666764656a6b68696e6f6c6d626360616 Puzzle solved!
The bytecode for the third puzzle is:
The disassembled code:
00: CALLVALUE 01: ISZERO 02: PUSH1 0x21 04: JUMPI --- 05: PUSH1 0x00 07: CALLDATALOAD 08: PUSH1 0x00 0a: MSTORE --- 0b: CALLDATASIZE 0c: PUSH1 0x00 0e: PUSH1 0x00 10: CREATE --- 11: PUSH1 0x00 13: PUSH1 0x00 15: PUSH1 0x00 17: CALLVALUE 18: PUSH1 0x00 1a: SWAP5 1b: GAS 1c: CALL --- 1d: ISZERO 1e: PUSH1 0x26 20: JUMPI 21: JUMPDEST 22: PUSH1 0x00 24: DUP1 25: REVERT 26: JUMPDEST 27: STOP
As you can see above, I first divided the disassembled code in 5 sections. I can summarize them as follows:
CALLVALUEis zero, execution reverts.
- Stores calldata in memory.
- Creates a contract using provided calldata as creation code.
- Calls the created contract with no input.
- If call fails, execution reaches
Thus I needed to provide the creation code of a contract that could be succesfully created, though reverted when called.
One 3-bytes-long piece of EVM code that reverts when executed is
3D3DFD. Where the two
3Ds push two zeros to the stack, and
FD triggers the revert, consuming the two pushed elements.
With that in mind, I now needed to craft the creation EVM code that would return such 3 bytes.
If I wanted to return something, then I knew my code would somehow need to reach a
RETURN consumes two stack elements, which indicate memory offset and size of the returned data. Such data is read from memory, and thus must be somehow stored there before reaching the
RETURN. My data would be 3 bytes long:
3D3DFD. One way to place that in memory is harcoding the bytes in the creation code, and then use
39). This instruction consumes 3 elements from stack: the memory offset, the calldata offset, and the size. Putting everything together, I came up with the following sequence of instructions:
00: PUSH1 0x03 --- size for CODECOPY 02: PUSH1 0x0d --- offset for CODECOPY 04: PUSH1 0x00 --- memory position for CODECOPY 06: CODECOPY 07: PUSH1 0x03 --- size for RETURN 09: PUSH1 0x00 --- memory position for RETURN 0b: RETURN 0c: STOP 0d: RETURNDATASIZE --- memory position for REVERT (same as PUSH1 0x00) 0e: RETURNDATASIZE --- memory position (same as PUSH1 0x00) 0f: REVERT
In raw bytecode:
Using these bytes as calldata and providing at least 1 wei in value, I was able to pass the challenge:
$ npx hardhat play ############ # Puzzle 3 # ############ ? Enter the value to send: 1 ? Enter the calldata: 0x6003600d60003960036000f3003d3dfd Puzzle solved!
The bytecode for the fourth puzzle is:
The disassembled code:
00: PUSH1 0x20 02: CALLDATASIZE 03: GT 04: PUSH1 0x21 06: JUMPI 07: CALLDATASIZE 08: PUSH1 0x00 0a: CALLDATASIZE 0b: PUSH1 0x20 0d: SUB 0e: CALLDATACOPY 0f: CALLDATASIZE 10: DUP1 11: PUSH1 0x20 13: SUB 14: KECCAK256 15: PUSH1 0xe0 17: SHR 18: PUSH4 0x890d6908 1d: EQ 1e: PUSH1 0x26 20: JUMPI 21: JUMPDEST 22: PUSH1 0x00 24: DUP1 25: REVERT 26: JUMPDEST 27: STOP
First I noticed these instructions:
00: PUSH1 0x20 02: CALLDATASIZE 03: GT 04: PUSH1 0x21 06: JUMPI
They're restricting the calldata's size to a max of 32 bytes. Otherwise execution would jump to position
21 and revert.
Then some weird shit happens between positions
20. Some copying of calldata, some hashing, some bit-shifting... Not trivial stuff. Even so, it's not that difficult to get an idea of what's going on. Look at these instructions:
14: KECCAK256 15: PUSH1 0xe0 17: SHR 18: PUSH4 0x890d6908 1d: EQ 1e: PUSH1 0x26 20: JUMPI
The above means that:
- Something is hashed
- The result is shifted right 224 (0xe0) bits.
- The result is compared against
- If things are equal, jumps to position
26and pass the challenge.
The challenge now boiled down to figuring out what on earth would have its first four bytes equal to
890d6908 when hashed.
My first (naive) thought was to code something in Golang to hash random stuff until I managed to get those first four bytes. I was already coding the script when a far simpler solution came to mind.
First four bytes, hashing, comparing, jumping... This resembled Solidity's function dispatcher! If I was right, then
890d6908 would play the part of the first four bytes of a function signature. Perhaps it's known ?
890d6908 is the function identifier of
solve(). Perhaps I could just send
solve() in the calldata, and pass the challenge ? It felt worth trying. Even if I still hadn't figured out what instructions from
I used Python to get the hex representation of the ASCII-encoded string "solve()".
Finally, I used those bytes as calldata:
$ npx hardhat play ############ # Puzzle 4 # ############ ? Enter the calldata: 0x736f6c76652829 Puzzle solved!
The bytecode for the fifth puzzle is:
The disassembled code:
00: PUSH1 0x04 02: CALLDATASIZE 03: GT 04: PUSH1 0x38 06: JUMPI 07: PUSH1 0x00 09: CALLDATALOAD 0a: PUSH1 0xe0 0c: SHR 0d: PUSH1 0x00 0f: PUSH1 0x00 11: CALLVALUE 12: CREATE2 13: PUSH32 0x00000000000000000000000034d5cbd8a2b5e1bb6952581ae65b47ed2bd5ef2d 34: EQ 35: PUSH1 0x3d 37: JUMPI 38: JUMPDEST 39: PUSH1 0x00 3b: DUP1 3c: REVERT 3d: JUMPDEST 3e: STOP
I spent far more time on this one than I'd dare to admit.
06 I could tell that the calldata's size couldn't be larger than 4 bytes.
Then, from instructions
12 it seemed a contract was created with
CREATE2, using the 4-bytes-long calldata as salt.
Then, the deployment address would be compared against
34d5cbd8a2b5e1bb6952581ae65b47ed2bd5ef2d. The challenge would only succeed as long as both addresses were equal.
Thus I arrived at the gist of the challenge. I needed to find the salt that would
CREATE2 a contract with no code to address
Believe it or not, once again I went down the mining road. The idea was trying out pseudo-random salts with
CREATE2 until I got that address. I left the script running for like 20 minutes. What a noob.
As I was taking a break with a cup of coffee, the answer popped in my mind. I honestly don't know how.
In CTFs, challenge prompts and names matter. Sometimes, a lot. They can give you useful hints to crack a challenge way faster.
Puzzle 5 is called "You never eat your stake alive".
"You never eat your stake alive". I needed a salt. Something I could give to
CREATE2 to deploy to a specific address. My script clearly wouldn't work.
"You never eat your stake alive". Why would you even use that name anyway ?
"You never eat your stake alive".
"You never eat your stake alive". Your stake is dead when you eat it.
"You never eat your stake alive". Stake... Stake is beef, isn't it ?
"You never eat your stake alive". I need a word, something to use as salt. Once I find it, I'll need to write it in hex.
"You never eat your stake alive". A dead beef.
"You never eat your stake alive".
If you attempt to solve this challenge in your local environment,
0xdeadbeef is not considered a valid solution.
$ npx hardhat play ############ # Puzzle 5 # ############ ? Enter the calldata: 0xdeadbeef Wrong solution :(
But if you play it on the evm.codes playground, it works.
Below's a screenshot of my solution using
0xdeadbeef as input.