Post

Android UnCrackable Level 1: Static First, Then Frida

Solving the OWASP MASTG UnCrackable Level 1 crackme the way you'd approach a real app: decompile with jadx to find where the secret is checked, then hook the decrypt with Frida so the app hands you the plaintext. Plus a no-Frida cross-check with openssl.

Android UnCrackable Level 1: Static First, Then Frida

This is the first in a short series on the OWASP MASTG UnCrackable Android crackmes. They’re a clean way to practice the loop that mobile reverse engineering actually runs on — decompile, find where a secret is used, hook that point at runtime — without needing a novel target. Level 1 is all Java and takes a few minutes; it’s here to set the method I’ll reuse on the harder levels.

Everything ran on a local emulator (Android 14, x86_64, root via adb root) with Frida 17.25, jadx 1.5.6, and nothing leaving the machine.

These are deliberately-built training apps from the OWASP Mobile Application Security Testing Guide. The point isn’t that I “cracked” anything — it’s to show the technique on the smallest possible target.

The target

One text box, one VERIFY button. Type the secret, get “Success!”.

UnCrackable Level 1 on launch The whole app. On a Magisk-rooted phone it would normally pop “Root detected!” and exit — more on why it didn’t here in a moment.

Step 1: decompile, don’t guess

The first move on any APK is to get readable code. jadx turns the Dalvik bytecode back into Java:

1
jadx -d decompiled UnCrackable-Level1.apk

The app is only five small classes, and the names are obfuscated down to a, b, c. Read them for what they do, not what they’re called. The two that matter:

MainActivity.onCreate — the startup checks:

1
if (c.a() || c.b() || c.c()) { a("Root detected!"); }

sg.vantagepoint.a.c holds three classic root checks: is there an su binary on $PATH, is Build.TAGS set to test-keys, and do any of a list of known SuperSU file paths exist.

sg.vantagepoint.uncrackable1.a.a(String) — the secret check. This is the heart of it:

1
2
3
4
bArrA = sg.vantagepoint.a.a.a(
            b("8d127684cbc37c17616d806cf50473cc"),
            Base64.decode("5UJiFctbmgbDoLXmpL12mkno8HT4Lv8dlat8FxR2GOc=", 0));
return str.equals(new String(bArrA));

And sg.vantagepoint.a.a.a(key, ciphertext) is just AES decryption.

Here’s the insight that decides everything: the app decrypts the real secret at runtime and compares it to your input. The plaintext has to exist, in memory, every time you press VERIFY. I don’t need to break AES or find the key by hand — I just need to read the output of that decrypt function. That’s the hook.

Step 2: the hook plan

Hook Why
sg.vantagepoint.a.c.a / b / c → return false Kill root detection at the source, not by hooking System.exit
sg.vantagepoint.a.a.a → call the original, log the return The return value is the secret

Hooking the three checks rather than System.exit() is the better habit: it’s targeted, it doesn’t disturb other code paths that might legitimately call exit, and logging the real return value first tells me which check would have fired.

Step 3: the script

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Java.perform(() => {
  // 1. Neutralise the three root checks, but log what they really returned.
  const RootCheck = Java.use('sg.vantagepoint.a.c');
  ['a', 'b', 'c'].forEach(name => {
    RootCheck[name].implementation = function () {
      const real = this[name]();
      console.log(`[root] c.${name}() real=${real}  -> returning false`);
      return false;
    };
  });

  // 2. Let the app decrypt the secret for us, and read it on the way out.
  const Crypto = Java.use('sg.vantagepoint.a.a');
  Crypto.a.implementation = function (key, ciphertext) {
    const plain = this.a(key, ciphertext);
    console.log(`[aes]  plaintext = "${bytesToStr(plain)}"`);
    return plain;
  };
});

Spawn the app with the script (-f), so the hooks are in place before onCreate runs:

1
frida -U -f owasp.mstg.uncrackable1 -l hook.js

At launch:

1
2
3
4
[*] hooks installed
[root] c.a() real=false  -> returning false
[root] c.b() real=false  -> returning false
[root] c.c() real=false  -> returning false

App running under Frida

An aside: the root checks never fired anyway

Notice all three checks really returned false. That’s why Step 1’s screenshot showed no “Root detected!” dialog even on a rooted emulator. Two reasons I confirmed, one I’m still honest about:

  • Build.TAGS on this image is dev-keys, not test-keys (adb shell getprop ro.build.tags), so checkRoot2 is false.
  • None of the 2014-era SuperSU paths exist on a modern image, so checkRoot3 is false.
  • /system/xbin/su does exist and is on $PATH, yet checkRoot1 returned false. My working theory is that SELinux stops an untrusted app from stat-ing the su binary, so File.exists() comes back false — but I haven’t fully confirmed that, so I’m flagging it as a theory, not a fact.

The hook still earns its place: on a Magisk-rooted physical phone some of these can fire, and neutralising them is the realistic case. And the lesson is a defensive one too — these checks miss modern root completely.

Step 4: read the secret

Type anything and hit VERIFY. The comparison happens after decryption, so the hook fires even on a wrong guess:

A wrong guess

1
[aes]  plaintext = "I want to believe"

There it is. Enter it:

Success

Step 5: prove it without Frida

Dynamic analysis found the answer fast, but the key and ciphertext are both hard-coded in the APK, so the same secret falls straight out of static analysis — no device, no hooking:

1
2
3
echo "5UJiFctbmgbDoLXmpL12mkno8HT4Lv8dlat8FxR2GOc=" | base64 -d \
  | openssl enc -d -aes-128-ecb -K 8d127684cbc37c17616d806cf50473cc
# I want to believe

Two independent methods, same result. That’s the cross-check I want before I believe my own tooling.

Takeaways

  • Static first, then dynamic. jadx told me exactly which method to hook. Frida just confirmed it at runtime. Reversing the code is what makes the hook obvious.
  • Hook where the secret is used, not where it’s stored. Anything shaped like “decrypt the secret, then compare” leaks the plaintext to a hook on the decrypt (or on String.equals).
  • Spawn vs. attach. Startup checks run in onCreate, so you need frida -f to hook before they run. Attaching to a running app is too late.
  • Root detection is fragile. These checks miss modern root entirely — a lesson worth remembering from the defender’s side too.

Level 1 is pure Java. Level 2 moves the secret check down into a native library — which is where JNI, native hooking, and reading AArch64 come in.

This post is licensed under CC BY-NC-ND 4.0 by the author.