The last two weeks Hackerone have been hosting a CTF as a qualifier for their Las Vegas H1-702 event. The goal was to solve a few Android challenges and a web challenge. To qualifiy for the main event you had to, apart from solving the levels, submit writeups of how you did it. These are the writeups I submitted for my solutions.
There were five android challenges. The first three were reverse engineering challenges and the last two were exploitation levels, a very welcome format which I haven't seen before when it comes to mobile challenges. All challenges except number three started out with the same process of unpacking and decompiling the app. This is standard procedure stuff when working with android apps but for completeness I have put a description in the appendix on how to unpack and decompile android APK files.
The first challenge is just a simple exercise in static analysis and kind of shows off where interesting stuff typically is located within an app. The flag is split into five parts. Android apps very roughly consist of code and resources. The code is mostly Java (or Kotlin) but can also be native code. The resources are typically text based in the form of XML files or media files such as images.
We start looking in the Java code, specifically in the MainActivity class and on line 19 of "com/hackerone/mobile/challeng1/MainActivity.java", we find:
There is also another Java class called FourthPart in the file "com/hackerone/mobile/challeng1/FourthPart.java"
Rearranging the return values in order of the function names gives us: "much_wow". Having looked at the Java code, we now switch our attention over to the resources. Specifically, let's look in "res/values/strings.xml" since strings can usually tell us a lot about a program being reverse engineered. On line 33 we find:
Finally we take a look at the native code. The "lib" directory of the app contains the native code compiled for several platforms. In most cases (but not always), the code will be the same for all of them but compiled for different targets. I chose to look at the x86 version and open "lib/x86/libnative-lib.so" in IDA Pro.
The native code exposes some functions to the Java part of the app which is how you transfer between executing Java and native code. The function signatures of these functions are typically named after the class they occur in. We find one such function called "Java_com_hackerone_mobile_challenge1_MainActivity_stringFromJNI". Decompiling it with Hex-Rays gives us something like this which includes part two on line nine:
Finally, just like in the Java case, we have a number of native functions, each returning one character each. Taking them all together in the order they occur in the binary we get "_and_cool}".
Putting it all together in the right order we get the flag.
Looking at the MainActivity class in "com/hackerone/mobile/challenge2/MainActivity.java" in this app we can quickly establish that the app takes a PIN code from the user, passes it to a native function to generate a key which is then used together with a static IV to decrypt a string in the class.
Specifically note how the "cipherText" field is set to the static value in the constructor and how the PIN String variable "s" is passed to getKey(s) and finally everything is decrypted by the SecretBox class. SecretBox is part of the NaCl crypto library and uses the xsalsa20+poly1305 cipher algorithm.
We can also check what constraints we have on the PIN by referencing the id of the view, 2131296283 (0x7f09001b) in "res/values/public.xml" to find the correct activity definition:
Looking in "res/layout/acticity_main.xml" we see that the PIN is probably 6 digits.
This means that as long as we know the key generation algorithm, we can just try which of the 1000000 PIN codes leads to a correct decryption.
Decompiling the key generation algorithm in the getKey() function is a bit messy since the code generated by the compiler is not as straight forward as it could have been. There is one loop containing two magic constants: "0x811C9DC5" and "16777619" which are the values used in the fnv1 and fnv1a hashes (32 bit flavor). By checking the order of the multiplication and the XOR operation, we conclude that it's most likely the fnv1a hash that is being used.
Further analysis reveals that the algorithm works by looping over the digits as characters, twice. Each time it takes a digit and constructs the string consisting of the digit repeated as many times as the number it represents, i.e. a "4" yields the string "4444". This string is then hashed with fnv1a and the resulting 32 bit value is XOR:ed into an array of 8 elements. Since the PIN is 6 digits and we loop over it twice, this means that the first 4 elements of the array is the XOR of two elements while the last 4 elements is just a single hash.
Re-implementing the same algorithm in Python, including the decryption yields the following script:
Note that we can directly use SecretBox from NaCl as it has a Python binding as well. We can also verify that the algorithm is correct by running the app in an emulator, checking the output of "logcat" and comparing the logged key from line 8 of the onComplete() function. For example, I used the following log output from the emulator to compare it to the output of my algorithm, which matched:
06-23 13:11:55.045 4291 4291 D PinLock : Pin complete: 111111
06-23 13:11:55.045 4291 4291 D TEST : 000000000000000000000000000000001CA70C341CA70C341CA70C341CA70C34
Running the script gives us the correct PIN, key and flag.
In this challenge we are not given an APK app file but instead a "boot.oat" and "base.odex". An odex file is a conversion from the classes.dex file which contains the JVM code to optimize loading of the app. It is typically done for apps in the base image, i.e. "built-in" apps.
Anyway, this is not much of a problem as we can use the following command to first convert the odex file into a collection of smali files, then package them into a dex file and finally convert the dex file into a jar file which we decompile with Procyon as with the other apps:
Looking at the Java code in the MainActivity we find something like this:
The encryptDecrypt() function is simply a byte-wise XOR. Re-implementing this in python and running it gives us the flag:
In this level we are given a vulnerable APK app and the goal is to submit an APK app of our own which will be run in the same emulator as the vulnerable one. The flag is a text file owned by the vulnerable app. This is very similar to the typical "pwnable" challenge setup but in a mobile context.
The vulnerable app is a traditional maze game. You can control the player in two ways. First of you can swipe on the screen but that doesn't really help us as we want to our exploit to be completely without interaction. The other way you can interact with the app is through intents. An intent is a way of communicating inside and between apps. You can for example launch specific activities in another app. This is what is used for example when you open a pdf file in the Dropbox app and it opens in the Adobe Reader app.
The vulnerable app exposed one broadcast receiver.
This receiver can handle three different commands depending on the extra data you attach to the intent:
The first two are pretty self explainatory but the last one is a bit strange. The handler for that message looked like this:
So here an object we attach to the intent is deserialized. This raises a red flag immediately. Looking at the GameState class however, nothing obviously suspiscious is found. However, the GameState has a field of type StateController called stateController. This field is used in the following way:
The StateController class is actually an abstract class with two different implementations: BroadcastAnnouncer and StateLoader. After looking at the StateLoader class there isn't really anything there which helps us, however, the load() and save() functions of the BroadcastAnnouncer are interesting. Note that the save function only triggers if you have solved enough levels.
Basically, the load() function reads the content of a file based on the value of the String field "stringRef" and the save() function sends this to a URL specfified by the String field "destUrl". This means that by creating a serialized GameState object with a BroadcastAnnouncer in the "stateController" field with its "stringRef" set to "/data/local/tmp/challenge4" (this path was given in the challenge instructions) and "destUrl" set to "http://zeta-two.com" the flag will be sent to us when the finalize() function of the GameState object is called.
To put it all together, I created an app which did the following:
- Send a launch intent for "com.hackerone.mobile.challenge4.MenuActivity" to launch the app.
- Send a broadcast intent for "com.hackerone.mobile.challenge4.menu" with the extra data "start_maze" to go into the game.
- Send a broadcast intent for "com.hackerone.mobile.challenge4.broadcast.MAZE_MOVER" with "get_maze" in the extra data.
- Do a standard BFS to solve the algorithm and generate a series of moves to solve it.
- Send the moves one at a time with a braodcast event to "com.hackerone.mobile.challenge4.broadcast.MAZE_MOVER" with the "move" extra data set.
- Send a broadcast event to "com.hackerone.mobile.challenge4.broadcast.MAZE_MOVER" with the serialized payload in the "cereal" extra data.
- Repeat 3-6 until the flag appears in the access log of my web server.
As stated before, step 3-6 has to be repeated a few times for enough levels to be solved and for the finalize() function to be called.
Running this on the victim emulator gives the flag.
This level had the same setup as the previous challenge. The goal is to submit an app which is ran on the same emulator as the vulnerable app containing a flag.
The functions exposed have the following signatures:
- public String censorMyCats(String string)
- public String censorMyDogs(final int n, final String s)
- public String getMySomething()
First we can look at the censorCats function:
This is just a vanilla stack buffer overflow which is just long enough to overwrite the saved return address but not more. So, to be able to use this we need either a one-shot gadget or some kind of pivot.
Next up is the censorDogs() function:
This function takes a string, base64 decodes it, stores it in a global buffer and a stack buffer. Then it uses the input length to decide how much data to return. This means we have a Heartbleed style memory leak where we can set the length to a high number to leak data on the stack. This enables us to leak the stack cookie which is required for the buffer overflow in the previous function to work.
Finally the getSomething() function returns the address of the global buffer which censorDogs() copies data to. This means we can write almost arbitrary data (memcpy stops on null bytes) to a known location. This buffer is no executable by the way so we can not inject shellcode here.
This means that we fully control the contents of rbx, r14, r15 and rip. Now, one possible strategy is to try to call the libc system() function with a command stored in the global buffer we know the address of. There are two problems we need to solve for that. First of all, we still have no idea where the system function is located and secondly we do not control the contents of the rdi register which should hold the argument to the system() function (standard x64 calling convention).
To solve the second problem we take the libc.so from the target Android version (given in the challenge description) and use a tool called xrop to search for gadgets. I simple used grep to search the output for registers I control and eventually found this gadget:
This is perfect, we put the adress of system() in r15 and the address of the global buffer in rbx and we are done.
The first problem is solved by a interesting property of Android. It uses ASLR so libraries are loaded in random location, however they use a shared memory model so that every process will have libraries loaded in the same adress. This means that adresses are random between reboots but not between two apps running at the same time. This means that we can create a small piece of native code in our app like this:
which we then use in the Java part to trigger out exploit like this:
Setting up this in the web listener and submitting the app results in the follow hit in the log
Shortly followed by the following output in my listener
Appendix: Mobile setup
When analyzing an Android app, there are three things we want to extract from it: the java code, the resources and any native code. Android apps come in the form of an APK file which is pretty much a zip file. To decode this file to get the resources and native code, we use a tool called "apktool", specifically the decode command. You call it with the APK file as an argument and it unpacks a directory with the contents of the app.
The resources and native code is directly accessible but the java code is not in a desirable format. To get the java code, we first use a tool called "dex2jar" to convert the "classes.dex" file into a Java jar. The classes.dex file contains all the JVM code in a specific format for the dalvik virtual machine. By converting it into a Java JAR file we can the use the Procyon decompiler to decompile the app into human readable Java code.
Now you are all set up to perform static analysis of an Android app.
In this web challenge we are given the address to a web server which serves an index page with instructions. The instructions says there is a RPC service somewhere on this server with a secret message we want to read.
After checking some typical standard paths such as "robots.txt", ".git", "index.php" and a couple of others I finally tried to see if there was a "rpc.php" and indeed there was. Unfortunately it just complained about the version being unknown. After exploring that rabbit hole for a couple of hours I figured I must have missed something and went for the brute force approach by running dirsearch. To make it run as fast as possible, I used whois to figure out where the server was hosted. It turns out it was hosted with Digital Ocean in their New York data center. So, I went to my DO account and set up a server there myself and ran the dirsearch from there. This yielded the following output:
Bingo! We found a README.html. This page describes the RPC API exposed by the rpc.php. By reading the documentation we find out that we can perform 4 actions:
- createNote(id, note)
However this requires an authenticated user via JWT token. One such token is given in the documentation. Decoding the JWT token in the docs tells us it belongs to user id "2" and is verified by a SHA256-HMAC. Here we use a classic JWT trick where we simply drop the the signature and change the declared signature algorithm to "None". This can be done in a number of ways but for example using PyJWT like this:
This means we can change our user id to id "1". Trying anything other than 1 and 2 only yields an error. Communicating with the api involves setting the method and params as get params, an Authorization header with the JWT token and an Accept header with the value "application/notes.api.v1+json".
Using the command we can create notes with a chosen or random key and chosen value, retrieve a message by key, delete all notes and list notes metadata. Note that this does not give us the note key but only their creating timestamp. When using the user with user id 1 we notice that there is one note staying even after calling the reset method. This is probably the secret flag note.
Looking at the html source of README.html we find the following comment:
This is interesting. In the first version, the timestamps returned when calling getNotesMetadata were always sorted in ascending time order but now they are instead sorted according to the order of their keys. We can use this as a sorting oracle. The way it works is we create a note with id "a" then check if this sorts before or after the secret note. We then try with "b" and "c" and so on until we the order of the timestamp change. We then know the first letter of the id and fixate it. We can then move on to the second letter and so on. This is easily scripted and after running for a few seconds we reveal the secret id to be "EelHIXsuAw4FXCa9epee". You can of course do it even faster by doing a binary search instead of a linear search but since we are working with such small values and short strings it's not really worth the extra effort. Retrieving this note gives us the base64 encoded flag.