> ## 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 L1
- URL: https://taiwanding.com/owasp-mas-crackmes-android-uncrackable-l1/
- Published: 2026-08-07T06:15:10.000Z
- Updated: 2026-08-10T11:09:59.000Z
- Author: Kevin Chen
- Tags: android, reverse, owasp

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

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

## 題目資訊

- **平台**: 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![](https://taiwanding.com/content/images/icon/logo_circle.png)logo![](https://taiwanding.com/content/images/thumbnail/uncrackable-logo.png)](https://mas.owasp.org/crackmes/?ref=taiwanding.com)

這系列一共 Android L1～L4 加 iOS L1～L2，難度曲線是設計過的，每關疊一層新防禦：L1 純 Java 層、L2 邏輯下沉 native、L3 反調試加完整性檢查、L4 模擬支付 app 防護等級。

任務敘述只有一句話，注意「**想辦法**」三個字是刻意留白——它沒指定要用靜態、動態還是改檔案，因為這關真的有好幾條路能通，而**選路本身就是它要考的東西**。

這篇我刻意不寫成「三行指令拿 flag」的速解文，L1 的答案 Google 三十秒就有，抄了沒有價值；真正值錢的是「拿到一個陌生 APK 的第一小時，腦袋怎麼運轉」，所以每一步我都會把**為什麼**攤開來寫。

## Step 1: 檔案初勘

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

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

```bash
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 上的坑）

```bash
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`，某些打包版本忘了還原使用者原始工作目錄。

修法——把路徑講死，**輸入輸出都要改**：

```bash
jadx -d "$PWD/out" "$PWD/UnCrackable-Level1.apk"

```

> 通則：Java 系 CLI 工具（jadx、apktool、Ghidra headless、Burp CLI）在 Linux 發行版打包後相對路徑常不可靠，遇到「檔案明明在卻說找不到」，第一個動作永遠是換絕對路徑試一次。

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

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

```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: 解密引擎，與一個精緻的陷阱

```java
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: 重建解密

```bash
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，請勿將相關技術用於未經授權的目標。*