OWASP MAS Crackmes: Android UnCrackable Level 1 Walkthrough
- ▸ Level 1 — Decompile the APK, recover the AES key (Easy) (this post)
- Level 2 — Stripped JNI library & ptrace anti-debugging (Medium)
Challenge Info
- Platform: OWASP MASTG (Mobile Application Security Testing Guide)
- Challenge name: Android UnCrackable Level 1 (MASTG-APP-0003)
- Difficulty: Easy
- Goal: there's a secret string hidden inside the app — find a way to dig it out
- Environment: WSL2 Kali Linux

The series runs Android L1–L4 plus iOS L1–L2, and the difficulty curve is deliberately designed: each level stacks on one more layer of defence. L1 is pure Java, L2 sinks the logic down into native code, L3 adds anti-debugging plus integrity checks, and L4 is protected at the level of a real payment app.
The task description is a single sentence, and the words "find a way" are intentionally vague — it doesn't tell you to go static, dynamic, or patch the file, because this level genuinely has several viable routes, and choosing your route is itself part of what's being tested.
I deliberately did not write this up as a "three commands and here's the flag" speedrun. The answer to L1 is thirty seconds away on Google, and copying it is worth nothing; what's actually valuable is "how your brain runs during the first hour with an unfamiliar APK," so at every step I'll spell out the why.
Step 1: First Look at the File
mkdir -p ~/mas/uncrackable-l1 && cd ~/mas/uncrackable-l1
curl -Lo UnCrackable-Level1.apk \
https://github.com/OWASP/mastg/raw/master/Crackmes/Android/Level_01/UnCrackable-Level1.apk
file UnCrackable-Level1.apk
sha256sum UnCrackable-Level1.apk
UnCrackable-Level1.apk: Android package (APK), with AndroidManifest.xml, with APK Signing Block
1da8bf57d266109f9a07c01bf7111a1975ce01f190b9d914bcd3ae3dbef96f21
66K is good news — no library bloat, which means whatever comes out of the decompiler will be small enough for a human to read end to end.
The point of keeping the sha256 is "being able to tell later what I broke myself." If you eventually go down the patch-and-repackage route, you'll end up with several variant files on disk, and the original hash is your only anchor.
with APK Signing Block is a piece of foreshadowing: this APK uses v2-or-later signing, which telegraphs that if you later pick the "modify the APK" route, the signature will break, Android will refuse to install it, and you'll have to re-sign it yourself — which makes the "modify the runtime" route comparatively cheap.
Step 2: An APK Is Just a ZIP
unzip -l UnCrackable-Level1.apk
1648 AndroidManifest.xml
1319 META-INF/CERT.RSA
1139 META-INF/CERT.SF
1077 META-INF/MANIFEST.MF
5528 classes.dex
1260 res/layout/activity_main.xml
456 res/menu/menu_main.xml
4751 res/mipmap-hdpi-v4/ic_launcher.png
2348 res/mipmap-mdpi-v4/ic_launcher.png
7275 res/mipmap-xhdpi-v4/ic_launcher.png
14398 res/mipmap-xxhdpi-v4/ic_launcher.png
22801 res/mipmap-xxxhdpi-v4/ic_launcher.png
2748 resources.arsc
13 files
The .apk extension is a lie. It is literally a ZIP file. The first layer of protection on an Android app is exactly zero: anyone can see what's inside using a tool that already existed in the 1990s.
Why -l to list rather than actually extracting? First, don't touch disk (when analysing an unknown sample, "look at the listing before deciding whether to write it out" is basic discipline, and habits should be consistent). Second, the listing itself is intelligence.
Every time I get a file listing, I ask myself these five questions:
- ① Is there a
lib/? → No, and this is the big one.lib/holds native.sofiles (C/C++ output); if it's there you're opening Ghidra and reading assembly, and if it's not, the logic is 100% at the Java layer and will decompile into something human-readable. This single glance decides which tool you'll be living in for the next three hours. - ② How many
classes.dex, and how big? → One, 5528 bytes. A normal commercial app's dex is routinely 5–50MB; this is 5.5KB, roughly a dozen classes, all hand-written. Tactical implication: the entire app can be read cover to cover, a luxury that essentially never exists on a real engagement. - ③
res/+resources.arsc? → Only layouts, menus and icons, and the arsc is a mere 2.7KB. There are pitifully few strings, so the odds of the secret "just sitting in strings.xml" are low. - ④
assets/? → Doesn't exist, so one classic hiding spot is ruled out (no smuggled config files, encrypted blobs, or second-stage dex). - ⑤
META-INF/signature files → v1 signing (CERT.RSA/SF+MANIFEST.MF) plus the APK Signing Block thatfilereported (v2), i.e. dual v1+v2 signing. Touch any file and both layers die together.
Stage conclusion: native layer, hidden assets, and large third-party libraries are all ruled out.
Step 3: Read the AndroidManifest
aapt2 dump badging UnCrackable-Level1.apk
aapt2 dump xmltree UnCrackable-Level1.apk --file AndroidManifest.xml
Why not just cat it? Because you'll get garbage. The AndroidManifest.xml inside an APK isn't a text file — it's compiled binary XML (AXML). Android squeezes it into binary at packaging time to save parsing effort, and this is the first wall people walk into.
Why does the manifest come before reading code? Because it's the app's table of contents and street address.
package: name='owasp.mstg.uncrackable1'
sdkVersion:'19' targetSdkVersion:'28'
launchable-activity: name='sg.vantagepoint.uncrackable1.MainActivity'
android:allowBackup=true
intent-filter: action.MAIN + category.LAUNCHER
Five pieces of intelligence come out of that:
- ① Found the entry point:
sg.vantagepoint.uncrackable1.MainActivity. That's where code reading starts — open jadx, jump straight there, begin atonCreate(). The program's entry point is the analysis's entry point. Note in passing that the package name (owasp.mstg.uncrackable1, the one adb/Frida want) differs from the classes' package (sg.vantagepoint, the code's internal namespace). That's perfectly normal, but keep them straight or your Frida commands will have you chasing your tail. - ②
android:debuggableis absent → it defaults tofalse. If it were true you could attach a debugger without root and halve the difficulty. Not an option here, so we do this properly. - ③
android:allowBackup="true"→ useless for this level, but in the real world it's a data-leak vector (adb backupcan pull private data), and it's exactly the kind of thing MASVS-STORAGE checks. File it away. - ④
minSdk 19 / targetSdk 28→ a time machine. minSdk 19 = Android 4.4 (2013); old means naive defences. targetSdk 28 has tactical value too: before Android 9, memory access and SELinux were more permissive, so if you later want to dump memory and hunt for strings, that version number affects which device you pick. - ⑤ exported components → just the one launcher activity; no externally reachable Service/Receiver/Provider. The IPC attack surface is zero, so that route is out.
The map takes shape:
user taps icon → MainActivity.onCreate() → (input received/validated somewhere) → ???
Step 4: Decompiling (Plus One Kali Gotcha)
jadx -d out UnCrackable-Level1.apk
ERROR - Incorrect arguments: File not found /usr/share/jadx/bin/UnCrackable-Level1.apk
This error is worth thirty seconds of your time, because it's a masterclass in "how to read an error message."
The path /usr/share/jadx/bin/ appears nowhere in your command — it materialised out of thin air, and that's the whole clue: you supplied a relative path, jadx joined it to its own install directory, which means the program quietly changed the working directory before parsing your arguments.
The cause: Kali's /usr/bin/jadx isn't the executable itself, it's a Gradle-generated wrapper script that cds in order to locate the install directory, and some packaged builds forget to restore the user's original working directory.
The fix — spell the paths out in full, for both input and output:
jadx -d "$PWD/out" "$PWD/UnCrackable-Level1.apk"
General rule: Java-based CLI tools (jadx, apktool, Ghidra headless, Burp CLI) frequently handle relative paths badly once a Linux distro has packaged them. When a file is plainly right there but the tool says it can't find it, your first move is always to retry with an absolute path.
find out -name '*.java'
out/sources/owasp/mstg/uncrackable1/R.java
out/sources/sg/vantagepoint/a/a.java
out/sources/sg/vantagepoint/a/b.java
out/sources/sg/vantagepoint/a/c.java
out/sources/sg/vantagepoint/uncrackable1/a.java
out/sources/sg/vantagepoint/uncrackable1/MainActivity.java
Triage before you read (when a pile of classes lands in front of you, split noise from signal first — don't just cat them one by one from the top):
R.javagets skipped outright — it's the auto-generated resource index produced at build time, machine-written, present in every app. When you seeR.java, ignore it as a rule.- The remaining five all live under
sg.vantagepoint, hand-written by the author, all signal. - Note the
sg/vantagepoint/a/directory: single-letter filenamesa/b/c, and the package is also calleda— those are obfuscation (ProGuard) fingerprints. But here's the irony: whatever somebody went out of their way to hide is usually the important thing. Flag it as highly suspicious.
Step 5: Reading MainActivity
protected void onCreate(Bundle bundle) {
if (c.a() || c.b() || c.c()) { a("Root detected!"); }
if (b.a(getApplicationContext())) { a("App is debuggable!"); }
super.onCreate(bundle);
setContentView(R.layout.activity_main);
}
public void verify(View view) {
String string = ((EditText) findViewById(R.id.edit_text)).getText().toString();
if (a.a(string)) { /* "This is the correct secret." */ }
else { /* "That's not it. Try again." */ }
}
This one file is doing two completely different jobs; separate them first.
Part one (defence): inside onCreate, c.a/b/c are root detection and b.a() is debuggable detection; trip either and you get System.exit(0).
The key judgement call: do these have anything to do with digging out the secret? No. They're gatekeepers, not treasure. They only block you if you want to run the app on a rooted phone or under a debugger. If you take the pure static route (read the code, never run it), they can't touch you — they're runtime checks, and we're simply not running anything.
Part two (treasure): verify(). Apply the three core questions of reading code in reverse — where does the input come from (edit_text → string), where is the comparison (a.a(string), i.e. the entire validation is condensed into one boolean call), and is there any crypto (not in this file; the actual decision is thrown into the a.a() black box).
A common bad instinct: "open a.a() and there'll be an if (input.equals("the secret")) in there." Quite possibly not. a.a() only returns yes/no, and there are two plausible implementations: possibility A, a plaintext comparison (the secret is just lying there, level cleared in ten seconds), or possibility B, decrypt then compare (the secret is *computed*, not *stored*, and you'll have to re-run the decryption yourself).
Step 6: Opening the Black Box, a.java
public static boolean a(String str) {
byte[] bArrA = sg.vantagepoint.a.a.a(
b("8d127684cbc37c17616d806cf50473cc"),
Base64.decode("5UJiFctbmgbDoLXmpL12mkno8HT4Lv8dlat8FxR2GOc=", 0)
);
return str.equals(new String(bArrA));
}
public static byte[] b(String str) {
// hex string → byte[] (two chars per byte, <<4 to place the high nibble)
}
The answer is possibility B. Read it from the last line up: your input str == new String(bArrA), where bArrA is plaintext decrypted from something.
And here is a design flaw that is extremely friendly to attackers: at the instant of the comparison, the secret exists in memory in full, in the clear. The developer assumed that encrypting it made it safe, but no matter how well you encrypt something, you have to turn it back into plaintext to compare it — and that moment of restoration is when it's most vulnerable. This directly telegraphs a route that clears the level without doing any decryption at all (dynamic hooking: don't fight the crypto, just intercept the instant the plaintext appears).
Unpacking b(): don't read the implementation first, ask what transformation it performs. The input is 32 characters of 0-9a-f (looks like hex), the loop does i+=2, eating two characters and emitting one byte per iteration — no line-by-line arithmetic required to conclude that b() is a hex-string → byte[] converter. And that hex string is an AES key (32 hex chars = 16 bytes = AES-128; the numbers line up perfectly).
All three ingredients are now on the table:
| Code | What it actually is |
|---|---|
b("8d1276...") |
AES key (16 bytes) |
Base64.decode("5UJiFc...") |
Ciphertext |
sg.vantagepoint.a.a.a(key, ct) |
Decryption function |
Both the key and the ciphertext are hardcoded in the code, which is the ironclad proof of the lesson this level exists to teach: a secret hardcoded into an app is not a secret. The developer shipped you the key alongside the ciphertext, which is like mailing someone the safe and the key in the same box.
Step 7: The Decryption Engine, and One Exquisite Trap
public static byte[] a(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);
}
Line 1 is a little con job. The second parameter of SecretKeySpec is called algorithm and merely tags "which algorithm this key is for"; only the leading "AES" is used, and the trailing /ECB/PKCS7Padding is noise it ignores entirely. That very official-looking string sitting there is really the author's small mistake (or a smoke grenade).
What actually determines the mode is line 2. The format for Cipher.getInstance("AES") is "algorithm/mode/padding", and here only "AES" is given → so it takes the defaults, and in the Java standard library AES with no mode specified defaults to ECB + PKCS5Padding. So the real behaviour is AES/ECB, and that PKCS7 on line 1 is a feint (and PKCS5 and PKCS7 are completely equivalent for AES's 16-byte blocks anyway, so either one decrypts it).
Rule to take away: when reading crypto code, the algorithm field ofSecretKeySpecis not to be trusted; the parameters ofCipher.getInstance()are the sole authority. This is a pit you'll fall into repeatedly in real reverse engineering — plenty of people get fooled by the former, pick the wrong mode, and get stuck unable to decrypt anything.
The 2 in cipher.init(2, ...) is a magic number; it's really Cipher.DECRYPT_MODE (1 is ENCRYPT). jadx can't recover the constant name, so it just prints the number — when you see a 2 as the first argument to Cipher.init, read it as "this is decrypting."
The full chain:
key = hexToBytes("8d127684cbc37c17616d806cf50473cc") # 16 bytes, AES-128
cipher = base64Decode("5UJiFctbmgbDoLXmpL12mkno8HT4Lv8dlat8FxR2GOc=")
secret = AES-128-ECB-Decrypt(cipher, key)
check = (your input == secret)
Step 8: Weighing Up the Three Routes
At this point it's a foregone conclusion that the secret is obtainable; all that's left is picking a route. That decision process is the most valuable training this level offers:
- Route 1 | Pure static, recompute the decryption yourself: take the key + ciphertext + mode and run it once in Python. Upside: no phone, no root, no Frida, doable immediately, and it best proves "I actually understand what this thing is doing." Downside: you need to get the AES parameters right (but that's revision, not a downside).
- Route 2 | Dynamic hooking, intercept the plaintext: use Frida to hook the return of
a.a()orString.equalsand grab the plaintext when the app decrypts it for you. Upside: you don't need to understand any cryptography. Downside: you have to stand up an Android + Frida environment and deal with those two gatekeepers first — the highest cost of the three. - Route 3 | Patch, bypass the validation: edit the smali so
verify()always returns true. For "dig out the secret," this is the wrong route — it lets you pretend you've beaten the level while never learning what the secret is. It's listed here to make a distinction: "bypassing the check" and "obtaining the secret" are two different goals.
This write-up takes route 1: zero friction with the environment, and it forces you to genuinely understand the whole crypto flow — an understanding that pays out directly later when you're reversing apps hunting for hardcoded keys. Route 2 stays on the shelf as a second solution to the same level.
Step 9: Rebuilding the Decryption
pip install pycryptodome --break-system-packages
python3 - <<'EOF'
import base64
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
key = bytes.fromhex("8d127684cbc37c17616d806cf50473cc")
ciphertext = base64.b64decode("5UJiFctbmgbDoLXmpL12mkno8HT4Lv8dlat8FxR2GOc=")
plaintext_padded = AES.new(key, AES.MODE_ECB).decrypt(ciphertext)
print("raw :", plaintext_padded)
print("secret:", unpad(plaintext_padded, 16).decode())
EOF
raw : b'I want to believe\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f'
secret: I want to believe
I want to believe — an X-Files reference, a trademark of the author, Bernhard Mueller.
Why keep that print("raw", ...) line? It isn't decoration, it's the best opportunity you'll get to actually see padding. The plaintext is 17 bytes, an AES block is 16, so padding it up to 32 requires 15 bytes — and every one of those padding bytes has the value 0x0f = 15. That's the elegance of PKCS: the number of bytes you add is the value of each added byte, so the decrypting side reads the final byte and knows exactly how much to chop off. Look at raw first, then at the result after unpad, and you've watched padding go from "there" to "gone" with your own eyes.
Key Takeaways
- An APK's first layer of protection is zero: an
.apkis a ZIP, andunzip -lexposes its structure at a glance. Whether there's alib/orassets/, and how big the dex is, is already intelligence before you open a single file. - Static analysis: draw the map first: the manifest hands you the entry activity and the debuggable state, turning "an unfamiliar app" into "a path with a starting point," and only then do you decide whether to go dynamic.
- A hardcoded secret is not a secret: both key and ciphertext are baked into the code, and a reverse engineer recovers the plaintext in ten lines of pycryptodome.
What This Level Means in the Real World
L1's core thesis in one sentence: a program running in the user's hands has no secrets.
An Android app is installed on a device the attacker completely controls — files, memory, and in theory every single function call can be observed and tampered with. Developers routinely have the instinct that "I hid the key inside the app, so the user can't see it," and this level exists to smash that with your own hands.
That realisation converts straight into results: hunting for hardcoded API keys, backend endpoints, and internal service URLs inside mobile apps is a solid bug bounty opening move, and what you dig up usually loops right back into the web attack surface you already know — the app is just the doorway, and the real hole is often in the API it calls.
This post is a practice log from the OWASP MAS Crackmes series. All target programs are officially published, educational-purpose apps from OWASP. Do not apply these techniques to targets you are not authorised to test.

Member discussion