Android DEX 檔案格式
Dalvik Executable 格式——Android 執行環境用的編譯後 bytecode。
- MIME 類型
- application/octet-stream
- 副檔名
- .dex
- 分類
- 執行檔與位元碼
Magic number(檔案簽章)
| 0x00000000 | 6465780A | dex· | 'dex' + newline |
關於 Android DEX 格式
DEX 檔案是把一個 Android 應用程式所有編譯後的類別合併成單一檔案,而這正是這個格式的重點:與其用好幾千個小小的 class 檔各自重複寫下它們碰到的型別名稱,DEX 只有一組共用的表——字串、型別、原型、欄位、方法——每個類別定義都用索引指進去。標頭是固定 112 個位元組的大小與位移,指向那些表,所以讀取端可以直接跳到需要的那一張;而 bytecode 本身是暫存器式而不是堆疊式的,這就是為什麼 DEX 指令和 JVM 指令長得完全不一樣。
檔頭結構,一個位元組一個位元組看
| 位移 | 長度 | 欄位 | 意義 |
|---|---|---|---|
| 0 | 8 | magic (DEX_FILE_MAGIC) | 位元組 64 65 78 0A,接著三個 ASCII 數字,再一個 NUL——例如 'dex\n039\0'。那三個版本數字有意義:035 早於 Android 7、037 隨 Android 7 與預設方法登場、038 是 Android 8、039 是 Android 9、040 是 Android 10。裝置會拒絕版本比它認識的還新的 DEX。 |
| 8 | 4 | checksum | 這個欄位之後整個檔案的 Adler-32。它是執行環境第一個驗證的東西,所以在 DEX 任何地方做任何編輯,最後都必須重算這個值——這正是為什麼手動改一個位元組,做出來的檔案裝不起來。 |
| 12 | 20 | signature | 它之後所有內容的 SHA-1。這不是安全性簽章——它是內容識別碼,執行環境與建置快取用它判斷兩個 DEX 是不是同一個,而且任何修改之後同樣要重算。 |
| 32 | 4 | file_size | DEX 完整的位元組大小。把它和實際檔案長度比對,是最便宜的截斷測試,也是抓出「從壞掉的 APK 裡解出來的 classes.dex」的方法。 |
| 36 | 4 | header_size | 標頭自己的長度,固定為 0x70(112)。底下每個表的位移都是從檔案開頭起算,而真正的資料從這個標頭之後開始,所以出現 0x70 以外的值,代表這個檔案原廠執行環境不會接受。 |
| 40 | 4 | endian_tag | 以這個檔案自己的位元組序寫下的 0x12345678。存成 0x78563412 的檔案是大端序,規格允許但實際上等於看不到;它的實用價值是在讀任何位移之前,先證明解析器的位元組序判斷是對的。 |
| 44 | 8 | link_size + link_off | 兩個 4 位元組欄位,給靜態連結資料用。所有正常工具鏈都不用,所以兩個都是 0;非零的話,強烈暗示這個檔案不是 d8 或 dx 產生的。 |
| 52 | 4 | map_off | map_list 的位移,那是一張列出檔案裡每個區段及其型別碼、數量與位移的表。它是 DEX 裡最接近目錄的東西,工具要找標頭本身沒有指名的區段(例如 call site 與 method handle)就是查它。 |
| 56 | 8 | string_ids_size + string_ids_off | 字串識別碼表的數量與位移——應用程式裡每個類別名稱、方法名稱、欄位名稱與字面值,以 modified UTF-8 儲存。要快速掌握狀況,這張表是 DEX 裡最有用的東西,因為讀完它,一行反組譯都不用做就知道這個應用程式碰了什麼。 |
| 64 | 24 | type_ids, proto_ids, field_ids | 位移 64、72、80 各一對數量與位移,分別索引型別描述子、方法原型與欄位參照。每一對都是兩個 4 位元組值、同樣是先大小後位移,而這些 id 表有硬上限——那個逼人用 multidex 的 65536 個方法限制,就來自這些表所用的索引寬度。 |
| 88 | 16 | method_ids + class_defs | 位移 88 是方法參照表的數量與位移,位移 96 是類別定義表的。任何要列出應用程式類別的工具,入口都是 class_defs 表:每一筆指出型別、父類別、實作的介面、原始檔名稱,以及它的資料放在哪裡。 |
| 104 | 8 | data_size + data_off | 資料區段的大小與位移,所有長度不定的東西都在裡面:code item、字串位元組、註解、編碼後的陣列。它一路延伸到標頭所描述的檔案結尾,而上面那些 id 表基本上就是它的索引。 |
怎麼跟長得像的格式分開
| 容易搞混的 | 怎麼分 |
|---|---|
| Java class 檔 | 兩者都是編譯後的 Java,DEX 也確實是從 class 檔建出來的,但它們沒有共用任何位元組。class 檔開頭是 CA FE BA BE,只裝一個型別,用堆疊式指令集;DEX 開頭是 'dex' 加一個換行,裝著應用程式所有型別,用暫存器式指令集。APK 裡面裝的是 DEX,完全沒有 .class 項目。 |
| Android APK | 大家最常搞錯的關係:DEX 不是壓縮檔,APK 也不是 bytecode。APK 才是那個 ZIP;classes.dex、classes2.dex 之類的是裡面的項目。位移 0 讀到 50 4B 03 04 就代表你手上是那個套件,DEX 的 magic 要再往裡面、在每個項目解壓縮後的資料開頭才會出現。 |
| odex/vdex/oat | 裝置上預先編譯出來的產物是各自獨立、各有 magic 的格式,不是「多加了欄位的 DEX」,儘管工具常常籠統地叫它們「最佳化過的 dex」。從裝置上撈一個出來、指望 DEX 解析器讀得動,是行不通的;解析器要的是 APK 裡面那個 classes.dex。 |
| Multidex classes2.dex | 它不是另一種格式——標頭一樣、版本一樣——只是同一個 APK 裡的第二個檔案,因為方法 id 表不能超過 65536 筆。只回報第一個檔案裡類別的工具,是讀完 classes.dex 就停了;其他的檔名是 classes2.dex、classes3.dex 這樣往上排,而且不補零。 |
常見的壞法
- 編輯之後 checksum 或 signature 沒更新。改一個位元組的 bytecode,位移 8 的 Adler-32 和位移 12 的 SHA-1 就不再描述這個檔案,執行環境會在安裝時拒收。每個正經的 DEX 改寫工具都會把兩個都重算,這就是為什麼手改 DEX 幾乎沒有成功過。
- 被截斷。位移 32 的 file_size 說明這個檔案應該多長,所以解壓縮失敗導致缺一截的 DEX,不用解析任何東西、看 4 個位元組就抓得到。不然的症狀就是某個表的位移指到檔案結尾之外。
- 插入內容之後表的位移全部對不上。每張表都是用「從檔案開頭起算的絕對位移」定位的,所以插入或移除一個字串就會讓後面所有東西位移,而沒有整份重寫的標頭會把解析器送進另一張表的中間。它會讀到一個看似合理的數量,然後卡在一堆亂七八糟的東西上。
- 版本比裝置還新。這不是損壞,但表現得像壞檔案:APK 用的 DEX 版本超過執行環境支援的範圍,安裝失敗時跳的訊息是說檔案無效、而不是說版本太新,而真正的答案在位移 4 那三個 ASCII 數字。
這個格式現在還在用嗎
Android 真正在跑的就是 DEX。每個 APK 裡至少有一個 classes.dex,由 d8 從 javac 或 Kotlin 編譯器吐出來的 class 檔產生,ART 則是直接解譯它、或在裝置上預先編譯。它在 Android 之外沒有生命——沒有別的東西吃它,也沒有別的平台採用它——但在 Android 裡面躲不掉:程式碼縮減、混淆,以及那個逼人用 multidex 的 65536 個方法上限,全都是這個格式的表帶來的後果。做分析的話,它比 strip 過的原生執行檔好處理太多,因為就算應用程式做了混淆,字串表與型別表照樣把類別名稱、方法名稱與字面值明明白白攤出來——這也是為什麼理解一個 APK 比理解同等規模的 ELF 快得多。
在這個站上打開 Android DEX 檔
這些全部在你的瀏覽器裡跑,檔案不會離開你的電腦。
相關格式
容易和 Android DEX 搞混的格式,可以比對它們的開頭位元組。
| 格式 | 開頭位元組 | 副檔名 |
|---|---|---|
| Android APK | 50 4B 03 04 | .apk |
| Java class 檔 | CA FE BA BE | .class |
| ELF 執行檔 | 7F 45 4C 46 | .elf |