Reverse Engineering Android Applications - Part 2

Reversing our first application

In the previous article we set up our analysis environment and now are ready to reverse engineer our first application.

While searching for a suitable target for this article, I came across these challenges from OWASP. The ones in the link above become progressively more difficult so they will make good practice.

They are good to practice on but for this article I wanted to keep it simple and avoid native code and obfuscations. Only L1 serves this purpose.

For this article we will need the following:

  • Android UnCrackable L1 - the application we will reverse engineer
  • JADX - a decompiler for Android applications
  • Apktool - a tool we can use to extract and modify resources and code from an app
  • Frida - we can use Frida to modify a binary at runtime

APK

An APK file is a format used for Android applications. It is essentially a ZIP archive that contains a specific structure of files and directories, as described in the APK article on Wikipedia.

This archive contains code and resources required by the application.

Unzipping the file is not enough to have something usable, as the resources are encoded and we only have DEX bytecode. This is where Apktool comes in.

Apktool

Apktool converts the .dex file(s) to smali and decodes the resources.

Most Android applications are primarily written in Java or Kotlin, but some can contain native code or be built using frameworks such as React Native.

Android applications run on the Android RunTime (ART) instead of the Java Virtual Machine (JVM). In earlier versions, they run on the Dalvik virtual machine.

DEX is the bytecode format used by ART and Dalvik, and we can convert it to smali, which would be analogous to Assembly language for native code.

Apktool will convert the .dex file or files to smali, and decode the resources.

Note that the resources are not encrypted, simply encoded. The documentation for resources can be found on Google’s Android documentation.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
PS C:\Users\User\Desktop> .\apktool.bat d .\UnCrackable-Level1.apk
I: Using Apktool 2.7.0 on UnCrackable-Level1.apk
I: Loading resource table...
I: Decoding AndroidManifest.xml with resources...
I: Loading resource table from file: C:\Users\User\AppData\Local\apktool\framework\1.apk
I: Regular manifest package...
I: Decoding file-resources...
I: Decoding values */* XMLs...
I: Baksmaling classes.dex...
I: Copying assets and libs...
I: Copying unknown files...
I: Copying original files...
Press any key to continue . . .

To install the application, drag and drop the APK file to the Android emulator.

The goal is to find the key or somehow bypass the check.

We will start with simply bypassing the check.

Let’s open AndroidManifest.xml.

<?xml version="1.0" encoding="utf-8" standalone="no"?><manifest xmlns:android="http://schemas.android.com/apk/res/android" package="owasp.mstg.uncrackable1">
    <application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:theme="@style/AppTheme">
        <activity android:label="@string/app_name" android:name="sg.vantagepoint.uncrackable1.MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
    </application>
</manifest>

We can see that application begins at sg.vantagepoint.uncrackable1.MainActivity.

We will now open UnCrackable-Level1\smali\sg\vantagepoint\uncrackable1\MainActivity.smali.

Looking at the methods, we see one called verify.

.method public verify(Landroid/view/View;)V
    .locals 3

    const p1, 0x7f020001

    invoke-virtual {p0, p1}, Lsg/vantagepoint/uncrackable1/MainActivity;->findViewById(I)Landroid/view/View;

    move-result-object p1

    check-cast p1, Landroid/widget/EditText;

    invoke-virtual {p1}, Landroid/widget/EditText;->getText()Landroid/text/Editable;

    move-result-object p1

    invoke-virtual {p1}, Ljava/lang/Object;->toString()Ljava/lang/String;

    move-result-object p1

    new-instance v0, Landroid/app/AlertDialog$Builder;

    invoke-direct {v0, p0}, Landroid/app/AlertDialog$Builder;-><init>(Landroid/content/Context;)V

    invoke-virtual {v0}, Landroid/app/AlertDialog$Builder;->create()Landroid/app/AlertDialog;

    move-result-object v0

    invoke-static {p1}, Lsg/vantagepoint/uncrackable1/a;->a(Ljava/lang/String;)Z

    move-result p1

    if-eqz p1, :cond_0

    const-string p1, "Success!"

    invoke-virtual {v0, p1}, Landroid/app/AlertDialog;->setTitle(Ljava/lang/CharSequence;)V

    const-string p1, "This is the correct secret."

    :goto_0
    invoke-virtual {v0, p1}, Landroid/app/AlertDialog;->setMessage(Ljava/lang/CharSequence;)V

    goto :goto_1

    :cond_0
    const-string p1, "Nope..."

    invoke-virtual {v0, p1}, Landroid/app/AlertDialog;->setTitle(Ljava/lang/CharSequence;)V

    const-string p1, "That\'s not it. Try again."

    goto :goto_0

    :goto_1
    const/4 p1, -0x3

    const-string v1, "OK"

    new-instance v2, Lsg/vantagepoint/uncrackable1/MainActivity$2;

    invoke-direct {v2, p0}, Lsg/vantagepoint/uncrackable1/MainActivity$2;-><init>(Lsg/vantagepoint/uncrackable1/MainActivity;)V

    invoke-virtual {v0, p1, v1, v2}, Landroid/app/AlertDialog;->setButton(ILjava/lang/CharSequence;Landroid/content/DialogInterface$OnClickListener;)V

    invoke-virtual {v0}, Landroid/app/AlertDialog;->show()V

    return-void
.end method

The listing above may not make a lot of sense, but we see a few strings that will hint at what we want to change, even if you don’t yet know smali.

 if-eqz p1, :cond_0

   const-string p1, "Success!"
   ...
:cond_0
const-string p1, "Nope..."

Looking at the Android documentation, if-eqz works like this:

Branch to the given destination if the given register's value compares with 0 as specified.

We can simply remove that line, rebuild and sign the APK.

1
2
3
4
5
6
7
8
PS C:\Users\User\Desktop> .\apktool.bat b .\UnCrackable-Level1\
I: Using Apktool 2.7.0
I: Checking whether sources has changed...
I: Checking whether resources has changed...
I: Building resources...
I: Building apk file...
I: Copying unknown files/dir...
I: Built apk into: .\UnCrackable-Level1\dist\UnCrackable-Level1.apk

Signing is required, and the easiest way to sign the APK is with uber-apk-signer.

1
PS C:\Users\User\Desktop> java -jar .\uber-apk-signer-1.2.1.jar --apks .\UnCrackable-Level1\dist\UnCrackable-Level1.apk

You will need to uninstall the old version first, as the signature won’t match the previous one. When you run the app, any input will work.

JADX

The former solution works, but reading smali will become a chore soon enough. Let’s open the APK on JADX and see if it’s any easier.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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 (a.a(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();
    }
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* loaded from: classes.dex */
public class a {
    public static boolean a(String str) {
        byte[] bArr;
        byte[] bArr2 = new byte[0];
        try {
            bArr = sg.vantagepoint.a.a.a(b("8d127684cbc37c17616d806cf50473cc"), Base64.decode("5UJiFctbmgbDoLXmpL12mkno8HT4Lv8dlat8FxR2GOc=", 0));
        } catch (Exception e) {
            Log.d("CodeCheck", "AES error:" + e.getMessage());
            bArr = bArr2;
        }
        return str.equals(new String(bArr));
    }

    public static byte[] b(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;
    }
}

That is way easier. We are comparing our input against the result of decrypting a string using AES. Let’s rename some variables to make this easier to understand. If you are confused about b, remember that 2 hex characters in the string represent a single byte.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class KeyVerifier {
    /* renamed from: a */
    public static boolean VerifyKey(String inputString) {
        byte[] decryptedData;
        byte[] bArr = new byte[0];
        try {
            decryptedData = Decrypt.DecryptData(ConvertToByteArray("8d127684cbc37c17616d806cf50473cc"), Base64.decode("5UJiFctbmgbDoLXmpL12mkno8HT4Lv8dlat8FxR2GOc=", 0));
        } catch (Exception e) {
            Log.d("CodeCheck", "AES error:" + e.getMessage());
            decryptedData = bArr;
        }
        return inputString.equals(new String(decryptedData));
    }

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

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

Now we can write our own decryption code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public static void main(String[] args) {
    byte[] key = ConvertToByteArray("8d127684cbc37c17616d806cf50473cc");
    byte[] encryptedData =  Base64.getDecoder().decode("5UJiFctbmgbDoLXmpL12mkno8HT4Lv8dlat8FxR2GOc=");
    SecretKeySpec secretKeySpec = new SecretKeySpec(key, "AES");
    Cipher cipher = null;
    try {
        cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
        cipher.init(2, secretKeySpec);
        byte[] decrypted = cipher.doFinal(encryptedData);
        System.out.println(new String(decrypted));
    } ...
}
// prints "I want to believe"

We now have the key, and don’t need to modify the program to pass the check.

Frida

There is another way to solve this challenge, by modifying the binary at runtime.

The instructions for setting up Frida are available on their documentation. If you followed along with the last video you will want to download frida-server-*-android-x86_64.xz. We need a server running on the Android device so that we can interact with it and run scripts. There are other ways to use Frida on Android, but for this one we need a rooted device.

What we will do is write a script to modify the VerifyKey method to always return true.

On JADX, right click the VerifyKey function and select “Copy as frida snippet”.

1
2
3
4
5
6
7
8
9
Java.perform(() => {
    let KeyVerifier = Java.use("sg.vantagepoint.uncrackable1.a");
    KeyVerifier["a"].implementation = function () {
        console.log('a is called');
        let ret = this.a();
        console.log('a ret value is ' + ret);
        return true;
    };
});

Now install frida-tools.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
python3.9.exe -m pip install frida-tools --user
.\frida-ps.exe -U
 PID  Name
----  -------------------------------------------------------------
3410  Gmail
1331  Google
1361  Messages
4116  Phone
 894  SIM Toolkit
3852  Settings
5107  Uncrackable1
4510  YouTube Music

Now we modify one of the example scripts from the documentation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import frida, sys

jscode = """
Java.perform(() => {
    let KeyVerifier = Java.use("sg.vantagepoint.uncrackable1.a");
    KeyVerifier["a"].implementation = function (input) {
        console.log('a is called');
        console.log(input);
        return true;
    };
});
"""

process = frida.get_usb_device().attach('Uncrackable1')
script = process.create_script(jscode)
script.load()
sys.stdin.read()
1
python3.9.exe .\always_true.py

The script itself is JS, but we can write a simple wrapper to run it using Python.

After running the script, any key will work since we have modified VerifyKey to return true always.

Conclusion

We looked at several ways of solving the same challenge, and now you are familiar with the most common tools used for Android reverse engineering.

Attempting to solve the next challenges on the OWASP website will be a good way to practice 😃

updatedupdated2023-05-122023-05-12