Android UnCrackable Level 2: Into the Native Library
UnCrackable Level 2 moves the secret check into a native library and crashes on launch from a broken anti-debug trick. Reading the AArch64 disassembly recovers the secret statically; Frida hooks on libc's fork and strncmp recover it dynamically and bypass the check.
Level 1 was pure Java — jadx handed me the logic and one Frida hook read the secret. Level 2 of the OWASP MASTG UnCrackable series pushes the interesting part down into a native library (libfoo.so) called over JNI, and wraps it in a native anti-debugging routine. The Java side goes quiet; the work moves to the .so.
Same setup as before: local emulator (Android 14, x86_64), Frida 17.25, jadx 1.5.6, and the Android NDK’s LLVM tools for the disassembly. The emulator runs the x86_64 build of the library, but the version real phones ship is arm64-v8a, so that’s the one I read.
Training app from the OWASP MASTG crackmes. Everything runs offline in an emulator.
It won’t even start
Install, launch, and the app flashes and dies straight back to the home screen. adb logcat says why — and it isn’t a root check:
1
2
3
E AndroidRuntime: FATAL EXCEPTION: main <- pid 7427, NOT the app's pid (7415)
E AndroidRuntime: java.lang.RuntimeException: ...: Bad file descriptor
I ActivityManager: Process owasp.mstg.uncrackable2 (pid 7415) has died
A second process, running the app’s code, crashed with “Bad file descriptor”, and then the real app exited. Hold that thought.
The Java side goes quiet
1
jadx -d decompiled UnCrackable-Level2.apk
MainActivity now loads a native library and calls into it:
1
2
3
4
5
6
7
8
static { System.loadLibrary("foo"); }
private native void init(); // runs FIRST in onCreate
protected void onCreate(Bundle b) {
init();
if (b.a() || b.b() || b.c()) { a("Root detected!"); } // same 3 checks as Level 1
...
}
And CodeCheck:
1
2
private native boolean bar(byte[] bArr);
public boolean a(String s) { return bar(s.getBytes()); }
Both the thing I want (bar) and the thing killing the app (init) are native. Time to leave Java entirely.
Reading AArch64
The APK ships libfoo.so for four ABIs. The JNI functions are exported by name — Java_<package>_<class>_<method> — so they map one-to-one onto the Java native methods and nm lists them directly:
1
2
3
4
5
6
T=$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin
$T/llvm-nm -D --defined-only lib/arm64-v8a/libfoo.so | grep Java_
# ...CodeCheck_bar
# ...MainActivity_init
$T/llvm-nm -D --undefined-only lib/arm64-v8a/libfoo.so
# fork getppid ptrace waitpid pthread_create _exit strncmp ... <- anti-debug + the compare
bar() — the secret check
Disassembling the function and annotating it:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
dac: sub sp, sp, #0x50
db8: stp x29, x30, [sp, #0x40] ; save frame record (frame ptr + link register)
dc0: mrs x22, TPIDR_EL0 ; thread pointer...
dc4: ldr x8, [x22, #0x28] ; ...+0x28 = Bionic's stack-guard slot = the stack canary
dc8: mov x19, x2 ; x2 = 3rd arg = your byte[] (x0=JNIEnv*, x1=this)
...
dec: ldr q0, [x8, #0xea0] ; 16 bytes of .rodata = "Thanks for all t"
df0: mov w8, #0x6568 ;
df4: movk w8, #0x6620, lsl #16 ; w8 = "he f"
df8: mov w9, #0x7369 ; w9 = "is"
dfc: mov w10, #0x68 ; w10 = "h"
; ...assembled on the stack into "Thanks for all the fish"
e48: cmp w0, #0x17 ; input length must be exactly 23...
e4c: b.ne fail ; ...or it never even compares
e5c: bl strncmp@plt ; strncmp(input, secret, 23)
e60: cbz w0, success
A few things worth pointing out, because this is the ARM64 reading practice the whole exercise is really for:
- The calling convention (AAPCS64): arguments land in
x0–x7. For a JNI function that’sx0 = JNIEnv*,x1 = this,x2 =first real argument. - The frame record:
stp x29, x30saves the frame pointer and the link register (the return address) together — the ARM equivalent of the saved return address on an x86 stack. - The stack canary is loaded from the thread pointer (
TPIDR_EL0 + 0x28), not from a global. - The secret is never one string in the binary. It’s split across a
.rodataload and a few immediatemovs, sostringswon’t find it. Decoding the bytes by hand givesThanks for all the fish— 23 characters, which is exactly the length thecmp w0, #0x17demands.
init() and the anti-debug trick
init() sets a flag that bar() requires, and then calls the routine that’s crashing the app. In pseudo-assembly:
1
2
3
4
5
6
7
8
fork
cbz w0, child ; child branch
; parent: spawn a watcher thread and return
child:
getppid
ptrace(PTRACE_ATTACH, parent) ; occupy the parent's one tracer slot
cbnz x0, return ; *** if attach FAILS, just return ***
... ; else sit in a PTRACE_CONT/waitpid loop
The idea is a classic: a process can have only one tracer, so if the app’s own child attaches to the parent first, a debugger can’t. But on Android 14 the child’s PTRACE_ATTACH fails (an untrusted app isn’t allowed to ptrace like that), so the child falls through, keeps running the app’s Java code in a forked copy whose Binder file descriptor is now invalid, and crashes — “Bad file descriptor”. The parent’s watcher sees the child die and calls _exit(0). That’s the entire launch crash, read straight off the disassembly.
The hook plan
| Problem | Hook |
|---|---|
Anti-debug fork/ptrace kills the app |
Replace libc fork(), only for calls from libfoo, to return 0. The app takes the harmless child branch in its own process; the attach fails, the routine returns, and init() still sets its flag. No child, no watcher. |
| Root checks | Same three as Level 1 |
| Read the secret | Interceptor.attach on libc strncmp, filtered to calls from libfoo; read argument 1 |
| Bypass without the secret | Interceptor.attach on ..._CodeCheck_bar, then retval.replace(1) |
Two native-hooking details matter here:
- Filter by caller.
strncmpandforkare called all over a process. Comparingthis.returnAddressagainstlibfoo.so’s base and size limits the hook to the library’s own calls. - Timing.
libfoo.soisn’t loaded yet when the script starts (it loads in a static initializer), butlibc.soalways is. So hook the libc functions immediately and look up libfoo lazily.
Running it
1
frida -U -f owasp.mstg.uncrackable2 -l hook.js
1
2
3
4
5
6
[*] fork hook installed
[*] native strncmp hook installed
Spawned `owasp.mstg.uncrackable2`. Resuming main thread!
[fork] called from libfoo+0x8ea -> returning 0 (no child) <- anti-debug neutralised
[root] b.a() real=false -> returning false
...
The app survives launch:
Reading the secret
A short wrong guess (wrongguess, 10 chars) produces “Nope” and no strncmp line — exactly what cmp w0, #0x17 predicts, the wrong length never reaches the compare:
Pad to 23 characters and the hook fires:
1
2
3
[strncmp] called from libfoo+0x1196, n=23
[strncmp] your input = "AAAAAAAAAAAAAAAAAAAAAAA"
[strncmp] secret = "Thanks for all the fish"
The dynamic hook and the static decoding agree — the cross-check I wanted. Enter it:
Bonus: bypass without knowing the secret
Flip one flag in the script to hook bar() itself and force its return value:
1
[bar] real return = 0 -> forcing 1
These are two genuinely different results. Recovering the secret reads data out of the app. Forcing the return value changes its control flow. Any app that gates a feature on a native boolean is exposed to the second one, and that’s the more interesting finding from a defender’s point of view.
Takeaways
- JNI is the seam. The Java side only declares
native boolean bar(byte[]). The real logic sits behind aJava_<pkg>_<class>_<method>export thatnmlists outright. - AArch64 reading practice: the AAPCS64 argument registers, the
stp x29, x30frame record, the canary fromTPIDR_EL0+0x28,mov/movkimmediates assembling constants, and JNIEnv vtable calls (ldr x8,[x8,#off]; blr x8). This is the foundation for memory-corruption work on ARM, which is where I’m headed next. - Anti-debug that kills its own app. The fork-and-ptrace-self trick is broken on modern Android. The fix wasn’t to “bypass a debugger check” — it was to understand the control flow well enough to steer
fork()down the branch that does no harm. - Hook libc, filter by caller.
this.returnAddressplus module ranges lets you hook globally-used functions surgically, even before the target library is loaded. - Read the length check first.
cmp w0, #0x17is why a short wrong guess never hit the hook — knowing that saved a confused minute.
Next in the series is Level 3, which adds integrity/CRC checks over the APK and actively hunts for instrumentation — the first level where the hooks themselves have to stay out of sight.




