For Project 2, we are given a binary named rpisec_nuke and tasked with exploiting a service running this binary on the remote warzone server.
No source code is given.
If we run checksec, we can see that stack canaries, NX, PIE and full RELRO are enabled.
The program starts by malloc()-ing a chunk of size 0x34 for a wopr object.
We can define the struct as follows.
Next, if the files GENERAL_DOOM.key, GENERAL_HOTZ.key and GENERAL_CROWELL.key exist, these keys are loaded into memory and the following menu is printed:
We are given the option of unlocking 3 keys, and each key is unlocked using a different function.
If we unlock all the keys, we will be given access to arm the nuke, and if we arm the nuke, we can launch it and “win”.
While we do this, we also need to keep an eye out for ways to control EIP so that we can gain code exec and spawn a shell.
The first vulnerability we can observe is in the menu that is printed.
The address of our wopr chunk is leaked when LAUNCH SESSION - 1448484872 is printed.
Next, let’s try to unlock key 1.
KEY 1
We can define the key 1 struct as follows.
And the pseudocode for keyauth_one() looks like the following:
We are asked to enter a launch key, and this launch key is strncmp()-ed with the string in GENERAL_HOTZ.key for not more than strlen(GENERAL_HOTZ.key) bytes.
If we look carefully, we can see there is an integer underflow vulnerability that allows us to trivially bypass this check!Specifically, we can trigger this integer underflow vulnerability if we set our user launch key to be a single null byte, or \x00.
When we set our user key to \x00, unsigned __int8 len_user_key will be set to 0xff when len_user_key = strlen(&user_key) - 1; is called, because 0-1 = 0xffffffff and an unsigned __int8 type is 8 bits.
Next, strncpy(keyauth_one, &user_key, len_user_key); is called, which copies 0xff bytes from user_key, to keyauth_one. However, we’ve only allocated 0x80 bytes for keyauth_one. keyauth_one+0x80 is where the actual data from GENERAL_HOTZ.key is stored, and when this strncpy() is called, the data there will be overwritten!
Interestingly, if we read the manpage for strncpy(), we can see the following.
Therefore, even though the actual length of our user_key is 0 bytes, strncpy() will still fill the rest of the 0xff bytes with null bytes!
So the data stored in GENERAL_HOTZ.key will be overwritten with null bytes.
Finally, strncmp(), which is called next, contains the following statement in its manpage.
So, because both our user_key and hotz_key buffers contain a \x00 as their first byte, nothing after it will be compared.
And because \x00 == \x00, we will pass this check and unlock key 1 :)
KEY 3
Unlocking keys 3 and 2 is a little trickier.
And the reason I say keys 3 and 2, instead of 2 and 3, is because I unlocked key 3 before I unlocked key 2 using a use-after-free vulnerability I found, that overlayed a key 2 object over a key 3 object.
If we examine key 3, we can define its 0xc4 byte struct as follows.
If we look at the pseudocode, we can see the following:
Notice that if we don’t enter the correct launch session #, our key 3 chunk is freed!
Otherwise, the normal routine for checking key 3 is carried out.
If we skip to the end, we notice that the primary check occurs here:
If the data that exists at keyauth_three + 0x84 is equal to 0x31337, we will unlock key 3!
Let’s keep this in mind and move onto key 2.
Now, if we examine the struct for key 2, we see the following.
And the pseudocode for the key 2 checking routine is as follows:
Let’s break this function down.
keyauth_two() first takes both a 16-byte AES-128 crypto key and 16 or 32-bytes of data from the user. The user provided key is then used to AES encrypt the data using a static initializtion vector (IV), 0xfeedfacfdeadc0debabecafe0a55b00b.
The same data is also encrypted in the same way except using the actual data from GENERAL_CROWELL.key.
The ciphertext of the data that is encrypted using the actual crowell_key is stored at offset keyauth_two_chunk+0x54, and the ciphertext that was generated using our user provided key is stored at offset keyauth_two_chunk+0x74.
If we consider the fact that we can free the key 3 object and allocate a key 2 object over this, our goal becomes clear.
We need to generate a ciphertext using our user-provided AES crypto key in such a way that 0x31337 is written to offset keyauth_two_chunk+0x84. If we do this, then the next time we attempt to unlock key 3, we will pass the check at the end of the key 3 validation function, and unlock key 3!
We can easily find the plaintext that will generate a ciphertext that meets this condition using the python library, PyCrypto.
We first set our key to be 16 0x0’s. Then we set our ciphertext to contain 0x31337 at offset ciphertext+0x10.
When we decrypt it, we are able to produce a plaintext that encrypts to a ciphertext with 0x31337 at offset ciphertext+0x10!
After using this plaintext as the data to encrypt in keyauth_two(), we simply run the keyauth_three() method again, providing any input to pass the check and unlock key 3!
KEY 2
In order to unlock key 2, we need to exploit the use-after-free again, this time using key 3 to produce useful leaks about key 2.
Additionally, we need to understand a bit of crypto.
Let’s start with what happens when we submit a valid session ID when authenticating key 3.
When we do this, 0x40 bytes of data starting at keyauth_three+0x4 are xor’d with randomly generated bytes.
The resulting xor’d bytes from keyauth_three+0x4 through keyauth_three+0x44 are then printed out.
There are a couple things to notice here.
First, if we look again at the how the struct for the keyauth_two chunk is defined, we observe that the 16 byte crowell_key exists at offset keyauth_two+0x34, which aligns with the end of the 64-byte challenge starting at keyauth_three+0x4 that is printed out in keyauth_three().
Secondly, we can observe in the output that the current system time is leaked.
Let’s keep this leak in mind because we will need it soon.
Additionally, because we are using a PRNG, the “randomly” generated bytes that are used for the xor operations are not truly random. If we seed our PRNG twice with the same seed, the same “random” bytes will be generated by rand() in the exact same order! PRNGs are only as random as the seed used to initialize them!
And how is our PRNG seeded?
In the main() function, our PRNG is seeded using the address of our wopr heap chunk, and also the current calendar time.
Well, we already know what the value of wopr_self is because it is printed out in the menu. And although we don’t know what the current system time is, we can brute force it by using the leak of the system time we got in keyauth_three() and decrementing the time from there
Therefore, we can recover the seed that was used to initialize our PRNG and reliably predict the bytes that are generated each time rand() is called!
To do this, I actually wrote a separate helper program to print out the starting bytes for different seeds start at wopr+time and working my way down.
I did this after re-running rpisec_nuke and attempting to decrypt data using a random key 2 and blank data. This is so keyauth_three+0x4 through keyauth_three+0x24 will be filled with null bytes, allowing us to compare the resulting rand() generated bytes with our brute force results.
Putting everything together, this is the flow we will use to crack key 2.
allocate key 3 chunk
free key 3 chunk
allocate key 2 chunk with blank data
attempt to validate key 3 again
view challenge output & compare to expected rand() bytes
In our run session of rpisec_nuke, we get the following challenge output:
The beginning bytes match up with the following output from our brute force C program:
Therefore, to recover key 2, all we need to do is xor the last 16 bytes!
And with that, we’ve cracked key 2!
ARMING THE NUKE
We can define the nuke struct as follows:
The pseudocode for program_nuke() is the following:
We can see that the only condition needed to pass the check to arm our nuke, is that the first dword of our nuke object needs to be equal to 0xcac380cd^0xbadc0ded^0xacc3d489, or 0xdcdc59a9.
We can control the value of our first dword in our nuke object, which is calculated in the computer_checksum() function which has the following pseudocode:
Essentially, each dword of our inputted nuke code is xor’d against the checksum in the first dword of our nuke object, and the result replaces our current checksum, which is subsequently xor’d with the next dword in order to calculate the new checksum and so on and so forth until the checksum is finally xor’d with the 0x454e44 dword at offset nuke+0x204.
With this in mind, it is trivial to come up with a nuke code that passes this check.
LAUNCHING THE NUKE
Once we have armed the nuke, we can finally launch it.
However, we still haven’t found a way to control EIP yet.
Forunately, there is a way in the launch_nuke() function.
The pseudocode for launch_nuke() is as follows.
We can see that it initializes a target pointer that initially points to nuke+0x208.
It then iterates through each byte in our nuke_code_hex buffer that we set when we armed our nuke in the program_nuke() function.
Based on the byte it reads from our buffer, different actions, which are decided by a switch statement, are performed.
0x52 = reprogram nuke
0x53 = write to target
0x49 = target++;
0x4f = print target
0x44 = Disarm or Detonate
0x45 = END
If it encounters a byte not included in this list, it simply does nothing and moves onto the next byte.
Additionally, we notice that there is pointer to the function, disarm_nuke() at offset nuke+0x288 and a pointer to the function detonate_nuke() at offset nuke+0x28c.
On a related note, if the string, “DOOM”, is encountered, the function pointer to detonate_nuke() will be called.
Using a combination of the actions provided affords us two exploit primitives to work with.
the ability to leak the address of the executable
the ability to overwrite the function pointers at nuke+0x288 and nuke+0x28c
Additionally, if we are able to leak the address of the executable, we can use the leak to also calculate the base address of libc, since Ubuntu’s ASLR sucks, and the distance between the base addr of libc and the base addr of the executable does not change.
After we perform the leak, we can actually reprogram the nuke to reset target and have it perform a different set of actions. We will need to do this is we want to both leak and overwrite nuke+0x28c.
Initially I leaked libc to calculate the address of system@libc which I then wrote to nuke+0x28c while also writing a “/bin/sh\0” string to nuke+0x208, but this gave me a troll shell when I triggerd the function call.*
So to get around this, I had to write my own ROP chain to manually perform the syscall for execve("/bin/sh\0");
I used both gadgets from libc and a stack pivot gadget from the ELF executable to generate my ROP chain.
Putting everything together, we get a shell using the following exploit.
Exploit
*After I got the flag, I found out from Doom that they had overloaded one of the _libc_* bootstrapping routines to add their own code which hooks system@libc. The intent was to force students to write their own ROP chain. Very evil of them, but it was a good exercise :)