14 min read

OWASP MAS Crackmes — Android UnCrackable L1

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
MAS Crackmes - OWASP Mobile Application Security

這系列一共 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." */ }
}

這支做兩條完全不同戰線的事,先分清楚。

第一段(防守)onCreatec.a/b/c 是 root 偵測、b.a() 是 debuggable 偵測,中了就 System.exit(0)

關鍵判斷:這兩段跟「挖出 secret」有關嗎?沒有,它們是攔路門神不是寶藏,只在「你想在 root 手機/debugger 環境跑 app」時才擋你,走純靜態路線(只讀 code 不執行),它們完全礙不到你——它們是 runtime 才觸發的檢查,而我們根本沒 run。

第二段(寶藏)verify(),用逆向讀 code 的三個核心提問去套——輸入從哪來(edit_textstring)、哪裡在比較(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 從「有」到「無」。

技術要點

  1. APK 的第一層防護等於零.apk 就是 ZIP,unzip -l 一眼看穿結構,有沒有 lib/assets/、dex 多大,在打開任何檔案前就已經是情報。
  2. 靜態分析先畫地圖:manifest 給你入口 activity 與 debuggable 狀態,把「陌生 app」變成「有起點的路徑」,再決定要不要動態。
  3. 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,請勿將相關技術用於未經授權的目標。