DownUnderCTF 2022 - EVM Vault Mechanism
long time no see - still no see since you’re reading this article
The Introor
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 Contextoor
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.
The Decompilatoor
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 label10
:
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 0x76726679
, 0x41414141
, …,0x46464646
, or, in ASCII, vrfy
, AAAA
, …, FFFF
. Each one is going to handle the second argument differently by getting it with POP(@0x174)
.
The Validatoor
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 label11
:
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.
The Generalizoor
Okay, so we need to set the storage slot at 0x1337
to 0xFF
. It would be nice to see if functions AAAA
through 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 label1
:
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, label14
:
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 0x4A
. label15
is then executed anyway, and it just jumps to label31
(the 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:
AAAA
-label13
-0x4A
BBBB
-label16
-0xD1
CCCC
-label19
-0x64
DDDD
-label22
-0xB2
EEEE
-label25
-0x63
FFFF
-label28
-0xC4
The Thinkoor
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 0xFF
:
The output suggests that we call any of the following combinations:
AAAA
+BBBB
+CCCC
AAAA
+CCCC
+DDDD
+EEEE
We have to call two functions anyway (AAAA
and CCCC
), so we’ll start reversing those.
The AAAAoor
We’ve already taken a look at AAAA
. To refresh your memory, here’s the deconstruction of lable1
:
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 0x1337
.
The CCCCoor
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 label19
and label20
, which are responsible for calling the real function and comparing the result:
The POP(@0xA6)
instruction needs to return false for the XOR operation to happen. Let’s take a look at label3
:
Don’t worry - the constraints are not too hard to figure out. 0x68
pushes msg.sender
on the stack. 0x6A
pushes the balance of msg.sender
, 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 AND
(&&
), 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 salt
of 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, 0x85
is 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:
The 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 0x78
is 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 :)
The Lazyoor
Remeber that there are 2 ways of getting 0xFF
into storage slot 0x1337
:
AAAA
+BBBB
+CCCC
AAAA
+CCCC
+DDDD
+EEEE
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 label2
:
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.
The DDDDoor
To get a sense of this function, let’s start with the wrappers, label22
and label23
:
THe value at 0xEE
should be false. Here’s label4
:
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 ABCD
.
0xE5
is 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).
0xD5
is 0x101 * (SHR(0x18, POP(@0x174)) & 0xFF)
, which translates to A * 0x101
. 0xCA
is 0x2 * (SHL(0x7, POP(@0xC0)) + 0xD)
, which could be written as 2 * (SHL(0x7, BC)+ 0xD)
, and 0xC0
is SHR(0x8, POP(@0x174) & 0xFFFF00)
, which is just BC
.
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
and 26
. Therefore, the argument for the function needs to be 0x1a001a87
. One more function and we’re done!
The EEEEoor
Let’s begin by taking a look at label25
and label26
:
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.
The Finalizoor
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.
yakuhito, over.