Intro
It’s certainly been a while, but turns out being full-time at a job takes away a lot of time you would have to write. Luckily, I had enough spare time this past weekend to do some challenges in the most recent HTB Business CTF, and while I wasn’t able to get extremely sweaty with it, I did want to highlight my favorite solve I was able to do: Satellite Hijack (sponsored by Bugcrowd™®).
Satellite Hijack was the hardest rated reversing challenge in the CTF, and while it wasn’t the hardest reversing challenge I’ve ever seen, I enjoyed it because of how I sped through the solve. I’ll start by taking a look at the initial binary, but spend most of the time exploring the shared library it gets shipped with. The shared library does some interesting traversal in memory to overwrite a function with new code, which actually contains the password encrypted with a simple XOR cipher.
Description
The crew has located a dilapidated pre-war bunker. Deep within, a dusty control panel reveals that it was once used for communication with a low-orbit observation satellite. During the war, actors on all sides infiltrated and hacked each others systems and software, inserting backdoors to cripple or take control of critical machinery. It seems like this panel has been tampered with to prevent the control codes necessary to operate the satellite from being transmitted - can you recover the codes and take control of the satellite to locate enemy factions?
Initial Analysis
This challenge gets interesting as soon as we unzip the challenge folder.
These look like standard ELF files. There’s no explicit RUNPATH added to the satellite
binary, but the library.so
is stripped, so it’s very likely to be the focus of the challenge.
There’s nothing too remarkable in the strings of the satellite
binary either, aside from a banner and the name of some functions. However, the library.so
file has a section that looks extremely odd.
I would normally dismiss the stars as being some kind of banner, but without newline characters or anything else in the mix, their purpose is confusing. Following these observations, we can run the satellite binary to quickly notice we’re in for a classic “crackme”.
We could play around with strace
and ltrace
as well, but I found it much easier to just dive into Ghidra.
satellite
Turns out the satellite
binary is extremely simple. Ghidra only finds one function, and since the binary isn’t stripped, it’s clearly labeled as main()
. Some light analysis and renaming variables leads us to this.
If we ignore any reference to uStack_420
, this function is pretty straightforward. After calling setbuf()
to clean up I/O and puts()
to print out a banner, we make a call to send_satellite_message()
with some kind of “START” message. Following this, I’m not entirely sure what the loop is for, but we seem to enter a loop where we call read()
to grab user input, and then call send_satellite_message()
with that input.
Though we don’t fully understand what puts("ERROR READING DATA")
and the loop are for yet, understanding this whole control flow likely hinges on what the send_satellite_data()
function is doing, so we should jump to the shared object.
library.so
send_satellite_message
Though the library is stripped, Ghidra identifies the send_satellite_message()
function for us. The raw output looks like this:
On first glance, a few things immediately stand out. For one, local_10
is a stack canary, we can disregard that variable. Continuing on, local_28
, local_20
, uStack_1b
, and uStack18
all look like random hexadecimal numbers. However, if you look at the numbers byte by byte (e.g. 0x45, 0x50, 0x53), you’ll notice that these are all printable ASCII characters.
After defining some variables, we have a for loop using local_2c
as the index, and operating on local_28
, which, as noted, contains printable bytes. Though Ghidra spits out some pointer nonsense within the loop, if you try to get the big picture, we’re iterating on local_28
, and subtracting 1 from each byte in the string, which is likely their way of encoding/obfuscating the string.
Finally, the new value of local_28
gets passed into getenv()
, which, as its name implies, retrieves an environment variable. We can rename all of these variables to get this, which is slightly more readable:
Having a better understanding of how this function works, we can try to decode the hex. I wouldn’t recommend copying and pasting the hex from the decompiler, as it will often put variables out of order. But, if you look at the assembly view, you can see the order that they’re placed in.
We can throw this into Python to see what the output is.
While that decoded to something, it certainly doesn’t feel right. We need to account for endianness, i.e., the order in which bytes get stored. In x86, the architecture we’re dealing with, we’re working with little endian, meaning the least significant byte is at the lowest address. When I run into these errors, I’m a big fan of using CyberChef to solve my problems.
Looking back at our relabeled function, we see that the program uses getenv()
to retrieve the contents of that environment variable. As long as that value isn’t null, we call what I labeled if_env_not_null()
, and then proceed to the after_send_satellite
in the return (great naming, I know).
Diving Deeper
The if_env_not_null()
function is much shorter, but the code is not as clear. I can relabel a reference to the .data section which contains the string “read”, and another with all of the *
’s we identified earlier, but it’s not entirely clear what’s happening.
In these reversing writeups, I will often glaze over lines and sections of code, because educated guesses can often save you more time on minutia that you’re not particularly invested in. However, I don’t know what getauxval()
, the stripped function, and memfrob()
do at all. Looking at the documentation, getauxval()
is described as follows:
getauxval - retrieve a value from the auxiliary vector
<...trim...>
The getauxval() function retrieves values from the auxiliary
vector, a mechanism that the kernel's ELF binary loader uses to
pass certain information to user space when a program is
executed.
While we now have the rough idea that this function returns some metadata about the program’s current state, I don’t know what getauxval(3)
is doing. The documentation uses declared constants, and no resources were giving me an explicit answer. However, scrolling further in the docs, we see:
The auxiliary vector resides just above the argument list and
environment in the process address space. The auxiliary vector
supplied to a program can be viewed by setting the LD_SHOW_AUXV
environment variable when running a program:
$ LD_SHOW_AUXV=1 sleep 1
I can write a quick C program to run getauxval(3)
, and compare the output.
Now we know that 3 corresponds to AT_PHDR
, which the documentation says returns “the address of the program headers of the executable”. This address then gets passed into another function, which looks extremely beefy.
If I was a good, patient reverse engineer, I’d go through and I’d relabel all of the variables and try and make sense of every line. That said, it would be extremely tedious to do so. Focusing on the only standard library call here, strcmp()
, we can see that we’re comparing some value to the string input, which, in this case, is read
.
read
is a standard function name, and we passed the base address of the program headers to this function. If we trace the values of local_38
and puVar3
back, we see that local_38
is first defined as some addition of hdr_address
to local_30
, the latter of which is the index for the for loop. All of this is to say that we have sufficient reason, between the loops, variables, and parameters, to assume that this function is traversing the program headers to find the address of read()
in memory. If our hypothesis is wrong, we can always revisit this and try to understand it better (which we do in Beyond the Flag).
Returning to the if_env_not_null()
function, we can relabel the function to have it make more sense.
To sum up what we have here:
- We get the base address of the program headers in memory
- We traverse the bytes in memory to find the location of the
read()
function mmap()
is called to allocate a new mapping of virtual memory containing the bytestring of all of the starts (0x1000 bytes!)- We call
memfrob()
, which, according to the docs, simply XORs the specified number of bytes with 42. Weird function, but at least this means we can figure out what those stars mean. - We copy the decrypted bytes into where the code for the
read()
function would normally exist.
A very interesting way for the binary to modify itself! I can copy out the bytes of str_lots_of_stars
, and then decrypt it in Python.
A Hijacked Function
Knowing that these bytes get copied into where ever read()
is defined, we can reasonably assume that this is a new function. To confirm this, we can throw these raw bytes into Ghidra- if we did our job right, we should get a decompile. We’ll have to specify the language and compiler as Intel/AMD 64-bit x86, but once we do, we get some clean output.
While we do have three other functions within this main one, the 0x7b425448
in the if statement sticks out to me, as those are all printable bytes. pwntools
comes with a nifty unhex
script to decode hex on the command line:
That is the beginning of our flag! Of course, we have to account for endianness again, but we’re close. I’ll skip FUN_000001a4()
to dive right into FUN_0000008c()
, which comes immediately after the if statement. As it’s structured very similarly to the code that obfuscated the SAT_PROD_ENVIRONMENT
variable, we can go ahead and relabel values to make some sense of the function.
The flag is encrypted and contained across all of the flag_enc*
variables, and it’s compared to the user input passed in param_1
. The while loop XORs the bytes together, and checks if the result is equal to the index, i.e. the XOR of the two strings should be \x01\x02\x03\x04...
until we reach the end. Given the properties of XOR, we can simply XOR the bytes in this function with what we want the output to be (\x01\x02\x03\x04...
) to recover the original flag. After finagling with the encrypted bytes, we get this.
And that’s it! There’s our flag.
flag: HTB{l4y3r5_0n_l4y3r5_0n_l4y3r5!}
Beyond the Flag
Traversing memory is something I’m more familiar with in the malware development world, where, for example, people walk the PEB (process environment block) to do things like overwriting a specific section to masquerade as a different process. That said, I haven’t really seen it done in Linux until now, so exploring the traversal function we skipped past earlier is something that interested me. Taking a look at the first part, we can (roughly) break it down like so:
This documentation from Oracle (which I’m 50% sure is copied from the Linux spec but this showed up first in Google) actually does a not so bad job of explaining a lot of the structure of program headers in the ELF file format. The for
loops make it clear we’re iterating off of whatever base we came from, which I’m inclined to say is the base of the program headers and not the base of the binary. However, we do end up making the comparison *(int *)(base + (long)index * 0x38) == 2
.
This is likely some kind of constant we have to figure out. According to the Oracle documentation, a program header is defined as follows:
To make sense of this, let’s think about what should probably happen based on our early analysis. We’re pretty confident the ultimate goal of the binary is to hijack the read()
function, which, if you’re familiar with binary exploitation techniques, is easiest to do if you overwrite the Global Offset Table (GOT). If you’re not as familiar with the technique, this CryptoCat should give you a rough idea of what we’re working with.
After looking at what possibilities ‘2’ could correspond to in the struct’s members, we can guess that this loop is looking for p_type
, specifically the PT_DYNAMIC
type. PT_DYNAMIC
specifies dynamic linking information, which would make sense as libc is dynamically linked and accessed. Once we find a dynamic header, we enter another loop:
Notice how the pointer nonsense is now accessing index * 0x38 + 8
as opposed to index * 0x38
- this means we’re inspecting inside the structure of this dynamic section, which Oracle documents here. This structure looks like this:
d_val
represents some constant that’s interpreted, while d_ptr
represents virtual addresses. The if-else statements are comparing single constants, so it look like we care about the values of d_val
. Reading the docs, we can relabel like so:
DT_SYMTAB
refers to the address of the symbol tableDT_STRTAB
refers to the address of the string table, which stores names for symbolsDT_JMPREL
is the “address of relocation entries associated solely with the procedure linkage table”, or in other words, the offset of the.rela.plt
section.
The for loop ends when local_30 != 0
, which corresponds to DT_NULL
, which is the end of the dynamic array.
So, to recap what we have so far, is that we’ve located the program header that is of type PT_DYNAMIC
, and pulled down the address of the symbol table, string table, and .rela.plt
section. Keeping the goal of overwriting the GOT in mind (and the fact that the “read” string hasn’t come into play yet), we probably need this information to actually find our way back to the GOT. Let’s take a look at the next bit (having relabeled some local variables with our newfound information):
The very first if statement should be simpler to follow; as long as the addresses of those sections are not null, we continue. After declaring some local_50
, we enter another for loop where we’re reading through whatever the symtab
has in it, and accessing some member of its struct, since Ghidra is throwing more weird pointer arithmetic at us. Docs on symtab can be used to look at this once again, but based on the call to strcmp
including the “read” string, I think we can reasonably assume this first loop is walking the symbol table and string table looking for “read”. Once we find the string, local_50 = index2
, which is probably the index of the symbol. But what happens to jmprel
?
We iterate by adding 3 to jmprel
and check if some right-shifted version of jmprel
is equal to read_index
. I’ll be totally honest, I don’t entirely know what the >> 0x20
is for. It definitely means something, but in the time I had to investigate this, I wasn’t able to put a precise definition on it.
Edit: There is an official writeup of this challenge here that can tell you exactly what struct this is and what these numbers correspond to, I’m just giving the vibes based answer at this point.
Ultimately, what we do know is that this section returns the address we need to hijack read()
, which we have guessed is in the GOT. Recall that jmprel
has to do with the Procedure Linkage Table (you can find a good, high-level explanation of that from ir0nstone ). The oversimplified answer is that when you run a binary and call a linked function, the PLT is responsible for redirecting execution to the GOT entry for the function, or resolving where that function is and then redirecting you to it. Knowing this, I’m very comfortable with sitting on my hypothesis that this section of code is what finally figures out where the GOT entry is.
Now, if you’re someone who to truly understand why this works, you’re already a much better reverse engineer than I am. The truth is, from the Ghidra output and assembly alone, I do not currently have the Linux internals knowledge to give a 100% correct answer as to how we go from PLT to GOT, and I would rather show ways that you can compensate for this than act like I just knew exactly how this worked.
To wrap this up, the reason I liked this challenge was because it’s honestly extremely approachable. The technical details of what the binary does are extremely complex, but by striving to see the big picture instead of getting hooked on why a single assembly instruction is doing X instead of Y, we can spend our time more wisely, and then revisit the grittier “why-s” to go even deeper. The cycle, of course, continues after that.
If there are any technical inaccuracies here, please let me know! Until next time!
:D