> ## Content Index
> Fetch the complete content index at: https://taiwanding.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# OWASP MAS Crackmes — Android UnCrackable Level 2
- URL: https://taiwanding.com/en/android-uncrackable-level-2-writeup/
- Published: 2026-08-07T07:54:56.000Z
- Updated: 2026-08-10T11:10:11.000Z
- Author: Kevin Chen
- Tags: #en, #en-ctf

📚 Series · OWASP MAS Crackmes · Android UnCrackable

1. [Level 1 — Decompile the APK, recover the AES key (Easy)](https://taiwanding.com/en/owasp-mas-crackmes-android-uncrackable-l1-walkthrough/)
2. ▸ 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

[MAS Crackmes - OWASP Mobile Application Security![](https://taiwanding.com/content/images/icon/logo_circle-1.png)logo![](https://taiwanding.com/content/images/thumbnail/uncrackable-logo-1.png)](https://mas.owasp.org/crackmes/?ref=taiwanding.com)

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

```bash
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 are `android.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) and `META-INF/MANIFEST.MF` (the signature digest list — noise, skip it) are two completely different things.

## Step 2: Narrowing 300+ classes down to 4

```bash
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**:

```bash
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

```java
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

```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:

```bash
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**:

```bash
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:

```bash
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 had `Cipher` and AES, so the secret was "computed"; L2 has only `strncmp`, 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

```bash
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:

```bash
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:

- **`.rodata` is abnormally small.** A normal program's `.rodata` is 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 `t` following `for all` is obviously the start of `the`. 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

```bash
aarch64-linux-gnu-objdump -d --start-address=0xdac --stop-address=0xe98 \
  extracted/lib/arm64-v8a/libfoo.so

```

### ① Prologue (0xdac–0xdd4)

```asm
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"

```asm
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

```asm
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:

```python
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)

```asm
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

```asm
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)

```asm
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

```asm
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

```bash
aarch64-linux-gnu-objdump -d --start-address=0xd8c --stop-address=0xdac \
  extracted/lib/arm64-v8a/libfoo.so

```

```asm
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

1. **Stripping has a floor, and that floor is the attack surface**: `.symtab` can go, `.dynsym` can't — the JVM needs `dlsym()` 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 -D` for coordinates, `objdump --start-address` to read the contents.
2. **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. `strncmp` with no crypto = plaintext comparison; `fork`+`ptrace`+`getppid` \= fork-and-squat anti-debugging.
3. **`strings` may hand you an incomplete answer, and the incompleteness is itself the clue**: the compiler encodes the tail of short strings as `mov` immediates, where `strings` will 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](https://github.com/LaurieWired/GhidraMCP?ref=taiwanding.com) 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.*