> ## 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 L2
- URL: https://taiwanding.com/owasp-mas-crackmes-android-uncrackable-l2/
- Published: 2026-08-07T07:54:56.000Z
- Updated: 2026-08-10T11:09:59.000Z
- Author: Kevin Chen
- Tags: owasp, android, reverse

📚 系列文章 · OWASP MAS Crackmes · Android UnCrackable

1. [Level 1 — 反編譯 APK 取出 secret string (Easy)](https://taiwanding.com/owasp-mas-crackmes-android-uncrackable-l1/)
2. ▸ Level 2 — native 函式庫與 ptrace 反除錯 (Medium) （本篇）

## 題目資訊

- **平台**: OWASP MASTG（Mobile Application Security Testing Guide）
- **關卡名稱**: Android UnCrackable Level 2（MASTG-APP-0004）
- **難度**: Medium
- **目標**: app 裡藏了一段 secret string，想辦法把它挖出來
- **連結**: https://mas.owasp.org/MASTG/apps/android/MASTG-APP-0004/
- **環境**: 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)

L1 的秘密是 AES 加密後藏起來，要自己重跑解密才拿得到，L2 的秘密**根本沒加密**——它只是搬到 native 層，然後被**編譯器切成兩半**藏在不同地方。

從密碼學角度 L2 比 L1 弱，但從逆向工序角度 L2 更費工，**「難度」跟「安全性」是兩回事**，這個對比是這關最值得帶走的觀念之一。

這篇的重點不是答案，是「面對一顆 stripped 的 .so，怎麼知道要拆哪一塊」——因為這才是真實逆向裡最難的部分。

## Step 1: 拆結構，看差異

```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 個檔案、1.4MB。跟 L1 的 13 個檔案、66KB 天差地別，但**不要慌，先跟已知樣本比對**——手上有 L1 當基準線，差異處就是重點。

用 L1 建立的同一張五問清單：

- **② `classes.dex`** → 從 L1 的 5.5KB 暴增到 **720KB**，但別被嚇到，**要看「變大的是誰」**——後面會證明這 720KB 幾乎全是 `android.support` 撐起來的，作者的 Java code 跟 L1 差不多少。
- **③ `META-INF/MANIFEST.MF`** → 從 1KB 爆到 **50KB**，這是 v1 簽章對每個檔案算 digest 的清單，塞了整套 support library 才這麼大，**對逆向零價值，認得它、視而不見。**

**① 有沒有 `lib/`？** → **有，這次是重頭戲。**

```
lib/arm64-v8a/libfoo.so     14176
lib/armeabi-v7a/libfoo.so   13948
lib/x86/libfoo.so           13788
lib/x86_64/libfoo.so        14440

```

四個架構各一份（同一份 code 編給不同 CPU），檔名 `libfoo`——`foo` 是工程師的萬用佔位名，代表**作者自己寫的**，不是知名 library，自己寫的 native code + crackme = 關鍵邏輯八成在裡面。四個檔都才 14KB 上下，**讀得完**。

> 順帶釐清一個容易搞混的地方：`AndroidManifest.xml`（app 的門牌，主線，要讀）和 `META-INF/MANIFEST.MF`（簽章清單，雜訊，跳過）是完全不同的兩個東西。

## Step 2: 從 300 多個 class 收斂到 4 個

```bash
jadx -d "$PWD/out" "$PWD/UnCrackable-Level2.apk"
find out/sources -name '*.java' | wc -l    # 300+

```

300 多個 Java 檔，新手看到會慌，老手三秒篩完，方法是**反向過濾——不是找主線，是先把已知的雜訊全部劃掉**：

```bash
find out/sources -name '*.java' | grep -v -E 'android/(support|arch|annotation)/'

```

```
out/sources/owasp/mstg/uncrackable2/R.java              ← 自動產生，跳過
out/sources/sg/vantagepoint/uncrackable2/CodeCheck.java ← 新面孔！L1 沒有
out/sources/sg/vantagepoint/uncrackable2/MainActivity.java
out/sources/sg/vantagepoint/a/a.java
out/sources/sg/vantagepoint/a/b.java

```

**從 300 砍到 4**， `android.support`、`android.arch` 是 Google 的相容套件，不太可能藏秘密——跟 L1 的 `R.java` 同理。

**認得雜訊的長相，是 recon 最省時間的技能。**

比對 L1 的結構，差異立刻浮出來：作者沿用了 `sg/vantagepoint/a/` 的骨架，但**多了一個 `CodeCheck`**，這個新面孔就是重點。

## Step 3: 讀 MainActivity，找 Java 通往 native 的門

```java
public class MainActivity extends c {
    private CodeCheck m;
    static { System.loadLibrary("foo"); }        // ← ①
    private native void init();                  // ← ②

    protected void onCreate(Bundle bundle) {
        init();                                   // 開機第一件事
        if (b.a() || b.b() || b.c()) { a("Root detected!"); }
        if (a.a(getApplicationContext())) { a("App is debuggable!"); }
        new AsyncTask<Void, String, String>() {   // ← ③ 新增的第三道
            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! */ }  // ← ④
    }
}

```

四個關鍵發現：

**① `System.loadLibrary("foo")` 在 `static {}` 區塊裡**——class 載入時最先執行，代表 `libfoo.so` 在 app 一啟動就進記憶體了，證實了 `lib/` 那四個檔真的被使用。

**② `private native void init()`**——有宣告、沒身體，實作在 `.so` 裡，而且 `onCreate` 第一行就呼叫它，**先掛號：它在 native 幹嘛？**

**③ 防禦從 L1 的兩道升級成三道**，第三道是新的：開背景執行緒，**每 100ms 輪詢一次有沒有 debugger 接上來**。

但關鍵判斷跟 L1 一樣：**這三道擋的是哪條路？全部都是 runtime 才觸發的**，走純靜態（只讀檔、不執行），一道都不會遇到，標記為「動態分析才需要處理」，靜態路線直接無視。

**④ `verify()` 呼叫 `this.m.a()`**，而 `this.m` 是 `CodeCheck`。資料流下一站確定。

## Step 4: CodeCheck——Java 層的終點

```java
public class CodeCheck {
    private native boolean bar(byte[] bArr);
    public boolean a(String str) { return bar(str.getBytes()); }
}

```

五行，Java 層的路到此為止。

`a()` 沒有做任何驗證，只是把 String 轉成 `byte[]` **整包丟給 `bar()`**，而 `bar` 是 native。

**完整資料流畫完：**

```
使用者按鈕 → MainActivity.verify() → CodeCheck.a(string) → CodeCheck.bar(byte[]) ══╗
                                                                                    ║ Java 邊界
init() ═════════════════════════════════════════════════════════════════════════════╣
                                                                                    ▼
                                                                            libfoo.so

```

**兩個 native 入口都掛號了。**

## Step 5: 定位 native 座標

不能拿著「`bar`」三個字去 `.so` 裡搜，**JNI 有自己的命名規則**：

```
Java_<package路徑用底線連接>_<類別名>_<方法名>

```

所以 `sg.vantagepoint.uncrackable2.CodeCheck.bar` 在 `.so` 裡應該是 `Java_sg_vantagepoint_uncrackable2_CodeCheck_bar`。

**但先要確認綁定方式**，JNI 有兩種：**靜態註冊**（靠命名慣例自動配對，符號表找得到）和**動態註冊**（`RegisterNatives`，開發者可以把任意名字的函式綁到 `bar` 上，符號表就找不到了），那個開機就跑的 `init()` 很適合用來動這種手腳。

驗證：

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

```

**靜態註冊，沒有 `JNI_OnLoad`，動態註冊那條岔路不存在。**

**選架構的理由**：WSL2 是 x86\_64，但純靜態讀組語選 **arm64-v8a** 更好——ARM64 指令固定 4 bytes、暫存器規則清楚，比 x86\_64 乾淨得多。

**位址透露的額外情報**：`init` 在 `0xd8c`、`bar` 在 `0xdac`，差 `0x20` \= 32 bytes。ARM64 指令固定 4 bytes，**32 bytes = 最多 8 條指令**，八條指令做不了什麼有意義的事 → `init` 是個 stub，可能只是設個開關，**在讀它之前就能做這個判斷。**

### 關於 stripped

`file` 說 `stripped`——`.symtab`（靜態符號表）被砍了，但 `nm -D` 讀的是 `.dynsym`（動態符號表），**那個不能砍**。

為什麼不能砍？因為 JVM 執行時要用 `dlsym()` 按名字找 `Java_..._bar`，**名字被砍，JVM 找不到，app 直接崩。**

> **這條原則值得刻進腦子裡：strip 有一個下限，而那個下限剛好就是攻擊面。凡是外界必須呼叫得到的東西，就必須留著名字。**

實務影響：`objdump -d` 標函式名靠 `.symtab`，所以反組譯輸出裡**沒有 `<Java_..._bar>:` 標籤可以 grep**。解法是**用位址找**：

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

```

**名字是標籤，位址是東西本身。標籤沒了，東西還在。**

## Step 6: 讀 import table——這一步決定了後面所有動作

在讀任何一行組語之前，先看這個：

```bash
nm -D --undefined-only extracted/lib/arm64-v8a/libfoo.so

```

```
U __cxa_atexit@LIBC        ← C++ runtime，劃掉
U __cxa_finalize@LIBC      ← C++ runtime，劃掉
U __stack_chk_fail@LIBC    ← 編譯器插的 canary，劃掉
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

```

**這是這個 `.so` 向 libc 借用的函式清單，也是整篇文章最重要的一節。**

**為什麼它是金礦？** 同樣的「藏不住」原理：一個 `.so` 可以把 code 混淆到你看不懂，**但它不能隱藏「需要向系統借哪些功能」**——因為動態連結器要靠這份清單去解析位址，**它必須誠實申報。**

劃掉三個編譯器帶進來的，剩下八個，**而這八個自己會分成兩堆：**

### 第一群（七個）：`fork` `ptrace` `waitpid` `getppid` `pthread_create` `pthread_exit` `_exit`

單看每個都很普通，湊在一起只有一種解釋，推理鏈從最不起眼的 `getppid` 開始：

**一個程式為什麼要問「我的父行程是誰」？** 正常程式幾乎不需要，會需要的情境只有一種：**它剛 `fork` 過，現在跑在子行程裡，要對父行程做某件事。**

對父行程做什麼？下一個線索：`ptrace`。

**子行程 ptrace 自己的父行程——這就是 fork-ptrace 反調試。**

原理：**Linux 上一個行程同時只能有一個 tracer**， app 自己 fork 出小孩、小孩回頭 attach 到爸爸身上，**唯一的 ptrace 名額就被自家人佔走**，外面的 gdb、lldb 想 attach？系統回 `EPERM`，位子有人坐了。

`waitpid` 補完最後一塊：watchdog，有人想把佔位的子行程殺掉來釋放名額？另一邊立刻察覺，`_exit` 收工。`pthread_create` 說明這套跑在背景執行緒，不阻塞主流程。

### 第二群（一個）：`strncmp`

**這一個符號把整關的形態定死了**，三個角度：

**① 它是「比較」不是「運算」**， `strncmp` 逐 byte 比對，不轉換、不衍生、不計算。

**② 沒有任何加密函式——這是反向證據，跟看到什麼一樣重要**，沒有 `AES`、沒有 `EVP_*`、沒有 `MD5`、沒有 `SHA`，一個都沒有，而且整個 `.so` 才 14KB、`bar` 才 236 bytes，**塞不下一套自己實作的 AES**。

> **這就是 L1 和 L2 的分水嶺，而且在讀組語之前就能斷定**：L1 有 `Cipher`、有 AES，秘密是「算出來的」；L2 只有 `strncmp`，秘密必然是\*\*「明文躺在檔案裡」\*\*。

**③ 是 `strncmp` 不是 `strcmp`——那個 `n` 有意義**， `strcmp` 比到 `\0`，`strncmp` 比固定長度。作者選了帶長度的版本，代表**程式裡有一個寫死的長度常數**，暗示比對前很可能先檢查長度。

### 完全沒讀組語，就能寫出行為草稿

```
init()          ← 32 bytes，太小，是 stub
  └─ 啟動 fork/ptrace/waitpid 反調試
  └─ 可能設個開關

bar(byte[])     ← 236 bytes，驗證主體
  └─ 從 JNI 取出輸入的 byte[]
  └─ 檢查長度（因為用 strncmp，有固定 n）
  └─ strncmp(輸入, 明文常數, n)
  └─ 回傳比對結果

秘密：明文，就在這個檔案裡

```

**而「明文就在檔案裡」直接推出下一個動作：去 `.rodata` 找。**

## Step 7: 三秒短路，與一個殘缺的線索

```bash
strings -a extracted/lib/arm64-v8a/libfoo.so | grep -i thanks

```

```
Thanks for all t

```

**停在這裡想三秒**，**這句話是不完整的。**

一般人看到 `strings` 吐出半句話會覺得「撈到垃圾」然後丟掉，但逆向的正確反應是：**一句讀起來像人話卻突然斷掉的字串，代表它的尾巴在別的地方。**

而且我們已經從 import table 知道**這一定是明文比對**，所以這個殘缺不可能是「秘密就長這樣」，只能是「其餘部分不在 `.rodata`」。

驗證：

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

```

**整個 `.rodata` 段就 16 bytes，一個 byte 不多不少。**

三個獨立的訊號指向同一個結論：

- **`.rodata` 異常地小**，正常程式的 `.rodata` 住著錯誤訊息、格式字串、常數表，小到只剩半句話代表**其他東西不在那裡**，「該有東西的地方沒東西」跟「不該有東西的地方有東西」，同樣是訊號。
- **16 這個數字會亮燈**，SIMD 暫存器寬度、AES block size、常見對齊邊界。一句英文剛好斷在第 16 個字元，不是巧合，是**機器邊界**。
- **英文文法**，`for all` 後面接的 `t` 顯然是 `the` 的開頭，別小看這種非技術判斷，真實逆向裡它常常是最快的線索。

**剩下的唯一去處：code 裡的立即數。**

## Step 8: 拆 bar()——逐段核對草稿

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

```

### ① 序幕（0xdac–0xdd4）

```asm
dac:  sub  sp, sp, #0x50          ; 開 80 bytes 堆疊
dc0:  mrs  x22, tpidr_el0         ; 讀 thread pointer
dc4:  ldr  x8, [x22, #40]         ; 取 stack guard
dc8:  mov  x19, x2                ; x2 = jbyteArray（第三參數）
dcc:  mov  x20, x0                ; x0 = JNIEnv*（第一參數）
dd0:  mov  w0, wzr                ; 回傳值預設 = 0 (false)
dd4:  str  x8, [sp, #24]          ; canary 放進堆疊

```

**JNI 的 ABI 知識**：JNI 函式參數順序固定是 `(JNIEnv* env, jobject thiz, ...實際參數)`。所以 `x0`\=env、`x1`\=this、`x2`\=第一個真參數。

**`mov w0, wzr`——預設拒絕**，先假設失敗，走完所有檢查才改成 1，安全上這是對的設計。

### ② 旗閘（0xdd8–0xde4）——證實草稿的「可能設個開關」

```asm
dd8:  adrp  x8, 13000            ; .data 段基底
ddc:  ldrb  w8, [x8, #12]        ; w8 = *(0x1300c)
de0:  cmp   w8, #0x1
de4:  b.ne  e68                  ; ≠1 → 直接 return false

```

**`bar` 一進來就讀一個全域 byte，不是 1 就直接回 false，連比對都不做。**

`adrp` 值得懂：ARM64 沒辦法一條指令載入 64 位元位址，`adrp` 先算 4KB 對齊的頁基底，再用偏移補齊，看到 `adrp` \+ `ldrb #12` 就讀作「存取全域變數 `0x1300c`」。

**設計意圖**：確保 `bar` 只在正常啟動流程（`onCreate` → `init`）跑過之後才工作，有人想跳過初始化直接呼叫 `bar`（例如用 Frida 硬叫），旗標是 0，`bar` 直接擺爛。

**但對純靜態毫無影響**——我們根本沒執行任何程式碼。

### ③ 拼字串（0xde8–0xe14）——整關的心臟

```asm
de8:  adrp  x8, 0
dec:  ldr   q0, [x8, #3744]       ; 3744 = 0xea0，載入 .rodata 的 16 bytes
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]        ; 清零
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` 是 128-bit SIMD 暫存器，一條指令搬 16 bytes**——這就是「為什麼剛好斷在 16」的答案。

**`movk` 要懂**：ARM64 的 `mov` 一次只能塞 16 bits 立即數，組出 32 bits 要兩條——`mov` 塞低位、`movk`（move keep，保留其他位元）塞高位。

**解碼（小端序，低位元組在記憶體的前面）：**

| 暫存器 | 值          | 記憶體 bytes   | ASCII |
| --- | ---------- | ----------- | ----- |
| w8  | 0x66206568 | 68 65 20 66 | he f  |
| w9  | 0x7369     | 69 73       | is    |
| w10 | 0x68       | 68          | h     |

**注意那個尺寸階梯：`str`(4B) → `strh`(2B) → `strb`(1B)。** 7 bytes 拆成 4+2+1，這是把任意長度用 2 的次方拼起來的標準做法。

堆疊上的結果：

```
[sp+0 .. +15]  "Thanks for all t"   ← .rodata
[sp+16.. +19]  "he f"               ← 立即數
[sp+20.. +21]  "is"                 ← 立即數
[sp+22]        "h"                  ← 立即數
──────────────────────────────────────
              23 bytes = "Thanks for all the fish"

```

驗算：

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

```

**這就是為什麼 `strings` 只給半句話**，那 7 bytes 從來沒以「資料」的形態存在過——**它們是指令的一部分**，`strings` 掃可列印字元序列，`0x528cad08`（`mov w8, #0x6568` 的機器碼）看起來就是四個亂數 byte。

### ④ JNI 呼叫（0xe18–0xe44）

```asm
e18:  ldr   x8, [x20]            ; x8 = *env（函式表指標）
e28:  ldr   x8, [x8, #1472]      ; 0x5c0 → GetByteArrayElements
e2c:  blr   x8
e34:  mov   x21, x0              ; x21 = 輸入字串指標
e40:  ldr   x8, [x8, #1368]      ; 0x558 → GetArrayLength
e44:  blr   x8

```

**這個 pattern 一定要認得。** `JNIEnv*` 是「指向函式表的指標」，所有 JNI API 都是**用固定偏移從表裡取出函式指標再間接呼叫**。所以 native 逆向裡會反覆看到 `ldr x8,[x20]` → `ldr x8,[x8,#偏移]` → `blr x8` 這三連。

偏移對應哪個 API 要查 `JNINativeInterface` 結構定義。**看到 `[x8, #0x5c0]` 就查表**，這是日常。

### ⑤ 長度檢查（0xe48）——推論證實的那一刻

```asm
e48:  cmp   w0, #0x17            ; 長度必須 == 23
e4c:  b.ne  e64

```

**這行就是我從「為什麼用 `strncmp` 而不是 `strcmp`」推出來的東西。**

純從一個符號名的選擇建立假設，然後親眼在組語裡看到它——`0x17` \= 23，正是拼出來那個字串的長度。**這種「先推論、後證實」的閉環，就是逆向最值錢的訓練。**

### ⑥ 決勝（0xe50–0xe60）

```asm
e50:  mov   x1, sp               ; arg1 = 堆疊上拼出來的期望值
e54:  mov   w2, #0x17            ; arg2 = 23
e58:  mov   x0, x21              ; arg0 = 你的輸入
e5c:  bl    strncmp
e60:  cbz   w0, e8c              ; == 0 → 跳去回傳 true
...
e8c:  orr   w0, wzr, #0x1        ; w0 = 1

```

**`orr w0, wzr, #0x1` 是編譯器慣用的 `mov w0, #1` 寫法**（wzr 恆零，OR 1 得 1）。看到 `orr Wx, wzr, #imm` 直接讀作 `mov`。

**推論全中：純比對，零運算。**

### ⑦ 收尾

```asm
e64:  mov   w0, wzr              ; 失敗路徑
e68:  ldr   x8, [x22, #40]       ; 重讀 guard
e6c:  ldr   x9, [sp, #24]        ; 讀開頭存的 canary
e70:  cmp   x8, x9
e74:  b.ne  e94                  ; 不一致 → __stack_chk_fail
e88:  ret

```

**注意 `0xe68` 這個匯流點**：旗閘失敗、長度不符、比對失敗三條路全匯到這裡，帶著 `w0=0` 回去。**單一出口 + 預設拒絕**，控制流很乾淨。

### 完整控制流

```
進入 bar(env, this, byte[])
   ├─ *(0x1300c) ≠ 1 ──────────────► return false
   ▼
拼出 23 bytes 期望值到堆疊
   .rodata 16B + 立即數 4B + 2B + 1B
   ▼
GetByteArrayElements → x21
GetArrayLength       → w0
   ├─ 長度 ≠ 0x17 ────────────────► return false
   ▼
strncmp(輸入, 期望值, 23)
   ├─ ≠ 0 ────────────────────────► return false
   ▼
return true      ✅ "Thanks for all the fish"

```

## Step 9: 那個掛號中的 init()

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

```

```asm
d94:  bl    0x918         ; 反調試常式（fork/ptrace）
d9c:  mov   w9, #1
da0:  strb  w9, [x8, #12] ; *(0x1300c) = 1   ← 替 bar 開門

```

**32 bytes 的 stub 判斷完全正確**，它做兩件事：啟動反調試、設旗標。

**實務意涵**：走**動態**路線繞過時，別忘了 `init` 要先跑過（正常 `onCreate` 會呼叫），否則旗閘是 0，`bar` 永遠回 false。走**靜態**拿常數則完全不受影響。

## 技術要點

1. **strip 有下限，而那個下限就是攻擊面**：`.symtab` 可以砍，`.dynsym` 不行——JVM 要用 `dlsym()` 按名字找 JNI 函式。凡是外界必須呼叫得到的東西，就必須留著名字。名字沒了還有位址，`nm -D` 拿座標、`objdump --start-address` 讀內容。
2. **Import table 是二進位的「能力宣告」**：程式能隱藏它做了什麼，藏不住它需要什麼工具——動態連結器必須知道。`strncmp` 且無 crypto = 明文比對；`fork`+`ptrace`+`getppid` \= fork 佔坑反調試。
3. **`strings` 可能給你殘缺的答案，而殘缺本身就是線索**：編譯器會把短字串的尾巴編成 `mov` 立即數，`strings` 永遠掃不到。看到一句話莫名斷掉、長度剛好是 2 的次方，去組語裡把尾巴撿回來。

### 關於用 AI 做逆向：跑完了，但我沒學到

這一關中途我接了 [GhidraMCP](https://github.com/LaurieWired/GhidraMCP?ref=taiwanding.com) 到 Claude Code——讓 LLM 直接驅動 Ghidra 做反編譯、加註解，裝機本身踩了兩個坑（extension 版號要對齊 Ghidra 主版本否則拒載；plugin 的 HTTP server 只在 CodeBrowser 開著 program 時才綁 port），但裝好之後**它一路跑到底，答案、旗閘、`strncmp`，全部吐出來，還順手寫了報告。**

**然後我發現我根本不懂。**

不是不懂結論——結論寫得很清楚，是不懂**為什麼要從那裡開始看**，為什麼是 import table 不是別的？為什麼 `strings` 撈到半句話要去組語找尾巴而不是當成噪音？為什麼 `nm -D` 有東西但 `objdump -d` grep 不到？

所以我回頭**把整條路手動重推了一遍**——就是這篇文章前面 Step 1 到 Step 9 的內容，每一個指令自己跑、每一個推論自己做、每一行組語自己核。

**這個對照非常有意思**：AI 給我的是「地圖上的終點」，手動重推給我的是「怎麼在沒有地圖時走到終點」，**前者一次性，後者可複製。**

我的結論不是「別用 AI 做逆向」——GhidraMCP 對更硬的靶（有真正運算的 native、需要反覆 rename 和加註解的大型 binary）絕對是生產力工具，**但如果你還在建立方法論的階段，讓它跑完再回頭手動重推一遍，這個「事後補課」是值得的**，你要的不是答案，是下次沒有 AI 時你還能自己走到答案的能力。

---

*本文為 OWASP MAS Crackmes 系列練習紀錄*，*所有目標程式皆為 OWASP 官方公開發布的教學用途 app，請勿將相關技術用於未經授權的目標。*