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

In L1 the secret was AES-encrypted and hidden away — you had to re-run the decryption yourself to get it. In L2 the secret isn't encrypted at all. It just moved down to the native layer, and then the compiler chopped it in half and stashed the pieces in different places.
From a cryptographic standpoint L2 is weaker than L1, but in terms of reversing effort L2 takes more work. "Difficulty" and "security" are two different things, and that contrast is one of the most valuable takeaways from this level.
The point of this write-up isn't the answer — it's "given a stripped .so, how do you know which piece to pull apart?" Because that's the hardest part of real-world reversing.
Step 1: Break down the structure, look at the diffs
mkdir -p ~/mas/uncrackable-l2 && cd ~/mas/uncrackable-l2
curl -Lo UnCrackable-Level2.apk \
https://github.com/OWASP/mastg/raw/master/Crackmes/Android/Level_02/UnCrackable-Level2.apk
unzip -l UnCrackable-Level2.apk
414 files, 1.4MB. A world away from L1's 13 files and 66KB — but don't panic, diff it against a known sample first. We have L1 as a baseline, so the differences are where the action is.
Running the same five-question checklist we built during L1:
- ②
classes.dex→ ballooned from L1's 5.5KB to 720KB. Don't let that scare you — ask "what exactly got bigger?" We'll prove later that nearly all of those 720KB areandroid.support; the author's own Java code is about the same size as L1's. - ③
META-INF/MANIFEST.MF→ exploded from 1KB to 50KB. This is the v1 signature's per-file digest list; it's only that big because the whole support library got stuffed in there. Zero value for reversing — recognise it and look right past it.
① Is there a lib/? → Yes, and this time it's the main event.
lib/arm64-v8a/libfoo.so 14176
lib/armeabi-v7a/libfoo.so 13948
lib/x86/libfoo.so 13788
lib/x86_64/libfoo.so 14440
One copy per architecture (the same code compiled for different CPUs), and the filename is libfoo — foo is the engineer's universal placeholder name, which means the author wrote it themselves, it's not some well-known library. Self-written native code + crackme = the key logic is almost certainly in there. And all four files are around 14KB, so they're small enough to read end to end.
While we're here, let's clear up something people constantly mix up:AndroidManifest.xml(the app's nameplate — main storyline, read it) andMETA-INF/MANIFEST.MF(the signature digest list — noise, skip it) are two completely different things.
Step 2: Narrowing 300+ classes down to 4
jadx -d "$PWD/out" "$PWD/UnCrackable-Level2.apk"
find out/sources -name '*.java' | wc -l # 300+
300-odd Java files. A beginner panics; a veteran filters them in three seconds. The trick is inverse filtering — don't hunt for the main storyline, cross out all the known noise first:
find out/sources -name '*.java' | grep -v -E 'android/(support|arch|annotation)/'
out/sources/owasp/mstg/uncrackable2/R.java ← auto-generated, skip
out/sources/sg/vantagepoint/uncrackable2/CodeCheck.java ← new face! not in L1
out/sources/sg/vantagepoint/uncrackable2/MainActivity.java
out/sources/sg/vantagepoint/a/a.java
out/sources/sg/vantagepoint/a/b.java
From 300 down to 4. android.support and android.arch are Google's compatibility packages — very unlikely to be hiding a secret, same reasoning as L1's R.java.
Knowing what noise looks like is the single biggest time-saver in recon.
Diff this against L1's structure and the delta jumps right out: the author reused the sg/vantagepoint/a/ skeleton, but added a CodeCheck. That new face is the target.
Step 3: Read MainActivity, find the door from Java into native
public class MainActivity extends c {
private CodeCheck m;
static { System.loadLibrary("foo"); } // ← ①
private native void init(); // ← ②
protected void onCreate(Bundle bundle) {
init(); // first thing at startup
if (b.a() || b.b() || b.c()) { a("Root detected!"); }
if (a.a(getApplicationContext())) { a("App is debuggable!"); }
new AsyncTask<Void, String, String>() { // ← ③ the new third check
public String doInBackground(Void... v) {
while (!Debug.isDebuggerConnected()) { SystemClock.sleep(100L); }
return null;
}
public void onPostExecute(String s) { a("Debugger detected!"); }
}.execute(null, null, null);
this.m = new CodeCheck();
...
}
public void verify(View view) {
String string = ...getText().toString();
if (this.m.a(string)) { /* Success! */ } // ← ④
}
}
Four key findings:
① System.loadLibrary("foo") sits inside a static {} block — that runs first when the class loads, meaning libfoo.so is in memory the moment the app starts. Confirms those four files under lib/ are genuinely used.
② private native void init() — declared but with no body; the implementation lives in the .so. And onCreate calls it on the very first line, so park it on the list: what is it doing over in native land?
③ The defences went from L1's two checks up to three, and the third is new: it spins up a background thread that polls every 100ms to see whether a debugger has attached.
But the key judgement call is the same as in L1: which path do these three actually block? All of them only fire at runtime. Go purely static (just read files, never execute) and you won't hit a single one. Tag them "only matters for dynamic analysis" and ignore them entirely on the static route.
④ verify() calls this.m.a(), and this.m is a CodeCheck. Next stop on the data flow confirmed.
Step 4: CodeCheck — the end of the line for Java
public class CodeCheck {
private native boolean bar(byte[] bArr);
public boolean a(String str) { return bar(str.getBytes()); }
}
Five lines, and that's as far as the Java layer goes.
a() performs no validation whatsoever — it just converts the String to a byte[] and hands the whole thing to bar(), and bar is native.
The full data flow, drawn out:
user taps button → MainActivity.verify() → CodeCheck.a(string) → CodeCheck.bar(byte[]) ══╗
║ Java boundary
init() ═════════════════════════════════════════════════════════════════════════════╣
▼
libfoo.so
Both native entry points are now on the list.
Step 5: Pinpointing the native coordinates
You can't just grep the .so for the three letters "bar" — JNI has its own naming convention:
Java_<package path joined with underscores>_<class name>_<method name>
So sg.vantagepoint.uncrackable2.CodeCheck.bar should appear in the .so as Java_sg_vantagepoint_uncrackable2_CodeCheck_bar.
But first we need to confirm the binding style. JNI has two: static registration (auto-matched by naming convention, so it shows up in the symbol table) and dynamic registration (RegisterNatives, where the developer can bind a function with any name to bar, leaving nothing in the symbol table). And that init() that runs at startup would be a very convenient place to pull exactly that kind of stunt.
Let's check:
unzip -o UnCrackable-Level2.apk 'lib/*' -d extracted
file extracted/lib/arm64-v8a/libfoo.so
nm -D --defined-only extracted/lib/arm64-v8a/libfoo.so
ELF 64-bit LSB shared object, ARM aarch64, for Android 21, built by NDK r18b, stripped
0000000000000dac T Java_sg_vantagepoint_uncrackable2_CodeCheck_bar
0000000000000d8c T Java_sg_vantagepoint_uncrackable2_MainActivity_init
Static registration, no JNI_OnLoad — the dynamic-registration branch doesn't exist here.
Why this architecture: WSL2 is x86_64, but for pure static assembly reading, arm64-v8a is the better pick — ARM64 instructions are a fixed 4 bytes and the register conventions are clean, which makes it far tidier than x86_64.
Bonus intel from the addresses: init is at 0xd8c, bar at 0xdac — a gap of 0x20 = 32 bytes. ARM64 instructions are a fixed 4 bytes, so 32 bytes = at most 8 instructions. You can't do anything meaningful in eight instructions → init is a stub, probably just flipping a switch. And you can make that call before reading a single byte of it.
About "stripped"
file says stripped — .symtab (the static symbol table) got cut. But nm -D reads .dynsym (the dynamic symbol table), and that one can't be cut.
Why not? Because at runtime the JVM uses dlsym() to look up Java_..._bar by name. Cut the name, the JVM can't find it, and the app crashes outright.
This principle is worth burning into your brain: stripping has a floor, and that floor is exactly the attack surface. Anything the outside world has to be able to call must keep its name.
The practical consequence: objdump -d labels functions using .symtab, so there's no <Java_..._bar>: label in the disassembly output to grep for. The workaround is to go by address:
aarch64-linux-gnu-objdump -d --start-address=0xdac --stop-address=0xe98 \
extracted/lib/arm64-v8a/libfoo.so
The name is the label; the address is the thing itself. Lose the label, the thing is still there.
Step 6: Read the import table — this one step dictates everything that follows
Before reading a single line of assembly, look at this:
nm -D --undefined-only extracted/lib/arm64-v8a/libfoo.so
U __cxa_atexit@LIBC ← C++ runtime, cross it out
U __cxa_finalize@LIBC ← C++ runtime, cross it out
U __stack_chk_fail@LIBC ← compiler-inserted canary, cross it out
U _exit@LIBC
U fork@LIBC
U getppid@LIBC
U pthread_create@LIBC
U pthread_exit@LIBC
U ptrace@LIBC
U strncmp@LIBC
U waitpid@LIBC
This is the list of functions this .so borrows from libc, and it's the single most important section in this entire article.
Why is it a gold mine? Same "can't hide it" principle: a .so can obfuscate its code beyond recognition, but it cannot hide which capabilities it needs to borrow from the system — because the dynamic linker relies on this list to resolve addresses. It has to declare honestly.
Cross out the three the compiler dragged in and eight remain — and those eight sort themselves into two piles:
Pile one (seven): fork ptrace waitpid getppid pthread_create pthread_exit _exit
Individually they're all mundane; together there's only one explanation. The chain of reasoning starts with the most unremarkable one, getppid:
Why would a program need to ask "who is my parent process"? Normal programs almost never do. There's really only one scenario where you would: it has just called fork, it's now running in the child, and it wants to do something to the parent.
Do what to the parent? Next clue: ptrace.
A child ptracing its own parent — that's fork-ptrace anti-debugging.
The mechanism: on Linux a process can only have one tracer at a time. The app forks a child, the child turns around and attaches to daddy, and the one available ptrace slot is now occupied by the family. Want to attach gdb or lldb from outside? The kernel says EPERM — seat's taken.
waitpid fills in the last piece: it's a watchdog. Someone tries to kill the seat-warming child to free the slot? The other side notices immediately, and _exit closes up shop. pthread_create tells us the whole thing runs on a background thread so it doesn't block the main flow.
Pile two (one): strncmp
This single symbol locks down the shape of the whole level, from three angles:
① It's a "comparison", not a "computation". strncmp compares byte by byte — it doesn't transform, derive, or calculate anything.
② There isn't a single crypto function — this is negative evidence, and it matters just as much as what you do see. No AES, no EVP_*, no MD5, no SHA, not one. And the entire .so is only 14KB with bar weighing in at 236 bytes — there's no room to cram in a hand-rolled AES.
This is the watershed between L1 and L2, and you can call it before reading any assembly at all: L1 hadCipherand AES, so the secret was "computed"; L2 has onlystrncmp, so the secret must be **"sitting in the file in plaintext"**.
③ It's strncmp, not strcmp — that n means something. strcmp compares up to \0; strncmp compares a fixed length. The author picked the version that takes a length, which means there's a hard-coded length constant in the program, and hints that it probably checks the length before comparing.
A behavioural draft, written without reading one line of assembly
init() ← 32 bytes, too small, it's a stub
└─ kicks off the fork/ptrace/waitpid anti-debug
└─ probably flips a switch
bar(byte[]) ← 236 bytes, the verification body
└─ pulls the input byte[] out via JNI
└─ checks the length (it uses strncmp, so there's a fixed n)
└─ strncmp(input, plaintext constant, n)
└─ returns the comparison result
The secret: plaintext, right here in this file
And "the plaintext is in the file" points straight to the next move: go look in .rodata.
Step 7: A three-second shortcut, and one incomplete clue
strings -a extracted/lib/arm64-v8a/libfoo.so | grep -i thanks
Thanks for all t
Stop here and think for three seconds. That sentence is incomplete.
Most people see strings spit out half a sentence, decide they've dredged up garbage, and throw it away. But the correct reversing instinct is: a string that reads like real English and then abruptly stops means its tail lives somewhere else.
And we already know from the import table that this has to be a plaintext comparison, so the truncation can't mean "the secret just looks like that" — it can only mean "the rest isn't in .rodata".
Let's verify:
aarch64-linux-gnu-objdump -s -j .rodata extracted/lib/arm64-v8a/libfoo.so
Contents of section .rodata:
0ea0 5468616e 6b732066 6f722061 6c6c2074 Thanks for all t
The entire .rodata section is 16 bytes. Not one byte more, not one less.
Three independent signals all point to the same conclusion:
.rodatais abnormally small. A normal program's.rodatais home to error messages, format strings, constant tables. Shrunk down to half a sentence means the other stuff isn't there. "Nothing where there should be something" is just as much a signal as "something where there shouldn't be anything".- The number 16 should light up a bulb. SIMD register width, AES block size, a common alignment boundary. An English sentence cutting off at exactly character 16 isn't a coincidence — it's a machine boundary.
- English grammar. The
tfollowingfor allis obviously the start ofthe. Don't underestimate this kind of non-technical reasoning — in real reversing it's often the fastest clue you get.
The only place left for the remainder: immediate values inside the code.
Step 8: Taking bar() apart — checking the draft section by section
aarch64-linux-gnu-objdump -d --start-address=0xdac --stop-address=0xe98 \
extracted/lib/arm64-v8a/libfoo.so
① Prologue (0xdac–0xdd4)
dac: sub sp, sp, #0x50 ; carve out 80 bytes of stack
dc0: mrs x22, tpidr_el0 ; read the thread pointer
dc4: ldr x8, [x22, #40] ; fetch the stack guard
dc8: mov x19, x2 ; x2 = jbyteArray (third parameter)
dcc: mov x20, x0 ; x0 = JNIEnv* (first parameter)
dd0: mov w0, wzr ; return value defaults to 0 (false)
dd4: str x8, [sp, #24] ; stash the canary on the stack
JNI ABI knowledge: JNI function parameters always come in the order (JNIEnv* env, jobject thiz, ...actual parameters). So x0=env, x1=this, x2=the first real argument.
mov w0, wzr — deny by default. Assume failure first, and only flip it to 1 after every check passes. Security-wise, that's the right design.
② The flag gate (0xdd8–0xde4) — confirming the draft's "probably flips a switch"
dd8: adrp x8, 13000 ; base of the .data section
ddc: ldrb w8, [x8, #12] ; w8 = *(0x1300c)
de0: cmp w8, #0x1
de4: b.ne e68 ; ≠1 → return false immediately
The moment bar is entered it reads a global byte, and if it isn't 1 it returns false straight away — no comparison at all.
adrp is worth understanding: ARM64 can't load a 64-bit address in a single instruction, so adrp first computes a 4KB-aligned page base and an offset makes up the difference. When you see adrp + ldrb #12, read it as "access global variable 0x1300c".
Design intent: make sure bar only works after the normal startup path (onCreate → init) has run. If someone tries to skip initialisation and call bar directly (say, by forcing the call with Frida), the flag is 0 and bar just refuses to play.
But it makes zero difference to pure static analysis — we're not executing any code at all.
③ Assembling the string (0xde8–0xe14) — the heart of the level
de8: adrp x8, 0
dec: ldr q0, [x8, #3744] ; 3744 = 0xea0, load the 16 bytes from .rodata
df0: mov w8, #0x6568
df4: movk w8, #0x6620, lsl #16 ; w8 = 0x66206568
df8: mov w9, #0x7369
dfc: mov w10, #0x68
e00: stp xzr, xzr, [sp] ; zero it out
e04: str xzr, [sp, #16]
e08: str q0, [sp] ; [sp+0 ..15] = "Thanks for all t"
e0c: str w8, [sp, #16] ; [sp+16..19] = "he f"
e10: strh w9, [sp, #20] ; [sp+20..21] = "is"
e14: strb w10, [sp, #22] ; [sp+22] = "h"
q0 is a 128-bit SIMD register — one instruction moves 16 bytes. That's the answer to "why did it cut off at exactly 16?"
You need to know movk: ARM64's mov can only load a 16-bit immediate at a time, so building a 32-bit value takes two instructions — mov loads the low half, and movk (move keep, i.e. keep the other bits) loads the high half.
Decoding (little-endian, so the low byte comes first in memory):
| Register | Value | Memory bytes | ASCII |
|---|---|---|---|
w8 |
0x66206568 |
68 65 20 66 |
he f |
w9 |
0x7369 |
69 73 |
is |
w10 |
0x68 |
68 |
h |
Notice the size ladder: str(4B) → strh(2B) → strb(1B). 7 bytes split into 4+2+1 — this is the standard way of assembling an arbitrary length out of powers of two.
The result on the stack:
[sp+0 .. +15] "Thanks for all t" ← .rodata
[sp+16.. +19] "he f" ← immediates
[sp+20.. +21] "is" ← immediates
[sp+22] "h" ← immediates
──────────────────────────────────────
23 bytes = "Thanks for all the fish"
Let's double-check the arithmetic:
import struct
b = bytearray()
b += bytes.fromhex('5468616e6b7320666f7220616c6c2074')
b += struct.pack('<I', 0x66206568)
b += struct.pack('<H', 0x7369)
b += bytes([0x68])
print(len(b), b.decode()) # 23 Thanks for all the fish
This is why strings only gave us half a sentence: those 7 bytes never existed as "data" in the first place — they are part of the instructions. strings scans for sequences of printable characters, and 0x528cad08 (the machine code for mov w8, #0x6568) just looks like four random bytes.
④ JNI calls (0xe18–0xe44)
e18: ldr x8, [x20] ; x8 = *env (pointer to the function table)
e28: ldr x8, [x8, #1472] ; 0x5c0 → GetByteArrayElements
e2c: blr x8
e34: mov x21, x0 ; x21 = pointer to the input string
e40: ldr x8, [x8, #1368] ; 0x558 → GetArrayLength
e44: blr x8
You absolutely must learn to recognise this pattern. JNIEnv* is a "pointer to a function table", and every JNI API is reached by pulling a function pointer out of that table at a fixed offset and calling it indirectly. So in native reversing you'll see this trio over and over: ldr x8,[x20] → ldr x8,[x8,#offset] → blr x8.
To find out which API an offset maps to, look up the JNINativeInterface struct definition. See [x8, #0x5c0], go check the table — that's just daily routine.
⑤ Length check (0xe48) — the moment the deduction is confirmed
e48: cmp w0, #0x17 ; length must == 23
e4c: b.ne e64
This line is exactly what I predicted from "why strncmp and not strcmp".
Building a hypothesis purely from one symbol name choice, and then seeing it with your own eyes in the assembly — 0x17 = 23, precisely the length of the string we just assembled. That "deduce first, confirm later" loop is the most valuable training reversing has to offer.
⑥ The decider (0xe50–0xe60)
e50: mov x1, sp ; arg1 = the expected value assembled on the stack
e54: mov w2, #0x17 ; arg2 = 23
e58: mov x0, x21 ; arg0 = your input
e5c: bl strncmp
e60: cbz w0, e8c ; == 0 → jump to return true
...
e8c: orr w0, wzr, #0x1 ; w0 = 1
orr w0, wzr, #0x1 is the compiler's idiomatic way of writing mov w0, #1 (wzr is always zero, and OR 1 gives 1). Whenever you see orr Wx, wzr, #imm, just read it as mov.
Every prediction landed: pure comparison, zero computation.
⑦ Epilogue
e64: mov w0, wzr ; failure path
e68: ldr x8, [x22, #40] ; re-read the guard
e6c: ldr x9, [sp, #24] ; read the canary stored at the start
e70: cmp x8, x9
e74: b.ne e94 ; mismatch → __stack_chk_fail
e88: ret
Note the confluence at 0xe68: flag-gate failure, length mismatch, and comparison failure all funnel here and head back with w0=0. Single exit + deny by default — very clean control flow.
Full control flow
enter bar(env, this, byte[])
├─ *(0x1300c) ≠ 1 ──────────────► return false
▼
assemble the 23-byte expected value on the stack
.rodata 16B + immediates 4B + 2B + 1B
▼
GetByteArrayElements → x21
GetArrayLength → w0
├─ length ≠ 0x17 ──────────────► return false
▼
strncmp(input, expected, 23)
├─ ≠ 0 ────────────────────────► return false
▼
return true ✅ "Thanks for all the fish"
Step 9: That init() we parked earlier
aarch64-linux-gnu-objdump -d --start-address=0xd8c --stop-address=0xdac \
extracted/lib/arm64-v8a/libfoo.so
d94: bl 0x918 ; anti-debug routine (fork/ptrace)
d9c: mov w9, #1
da0: strb w9, [x8, #12] ; *(0x1300c) = 1 ← opens the door for bar
The "32 bytes means it's a stub" call was spot on. It does two things: start the anti-debugging, and set the flag.
Practical implication: if you take the dynamic route to bypass this, don't forget init has to run first (a normal onCreate will call it), otherwise the flag gate is 0 and bar returns false forever. Taking the static route to lift the constant is completely unaffected.
Key Takeaways
- Stripping has a floor, and that floor is the attack surface:
.symtabcan go,.dynsymcan't — the JVM needsdlsym()to find JNI functions by name. Anything the outside world must be able to call has to keep its name. And when the name is gone you still have the address:nm -Dfor coordinates,objdump --start-addressto read the contents. - The import table is a binary's "capability declaration": a program can hide what it does, but it can't hide which tools it needs — the dynamic linker has to know.
strncmpwith no crypto = plaintext comparison;fork+ptrace+getppid= fork-and-squat anti-debugging. stringsmay hand you an incomplete answer, and the incompleteness is itself the clue: the compiler encodes the tail of short strings asmovimmediates, wherestringswill never find it. When a sentence cuts off for no reason and the length happens to be a power of two, go into the assembly and pick the tail back up.
On using AI for reversing: it finished the job, but I learned nothing
Partway through this level I hooked GhidraMCP up to Claude Code — letting the LLM drive Ghidra directly to decompile and annotate. The setup itself came with two gotchas (the extension version has to match Ghidra's major version or it refuses to load; the plugin's HTTP server only binds its port when CodeBrowser has a program open), but once it was working it ran the whole thing end to end — the answer, the flag gate, the strncmp, all of it — and wrote up a report for good measure.
And then I realised I didn't actually understand any of it.
Not the conclusions — those were written up perfectly clearly. What I didn't understand was why you'd start looking there. Why the import table and not something else? Why, when strings dredges up half a sentence, do you go into the assembly for the tail instead of writing it off as noise? Why does nm -D show something that objdump -d can't be grepped for?
So I went back and walked the entire path manually myself — which is exactly what Steps 1 through 9 above are. Every command run by hand, every deduction made by me, every line of assembly checked personally.
The contrast is fascinating: what the AI gave me was "the destination on the map"; what redoing it by hand gave me was "how to reach the destination when there is no map". The first is one-time; the second is repeatable.
My conclusion isn't "don't use AI for reversing" — for harder targets (native code with real computation, large binaries that need endless renaming and annotating) GhidraMCP is absolutely a productivity tool. But if you're still at the stage of building your methodology, letting it finish and then going back to walk the path manually is worth it. What you want isn't the answer; it's the ability to reach the answer yourself next time, when there's no AI around.
This post is part of my OWASP MAS Crackmes practice series. All target programs are officially published, educational-purpose apps from OWASP. Please do not apply these techniques to any target you are not authorised to test.

Member discussion