MobileReversingCryptoScripting +150 points

4 months ago - 130 views

Unlock Train Data

This was a medium Mobile challenge that not many people solved. In the end, I was one of only 20 people to solve it. I had learned some things about Mobile reversing a few months back, so it was really fun to be able to use this knowledge in the challenge. It took me a while to understand how the code worked but eventually, I got it.

The Challenge

Since this was a Mobile challenge, we got a APK file called travelportal.apk, which is the app we need to reverse engineer. I like to use Android Studio to run the app on an emulated phone, so I can get a quick glance at how the app looks. If you're new to Android Studio or APKs in general I am planning to make a full introduction to APK reversing in the future.

For now, just know that you can import APKs by going to File -> Profile or Debug APK. When it's open, you may need to create an Android device first. Do this in the AVD Manager that you can find under Tools. When you have created a device you can run the app using the green play button. This should then open an emulated phone with the APK installed and opened:

Screenshot of the phone after starting the app in Android Studio

As you can see in the picture above, the inputs are a bit messed up, probably because the app was made for a different phone size.

Note
After the challenge I tried to calculate what screen resolution it should be. We can see the background image and where it ends, so it should be doable. I measured the size of the inner image and the full screen. This was 285x529 and 379x672 respectively, making the width of the phone 379/285=1.33 times, and the height 672/529=1.27 times. This means the current phone resolution of 1440x2560 was 1.33 times too big in width, and 1.27 times too big in height. The resolution should then be: 1440/1.33=1083 by 2560/1.27=2016. The closest phone resolution to this that I could find was 1080x1920 from the Pixel 5.0 phone. When trying to launch the app on this specific phone it looks a lot better and is probably the test phone the creators of the challenge used.

The app is still usable though. We can click on the first input for a name and the second input for a "Ticket Nr.". The placeholder for this value is "CTF{...}" so this is probably the flag we need to find. A classic "find the correct input" challenge for reversing. When we input anything wrong here it just says "TICKET INVALID".

Decompiling

Now that we have an idea of the app, we can start reverse engineering the code. As you may know, APK files are just ZIP archives, and we can quite literally just unzip them to get their contents.

Shell

$ unzip travelportal.apk -d travelportal
$ l travelportal
AndroidManifest.xml  META-INF/  classes.dex  classes2.dex  classes3.dex  classes4.dex  res/  resources.arsc

The interesting files here are the 4 classes.dex files. These files contain the classes full of code that the app uses. To get this code out we can use a tool called dex2jar, which converts these .dex files into Java .jar files. You can run the script on the .dex files individually, but it can also automatically find them in a .apk file and combine them into a single JAR.

Shell

$ sh d2j-dex2jar.sh travelportal.apk
dex2jar travelportal.apk -> ./travelportal-dex2jar.jar

This .jar file now contains the compiled Java code of the app. JAR files are also just ZIP files that can be extracted using unzip.

Shell

$ unzip travelportal-dex2jar.jar -d travelportal-dex2jar

Now we have a folder travelportal-dex2jar that contains lots of .class files. These are known as Java bytecode, which is not yet very readable for us humans. We can use a Java decompiler to turn these .class files into .java files and then we'll finally have readable Java code. The simplest way I found was to just use IntelliJ IDEA which uses the Fernflower Java decompiler underneath. If you don't want to or can't install IntelliJ another option is to just use the Fernflower program alone which can be found online and then decompiling the files yourself. Then you can just use any other IDE to view the created .java files.

In this case, though I'll open the unzipped directory in IntelliJ. When open you can browse the code yourself in the left panel, then simply double-click on any file to open and decompile it. To find the main class for an Android app you should look for the MainActivity file. In IntelliJ, we can simply press Ctrl+Shift+N to search in filenames, then just type "MainActivity" and you should find one file that matches. Opening it shows all the Java code that is the main part of the app. Here is where we can start to really look at the code.

Understanding the Code

In the MainActivity.class file we see the following interesting code:

Java

package nl.picobello.itconsulting.travelportal;

public class MainActivity extends AppCompatActivity {
    private ActivityMainBinding binding;

    protected void onCreate(Bundle var1) {
        ...
        this.binding.verifyButton.setOnClickListener(new View.OnClickListener() {
            public void onClick(View var1) {
                try {
                    if (TicketVerification.checkTicket(MainActivity.this.binding.nameInput.toString(), MainActivity.this.binding.ticketInput.toString())) {
                        Toast.makeText(MainActivity.this.getApplicationContext(), "Valid Ticket", 1).show();
                    } else {
                        Toast.makeText(MainActivity.this.getApplicationContext(), "TICKET INVALID", 1).show();
                    }
                } catch (Exception var2) {
                    Toast.makeText(MainActivity.this.getApplicationContext(), "Exception during ticket verification", 1).show();
                    var2.printStackTrace();
                }

            }
        });
        ...
    }
}

It creates a listener for OnClick on the verifyButton. This seems to be what's happening when we click Verify in the app. It call the TicketVerification.checkTicket() function with the nameInput and ticketInput. Based on the return value of this function it tells us if the ticket is valid. Let's look at what this checkTicket() function does. It seems to come from the TicketVerification class, which is in the same directory as the MainActivity file. We can find the checkTicket() function in this new file:

Java

import nl.picobello.itconsulting.crypto.Paupercrypt;

public class TicketVerification {
    ...
    public static boolean checkTicket(String var0, String var1) throws Exception {
        byte[] var2 = Paupercrypt.encrypt(var0.getBytes(), var1.getBytes());
        System.out.println(toHex(var2));
        return toHex(var2).equals("b9725f22659b4469f84b4b800b740379bcafbb1fee9c941c0cca89a9ac2718f52e03df787f41bc568a63353b0084b956dc7a1ff0a58d88e20594c4fab8ee5df86e3da18d2ddcb579ff664636fa5a8e583ad2d35e7fe986f78754c7377a4f95a55aae80992da22547123374ea13235d9fc34e846f69b876a8e80d211f19b1c7a32ed4e48101b91448b5d5f9b5fe02488410015780353e14a9ef726073197d1377");
    }
    ...
    public static String toHex(byte[] var0) {
        StringBuilder var1 = new StringBuilder();
        int var2 = var0.length;

        for(int var3 = 0; var3 < var2; ++var3) {
            String var4 = Integer.toHexString(var0[var3] & 255);
            if (var4.length() == 1) {
                var1.append('0');
            }

            var1.append(var4);
        }

        return var1.toString();
    }
}

That seems interesting already. It takes our inputted name and ticket number into a Paupercrypt.encrypt() function. The encrypted value is then compared to a big hex string that is hardcoded into the app. Let's now finally see what this encrypt() function does, which comes from the crypto.Paupercrypt package one directory back. In the Paupercrypt.class file we find our function:

Java

public class Paupercrypt {
    ...
    private static byte[] AES_ECB_Encrypt(byte[] var0, byte[] var1) throws Exception {
        SecretKeySpec var3 = new SecretKeySpec(var1, "AES");
        Cipher var2 = Cipher.getInstance("AES/ECB/PKCS5Padding");
        var2.init(1, var3);
        ByteArrayOutputStream var4 = new ByteArrayOutputStream();
        CipherOutputStream var5 = new CipherOutputStream(var4, var2);
        var5.write(var0);
        var5.flush();
        var5.close();
        return var4.toByteArray();
    }

    public static byte[] encrypt(byte[] var0, byte[] var1) throws Exception {
        var0 = hash(var0);
        byte[] var2 = hash(new byte[]{var0[1], var0[var0.length / 2], var0[var0.length - 1]});
        var0 = var1;
        int var3 = 0;

        for(var1 = var2; var3 < 8; ++var3) {
            var0 = AES_ECB_Encrypt(var0, var1);
            var1 = hash(var1);
        }

        return var0;
    }

    private static byte[] hash(byte[] var0) throws Exception {
        MessageDigest var1 = MessageDigest.getInstance("MD5");
        var1.update(var0);
        return var1.digest();
    }
}

Finally, we see the real encryption logic. Taking a quick glance it does some weird things with the hash() function, and uses 8 AES encryption rounds on something. Remember that var0 here is our name input, and var1 is our ticket number or the flag. Let's follow this code line by line for a bit to understand what it exactly does.

First, the var0 = hash(var0) line just takes an MD5 hash of our name, so this will come out to 16 pretty random bytes. Then something weird happens: A variable var2 is created and set to the hash() of a new set of bytes. This new byte[]{...} syntax just makes a new array of bytes where we can specify the exact bytes one by one in between the curly braces. So the first byte will be var0[1] or the second character of the name hash. Then comes var0[var0.length / 2] which is the middle character of the name hash, and finally var0[var0.length - 1], the last character of the name hash. So it just takes 3 bytes out of the name hash, hashes them, and stores them in the new var2. This means it essentially just takes a hash of 3 random bytes.

Then right after it overwrites var0 (the name hash) with var1 (the ticket). Next, I think the decompiler wasn't sure about the order, but it seems to set var1 = var2 which means to overwrite the original ticket variable with the new hash of 3 bytes. Then a for loop using var3 as the iterator which starts at 0, counts up by one each iteration and ends when it is at 8. So the loop just runs for 8 iterations in total.

In this loop, some encryption and hashing is involved. The first thing it does is AES ECB encrypt the var0, which is the ticket now, with var1 being the key. This key was the hash of 3 random bytes we found earlier. The result of this encryption is stored in var0 itself, so it overwrites the value. Then after, it hashes var1 with md5 again and overwrites it. This makes the key change every iteration. Remember that these two steps are repeated 8 times in the for loop meaning the var0 variable is encrypted 8 times with different hashes of var1.

All of this might be a bit hard to get your head around at first, but it is the essence of this challenge and how it can be solved. Remember that this var0 variable gets returned, and compared against the big hardcoded hex value in TicketVerification.checkTicket(). So we need to somehow reverse this logic to go from the encrypted value back to the original ticket.

Reversing the Encryption

So what this code essentially does is use AES to encrypt the flag 8 times. At the start, the key is an MD5 hash of 3 random bytes. Then each iteration the new key is the previous key MD5 hashed again. This means that we can get all the keys for the AES decryption only having to guess the initial 3 bytes of the key. Since 3 bytes are not that much we can just brute-force these values and every time try to decrypt the message with the keys until it fits the CTF{...} format.

I'm way more comfortable writing code in Python than in Java, so we'll first need to translate this Java encrypt() function into Python code. It isn't too bad, because Python has lots of cryptography libraries with handy functions like AES and MD5. I made use of the pycryptodome library to do the AES ECB encryption.

Python

from hashlib import md5
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad

def hash(s):
    return md5(s).digest()

def encrypt(var0, var1):
    var0 = hash(var0)
    var2 = hash(bytes([var0[1], var0[len(var0) // 2], var0[-1]]))
    var0 = var1
    var1 = var2

    for var3 in range(8):
        cipher = AES.new(var1, AES.MODE_ECB)
        var0 = cipher.encrypt(pad(var0, AES.block_size))
        var1 = hash(var1)

    return var0

Just make sure you include the pad() function here because it doesn't do this automatically. If your initial input is not a multiple of 16 bytes the cipher.encrypt() function won't accept it. We can see in the Java AES_ECB_Encrypt() function that it uses "AES/ECB/PKCS5Padding" as the cipher, so in our case, we also use AES in ECB mode with PKCS 5 padding (the default).

This encrypt() function in Python is nice to have, but we want to decrypt with the brute-forcing technique from earlier, to decrypt the long hex string we saw. Let's think about what this function needs to do.

For encrypting the key at the start is a hash of 3 random bytes. We don't know these bytes, but we can brute-force them by trying every combination of bytes. Then we encrypt the flag 8 times, every time hashing the key again. This means that the last encryption uses the last hash of the key. The last encryption will be the first we decrypt, so we need to use the last key to decrypt the first time, and then the second-to-last key for the second decryption, etc. We'll do this by first making a list of all 8 keys we'll need, and then decrypt the ciphertext with the last key, each time removing the key when we use one. Then after the 8 decryptions, we know we have a possible plaintext, and we can check if it is the correct one by making sure it starts with "CTF{".

Another nice way we can know if some plaintext is correct is by looking at the padding. The unpad() function throws a ValueError when the padding is not in the correct form, and since we know the original code uses padding this must be correct in the correct plaintext. The best thing is that in all 8 iterations of AES this padding is applied during the encryption, so we can check during every round of the encryption if the padding is correct, to instantly stop this attempt when the padding is incorrect. When a ciphertext has survived 8 rounds of decryption ending up with correct padding it's pretty safe to say this must be the plaintext.

Python

import itertools

def decrypt(ciphertext, key):
    keys = [key]  # key is already hashed once
    for _ in range(7):
        keys.append(hash(keys[-1]))  # Hash previous iteration

    for _ in range(8):  # Do 8 AES decryptions with the last key, removing the used key every time with .pop()
        cipher = AES.new(keys.pop(), AES.MODE_ECB)
        try:
            ciphertext = unpad(cipher.decrypt(ciphertext), AES.block_size)
        except ValueError:
            return None  # Unpad failed, so must be wrong

    return ciphertext

def crack(ciphertext):
    for key in itertools.product(range(256), repeat=3):  # Brute-force 3 bytes from 0-256
        if key[2] == 0 and key[1] == 0:  # Each time the 2nd and 3rd byte repeat
            print(key)  # Status

        hashed_key = hash(bytes(key))

        plaintext = decrypt(ciphertext, hashed_key)

        if plaintext != None:  # If ciphertext survived all 8 rounds without incorrect padding
            print("KEY", key)  # (223, 4, 9)
            return plaintext

ciphertext = bytes.fromhex("b9725f22659b4469f84b4b800b740379bcafbb1fee9c941c0cca89a9ac2718f52e03df787f41bc568a63353b0084b956dc7a1ff0a58d88e20594c4fab8ee5df86e3da18d2ddcb579ff664636fa5a8e583ad2d35e7fe986f78754c7377a4f95a55aae80992da22547123374ea13235d9fc34e846f69b876a8e80d211f19b1c7a32ed4e48101b91448b5d5f9b5fe02488410015780353e14a9ef726073197d1377")
print(crack(ciphertext))

When we run this it takes a bit to finish because AES is pretty fast for a computer, but we're doing 256*256*256=16.777.216 attempts of it to brute-force. But after around 4 minutes the key gets to (223, 4, 9) and it correctly decrypts the ciphertext, revealing the flag!
CTF{Thou_Shall_Not_Roll_Your_Own_Crypto}

The Second Flag

This challenge consisted of 2 parts. Above here was the solution to the first part, but the second part was significantly easier. When we look at the code in MainActivity there is another interesting function:

Java

public class MainActivity extends AppCompatActivity {
    private static final int CLICKCOUNTER_VALUE = 25;
    private ActivityMainBinding binding;
    private int clickCounter;

    public MainActivity() {
    }

    protected void onCreate(Bundle var1) {
        this.clickCounter = 0;
        ...
        this.binding.backgroundImage.setOnClickListener(new View.OnClickListener() {
            public void onClick(View var1) {
                if (MainActivity.this.clickCounter == -1) {
                    MainActivity.this.binding.backgroundImage.setImageBitmap(BitmapFactory.decodeResource(MainActivity.this.getResources(), 2131165336));
                }

                if (MainActivity.this.clickCounter >= 25) {
                    try {
                        MainActivity.this.binding.backgroundImage.setImageBitmap(TicketVerification.getDebugInfo(MainActivity.this.getResources().openRawResource(2131623936)));
                    } catch (IOException var2) {
                        var2.printStackTrace();
                    }

                    MainActivity.this.clickCounter = -1;
                } else {
                    MainActivity.access$112(MainActivity.this, 1);
                }
            }
        });
    }
}

It registers an OnClick listener with some clickCounter variable. It sets this listener on the backgroundImage binding so this probably triggers when tapping the background image on the main screen. When the clickCounter reaches more or equal to 25, it seems to set the background image to some other resource. Let's just try to tap the background 25 times in the emulator to see what happens:

Flag shows on the background image after tapping 25 times

It shows the flag on the background image!
CTF{Keep_Clicking_For_The_Win}