Salutes from Araraquara!

This post contains my solution to one of the OWASP MAS Android Crackmes, namely UnCrackable L1. ;)

Getting the APK

To start off, we need to download the APK.

UnCrackable L1 is available here.

App Behavior

After downloading the APK and running it to check out how it behaves, we find that the app displays an activity containing a single input, requesting a so called secret string:

Entering the wrong string makes the app display an error message:

So it looks like we’re supposed to guess the correct secret string that’ll solve the challenge.

Reversing the APK

After reversing the APK using JADX, we can follow its execution flow in order to understand where said secret string is coming from.

It looks like .MainActivity performs a few security checks as soon as the app starts, namely:

@Override // android.app.Activity
protected void onCreate(Bundle bundle) {
    if (C0002c.m5a() || C0002c.m4b() || C0002c.m3c()) {
        m2a("Root detected!");
    }
    if (C0001b.m6a(getApplicationContext())) {
        m2a("App is debuggable!");
    }
    super.onCreate(bundle);
    setContentView(R.layout.activity_main);
}

Delving into C0002c and C0001b, we find that, indeed, this is the case. If we look at C0002c:

As follows:

/* renamed from: a */
public static boolean m5a() {
    for (String str : System.getenv("PATH").split(":")) {
        if (new File(str, "su").exists()) {
            return true;
        }
    }
    return false;
}

/* renamed from: b */
public static boolean m4b() {
    String str = Build.TAGS;
    return str != null && str.contains("test-keys");
}

/* renamed from: c */
public static boolean m3c() {
    for (String str : new String[]{"/system/app/Superuser.apk", "/system/xbin/daemonsu", "/system/etc/init.d/99SuperSUDaemon", "/system/bin/.ext/.su", "/system/etc/.has_su_daemon", "/system/etc/.installed_su_daemon", "/dev/com.koushikdutta.superuser.daemon/"}) {
        if (new File(str).exists()) {
            return true;
        }
    }
    return false;
}

As for C0001b, we find that C0001b#m6a checks for the presence of FLAG_FACTORY_TEST in ApplicationInfo, by masking ApplicationInfo.flags with decimal 2. This is because the value of flag FLAG_FACTORY_TEST is 0x00000010 (namely decimal 2). It turns out FLAG_FACTORY_TEST specifies that the device is running in factory test mode, which is a means to make the app debuggable:

/* renamed from: a */
public static boolean m6a(Context context) {
    return (context.getApplicationContext().getApplicationInfo().flags & 2) != 0;
}

Getting back to .MainActivity, shall any of the methods above return true, then #onCreate will call #m2a, providing a message according to the check that resolved to true:

@Override // android.app.Activity
protected void onCreate(Bundle bundle) {
    if (C0002c.m5a() || C0002c.m4b() || C0002c.m3c()) {
        m2a("Root detected!");
    }
    if (C0001b.m6a(getApplicationContext())) {
        m2a("App is debuggable!");
    }
    super.onCreate(bundle);
    setContentView(R.layout.activity_main);
}

In turn, #m2a displays the message in question and makes the application exit:

/* renamed from: a */
private void m2a(String str) {
    AlertDialog create = new AlertDialog.Builder(this).create();
    create.setTitle(str);
    create.setMessage("This is unacceptable. The app is now going to exit.");
    create.setButton(-3, "OK", new DialogInterface.OnClickListener() { // from class: sg.vantagepoint.uncrackable1.MainActivity.1
        @Override // android.content.DialogInterface.OnClickListener
        public void onClick(DialogInterface dialogInterface, int i) {
            System.exit(0);
        }
    });
    create.setCancelable(false);
    create.show();
}

But if all checks resolve to false, then the app continues its happy path. .MainActivity contains yet another method, namely #verify. There’s no reference to this method whatsoever in .MainActivity, but it’s mentioned in .MainActivity’s layout, namely activity_main.xml, which lives inside the app’s layout resources:

<Button android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/button_verify" android:onClick="verify"/>

So #verify gets called whenever the user clicks the “Verify” button .MainActivity displays. Here’s what it looks like:

public void verify(View view) {
    String str;
    String obj = ((EditText) findViewById(R.id.edit_text)).getText().toString();
    AlertDialog create = new AlertDialog.Builder(this).create();
    if (C0005a.m1a(obj)) {
        create.setTitle("Success!");
        str = "This is the correct secret.";
    } else {
        create.setTitle("Nope...");
        str = "That's not it. Try again.";
    }
    create.setMessage(str);
    create.setButton(-3, "OK", new DialogInterface.OnClickListener() { // from class: sg.vantagepoint.uncrackable1.MainActivity.2
        @Override // android.content.DialogInterface.OnClickListener
        public void onClick(DialogInterface dialogInterface, int i) {
            dialogInterface.dismiss();
        }
    });
    create.show();
}

First, #verify gets the value of edit_text, which happens to be the input .MainActivity displays, then calls C0005a#m1a, providing said value as an argument. If C0005a#m1a returns true, then the method displays a success message, stating that the value corresponds to the correct secret. In contrast, shall C0005a#m1a return false, then the method displays an error message, stating that the value is not the correct secret. This is the error message we’ve obtained after we submitted “42” as an input string to the app while we were executing it.

So it seems that C0005a#m1a performs some sort of check to see if the submitted value corresponds to the correct secret or not. When we look at its code, we can confirm that this is, indeed, the case:

/* renamed from: a */
public static boolean m1a(String str) {
    byte[] bArr;
    byte[] bArr2 = new byte[0];
    try {
        bArr = C0000a.m7a(m0b("8d127684cbc37c17616d806cf50473cc"), Base64.decode("5UJiFctbmgbDoLXmpL12mkno8HT4Lv8dlat8FxR2GOc=", 0));
    } catch (Exception e) {
        Log.d("CodeCheck", "AES error:" + e.getMessage());
        bArr = bArr2;
    }
    return str.equals(new String(bArr));
}

First, the method fetches the value of variable bArr from C0000a#m7a, by calling it with the following arguments:

  • m0b("8d127684cbc37c17616d806cf50473cc"); and
  • Base64.decode("5UJiFctbmgbDoLXmpL12mkno8HT4Lv8dlat8FxR2GOc=", 0).

If we look at #m0b, we realize that it performs some sort of rudimentary scrambling over its argument, then returns whatever results from this procedure:

/* renamed from: b */
public static byte[] m0b(String str) {
    int length = str.length();
    byte[] bArr = new byte[length / 2];
    for (int i = 0; i < length; i += 2) {
        bArr[i / 2] = (byte) ((Character.digit(str.charAt(i), 16) << 4) + Character.digit(str.charAt(i + 1), 16));
    }
    return bArr;
}

As for the call to Base64#decode, it’s merely decoding the specified Base64 string using flag Base64.DEFAULT, whose value is 0.

Then, the method compares the value of bArr with its argument, returning true if they’re equal, and false otherwise.

Let’s now examine C0000a#m7a:

/* renamed from: a */
public static byte[] m7a(byte[] bArr, byte[] bArr2) {
    SecretKeySpec secretKeySpec = new SecretKeySpec(bArr, "AES/ECB/PKCS7Padding");
    Cipher cipher = Cipher.getInstance("AES");
    cipher.init(2, secretKeySpec);
    return cipher.doFinal(bArr2);
}

The method receives two byte arrays, namely bArr and bArr2, then gets an instance of the AES cipher, which it initializes in decryption mode (decimal 2), with key bArr, and uses the cipher to decrypt bArr2, which it returns. Hence, #m7a should return the secret that solves the challenge.

Obtaining the Secret

Now we know how the app works internally. We could solve this challenge by instrumenting the app and intercepting the return value of #m7a, but we’ll do something much simpler.

Since the source code is not obfuscated and we have a very precise idea of what it does, not to mention access to all of the important methods, we’ll merely craft an external Java program that will perform the very same operations the app would perform while it runs, and make the program print the secret we want. Such program can be crafted by merely copying code excerpts from the app and tinkering with them a little bit in order to account for the differences between the Android API and whatever version of Java our lab machine is running. I had openjdk 17.0.6 2023-01-17. Here’s what my program looked like after I was done (for the record, it’s actually an unnamed package, you can do that…):

import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import javax.crypto.spec.SecretKeySpec;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;

public class Main {

    public static void main(String[] args) throws InvalidKeyException, NoSuchAlgorithmException, NoSuchPaddingException, IllegalBlockSizeException, BadPaddingException {
        m7a(m0b("8d127684cbc37c17616d806cf50473cc"), Base64.getDecoder().decode("5UJiFctbmgbDoLXmpL12mkno8HT4Lv8dlat8FxR2GOc="));
    }

    public static byte[] m0b(String str) {
        int length = str.length();
        byte[] bArr = new byte[length / 2];
        for (int i = 0; i < length; i += 2) {
            bArr[i / 2] = (byte) ((Character.digit(str.charAt(i), 16) << 4) + Character.digit(str.charAt(i + 1), 16));
        }
        return bArr;
    }

    public static void m7a(byte[] bArr, byte[] bArr2) throws NoSuchAlgorithmException, NoSuchPaddingException, InvalidKeyException, IllegalBlockSizeException, BadPaddingException {
        SecretKeySpec secretKeySpec = new SecretKeySpec(bArr, "AES");
        Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
        cipher.init(2, secretKeySpec);
        System.out.println(new String(cipher.doFinal(bArr2)));
    }
}

And after we run it, we supposedly get the secret string:


Indeed, this is the secret string that solves the challenge:

Wrap Up

Well, this is it. It’s been fun.

I’ll make sure to post my answers to the other OWASP MAS Crackmes in the future.

Have a good one! :)