Post

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.

Android UnCrackable Level 2: Into the Native Library

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’s x0 = JNIEnv*, x1 = this, x2 = first real argument.
  • The frame record: stp x29, x30 saves 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 .rodata load and a few immediate movs, so strings won’t find it. Decoding the bytes by hand gives Thanks for all the fish — 23 characters, which is exactly the length the cmp w0, #0x17 demands.

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. strncmp and fork are called all over a process. Comparing this.returnAddress against libfoo.so’s base and size limits the hook to the library’s own calls.
  • Timing. libfoo.so isn’t loaded yet when the script starts (it loads in a static initializer), but libc.so always 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:

Level 2 running under Frida

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:

Short guess, no 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"

23-char guess, hook fires

The dynamic hook and the static decoding agree — the cross-check I wanted. Enter it:

Success

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

Forced success with junk input

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 a Java_<pkg>_<class>_<method> export that nm lists outright.
  • AArch64 reading practice: the AAPCS64 argument registers, the stp x29, x30 frame record, the canary from TPIDR_EL0+0x28, mov/movk immediates 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.returnAddress plus module ranges lets you hook globally-used functions surgically, even before the target library is loaded.
  • Read the length check first. cmp w0, #0x17 is 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.

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