Skip to main content

Command Palette

Search for a command to run...

MobileHackingLab: Strings Challenge (Exploiting Exported Android Activities)

Updated
8 min read

This CTF styled lab was part of the free android hacking course from mobilehackinglab.com on exploiting the exported android activities.

I installed the app on my test phone and there was literally nothing on the ui just a text saying “Hello from C++”.

The Initial Recon

The next phase was to start with recon, using jadx-gui, I opened the apk and checked the manifest file,
There were 2 activities that were exported=”true” but one caught my attention

<activity
    android:name="com.mobilehackinglab.challenge.Activity2"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.VIEW"/>
        <category android:name="android.intent.category.DEFAULT"/>
        <category android:name="android.intent.category.BROWSABLE"/>
        <data
            android:scheme="mhl"
            android:host="labs"/>
    </intent-filter>
</activity>

This activity is exported but also has defined uri schema hence it accepts the deeplink with mhl://labs URI scheme. This looked like an entry point to me.

Any exported activities like this, can be started using adb shell am start com.mobilehackinglab.challenge/.Activity2

But because it has uri schema, we could open the activity by simply passing the deeplink adb shell am start -a android.intent.action.VIEW -d "mhl://labs/"

I opened the activity using this deeplink, but the activity closes almost immediately.

Diving Into Activity2

Using jadx, I started going through Activity2's onCreate method, I would recommend reading Android Activity lifecycle which will give you a clear view of when onCreate() methods are executed during an activity. Understanding the lifecycle flow will help you quickly find out the entry point of the application.

protected void onCreate(Bundle savedInstanceState) throws BadPaddingException, NoSuchPaddingException, IllegalBlockSizeException, NoSuchAlgorithmException, InvalidKeyException, InvalidAlgorithmParameterException {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_2);
        SharedPreferences sharedPreferences = getSharedPreferences("DAD4", 0);
        String u_1 = sharedPreferences.getString("UUU0133", null);
        boolean isActionView = Intrinsics.areEqual(getIntent().getAction(), "android.intent.action.VIEW");
        boolean isU1Matching = Intrinsics.areEqual(u_1, cd());
        if (isActionView && isU1Matching) {
            // core logic redacted
            }
            finishAffinity();
            finish();
            System.exit(0);
            return;
        }
        finishAffinity();
        finish();
        System.exit(0);
    }

The most important thing here is the if condition - when it's not true, the app calls finishAffinity() and System.exit(0), which is exactly what happens when you open the activity.

Here's where things seemed exciting to me:

SharedPreferences sharedPreferences = getSharedPreferences("DAD4", 0);
String u_1 = sharedPreferences.getString("UUU0133", null);
boolean isActionView = Intrinsics.areEqual(getIntent().getAction(), "android.intent.action.VIEW");
boolean isU1Matching = Intrinsics.areEqual(u_1, cd());

If you have done android app development before, SharedPreferences is Android's key-value storage for saving small amounts of primitive data (strings, ints, booleans) that persists across app sessions.
Here, it is trying to retrieve a string value stored under the key "UUU0133" from a preferences file named "DAD4" (returns null if not found).

Immediately I went inside the device shell looked into this path /data/data/com.mobilehackinglab.challenge/shared_prefs to see if there are any SharedPreferences file, the filename should be DAD4.xml as evident from the code above. Unfortunately there were none.

This statement

boolean isActionView = Intrinsics.areEqual(getIntent().getAction(), "android.intent.action.VIEW");
boolean isU1Matching = Intrinsics.areEqual(u_1, cd());
  • First line is doing checks if the app was launched via a VIEW intent, this means the deeplink should be opened with the action VIEW, -a android.intent.action.VIEW, which we were already doing

  • The second statement, is troublesome, if you check it is comparing the values of u_1 from SharedPreferences with the return value of cd().
    The second statement compares the value of u_1 from SharedPreferences with the return value of cd(). For isU1Matching to be true, both values need to be equal. Since u_1 is currently null and cd() returns today's date, they don't match and the condition fails.

  • Our goal is to somehow make the if condition true, because SharedPreferences value was definitely null, lets check if cd() returns null somehow.

private final String cd() {
        SimpleDateFormat sdf = new SimpleDateFormat("dd/MM/yyyy", Locale.getDefault());
        String str = sdf.format(new Date());
        Intrinsics.checkNotNullExpressionValue(str, "format(...)");
        Activity2Kt.cu_d = str;
        String str2 = Activity2Kt.cu_d;
        if (str2 != null) {
            return str2;
        }
        Intrinsics.throwUninitializedPropertyAccessException("cu_d");
        return null;
    }

The cd() function returns today's date in dd/MM/yyyy format, and does not return null in any way for sure. Now we are left with only one option, the if condition would be set to true only when in SharedPreferences current date is stored as the above format. I searched the project for the DAD4 keyword to see if there is anywhere else the SharedPreferences was being set to. I found this in MainActivity

public final void KLOW() {
    SharedPreferences sharedPreferences = getSharedPreferences("DAD4", 0);
    SharedPreferences.Editor editor = sharedPreferences.edit();
    SimpleDateFormat sdf = new SimpleDateFormat("dd/MM/yyyy", Locale.getDefault());
    String cu_d = sdf.format(new Date());
    editor.putString("UUU0133", cu_d);
    editor.apply();
}

So the SharedPreferences values were being stored from KLOW() but there were 0 usage for this function, hence we saw no SharedPreferences were being set, u1 was therefore always null and the condition would always fail.

Now we have two options, we could use Frida to set SharedPreferences value, given we know how the values will look like, for example something like this, this is a rough idea how it should work, not actual code.


Java.perform(() => {
    var Context = Java.use("android.content.Context");
    var ActivityThread = Java.use("android.app.ActivityThread");
    var currentApplication = ActivityThread.currentApplication();
    var context = currentApplication.getApplicationContext();

    var sharedPreferences = context.getSharedPreferences("DAD4", 0);
    var editor = sharedPreferences.edit();

    var SimpleDateFormat = Java.use("java.text.SimpleDateFormat");
    var Date = Java.use("java.util.Date");
    var Locale = Java.use("java.util.Locale");

    var sdf = SimpleDateFormat.$new("dd/MM/yyyy", Locale.getDefault());
    var currentDate = sdf.format(Date.$new());

    editor.putString("UUU0133", currentDate);
    editor.apply();
});

OR we could make KLOW() execute!

We don't need the app to call KLOW() - we could just call it with Frida. My first attempt was using Java.choose() to find an existing MainActivity instance and call the method:

Java.perform(() => {
    setTimeout(() => {
        Java.choose("com.mobilehackinglab.challenge.MainActivity", {
            onMatch: function(instance) {
                instance.KLOW();
            },
            onComplete: function() {}
        });
    }, 1000);
});

What this does is hook into the MainActivity and executes the KLOW() function, thereby writing the SharedPreferences value.

This hook can be executed using this command frida -U -f com.mobilehackinglab.challenge -l script.js

Once SharedPreferences values were set, I checked the path and saw the DAD4.xml file with the value

d2s:/ # cd /data/data/com.mobilehackinglab.challenge/shared_prefs
d2s:/data/data/com.mobilehackinglab.challenge/shared_prefs # ls
DAD4.xml
d2s:/data/data/com.mobilehackinglab.challenge/shared_prefs # cat DAD4.xml
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
    <string name="UUU0133">22/01/2026</string>
</map>
d2s:/data/data/com.mobilehackinglab.challenge/shared_prefs #

Now the if condition was true and if you open the deeplink again, you see the activity once again closes because if you check the code

 if (isActionView && isU1Matching) {
            Uri uri = getIntent().getData();
            if (uri != null && Intrinsics.areEqual(uri.getScheme(), "mhl") && Intrinsics.areEqual(uri.getHost(), "labs")) {
                String base64Value = uri.getLastPathSegment();
                byte[] decodedValue = Base64.decode(base64Value, 0);
                if (decodedValue != null) {
                    String ds = new String(decodedValue, Charsets.UTF_8);
                    byte[] bytes = "your_secret_key_1234567890123456".getBytes(Charsets.UTF_8);
                    Intrinsics.checkNotNullExpressionValue(bytes, "this as java.lang.String).getBytes(charset)");
                    String str = decrypt("AES/CBC/PKCS5Padding", "bqGrDKdQ8zo26HflRsGvVA==", new SecretKeySpec(bytes, "AES"));
                    if (str.equals(ds)) {
                        System.loadLibrary("flag");
                        String s = getflag();
                        Toast.makeText(getApplicationContext(), s, 1).show();
                        return;
                    } else {
                        finishAffinity();
                        finish();
                        System.exit(0);
                        return;
                    }
                }
                finishAffinity();
                finish();
                System.exit(0);
                return;
            }

Even if the first condition match, there is this line that is checking the lastPathSegment of the deeplink URI

String base64Value = uri.getLastPathSegment();
byte[] decodedValue = Base64.decode(base64Value, 0); //indicates the value is b64 of something

getLastPathSegment() returns the last segment of the uri, for example if the uri is mhl://labs/helloworld, then the helloworld would be returned as getLastPathSegment()

This block is probably the most compelling part

 String str = decrypt("AES/CBC/PKCS5Padding", "bqGrDKdQ8zo26HflRsGvVA==", new SecretKeySpec(bytes, "AES"));
                    if (str.equals(ds)) {
                        System.loadLibrary("flag");
                        String s = getflag();
                        Toast.makeText(getApplicationContext(), s, 1).show();
                        return;
                    }

Here, it is doing AES decryption of bqGrDKdQ8zo26HflRsGvVA== and the flag would be loaded only when our b64(lastPathSegment) == aes_decryption(“bqGrDKdQ8zo26HflRsGvVA==“)

Lets check the function that does decryption

public final String decrypt(String algorithm, String cipherText, SecretKeySpec key) throws BadPaddingException, NoSuchPaddingException, IllegalBlockSizeException, NoSuchAlgorithmException, InvalidKeyException, InvalidAlgorithmParameterException {
        Intrinsics.checkNotNullParameter(algorithm, "algorithm");
        Intrinsics.checkNotNullParameter(cipherText, "cipherText");
        Intrinsics.checkNotNullParameter(key, "key");
        Cipher cipher = Cipher.getInstance(algorithm);
        try {
            byte[] bytes = Activity2Kt.fixedIV.getBytes(Charsets.UTF_8);
            Intrinsics.checkNotNullExpressionValue(bytes, "this as java.lang.String).getBytes(charset)");
            IvParameterSpec ivSpec = new IvParameterSpec(bytes);
            cipher.init(2, key, ivSpec);
            byte[] decodedCipherText = Base64.decode(cipherText, 0);
            byte[] decrypted = cipher.doFinal(decodedCipherText);
            Intrinsics.checkNotNull(decrypted);
            return new String(decrypted, Charsets.UTF_8);
        } catch (Exception e) {
            throw new RuntimeException("Decryption failed", e);
        }
    }

If you check this line, Activity2Kt.fixedIV it is getting the IV values from Activity2Kt, which is this

public final class Activity2Kt {
    private static String cu_d = null;
    public static final String fixedIV = "1234567890123456";
}

So we have everything to decrypt this aes value

  • algorithm,

  • cipherText,

  • SecretKey,

  • IV

I used this website https://anycript.com/crypto to do so,

So the decrypted string is “mhl_secret_1337“. If you remember the getLastPathSegment() was also being base64 encoded, hence our uri becomes

mhl://labs/base64(mhl_secret_1337)

Now lets open the activity again using the new deeplink, replace that base64(mhl_secret_1337) with actual values

   adb shell am start -a android.intent.action.VIEW -d "mhl://labs/bWhsX3NlY3JldF8xMzM3"

The activity opens with success toast message. When you closely check the code,

String s = getflag();
Toast.makeText(getApplicationContext(), s, 1).show();

This is funny, I thought the flag would be on the toast, but MHL made it fun by only returning success from the getflag()!

So getflag() is returning "success"? That didn't seem right for a CTF flag. I thought maybe I should hook getflag() to see what it's actually returning. But even when I hook, I would surely get the same “success” as return value.

Reading The Hints Again

That's when I re-read the challenge hints:

"Utilize Frida for tracing or employ Frida's memory scanning" "Don't have to spend time on static analysis of the Android library, as the code is obfuscated"

They're telling not to try to reverse engineer or trace the native library. It's obfuscated anyway. The flag string has to exist somewhere in memory - probably hardcoded in the library and maybe I should just scan for it.

So I wrote this in Frida

Java.perform(() => {
    var module = Process.findModuleByName("libflag.so");

    Memory.scan(module.base, module.size, "4D 48 4C 7B", {  // "MHL{" in hex
        onMatch: function(address, size) {
            console.log('Found at: ' + address);
            console.log('Flag: ' + Memory.readUtf8String(address));
        },
        onComplete: function() {
            console.log('Scan complete');
        }
    });
});

This is basically finding the module libflag, when it matches, scans the memory, and finds the one matching with the hex value as our flag MHL{ as instructed in the challenge description. You must trigger the deeplink first so that libflag.so loads into the memory, otherwise memory scan will not work.

And there it was. The actual flag, sitting in the library's memory.

Very fun to play with!

Disclaimer: While all technical work and problem-solving was done by me, this writeup was edited with assistance from an LLM to improve grammar, clarity, and flow.

More from this blog

Dev&Security

7 posts