Making of HappyNewYear CTF Puzzle

Imagine there is a contract that can execute any code you send to it. And imagine it also holds some money. How easy would it be to hack it?

It would be quite easy, especially if all the opcodes are available. That’s why I decided to create a puzzle like this, but with a few extra tricks to make it a bit harder to solve (even if the tricks are mostly “smoke and mirrors”).

I announced the puzzle on Twitter two days before 2023, and it has already been solved:

But if you still want to try and solve it yourself before reading the rest of this article, here is the saved bytecode (the contract self-destructed after it was solved):

Note: the rest of this article contains a full code review of the puzzle, so it will ruin the fun if you don't want any help solving it!

SPOILERS AHEAD!

Now that all the warnings have been given, let’s dive into how the puzzle was constructed and see how I wrote the contract…

ASCII salute 🫡

To start off, why not begin the contract bytecode with an ASCII encoded string? And let’s make it thematic. Just because we can :)

"Happy New Year Everybody!!!!!!!" encoded at the beginning of the bytecode
"Happy New Year Everybody!!!!!!!" encoded at the beginning of the bytecode

I opened my fork of evm.codes, which instantly decompiles bytecode, and played around with different ASCII text to see which opcodes it decompiled to. I ended up with a “Happy New Year” greeting – and I was pretty lucky:

  • “H” is ASCII 0x48, which is just a BASEFEE opcode – it can stay on the stack.

  • “a” is 0x61, which is PUSH2, pushing the next two chars “pp” to the stack.

  • “y” is 0x79, which is PUSH26, pushing the rest of the string with some exclamation marks at the end.

This way, at the start of the contract we have these three things on the stack – but do we really need them? We’ll see.

Unfortunately, Etherscan doesn’t have an option to show contract code as UTF-8 or ASCII (as it does with calldata). Otherwise, everyone would be able to see the greetings :-/

I realized this only after deploying the contract. But it’s a nice Easter egg (Santa egg?) for anyone who is curious.

Now let’s do the “can run anything” part

Contracts can’t run arbitrary code from input – there is no opcode for that. The only way to make them do that is by deploying the code into a new contract and then doing a DELEGATECALL – which means that the code from the external contract will be executed by the current contract in the current context. And that’s exactly what we need!

How do we deploy an external contract from bytecode? Using the CREATE or CREATE2 bytecodes. Let’s go with a simple CREATE – it just needs a few parameters, such as the value of the money to pass (0), the offset (location of the code) in memory, and the size of the code in memory.

CREATE op-code
CREATE op-code

That’s all easy, but how do we deploy a contract whose code only contains the “send money to me” instruction? How does a contract deployment happen anyway?

When a contract is being deployed on an address, it’s constructor code is executed on that address (even before the address has any code). And for the contract to be considered “deployed” and to have some code remain forever on the address, the constructor code must RETURN the deployed code at the end of its execution.

This may sound strange, so let’s see what it means in practice.

Here is a constructor code that deploys a contract with “32FF” in its bytecode:

PUSH2 0x32FF // Push 32FF into stack
PUSH1 0x00   // Push 0 offset where to save it
MSTORE       // Save it to 0 position in memory (0x00.....0032FF)
PUSH1 0x02   // Push 2 into stack (size of our bytecode)
PUSH1 0x1e   // Push 0x1E (18) - which skips all the 18 zeroes
RETURN       // Returns just 32FF from memory - this is our bytecode

After executing this constructor code, the 32FF will remain on the contract forever. Well, not forever in this case - it will self-destruct on any call :-D

There is an easier way of returning the bytecode than copying it to memory from the stack and that is using CODECOPY:

PUSH1 0x02   // Push 2 into stack (size of our code)
PUSH1 0x0c   // 0x0C is where our code begins in this code
PUSH1 0x00   // 0x00 where to save code in memory
CODECOPY     // Copy 2 bytes of code from 0x0C and save to memory
PUSH1 0x02   // 2 is size of the code in memory
PUSH1 0x00   // 0 is location in memory
RETURN       // Returns 2 bytes from 0 location in memory
ORIGIN       // This is offset 0x0C - where our code begins
SELFDESTRUCT // Origin is who called the TX, selfdestruct sends money to that address

As you can see, this is a bit different: we put our contract code "as is" - just after the RETURN, and then we just say "hey, CODECOPY from this offset please" - and we have this code in memory and return it - thus deploying it on-chain.

That's the general template for deploying some bytecode, and in our case - we need to take the bytecode from calldata and also determine its size. Luckily, we can use PUSH2 for the size, and it will also work the same as PUSH1, which will give us enough slack for deploying even 65535 bytes of bytecode (and contracts can't be that large - so that's a universal solution).

This code looks like this in bytecode:

0x61000080600a3d393df3

Where instead of four zeros "0000" we should paste the size of the bytecode we are about to execute (which is equal to calldata size).

To make it a little bit more obscure - let's XOR it with our HAPPY NEW YEAR intro and deXOR it at runtime - it's on the stack already, so why not:

XOR'ing the constructor
XOR'ing the constructor

This will make it a little bit harder for anyone decompiling to understand why this is there. But just a little bit - any experienced developer will just be delayed a couple of minutes more.

Huffin

After some experimentation with bytecode, I've decided it's time to organize and start a Huff project - it's also a good excuse to finally learn Huff.

So far, we have this:

Basics of the CTF
Basics of the CTF

I've added a DELEGATECALL after the contract is deployed - it will execute the freshly deployed code.

So right now, it's all about sending 32FF to the contract and it will self-destruct while sending any money it holds back to the transaction origin (which is us).

That's still too primitive, so let's add more complexity to it: SOCIAL!

Social mechanics

Initially, I wanted to just put 0.1 ETH as a prize on the contract and that's it, but it's not as interesting as some kind of "social game" - where you need to put some of your money at stake if you feel you can solve the puzzle fast enough to win it back, along with the whole prize pool. This creates some interesting social mechanics:

  • Initially, nobody would want to put their ETH on an unknown contract

  • But if you've already solved the puzzle and you're 100% sure about what's happening in the bytecode, then you can safely deposit and take the prize

  • But what if someone else also solved the puzzle and just waits to frontrun you, taking both your money and the prize?

That's why I've also introduced a 1-hour delay between depositing and being able to submit a solution - so no direct frontrunning is possible without also having the frontrunner's funds at stake. And also:

  • You have an incentive to deposit earlier, even if you're not 100% sure about your solution - but you already know the general principles and you're going to make it within 1 hour

  • If someone else has already deposited, you don't know if they already have the solution or if they're also just booking a place and still solving it

I've also introduced diminishing deposit - it goes to zero at 23:59 on December 31, 2022 - so booking a place at the beginning costs more, but diminishing still allows those who aren't sure to wait until it's cheaper, although with the cost of the prize being taken by someone else (which actually happened, but spoilers later).

Now, lets hope that these mechanics would make the game more fun and less risky to play!

Also I understood, that, as a creator, I know the solution and can frontrun anyone, so I've decided that my initial 0.1 ETH prize should be sent during contract deployment so it doesn't count as a deposit (i.e. I cannot submit solutions). This still doesn't provide any proofs or guarantees that I will not mimic an anonymous user and submit another deposit from a separate unknown wallet, but anyways, that's impossible to prove, so that's the most I could do to make the game fair.

XORing the calldata

To make it a little bit harder from a technical standpoint, I've also decided to XOR the provided calldata with the msg.sender address - this way we'll have an additional front-running protection: all solutions will be different for everyone. And this will act as an additional delay for hackers - it's not as easy as submitting "32FF" to the contract address anymore.

Writing the XOR loop in Huff was fun :) and required some effort. But in the end, here's how it looks:

XORing the calldata with msg.sender
XORing the calldata with msg.sender

How it works:

  1. It takes msg.sender and duplicates it to fit the whole 32-byte slot:
0xd48B1EBb6828D1a1760b5c70aaE1aAE3CdcD18c1
                  ^^^^^^^^^^^^^^^^^^^^^^^^
  vvvvvvvvvvvvvvvvvvvvvvvv
0x760b5c70aaE1aAE3CdcD18c1_d48B1EBb6828D1a1760b5c70aaE1aAE3CdcD18c1
                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  1. It calculates the size of calldata, determining how many 32-byte slots it takes - thus how many times we need to process it.

  2. It processes the calldata (copied to memory already) slot-by-slot, XORing it with this duplicated msg.sender 32bit value.

After this, 32FF becomes something else - so the hacker needs to figure this out and XOR it beforehand, so the contract de-XORs it.

How all these XORs work?

A little side note: talking about all those XORs, what is happening here?

For those who don't know, XOR is a bit-operation that can be applied to two numbers.

Technically, what it does is show the difference between the bit representations of two numbers:

A = 0001110001010000111
B = 0001110001111000000
XOR 0000000000101000111
              ^ ^   ^^^

As you can see, XOR shows which bits differ between two numbers and puts 1 there.

But another interesting property of XOR is that if you XOR a number with some other number, it becomes "encrypted." Why? Because if you XOR this encrypted number with the same number again, you'll get back the original number!

Here’s an example:

A = 000111000111
B = 010101010101
XOR 010010010010

and then you XOR the result with B again:
RES 010010010010
B = 010101010101
XOR 000111000111

And you see that XOR result here is the same as A!

That's why we XOR things back and forth - to "encode" and "decode" them - that's the simplest encoding human kind has known since punch cards became a trend. Any experienced hacker knows about it, but it's still always fun!

Registering players

How do we register the players who made a deposit? It's simple - just put something in storage at the msg.sender address slot. And this something will be a timestamp - because we're introducing this 1-hour delay between depositing and the solution.

Here's how it looks in Huff:

Registering player and check callvalue macro
Registering player and check callvalue macro

I guess everything is clear from the comments - first, we check that a proper ETH amount was sent with the transaction (which depends on the time until NEW_YEAR), and then we register the player by saving the current timestamp + SOLVING_DELAY to storage under the msg.sender address slot.

Verifying if player was registered

And when submitting a solution, we verify if the player was registered:

Verify if payer was registered and if they can already submit a solution
Verify if payer was registered and if they can already submit a solution

It's also pretty simple - just reading from storage and comparing it to 0 (if the player was registered at all) and to the current timestamp (to check if they can already submit a solution).

Piling everything up together

Now that we have all the functions, let's put everything together into one big Huff program :)

I won't copy the screenshots of the above functions, but I'll just show you the MAIN() macro instead:

MAIN() macro
MAIN() macro

I've added some more tricks like using RETURNDATASIZE instead of PUSH1 0x00 - because in this case it does the same thing - pushes zero onto the stack. But it also saves bytecode (because it's just 1 byte) and hopefully confuses hackers a little bit more.

I've also added the "CONGRATZ" text along with "chad]" as push instructions. Why? Because the delegate_call JUMPDEST is 0x5B, which is "[", so I wanted to use that and make it look like "CONGRATZ [chad]" when you look at the bytecode :)

Honestly, I also wanted to make it a string and jump inside of it, but in the end, I found out that you can't jump inside PUSH instructions data :-( (more on that in my previous article).

Results!

So my prediction was that an experienced hacker should be able to solve this within 1-2 hours (thus the 1-hour delay). And that's basically what happened - yannickcrypto.eth deposited his stake after about an hour or two after I deployed and announced the game, and then submitted a solution in another hour or two - grabbing the prize and making the whole contract lifetime just 4 hours. Congrats to Yannick!

The solution was exactly that: "32FF" encoded, so the contract self-destructed and sent all the money to the hacker.

The code is still available on dedaub:

And look how amazed I was with the output of their decompiler!

Decompiled bytecode by Dedaub
Decompiled bytecode by Dedaub

It decoded everything into very readable Solidity code! And it did the best job out of all the decompilers I've tested this bytecode with (some didn't even output anything because they were probably confused by the "HAPPY NEW YEAR" intro :-D)

It's too bad I didn't know about its existence when I was testing the game, because with such code, it shouldn't take longer than 15-30 minutes for anyone experienced enough to understand what's going on, making the game too easy.

But still, it was a good experience. I hope the next puzzles and games that I make will be better and more fun to participate in!

As always, stay tuned, and if you like the content, subscribe, collect, and follow me on Twitter to not miss the fun next time!

As always - stay tuned, and if you like the content: subscribe, collect, and follow me on Twitter to not miss the fun next time!

Subscribe to Convergence Boy
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.