OWASP MAS Crackmes — Android UnCrackable L2
題目資訊
- 平台: 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

L1 的秘密是 AES 加密後藏起來,要自己重跑解密才拿得到,L2 的秘密根本沒加密——它只是搬到 native 層,然後被編譯器切成兩半藏在不同地方。
從密碼學角度 L2 比 L1 弱,但從逆向工序角度 L2 更費工,「難度」跟「安全性」是兩回事,這個對比是這關最值得帶走的觀念之一。
這篇的重點不是答案,是「面對一顆 stripped 的 .so,怎麼知道要拆哪一塊」——因為這才是真實逆向裡最難的部分。
Step 1: 拆結構,看差異
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 個
jadx -d "$PWD/out" "$PWD/UnCrackable-Level2.apk"
find out/sources -name '*.java' | wc -l # 300+
300 多個 Java 檔,新手看到會慌,老手三秒篩完,方法是反向過濾——不是找主線,是先把已知的雜訊全部劃掉:
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 的門
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 層的終點
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() 很適合用來動這種手腳。
驗證:
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。解法是用位址找:
aarch64-linux-gnu-objdump -d --start-address=0xdac --stop-address=0xe98 \
extracted/lib/arm64-v8a/libfoo.so
名字是標籤,位址是東西本身。標籤沒了,東西還在。
Step 6: 讀 import table——這一步決定了後面所有動作
在讀任何一行組語之前,先看這個:
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: 三秒短路,與一個殘缺的線索
strings -a extracted/lib/arm64-v8a/libfoo.so | grep -i thanks
Thanks for all t
停在這裡想三秒,這句話是不完整的。
一般人看到 strings 吐出半句話會覺得「撈到垃圾」然後丟掉,但逆向的正確反應是:一句讀起來像人話卻突然斷掉的字串,代表它的尾巴在別的地方。
而且我們已經從 import table 知道這一定是明文比對,所以這個殘缺不可能是「秘密就長這樣」,只能是「其餘部分不在 .rodata」。
驗證:
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()——逐段核對草稿
aarch64-linux-gnu-objdump -d --start-address=0xdac --stop-address=0xe98 \
extracted/lib/arm64-v8a/libfoo.so
① 序幕(0xdac–0xdd4)
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)——證實草稿的「可能設個開關」
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)——整關的心臟
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"
驗算:
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)
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)——推論證實的那一刻
e48: cmp w0, #0x17 ; 長度必須 == 23
e4c: b.ne e64
這行就是我從「為什麼用 strncmp 而不是 strcmp」推出來的東西。
純從一個符號名的選擇建立假設,然後親眼在組語裡看到它——0x17 = 23,正是拼出來那個字串的長度。這種「先推論、後證實」的閉環,就是逆向最值錢的訓練。
⑥ 決勝(0xe50–0xe60)
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。
推論全中:純比對,零運算。
⑦ 收尾
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()
aarch64-linux-gnu-objdump -d --start-address=0xd8c --stop-address=0xdac \
extracted/lib/arm64-v8a/libfoo.so
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。走靜態拿常數則完全不受影響。
技術要點
- strip 有下限,而那個下限就是攻擊面:
.symtab可以砍,.dynsym不行——JVM 要用dlsym()按名字找 JNI 函式。凡是外界必須呼叫得到的東西,就必須留著名字。名字沒了還有位址,nm -D拿座標、objdump --start-address讀內容。 - Import table 是二進位的「能力宣告」:程式能隱藏它做了什麼,藏不住它需要什麼工具——動態連結器必須知道。
strncmp且無 crypto = 明文比對;fork+ptrace+getppid= fork 佔坑反調試。 strings可能給你殘缺的答案,而殘缺本身就是線索:編譯器會把短字串的尾巴編成mov立即數,strings永遠掃不到。看到一句話莫名斷掉、長度剛好是 2 的次方,去組語裡把尾巴撿回來。
關於用 AI 做逆向:跑完了,但我沒學到
這一關中途我接了 GhidraMCP 到 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,請勿將相關技術用於未經授權的目標。

Member discussion