OWASP MAS Crackmes — Android UnCrackable L1
題目資訊
- 平台: OWASP MASTG(Mobile Application Security Testing Guide)
- 關卡名稱: Android UnCrackable Level 1(MASTG-APP-0003)
- 難度: Easy
- 目標: app 裡藏了一段 secret string,想辦法把它挖出來
- 環境: WSL2 Kali Linux

這系列一共 Android L1~L4 加 iOS L1~L2,難度曲線是設計過的,每關疊一層新防禦:L1 純 Java 層、L2 邏輯下沉 native、L3 反調試加完整性檢查、L4 模擬支付 app 防護等級。
任務敘述只有一句話,注意「想辦法」三個字是刻意留白——它沒指定要用靜態、動態還是改檔案,因為這關真的有好幾條路能通,而選路本身就是它要考的東西。
這篇我刻意不寫成「三行指令拿 flag」的速解文,L1 的答案 Google 三十秒就有,抄了沒有價值;真正值錢的是「拿到一個陌生 APK 的第一小時,腦袋怎麼運轉」,所以每一步我都會把為什麼攤開來寫。
Step 1: 檔案初勘
mkdir -p ~/mas/uncrackable-l1 && cd ~/mas/uncrackable-l1
curl -Lo UnCrackable-Level1.apk \
https://github.com/OWASP/mastg/raw/master/Crackmes/Android/Level_01/UnCrackable-Level1.apk
file UnCrackable-Level1.apk
sha256sum UnCrackable-Level1.apk
UnCrackable-Level1.apk: Android package (APK), with AndroidManifest.xml, with APK Signing Block
1da8bf57d266109f9a07c01bf7111a1975ce01f190b9d914bcd3ae3dbef96f21
66K 是好消息——沒有 library 灌水,等下反編譯出來的東西人眼看得完。
留下 sha256 的用途是「以後能發現自己改壞了什麼」,玩到後面若走 patch APK 再重打包的路線,手上會有好幾個變體檔案,原始 hash 是唯一的錨點。
with APK Signing Block 是一條伏筆:這 APK 用了 v2 以上簽章,它預告若之後選「改 APK」路線,簽章會壞、Android 會拒裝,得自己重簽——這會讓「改 runtime」那條路相對省事。
Step 2: APK 本質上就是一個 ZIP
unzip -l UnCrackable-Level1.apk
1648 AndroidManifest.xml
1319 META-INF/CERT.RSA
1139 META-INF/CERT.SF
1077 META-INF/MANIFEST.MF
5528 classes.dex
1260 res/layout/activity_main.xml
456 res/menu/menu_main.xml
4751 res/mipmap-hdpi-v4/ic_launcher.png
2348 res/mipmap-mdpi-v4/ic_launcher.png
7275 res/mipmap-xhdpi-v4/ic_launcher.png
14398 res/mipmap-xxhdpi-v4/ic_launcher.png
22801 res/mipmap-xxxhdpi-v4/ic_launcher.png
2748 resources.arsc
13 files
.apk 這個副檔名是騙人的,它本質就是一個 ZIP,Android app 的第一層防護等於零,任何人用一個 1990 年代就有的工具就能看到裡面有什麼。
為什麼用 -l 只列表、不解壓縮?一是不落地(分析未知樣本時「先看目錄再決定要不要落地」是基本紀律,習慣要一致),二是列表本身就是情報。
拿到檔案清單,我每次都會問自己這五個問題:
- ① 有沒有
lib/? → 沒有,這最重要,lib/放 native.so(C/C++ 產物),有它就得開 Ghidra 讀組語,沒它代表邏輯 100% 在 Java 層、反編譯出來人類可讀。這一眼直接決定接下來三小時開哪個工具。 - ②
classes.dex幾個多大? → 一個,5528 bytes,正常商業 app 的 dex 動輒 5~50MB,這裡只有 5.5KB、大概十來個 class 且全手寫,戰術意義:整個 app 可以從頭讀到尾,這種奢侈在真實案子裡幾乎不存在。 - ③
res/+resources.arsc? → 只有 layout/menu/圖示,arsc 才 2.7KB,字串少得可憐,秘密「單純躺在 strings.xml」的機率偏低。 - ④
assets/? → 不存在,一個藏東西的熱點被排除(沒有偷渡設定檔、加密 blob、第二層 dex)。 - ⑤
META-INF/簽章檔 → v1 簽章(CERT.RSA/SF+MANIFEST.MF)加上file報的 APK Signing Block(v2),是 v1+v2 雙簽。動任何檔案兩層一起死。
階段結論:已排除 native 層、assets 藏檔、大型第三方 library。
Step 3: 讀 AndroidManifest
aapt2 dump badging UnCrackable-Level1.apk
aapt2 dump xmltree UnCrackable-Level1.apk --file AndroidManifest.xml
為什麼不直接 cat?會噴亂碼,APK 裡的 AndroidManifest.xml 不是文字檔,是編譯過的二進位 XML(AXML),Android 為了省解析時間在打包階段就把它壓成二進位了,這是常撞的第一面牆。
為什麼 manifest 排在讀 code 之前?因為它是這 app 的目錄與門牌。
package: name='owasp.mstg.uncrackable1'
sdkVersion:'19' targetSdkVersion:'28'
launchable-activity: name='sg.vantagepoint.uncrackable1.MainActivity'
android:allowBackup=true
intent-filter: action.MAIN + category.LAUNCHER
讀出來五條情報:
- ① 入口找到了:
sg.vantagepoint.uncrackable1.MainActivity,這就是讀 code 的起點——打開 jadx 直接跳這,從onCreate()讀起,程式的入口就是分析的入口,順帶注意 package name(owasp.mstg.uncrackable1,adb/Frida 用這個)和 class 的 package(sg.vantagepoint,程式碼內部命名空間)不同,這完全正常但要分清,不然下 Frida 指令會鬼打牆。 - ②
android:debuggable沒出現 → 吃預設false,如果是 true 可以不 root 直接接 debugger、難度砍半。現在不行,要正經走。 - ③
android:allowBackup="true"→ 這關用不上,但它是真實世界的資料外洩點(adb backup可拉私有資料),正是 MASVS-STORAGE 會查的項目,記著。 - ④
minSdk 19 / targetSdk 28→ 一台時光機,minSdk 19 = Android 4.4(2013),老 = 防禦樸素,targetSdk 28 有戰術價值:Android 9 之前記憶體與 SELinux 較寬鬆,之後若要 dump 記憶體找字串,這版本號會影響裝置選擇。 - ⑤ exported components → 只有一個 launcher activity,沒有對外開放的 Service/Receiver/Provider。IPC 攻擊面是零,這條路排除。
地圖成形:
使用者點圖示 → MainActivity.onCreate() → (某處接收輸入/驗證) → ???
Step 4: 反編譯(附一個 Kali 上的坑)
jadx -d out UnCrackable-Level1.apk
ERROR - Incorrect arguments: File not found /usr/share/jadx/bin/UnCrackable-Level1.apk
這個錯誤值得停三十秒,因為它示範了「怎麼讀錯誤訊息」。
你的指令裡完全沒出現 /usr/share/jadx/bin/ 這路徑,它是憑空冒出來的——這就是全部線索:你給的是相對路徑,jadx 卻拿去跟自己的安裝目錄拼接,代表程式在解析參數前已偷偷換掉工作目錄。
原因:Kali 的 /usr/bin/jadx 不是執行檔本體,是 Gradle 產生的 wrapper script,為了定位安裝目錄會 cd,某些打包版本忘了還原使用者原始工作目錄。
修法——把路徑講死,輸入輸出都要改:
jadx -d "$PWD/out" "$PWD/UnCrackable-Level1.apk"
通則:Java 系 CLI 工具(jadx、apktool、Ghidra headless、Burp CLI)在 Linux 發行版打包後相對路徑常不可靠,遇到「檔案明明在卻說找不到」,第一個動作永遠是換絕對路徑試一次。
find out -name '*.java'
out/sources/owasp/mstg/uncrackable1/R.java
out/sources/sg/vantagepoint/a/a.java
out/sources/sg/vantagepoint/a/b.java
out/sources/sg/vantagepoint/a/c.java
out/sources/sg/vantagepoint/uncrackable1/a.java
out/sources/sg/vantagepoint/uncrackable1/MainActivity.java
先分類再細看(拿到一堆 class,先分雜訊/主線,不要從頭一個個 cat):
R.java直接跳過——建置時自動產生的資源索引,機器寫的、每個 app 都有,看到R.java通常一律無視。- 剩下五個都在
sg.vantagepoint底下,作者手寫,全是主線。 - 注意
sg/vantagepoint/a/這目錄,檔名是單字母a/b/c、package 也叫a——這是混淆(ProGuard)痕跡,但這裡有個反諷:被特意藏起來的東西,往往就是重要的東西。先標記為高度可疑。
Step 5: 讀 MainActivity
protected void onCreate(Bundle bundle) {
if (c.a() || c.b() || c.c()) { a("Root detected!"); }
if (b.a(getApplicationContext())) { a("App is debuggable!"); }
super.onCreate(bundle);
setContentView(R.layout.activity_main);
}
public void verify(View view) {
String string = ((EditText) findViewById(R.id.edit_text)).getText().toString();
if (a.a(string)) { /* "This is the correct secret." */ }
else { /* "That's not it. Try again." */ }
}
這支做兩條完全不同戰線的事,先分清楚。
第一段(防守):onCreate 裡 c.a/b/c 是 root 偵測、b.a() 是 debuggable 偵測,中了就 System.exit(0)。
關鍵判斷:這兩段跟「挖出 secret」有關嗎?沒有,它們是攔路門神不是寶藏,只在「你想在 root 手機/debugger 環境跑 app」時才擋你,走純靜態路線(只讀 code 不執行),它們完全礙不到你——它們是 runtime 才觸發的檢查,而我們根本沒 run。
第二段(寶藏):verify(),用逆向讀 code 的三個核心提問去套——輸入從哪來(edit_text → string)、哪裡在比較(a.a(string),整個驗證濃縮成一個 boolean 呼叫)、有沒有加解密(這支裡沒有,真正判斷被丟進 a.a() 這黑盒子)。
一個常見錯誤直覺:「打開 a.a() 一定有 if (input.equals("秘密"))」,很可能不是。a.a() 只回「對/不對」,有兩種實作可能:可能 A 明文比對(秘密躺著,十秒破關)、可能 B 先解密再比對(秘密是「算出來的」不是「放著的」,得自己重跑解密)。
Step 6: 打開黑盒子 a.java
public static boolean a(String str) {
byte[] bArrA = sg.vantagepoint.a.a.a(
b("8d127684cbc37c17616d806cf50473cc"),
Base64.decode("5UJiFctbmgbDoLXmpL12mkno8HT4Lv8dlat8FxR2GOc=", 0)
);
return str.equals(new String(bArrA));
}
public static byte[] b(String str) {
// hex 字串 → byte[](每兩字元一個 byte,<<4 拼高位)
}
答案是可能 B, 從最後一行讀起:你的輸入 str == new String(bArrA),而 bArrA 是某個東西解出來的明文。
這裡有一個對攻擊者極度友善的設計缺陷:秘密的明文在比對那一瞬間,完整地存在於記憶體裡,開發者以為加密藏起來就安全,但再怎麼加密,比對時總得還原成明文——而還原後那一刻就是它最脆弱的時候,這直接預告一條不用解密就能破關的路(動態 hook:不對抗加密,只在明文出現的瞬間攔截)。
拆 b():先別讀實作,先問它做了什麼轉換,輸入是 32 字元的 0-9a-f(hex 長相)、迴圈 i+=2 每次吃兩字元吐一個 byte——結論不用逐行算:b() 是 hex-string → byte[] 轉換器。而那串 hex 是一把 AES 金鑰(32 hex = 16 bytes = AES-128,數字對得剛剛好)。
三個材料到齊:
| 程式碼 | 真實身分 |
|---|---|
b("8d1276...") |
AES 金鑰(16 bytes) |
Base64.decode("5UJiFc...") |
密文 |
sg.vantagepoint.a.a.a(key, ct) |
解密函式 |
key 和 ciphertext 都寫死在 code 裡,這就是這關要教的那句話的鐵證:hardcode 在 app 裡的秘密不是秘密——開發者把 key 跟密文一起打包給你,等於把保險箱跟鑰匙一起寄到你家。
Step 7: 解密引擎,與一個精緻的陷阱
public static byte[] a(byte[] bArr, byte[] bArr2) {
SecretKeySpec secretKeySpec = new SecretKeySpec(bArr, "AES/ECB/PKCS7Padding");
Cipher cipher = Cipher.getInstance("AES");
cipher.init(2, secretKeySpec);
return cipher.doFinal(bArr2);
}
第 1 行是個小騙局。 SecretKeySpec 的第二參數叫 algorithm,只拿來標記「這把 key 給哪個演算法用」,只取開頭的 "AES",後面的 /ECB/PKCS7Padding 對它而言是被忽略的雜訊,那串看起來很完整的字串寫在這裡其實是作者的小錯誤(或煙霧彈)。
真正決定模式的是第 2 行。 Cipher.getInstance("AES") 的格式是 "演算法/模式/填充",這裡只給 "AES" → 吃預設,Java 標準庫裡 AES 沒指定模式的預設就是 ECB + PKCS5Padding。所以實際行為是 AES/ECB,第 1 行那個 PKCS7 是假動作(而 PKCS5 與 PKCS7 在 16-byte block 的 AES 上完全等價,用哪個都解得出來)。
帶走的通則:讀加密 code 時,SecretKeySpec的 algorithm 欄位不可信,一切以Cipher.getInstance()的參數為準。這是真實逆向反覆遇到的坑,很多人被前者騙去用錯模式然後解不出來卡住。
cipher.init(2, ...) 的 2 是 magic number,其實是 Cipher.DECRYPT_MODE(1 是 ENCRYPT)。jadx 還原不回常數名就直接顯示數字——看到 Cipher.init 第一參數是 2 就讀作「這是在解密」。
完整的鏈:
key = hexToBytes("8d127684cbc37c17616d806cf50473cc") # 16 bytes, AES-128
cipher = base64Decode("5UJiFctbmgbDoLXmpL12mkno8HT4Lv8dlat8FxR2GOc=")
secret = AES-128-ECB-Decrypt(cipher, key)
驗證 = (你的輸入 == secret)
Step 8: 三條路線的取捨
到這裡秘密「一定能拿到」已成定局,剩下只是選路。選路的思考本身就是這關最有價值的訓練:
- 路線 1|純靜態・自己重算解密:拿 key + 密文 + 模式,用 Python 自己跑一次。優點是不用手機/root/Frida、馬上能做,最能證明「我真懂它在幹嘛」;缺點是要對 AES 參數(但這是複習不是缺點)。
- 路線 2|動態 hook・攔截明文:用 Frida 掛
a.a()回傳或String.equals,等 app 自己解出明文時攔截。優點是不用懂密碼學;缺點是要架 Android + Frida 環境、還得先處理那兩道門神,成本最高。 - 路線 3|patch・繞過驗證:改 smali 讓
verify()永遠 true,這條對「挖出 secret」是錯的路——它讓你假裝通關,卻永遠不知道秘密是什麼,放進來是要辨別:「繞過檢查」和「取得秘密」是兩件不同的目標。
本文走路線 1:跟環境零摩擦,且逼你把整個加密流程真正搞懂——這個理解在之後做 app 逆向挖 hardcoded 密鑰時是直接變現的能力,路線 2 留著當同一關的第二種解法。
Step 9: 重建解密
pip install pycryptodome --break-system-packages
python3 - <<'EOF'
import base64
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
key = bytes.fromhex("8d127684cbc37c17616d806cf50473cc")
ciphertext = base64.b64decode("5UJiFctbmgbDoLXmpL12mkno8HT4Lv8dlat8FxR2GOc=")
plaintext_padded = AES.new(key, AES.MODE_ECB).decrypt(ciphertext)
print("raw :", plaintext_padded)
print("secret:", unpad(plaintext_padded, 16).decode())
EOF
raw : b'I want to believe\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f'
secret: I want to believe
I want to believe——X 檔案的哏,作者 Bernhard Mueller 的招牌。
為什麼要留那行 print("raw", ...)? 那不是裝飾,是看懂 padding 的最好機會,明文 17 bytes,AES block 16,補到 32 要補 15 個 byte——而每個補的 byte 值都是 0x0f = 15。這就是 PKCS 的巧妙:補幾個、每個 byte 的值就是幾,解密方看最後一個 byte 就知道砍掉多少,先看 raw、再看 unpad 後結果,等於親眼見證 padding 從「有」到「無」。
技術要點
- APK 的第一層防護等於零:
.apk就是 ZIP,unzip -l一眼看穿結構,有沒有lib/、assets/、dex 多大,在打開任何檔案前就已經是情報。 - 靜態分析先畫地圖:manifest 給你入口 activity 與 debuggable 狀態,把「陌生 app」變成「有起點的路徑」,再決定要不要動態。
- hardcoded 秘密不是秘密:key 與密文都寫死在 code 裡,逆向者拿 pycryptodome 十行還原明文。
這關對現實的意義
L1 的核心命題一句話:跑在使用者手上的程式,沒有秘密可言。
Android app 安裝在攻擊者完全掌控的裝置上,檔案、記憶體、每個 function 呼叫理論上都可被觀察竄改。開發者常有「我把 key 藏在 app 裡使用者又看不到」的直覺,這關就是親手打碎它。
這認知直接變現:行動 app 裡挖 hardcoded API key、後端 endpoint、內部服務網址,是很扎實的 bug bounty 起手式,而且挖出來往往就接回本來就熟的 web 攻擊面——app 只是入口,真正的洞常在它呼叫的那個 API 上。
本文為 OWASP MAS Crackmes 系列練習紀錄,所有目標程式皆為 OWASP 官方公開發布的教學用途 app,請勿將相關技術用於未經授權的目標。

Member discussion