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.
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!”.
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
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.TAGSon this image isdev-keys, nottest-keys(adb shell getprop ro.build.tags), socheckRoot2is false.- None of the 2014-era SuperSU paths exist on a modern image, so
checkRoot3is false. /system/xbin/sudoes exist and is on$PATH, yetcheckRoot1returned false. My working theory is that SELinux stops an untrusted app from stat-ing thesubinary, soFile.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:
1
[aes] plaintext = "I want to believe"
There it is. Enter it:
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 needfrida -fto 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.


