DownUnderCTF 2022 - EVM Vault Mechanism
long time no see - still no see since you’re reading this article
I’m not sure exactly when, but DownUnderCTF took place some time in the last 14 days. I spent the whole CTF solving the blockchain category, with the majority of my time being allocated to a challenge titled ‘EVM Vault Mechanism’. From my point of view, this was the hardest and most interesting blockchain challenge, so I thought doing a write-up post would be nice. You can find the other challenges, my solution, and very short explanations for each one in this GitHub repo.
The ‘hard’ challenge that I finished before starting this one was pretty straightforward, so I didn’t expect much. However, as soon as I opened the challenge description, I knew something was wrong - there were no
.sol files available for download!
Don’t worry, though - if I managed to solve this challenge, anyone can! While reading, just remember that some tasks just require the willingness to learn and (lots of) time.
At this point, I there are 2 pieces of information available for the challenge:
- The challenge title & description: ‘EVM Vault Mechanism’ and ‘Unlock the vault and the treasures are yours.’
- The blockchain
Each player has their personal RPC endpoint, so the blockchain only contains ‘setup’ transactions in the beginning. Here’s how to get the transaction blocks with ethers:
The bytecode was deployed in the 2nd block and looks like this:
In order to solve the challenge, we’ll first need to read the bytecode. After failing to decompile it to Solidity code multiple times, I accepted the fact that I’ll probably have to reverse engineer assembly code to see what the contract does.
During my search for the ultimate evm decompiler, I stumbled upon evmdis, which disassembled the bytecode quite well. After installing it with
go, I wrote the bytecode in a file and used the following command:
If you want to see the result, see EVMVaultMechanism.sol.disassembled in the write-up repo.
As you’ll soon see, 387 lines are not that much.
The Function Selectoor
Now that we have the disassembled bytecode, solving the challenge is just a matter of understanding what we’re seeing. Let’s start with the beginning of the code:
The constructor part can be safely ignored for now. The code does a conditional jump to
label0 if the calldata size is greater than or equal to 8. If the jump doesn’t happen, the code will revert, meaning that we need to call this contract with at least 8 bytes of data.
label0 just does a jump to
Take a deep breath and don’t panic. This function is not very hard to reverse - it’s very similar to Solidity’s function selector. The first two lines basically divide the first 8 bytes of calldata into 2 values / halves. Then, depending on the first half, a conditional jump will be taken or the contract will revert.
The function ‘names’ are
0x46464646, or, in ASCII,
FFFF. Each one is going to handle the second argument differently by getting it with
We still don’t know what the challenge is about, so the best course of action is to start with the
vrfy function. Judging by its name, the function is supposed to verify something, maybe if the challenge was solved. The function selector takes us to
The code loads a value located at storage slot
0x1337. If it is equal to
0xFF, it will set storage slot
0x736F6C766564 (ASCII: ‘solved’) to 1. The code will then jump to
label31, no matter what the value is:
label31 gracefully stops the execution of the code, much like a
return statement would. Keep this in mind - it’s referenced in a lot of places.
Okay, so we need to set the storage slot at
0xFF. It would be nice to see if functions
FFFF follow a ‘template’ - let’s follow the execution flow of
AAAA to get a sense of what it’s doing, starting at
label13 (see function selector):
That looks pretty strange, but the function is only pushing another reference to the stack (maybe a callback?) and calls
There’s some fancy math going on, but, for now, we’re interested in the general execution flow of the code. Notice that the last line jumps to the thing I compared to a callback,
There it is! The function jumps to
label15 if something ‘returned’ from
label1 is true. If the jump isn’t taken, slot
0x1337 is XORed with
label15 is then executed anyway, and it just jumps to
return equivalent we looked at earlier).
It seems like we are getting somewhere! Indeed, each function executes some code and, based on something returned from the code, XORs storage slot
0x1337 with a value (or doesn’t). Here’s a list of the functions, their associated labels, and the value they XOR the slot with:
A normal person would start reversing all functions at this point, but I’m really grateful that I did the following thing:
Executing all functions would not be a good idea, since the slot’s value wouldn’t be
0xFF. XOR is commutative and
x ^ x = 0 for all
x, so calling a function twice is the same as not calling it at all. This means that we need to call each function at most once, but calling all of them won’t work. The following python script prints all the possible function call combinations that will get storage slot
0x1337 to hold
The output suggests that we call any of the following combinations:
We have to call two functions anyway (
CCCC), so we’ll start reversing those.
We’ve already taken a look at
AAAA. To refresh your memory, here’s the deconstruction of
Also, recall the condition of the conditional jump just before the XOR takes place:
The code pushed at location
0x3A needs to be true for the jump to be taken, so we actually need it to be false for the XOR to be false. The condition is
!(0x346D81803D471 == POP(@0x2D)) - adding a NOT will cancel the first one out, so we have
0x346D81803D471 == POP(@0x2D). If we replace
POP instructions with the value pushed at the indicated code offset, we can deduce what the first argument should be:
That’s it! Calling the vault contract with the calldata
0x41414141b1f19388 will indeed xor the value of storage slot
Ladies and gentlemen, please fasten your seatbelts. This is the hardest ‘pin’ to unlock - you’ll see why in a moment.
Let’s first look at
label20, which are responsible for calling the real function and comparing the result:
POP(@0xA6) instruction needs to return false for the XOR operation to happen. Let’s take a look at
Don’t worry - the constraints are not too hard to figure out.
msg.sender on the stack.
0x6A pushes the balance of
0x6C its code size, and
0x6E its hash. Notice that these are all things that we can control.
We want the value pushed at
0xA6 to be false, but it starts with a negation (
!). Since a double negation ‘cancels itself out’ (i.e., not not false is false and not not true is true), we need to satisfy the following condition:
Multiplying values is the equivalent of
&&), since, if any of them are 0, the result will also be 0. That means that we are going to keep 4 constraints in mind while designing the contract that will call the vault (you’ll see later that only a special contract can satisfy these constraints).
Let’s start with the first one,
POP(@0x68) & 0xFF == 0x77. This basically says that the address of the calling contract needs to end with
77. This is easily doable by bruteforcing the
CREATE2 - a function that lets us determine a contract’s address before deploying it.
The second one is
POP(@0x6A) > 0xDE0B6B3A7640000. This is, again, a very simple constraint to satisfy: it just asserts that
msg.sender’s balance is greater than
10 ^ 18 wei, which is the equivalent of one ether.
You might think that the two other constraints are just as easy to satisfy, but you’d be extremely wrong. I’ll start with
POP(@0x89) == 0x77, since the third one should be approached last (it relies on the contract’s hash). The constraint says that
POP(@0x85) & 0xFF should be equal to
0x77. But, if you look at the code,
SHA3(0x7, 0x4). The
0x7 storage slot is assigned one line before:
EXTCODECOPY(POP(@0x68), 0x7, 0xB, 0x4).
Okay, let’s recap. The contract copies 4 bytes of code from the calling contract (offset
0xB), hashes them, and then checks the last bytes of the
SHA3 hash. To design a contract that fits this constraint, we first have to find one of the many possible 4-byte values that will work:
If you run the code, you’ll see that the output is
0x1234576c. But where should we keep this value? To find out, let’s use the following skeleton code for our contract:
fallback function uses some assembly in order to make the resulting shellcode shorter - I’m not sure if that is required, but it was fun making a call with in-line assembly. If we deploy this contract, and get its code at offset
0xB, we will get
0x331333337, which is exactly our call value! I still didn’t want to ruin such a nice function argument (it’s ‘1337’ in ASCII), so I declared another variable before it:
Indeed, that would satisfy the 3 constraints we have so far, since the contract address can be bruteforced before deployment, the bytecode contains the magic value at offset
0xB, and the contract can be given funds when the
fallback function is activated. Let’s move on to the fourth and last constraint:
POP(@0x6C) == POP(@0x78).
0x6C is the code size of the caller and
SHR(0x18, POP(@0x6E) & 0xFF000000) (
0x6E is the code hash of our contract). In other words, the byte located somewhere in the hash of the contract’s code needs to match the code’s length. Notice that
0x78 is only 1 byte long - the contract code can’t be longer than 255 bytes.
At this point, some advanced users might try taking the ‘metadata’ that the solidity compiler adds at the end of compiled bytecode and change that. However, I am a yak with a blog, not an advanced user. I remembered that the
thingy variable above had a lot of unused bytes and decided to put them to good use: I just incremented the value until I got a bytecode that fit the limitations:
One more detail -
solcx doesn’t work well with newer solidity versions, so I needed to compile the code with
0.8.9. The resulting bytecode is given to my solve contract as a parameter, since different compilers might generate slightly different bytecode (because of that metadata).
That’s it! This was the hardest part of the challenge - only uphill from here :)
Remeber that there are 2 ways of getting
0xFF into storage slot
The first one involves fewer function calls, so let’s try reversing
BBBB. Here’s the function’s wrapper code:
The value at
0x5C needs to be false. Let’s take a look at
Not too complex now that we’ve seen
CCCC, right? The code checks that some bytes of
SHA3(arg1) are equal to
0xFD28448C97D19C. Let’s just make a quick python script that bruteforces
0xFFFFFFFF values and we’ll be done!
Well, after running that script twice and making sure there are no errors in it, I have to tell you that no 4-byte value can satisfy the constraint. It’s impossible, so we’ll have to take a look at the other two functions.
To get a sense of this function, let’s start with the wrappers,
THe value at
0xEE should be false. Here’s
To get the XOR, we need to satisfy the following condition:
0x0 == POP(@0xD5) ^ POP(@0xCA) + POP(@0xE5). To better understand the conditions, I will divide the argument passed to this function into 4 1-byte groups and write it as
BLOCKHASH(NUMBER() - 0x3 + (0x2 * (POP(@0x174) & 0xFF) & 0xFF)) - it translates to
BLOCKHASH(NUMBER() - 0x3 + (0x2 * D & 0xFF)). The
BLOCKHASH functions returns
0x0 for all ‘future’ blocks, so a value such as
0x87 should do the job (
number + 14 - 3 = number + 11, which is in the future).
0x101 * (SHR(0x18, POP(@0x174)) & 0xFF), which translates to
A * 0x101.
0x2 * (SHL(0x7, POP(@0xC0)) + 0xD), which could be written as
2 * (SHL(0x7, BC)+ 0xD), and
SHR(0x8, POP(@0x174) & 0xFFFF00), which is just
To find A, B, and C, we just need to find numbers that satisfy this condition:
A * 0x101 == 2 * (SHL(0x7, BC)+ 0xD). So, how could we find the appropriate values for A and BC? Simple - bruteforce! For all values of BC, see if A can be computed. Here’s the short python code:
The only values of A and BC for which the statement is true are
26. Therefore, the argument for the function needs to be
0x1a001a87. One more function and we’re done!
Let’s begin by taking a look at
This time, we need to make
0x151 return 1. If we go to
label5, however, you’ll see that the flow is a little bit more complex this time:
At this point, it’s just time to take out a pen and draw a schematic of the program. In short, it’s a while loop that has 3 variables - say A, B, and C. A starts from 0 and the loop stops when it reaches
0x20. If the bit at index A of
ARG1 is set to 1, then B will get incremented and a value based on the vault’s code hash will be added to C. The target is to get B to equal
0x11 (= 17 bits set to one in
ARG1) and satisfy the following condition:
C % 0x539 == 0x309. We could compute these using combinations of the hash bytes and whatnot, but it’s easier to just bruteforce a valid parameter:
The first value that satisfies the conditions is
0x000f97ff, but I’m sure there are many others.
That’s it! After calling the ‘vrfy’ function, the storage slot ‘solved’ gets set to one! You can find a 2 AM implementation of the solution here (weird implementation because I had to debug it - gas issues!). You can also find the original source code of the contract here.
I don’t know how you felt when reading this article, but I’m really glad I solved this challenge. I spent a lot of hours on it, but managed to learn Solidity assembly and do a little bit of reverse engineering. It’s probably one of my favorite challenges, even though it might seem ordinary if you haven’t worked through it yourself.
now please excuse me but I have to do Minerva stuff
Until next time, hack the world.