JOP is a Capture the Flag challenge created for ParadigmCTF 2021. This puzzle was not solved during the initial event — owing that to the extreme challenge it offers. As an aspiring security auditor, I felt that learning both JOP and Foundry together would be a great idea, and this post reflects the understanding I’ve gained from working with both.
Brief Background
Why JOP?
JOP poses a unique challenge in that you don't have the source code available! Poking holes in a program that you can't read is something I’ve wanted to try and would advocate others to try as well. Approaching problems through first-principles is a great guiding force, and learning the barebones bytecode will round out your skillset alongside Solidity.
Also, it currently does not have a hosted write-up explaining its intricacies and I wanted to fill this void.
Why Foundry?
Foundry separates itself from the pack with the combination of providing EVM cheatcodes and a kickass debugger. Being able to use prank() to change msg.sender, deal() to change balances, and especially load() & store() to read & alter storage slot values promotes quickly testing new ideas and speeds up the exploratory programming phase.
Foundry also is designed to have the user writing only Solidity. It’s a very enjoyable experience to stay in Solidity-mode rather than context switching between JS/Python for deployment and testing your ideas.
From a CTF-solver’s standpoint I only wish there were a storage change log. The record() and accesses() cheat codes are great, but a storage log would be useful to view storage location changes (slot & value) during debugging — especially so for the massive multi-step exploit functions that CTFs cater to. Nonetheless, I am quickly becoming a Foundry-main and only go to other debuggers like Remix for storage-related problems. Foundry is a dream to use, and we will do just fine solving JOP with what is available.
Resources:
ParadigmCTF source: https://github.com/paradigm-operations/paradigm-ctf-2021
Foundry: https://github.com/gakonst/foundry
My Solution Repo: https://github.com/plotchy/jop-foundry
Let’s Look At The Challenge
Introductory Phase
So where do you start? The challenge provides a Setup.sol file, some ERC20 dependencies, and a compilation output.
Setup
Setup.sol provides some guidance at the very beginning. We can see that the Challenge is initialized with two values (990, 1100), and that a subsequent call is made to buyTokens() with 50 ether.
We also know that we have three goals to accomplish with our exploit:
Change the owner of the challenge contract to 0xdeaddead….
Have the ERC20 balance of the Setup contract be 0 — with regard to the Challenge ERC20 token.
Drain the Challenge contract of all ETH
Before we dive in, we need to step back and do our first modifications to this challenge to have it work with Foundry. We need to re-create Setup to use the raw creation code we have in compiled.bin.
We take the creation code from the Challenge.sol portion of combiled.bin, and we append our constructor arguments (990 & 1100) to the end using abi.encodePacked(). From here, we run create(), populate some helper variables for later, and call buyTokens() with 50 ether.
Foundry Testing Suite
We also want to set up our way of running our exploit. For the sake of simplicity, I have my Exploit contract create a new Setup contract which in turn creates a new Challenge contract.
In our Foundry project directory, we also add a myExploit.t.sol file, which is ran when we run forge test in the command line.
Running forge test
The Power of Cheatcodes
Before diving in to the bytecode of Challenge, we can first use the OP features of Foundry to learn more of our goals.
For instance, the verbose logging forge test -vvvvv will mark all calls taking place during the test. We can use this to our advantage to see which contracts become what addresses, how the overall call chaining looks between Exploit → Setup → Challenge, and view any emitted events that occurred during deployment.
Adding in cheatcodes record() and accesses() during our setUp() function will also expose the relevant storage slots written to during deployment. Altogether, our new testing code looks like this:
And we run it with forge test -vvvvv, outputting:
Wow! This is dense! Let’s break it down:
We take note of each Contract:Address pair. This will be useful when inside the debugger later.
myExploitTest = 0xb4c7…
myExploit = 0xce71…
mySetup = 0x037f…
<Unknown> (Challenge) = 0x1276…
We note the event that was emitted
topic 0: 0xddf252…
After a quick googling of 0xddf252, you’ll notice this is tied to the ERC20 Transfer event!
topic 1-2: 0x0000 → 0x037f…
The Transfer is occuring from the zero address to the mySetup contract.
data: 0xa7b667…
This is the amount! Converting the Hex value to Dec shows that this is a transfer of 49500 * 10**18 tokens.
We note the massive VM::accesses block. There are a few dozen entries in this block, not all of which are noteworthy.
The 0x3c11… slot sticks out most. Through knowing how storage slots work in Solidity, we can deduce that this slot is produced through a keccak() hash, which are used in mappings. Knowing we had a Transfer event related to 49500 tokens, this relates to our mapping for _balances() and holds the balance that mySetup has in the ERC20 token.
We also know one of these slots holds the owner() value. We can’t deduce which one from this output, but we can creatively do so in the next section. 😉
Using isSolved() to our Advantage
Knowing that we can view storage slots that are accessed during tests, let’s use setup.isSolved() to our advantage to view which slot is peeked at for the owner() value.
Let’s make a new testing function named testViewIsSolved() and look at the storage slots accessed inside.
Run forge test -vvvvv -m testViewIsSolved, outputting:
In the VM::accesses block, we notice a slot being read, 0x05! Now we know owner() is stored here. To explain why only one slot was accessed despite there being 3 conditions in isSolved(), Solidity if() statements work with short-circuiting, so the isSolved() function failed on the first condition. We can’t view any further until we find the solution for that first condition. Are we stuck?
Unless….
This is where the power of Foundry truly comes to form.
We can use a cheat code to manipulate the slot and pass the first condition. We will use store() to change the value of slot 5 to the 0xdeaddead… address.
First, let’s take a look inside to see how the owner() slot is used. We’d expect it to match the deployer which would be the mySetup address (0x037f..)
Running forge test -vvvvv -m testViewIsSolved outputs:
Turn your attention to the VM::load return line. As expected, our mySetup address (0x037f..) value is inside the slot. However, a keen eye notices that the end of the address normally ends with 0x..dd8f, and this slot shows it ending with a 0x…12? Huh? The answer is the slot shares it’s last byte with a previous variable following storage packing criteria. We aren’t sure what variable the 12 is tied to, but we do know it’s not being used for owner().
Now we know we need to fill the slot with 0xdeaddead…12. Let’s do so using store():
Now we see that the VM::accesses block has a new entry! It worked! isSolved() is now checking the 0x311c… slot for the next condition. We knew from earlier that this slot houses the ERC20 balance of our mySetup contract, so this makes sense. We can repeat the store() command to place an ERC20 balance of 0 into that slot to solve the second condition.
Now that the first and second condition are solved, we know the third condition is that the Challenge contract has no ETH balance. We can use deal() to set the ETH balance to 0, and use an assertTrue() to check the return condition of isSolved() making sure we really are solving the challenge.
Let’s run forge test -m testViewIsSolved:
Hurray! We’ve ‘solved’ the challenge. This acts as an amazing sanity check. We now have a gameplan of which slots to attack with what values, and can begin stepping through the bytecode.
Walking Through The Bytecode
Let me give you a rough image of what we’re working with:
Thankfully, we don’t need to hand annotate this brick wall. Instead we’re going to rely on a bytecode decompiler, and use the fantastic online version hosted at ethervm.
If you plop this bytecode in as is, you aren’t going to get the correct output. The initcode found in the compiled.bin file is creation code and we are wanting to analyse the runtime code. The runtime code can be found by using a simple helper function like this:
Or, I can let you know that there’s a very common pattern in Solidity where the second occurence of 6080 within the creation code is the beginning of the runtime code. This pattern is so common that ethervm also recommends you try this as a default. It works here too.
If you’d like to follow along with decompiling, visit this file on my solution repo and copy & paste the 4th line’s Runtime only bytecode into ethervm's decompiler.
Looking at the Decompiled runtime code
The output of our runtime code is separated into 4 sections:
Public methods
All externally facing functions
Some have names if they match registered functions from 4byte.directory
Think of these as our entry points for the exploit
Internal methods
Functions that are called from inside the contract
Decompilation
Attempt to convert the contract back into Solidity-style language
Disassembly
Annotated opcodes sharing labelling from Internal Methods
Where we’re going to spend most of our time 😁
Starting with the Public methods, we want to look over the available entry points with our goals in mind: 1. Change the Owner, 2. Remove the ERC20 Balance, 3. Drain the ETH
We see several familiar ERC20 functions, but most are irrelevant. For a starting point there are only about 7 functions that have a sensible relation to our goals. This is a welcome reduction of 23 → 7 functions that we’ll investigate further.
Skimming over the internal methods, we see that these consist of a lot of the previous methods along with label_XXXX() methods that we don’t understand much of yet. This section won’t largely come into play as the pure disassembly will prove to be more useful later on.
Moving onto Decompilation, you’ll notice familiar syntax that we’re used to.
There are a few distinct differences from Solidity that will prove helpful to know:
main() is the starting point for every external call to the contract.
Public methods are then entered depending on the data sent to the contract.
memory[X:X] refers directly to the EVM memory at that point in time.
This is not typically accessible with Solidity unless you use an assembly{} block.
The [X:X] portion refers to a slice of that memory, typically in the form of bytes32 chunks.
label_XXXX refers to a jumping pattern.
You may notice a goto label; syntax, which redirects control flow to the beginning of the label.
The XXXX portion refers to a specific JUMPDEST opcode as we’ll see in the Disassembly section.
If you are interested in how Solidity boils down to EVM code, reading through Decompilation will be a treat. There are many neat things to be exposed to such as the dispatch table routing your function calls to the specific code location and looking at many behind-the-scenes protective measures Solidity employs to guard your functions against dirty bits and receiving ether it shouldn’t be expecting.
Before we dive into the Decompilation, let’s become familiar with Disassembly.
There are 4 key components to make note of:
The labeling system
label_XXXX refers to the beginning of a block of code.
Each block is designed to be a self-contained control flow. If you enter it, you don’t leave until you reach the ending opcode. Labels are separated according to this rule.
The program counter
This section refers to the position of each opcode within the overall runtime code. This is a running tally where each opcode sits in respect to an offset from the first byte.
The opcode byte
Each EVM opcode has a designated byte. These refer to specific actions that the EVM performs according to the value of the byte.
evm.codes is a fantastic online resource to learn more about each opcode
Annotated opcode bytes
This section adds annotated names to the opcode bytes and includes all PUSH’d values
The disassembly is a section that we will be searching through a lot in this challenge. Ctrl+F is your friend to search for common patterns, and we will use it strategically throughout our exploratory phase.
A Brief Recap
To recap what we’ve covered so far, we have:
A localized Challenge setup
A Foundry test suite to quickly run our ideas
An online decompiler (ethervm) to read through the bytecode
An online Opcode resource (evm.codes) to learn more of how the stack, memory, and storage are utilized throughout the contract
As a forewarning, the following content regarding the solution is going to be dense. The challenge requires a deep understanding of managing the EVM stack & finding exploitable label chunks, and I will do my best to explain it. I recommend a fellow writer’s EVM Deep Dives series if you find yourself needing a greater explanation of these concepts than I provide.
The Solution
Since we are dealing with such an open-ended problem, I’d like to frame this section through the mindset of a CTF-solver, yet in the interest of length I will lightly reference attempted paths that don’t lead to interesting results.
These are pending questions we have at this point:
Can we trust the ERC20 functions? Do they act as intended?
After testing, we can trust these to function as intended.
Are there any red flags 🚩 in the Decompilation or Disassembly?
What do the Unknown functions do?
How do we access + write to the owner() slot (slot 5)?
How do we send away the Eth?
How do we remove the balance of the ERC20 token?
Reviewing the Decompilation
Our goal with reviewing the Decompilation is not to fully understand what the program is doing, but instead to see if we can find any red flags. A nice feature of ethervm is that it adds helpful comments to the Decompilation. A quick tip is to check if they’ve flagged any lines with Error comments. If we search the webpage for Error, we get 6 results.
The worst of the bunch is this line:
// Error: Could not resolve jump destination!
Essentially, if we can get to this position, we can jump to a dynamically found JUMPDEST. We need to investigate further to see if we can manipulate the calculation for this JUMP. Let’s follow the Disassembly starting at label_1CB2 to see how this is calculated:
After arriving to label_1CB2, we immediately get jumped to label_1D0F if we send the tx with gas price being less than 200 gwei.
Greeting us in label_1D0F is a familiar comment letting us know that we get jumped here if our tx has a <200 gwei gas price. Notably, the end comment lets us know about the unconditional jump from the Error message! Let’s paste the full comment:
// Block ends with unconditional jump to 0xffffffff & 0xffffffffffffffff & (0x1f51 * !(storage[0x0b] / 0x0100 ** 0x00) | storage[0x0b] / 0x0100 ** 0x00)
To rephrase this in pseudocode:
If (storage slot 0x0b is empty) {
jump to 0x1f51
}
else {
jump to location specified in storage slot 0x0b
}
This is incredible! Taking over this storage slot with our jump location is the key to beginning this challenge, and is the namesake of the puzzle - Jump Oriented Programming (JOP) 🦘🦘🦘. We will bear witness to how powerful directing control flow can become.
Storing values into 0x0b
The EVM accesses storage locations using SLOAD, which takes in a value off the stack. We can search for PUSH1 0x0b to see the locations where this is accessed.
There are 2 locations:
label_1D0F
The location we just looked at. We know the value is only read from here.
label_0A20
This location uses an SSTORE to fill the slot!
We need to see how we get into label_0A20, and if we can manipulate the value sent into slot 0x0b. This requires a lot of backtracing from label to label. For brevity’s sake, we go backwards through the incoming jumps and remember any conditions we need to pass:
label_0A20 → 0365 → 034B →0334 → 00CC → 00C1 → 001D → 000C
msg.data >= 36 bytes
msg.value == 0
msg.data[0:4] == 0x27f83350
Simple enough! Let’s put together a test transaction and use the load() cheatcode to see what happens.
I’ve put a recognizable chunk of data (c0dec0dec0de…) to act as my payload. Now we add in a testing function to call this function and view the storage slot afterwards:
We run forge test -vvvvv -m testViewSlot0x0b:
We see that our payload is now stored in slot 0x0b! We could have determined this similarly by entering —debug mode and watching how the stack item is formulated, but this way is just as simple. It’s also nice to have a test handy if we run into later troubleshooting issues.
Now that we have a way to store a value into the slot, we’ll quickly double check that we can access this JUMP through the buyTokens() path we saw earlier. Let’s make a new function in myExploit and a new testing function to call it.
This time we run with forge test —debug testJumpFromBuyTokens. This places us into the debugger, where we can view the step-by-step opcodes.
To save you the trouble, I advanced in time through the opcodes to the position of the relevant SLOAD within label_1D0F that we found earlier. Here we see that directly after the SLOAD, our c0dec0dec0de… payload has made it onto the stack. Perfect! In the future we’ll be placing a legitimate JUMPDEST into slot 0x0b rather than the recognizable payload.
How Do We Use It?
Despite making undeniable progress, this finding does not directly progress our goals. Let’s keep in mind that we can start JUMPing to locations and begin looking for ways to abuse that.
Investigating the Unknown functions
Now that we’ve seen the first unknown function 0x27f83350 corresponded to a unique opportunity, it makes sense to check the other one! We’re going to find the entry point of 0x84e2b7f6.
After a quick search, we see that it leads us from 0x84e2b7f6 → label_054D → label_19FC.
Inside label_19FC is a promising discovery! It directly looks at the msg.data that we can provide to buyTokens(). Side note: Typically it is not allowable to provide data when the function asks for none, but in Solidity versions <0.8.x where abicoder v1 is used, nothing is stopping you!
Let’s Dig Deeper Into This
Now is the appropriate time to dive back into the Disassembly to see how this code manifests. We head to label_19FC and pay attention to Inputs and Outputs
Notably, the outputs are very promising. We can see that the function is parsing our msg.data into bytes32 sized chunks, and placing them onto the stack. I’ll be talking about these 4 stack values a lot, so I’m going to begin referring to these with a simpler rename:
word1 = stack[4]
word2 = stack[5]
word3 = stack[6]
word4 = stack[7]
This would be a huge discovery if it lets us use one of these stack values to JUMP to another location. Let’s continue following the trail into label_1A99, pay attention to any restrictions, and see if we can control an ending JUMP.
Tracing through:
label_19FC → 1A99 → 1B11 → 1BAA →1BBA
Translating relevant restrictions to pseudocode:
19FC:
if (word2 > 0) {
jump to label_1A99
} else revert;
1A99:
if (word3 > 0) {
jump to label_1B11
} else revert;
1B11:
if (word4 < 27) {
word4 += 27
jump to label_1BBA
} else jump to label_1BBA
1BBA:
jump to stack item underneath our four words
We can see that there are some basic restrictions based on our calldata input, and it looks like we can potentially control the final jump location? Overall it’s hard to grasp the big picture of what is happening here, so let’s use Foundry’s debugger to get a better view. (I highly recommend following along through the Disassembly + Debugger to familiarize yourself with these tools! My repo has each of these functions already set up for you!)
First, we need to store 19FC into that slot 0x0b that we got excited about previously. Next, I’m going to write a payload that lets us watch what is going on inside 19FC with some recognizable values. Remember, we enter through buyTokens() to JUMP using the 0x0b slot.
I’ve crafted calldata inside explore19FC() that resembles this layout:
0xd0febe4c // Function selector for buyTokens()
0000....0001 // word1
0000....0002 // word2
0000....0003 // word3
0000....0004 // word4
Now, we can run our testing function testExplore19FC() using:
forge test —debug testExplore19FC
For this debugging session, we only care about seeing how the explore19FC() function works, so let’s fast forward through the transaction until we get there. Use the capital C hotkey to advance forward in the calls until the you see the challengeAddress (0x1276…e134) come up for the second time. (Our first time calling the challenge address was for the placement of 19FC into the 0x0b slot.)
Here we can start using the arrow keys to navigate further into the call, and we’d like to collect ourselves onto the first occurence of 19FC:
I highly recommend turning stack labels on using the letter t hotkey, as it’ll help you parse what is occuring on the stack as we dive through the opcodes. If you had a keen eye, you’d notice that CALLVALUE was placed onto the top value of the stack before jumping into 19FC. Keep this in mind and watch closely how it is used throughout the following steps.
If we keep going, we begin seeing our recognizable wordX values!
Continue following along until program counter 1bd0. Here we need to make some notes:
Once the dust settles, we notice a few key takeaways:
Our CALLVALUE was being used as an offset position inside our msg.data
Program Counter 1BD0 let’s us JUMP to a stack item. In the ‘19FC’ picture a few figures above, 1D39 was the value directly underneath our CALLVALUE and here it is now at the top of the stack.
1D39 takes us immediately to another JUMP. The value is taken from word4
You can notice our inputted 4 got turned into a 1f hex value. This is due to our earlier restriction that if word4 < 27, word4 += 27. 1f == 31
The remaining ordering of the stack after the two JUMPs will be [word3, word2, word1]
We love to see that we can place items on the stack and control an impending JUMP location. Let’s think about this… Is there anyway this can be abused?
What if we re-enter label_19FC?
To re-enter label_19FC, it would mean setting word4 to 19FC, and having word3 act as our CALLVALUE for the next reiteration.
Let’s recraft our payload:
We set word3 = 0x84, as our next round of words will begin 132 characters deep into the calldata
We set word4 = 19FC
words5, 6, 7, 8 are set to recognizable payloads
Once again we run forge test —debug testExplore19FC and fast forward. Let’s reconvene again at program counter 1BD0, but this time at the second iteration through:
We notice some takeaways again:
word2 is the ending JUMP location for the second iteration
word8 is the calldata offset for the third iteration
word7 is going to be the ending JUMP location for the third iteration
If you keep playing with this, a pattern emerges. We can continually add stack items and control full flow of the program using our inputted values. We only need to mind the restrictions that any values placed in Word1 or Word2 must be greater than 0, and any value placed in Word4 will inevitably be shifted to be above 27 if it is inputted as lower.
Compiling all of this information together, we’ll be using the following payload template to fully takeover control of the stack:
Each one of the value entries will be safely placed onto the stack during the looping. Now that we can take complete control of the stack and JUMP to where we want from there, we can move onto fulfilling our win conditions!
Changing the Owner
We know the owner() variable is stored in slot 5. Let’s do a search for PUSH1 0x05 to see when it is accessed and stored to. In total there are 5 occurences.
The most interesting occurence is within label_0C4A. Not only does it access 0x05, it also stores into 0x05. At the bottom of the label block, we see a comment that - when translated - contains the following pseudocode:
Outputs:
storage[0x05] = storage[0x06]
Interesting. Can we put a value into slot 0x06? Let’s search similarly for PUSH1 0x06. There are 5 occurences.
Within label_102A we can directly save a stack item into slot 0x06, and then jump to the next stack item. Since we can control the stack, we can place both our 0xdeaddeaddead… address into storage slot 0x06, and then have 0C4A be next on the stack to jump to the location where the address is saved in storage slot 0x05 as owner().
Moving on to the next win condition…
Sending away the Eth
A useful fact to know of the EVM is that Ether is only transferable using the CALL and CALLCODE opcodes. Searching for both of these only reveals a single occurence of CALL.
label_191B has the only instance of CALL, so we have to go through here! We’re going to pay a lot of attention to the Inputs and Outputs comments to see what parameters the CALL uses and how we exit.
Inputs:
// @194B address(0xffffffffffffffffffffffffffffffffffffffff & stack[-2]).call.gas(msg.gas).value(stack[-1])(memory[memory[0x40:0x60]:memory[0x40:0x60] + (0x00 + memory[0x40:0x60]) - memory[0x40:0x60]])
Translates To:
address(stack[2]).call.value(stack[1])("some value in memory")
Also:
Outputs
Block ends with unconditional jump to stack[4]
Perfect! Since we can control the stack upon entry, we can manipulate the address we send the ETH to, the total value we are sending away, and the next location we jump to. This is enough to solve this condition and propel us straight into the next win condition…
Removing the balance of the ERC20 Token
Forewarning: This condition is tough to find!
Removing the balance of an ERC20 token can be approached in a few major ways.
Directly writing to the storage slot of the _balances() mapping
Using either transfer() or transferFrom() to use the internal function _transfer() to send the balance to another address
Using internal function _burn() to zero out the value
Of these three, the first option is not typically available for a standard ERC20 implementation. We can search around for random label_blocks that enable this, but it may not be possible.
The second and third options are doable with typical ERC20 behavior, and it’s likely that we can find these through searching the Disassembly creatively.
We know that our balance is stored in a mapping, and that mapping slot locations are determined using keccak() hashes. Let’s try to search for occurences where a SHA takes place in the Disassembly. It turns out there are 28 occurences… This is doable to backtrace each path, but is a bit much.
We also know that _transfer(), _burn() and _mint() each emit a Transfer() event. The Transfer() event uses 3 values, and is thus performed by using a LOG3 opcode. Let’s search for LOG3. It turns out there are 4 occurences! This is much simpler.
label_1263
label_1563
label_18AB
label_1E56
While reviewing each of these options, we want to fulfill 3 main conditions:
Access a storage slot according to a stack item
Supply a value to the slot according to a stack item
Jump to a new location according to a stack item
Secondly, a key fact to understand is how the _balances() mapping of an ERC20 is accessed. Storage slots for mappings are accessed using
//_balances() accessing pseudocode
concat(keccak(address), slot)
In our case, where address = mySetup and slot = 0x00:
concat(keccak(mySetup), bytes32(0x0))
Knowing this, we also want to look for places where SSTORE not only exists amongst the label (or in prior incoming jumps), but also that it accesses storage using our stack item for address, and a slot value of 0.
To keep it brief, label_1263 fails our criteria due to it concatenating the address with slot 0x01 position, which coincides with the _allowances() mapping in an ERC20 token.
On the other hand, label_1563 meets all of our criteria!
Let me pseudocode the output comment:
storage[stack[3].0x00] = stack[1]
Jump Out to stack[5]
In short, we are able to put in our mySetup address into memory, access the 0 slot position from there, place a value from the stack into the slot and jump out to a destination we control!
There’s only one sticking point… How do we put a 0 onto the stack?
If we refresh ourselves with the payload template, there are restrictive criteria for how we can place values onto the stack, and in particular we cannot have any values equal to 0..
As usual, with full jump access there is a creative solution we can employ! First we need to search through all potential JUMP points in the contract, and see if there are any that we can use to let us do this. Unfortunately, there are 197 occurences of JUMP so the searching is a bit tedious, however there is a perfect one located within label_0AF7 that we can use.
label_0AF7 provides a useful output for us, and is likely completely unintended! Recall that storage slot 0x06 previously held our 0xdeaddead… address before transferring it over to slot 0x05, and 0x06 ends up getting wiped in the process. This label will get the value stored in slot 0x06, place it on the stack, and jump out to the stack item underneath it. This looks like the duty of a typical public getter() Solidity function, and we are going to abuse it to place in a hard-to-get 0 to the top of the stack, and let us jump out to the nice label_1563 destination to place it into the _balances() mapping.
Lastly…
We Need to Make it Out
Now that our win conditions are theoretically solved for, we need to exit the contract. The simplest way to do this is search for JUMPDESTs immediately followed by a STOP. A brief search for STOP shows label_0366 performs this perfectly.
Putting It All Together
We finally have our gameplan! To review:
Call 0x27f83350 to place in a favorable JUMPDEST location 19FC into slot 0x0b
Next, enter contract again through buyTokens() with a msg.value of 4 wei
Loop repeatedly through 19FC to stack values that perform the following:
Jump to 102A letting us store 0xdeaddead… address in slot 0x06
Jump to 0C4A to transfer 0xdeaddead… from slot 0x06 → slot 0x05
This in turn will zero out slot 0x06
Jump to 191B to drain (50 ether + 4 wei) from the contract to address 0x1111…
(All non-challenge addresses will work in place of 0x1111…)
Jump to 0AF7 to place slot 0x06’s value (now housing a 0) onto the stack
Jump to 1563 to place address(mySetup) into memory[0x00:0x20] and have the SSTORE place the created 0x00 into our balances mapping.
Jump to 0366 to STOP execution in the contract and end our transaction.
Reviewing the payload template from earlier, we just need to place these variables into our calldata so that they are read back-to-front accordingly.
Finish Him! 😈
We finally head to our myExploit contract to add in the last piece of the puzzle. In the aptly named step2() function, we assemble our payload using bytes memory and abi.encodePacked() before sending it off to the challengeAddress using a call().
As a reminder, we use a selector of d0febe4c that lets us enter through buyTokens(), and we use a msg.value of 4 wei so that the beginning calldata offset begins after the buyTokens() selector.
We write another testing function and run forge test -m testStep2:
🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉
We did it! Our payload completes each of the 3 win conditions and exits the contract gracefully. Kudos to all who’ve made it to this point. You’ve tackled one of the hardest CTF puzzles Ethereum has to offer… so far 😉
If you’d like to run the code and view it all yourself, check out my repo: https://github.com/plotchy/jop-foundry
This is the debut of my content, so if you’re interested in deep dives similar to this one, feel free to follow me on the bird site.
If you’re a fellow student of Ethereum and want help with a hard problem that needs solving, reach out!
If you’re a recruiter within Ethereum Security, let’s talk! 👨💻
Twitter 🐦️ @plotchy