摘要
Android 應用程式套件可以經過特殊結構設計,使其完全不必執行、不要求任何權限,卻仍能終止每個讀取它的程序。這篇研究探討兩個相互協作的 Native libraries 為何會對同一個 ZIP entry 產生不同解讀、這種差異如何演變成記憶體錯誤,以及這對 Parser 設計有何啟示。
1. 背景:一個檔案,多種讀取者
APK 是一種 ZIP 封存檔,每個 entry 都會記錄 16 位元的壓縮方法,以及壓縮前與壓縮後的大小。Android 只實作兩種方法: stored 與 deflated ,但 ZIP 格式允許使用其他任何數值 [1] 。由於安裝、簽章驗證、資源載入及第三方掃描各自使用不同的解析程式碼,因此沒有任何機制能確保它們對同一組位元組做出一致的解讀 [1] 。過去的 Master Key、Janus 與 BadPack 篡改類型漏洞,也都是源自這種模式 [1] 。 GhostMap 的結果則有所不同:它不是將內容偷偷帶過檢查程序,而是讓讀取該 entry 的程序因解讀差異而發生 crash [1] 。
2. 根本原因:兩套分類規則之間的落差
第一個程式庫會驗證大小,但只在其中一個分支執行。程式碼 1 顯示,只有當 entry 標記為 stored 時,才會檢查宣告的未壓縮長度是否合理 [1] 。從程式碼推論,deflated entry 可以略過這項檢查,因為其未壓縮大小僅用來決定暫存 buffer 的大小,而讀取範圍仍受壓縮大小限制 [1] 。
- // system/libziparchive/zip_archive.cc
- if (data->method == kCompressStored && // only entries labelled "stored" enter this validation branch
- data->uncompressed_length > cd_offset - data_offset) { // declared size is larger than the bytes that exist in the file
- return kInvalidOffset; // reject the entry as malformed
- } // any other method value falls through with no size check at all
程式碼 1. 僅適用於 stored entry 的大小驗證。
第二個程式庫採用相反的判斷方式。程式碼 2 以 deflated 作為分支條件,並將所有其他數值都視為 stored,在沒有進行邊界檢查的情況下,依照宣告的未壓縮長度建立記憶體映射 [1] 。因此,壓縮方法既不是 stored 也不是 deflated 的 entry,能未經第一個程式庫檢查便通過,接著由第二個程式庫依攻擊者指定的大小建立映射 [1] 。第三個元件 ZipFileRO 則避開了這個問題:它只有在 stored entry 中才會使用未壓縮大小,顯示平台其他部分早已存在正確的判斷方式 [1] 。
- // frameworks/base/libs/androidfw/AssetsProvider.cpp
- if (entry.method == kCompressDeflated) { // taken only when the method field equals the deflate value
- // map compressed_length, then decompress // mapping size is bounded by bytes actually stored in the file
- } else { // every other value, including undefined ones, lands here
- // map entry.uncompressed_length directly <-- never bounds-checked // the declared length is trusted blindly
- } // end of method classification
程式碼 2. 以「不是 deflated」而非「是 stored」作為分類條件的資源載入器。
圖 1. 兩個 Parser 如何對同一個 entry 產生不同解讀。
3. 錯誤發生機制
Android 會對檔案建立記憶體映射,而不是複製檔案;Kernel 允許映射範圍大於其 backing file,因為超出檔案範圍的部分並沒有實際資料 [1] 。擷取到的證據顯示,一個 8,408 位元組的封存檔被映射到約 16 MB 的範圍,而錯誤位址遠遠超出檔案結尾 [1] 。這是 over-read,而不是記憶體破壞:沒有任何程式碼因此執行,也不會取得任何權限 [1] 。這種行為符合平台早已記載的語意:存取超出 backing object 範圍的 shared mapping 時,會觸發 SIGBUS [2] 。
有兩種讀取模式會觸發錯誤。在串流讀取中,每個 chunk 只受到宣告大小的限制,因此讀取會持續進行,直到其中一次讀取落入沒有資料的區域(程式碼 3) [1] 。在整個 entry 載入的情況下(例如 XML 資源與圖示),未對齊的映射會觸發一次依照宣告長度配置大小的對齊複製(程式碼 4),而複製操作本身就會發生錯誤 [1] 。
#00 __memcpy_aarch64_simd libc.so // the byte copy primitive is where the CPU raises the fault #01 android::_FileAsset::read libandroidfw.so // chunked read loop walks past the true end of the file #02 android::NativeAssetReadChar libandroid_runtime.so // native bridge that forwards the Java read request #03 art_jni_trampoline boot-framework.oat // transition from managed code into native code #04 AssetManager$AssetInputStream.read (framework) // framework stream API that started the call
程式碼 3. 串流讀取路徑的 crash 紀錄。
- // frameworks/base/libs/androidfw/Asset.cpp (_FileAsset::ensureAlignment)
- unsigned char* buf = new unsigned char[mLength]; // allocate a buffer sized by the declared, invented length
- memcpy(buf, data.unsafe_ptr(), mLength); // copy runs past the end of the file and faults while filling the buffer
程式碼 4. 整個 entry 載入時的對齊複製路徑。
4. 為什麼受害程序很重要
相同的異常位元組會因讀取者不同而造成不同程度的損害。錯誤的 manifest 會使安裝程式 crash,因此應用程式無法完成安裝;如果核心服務在列舉已安裝應用程式時解析到錯誤資源,就會使 system_server crash。system_server 重新啟動後再次讀取該檔案,接著又發生 crash [1] 。Safe mode 無法中斷這個循環,因為讀取動作發生在 safe-mode flag 設定之後、實際生效之前 [1] 。由於原生錯誤並不是 Java exception,即使掃描器使用 catch-all handler 包住解析呼叫,掃描器仍然會終止 [1] 。
08:24:32 Fatal signal 7 (SIGBUS) in system_server (pid 14536) // the service faults while reading the malformed resource 08:24:33 Zygote: system server (pid 14536) has terminated // the parent process notices the death 08:24:38 Fatal signal 7 (SIGBUS) in system_server (pid 15070) // a fresh instance repeats the same read and faults again 08:24:39 Zygote: system server (pid 15070) has terminated // second termination, identical cause 08:24:42 Fatal signal 7 (SIGBUS) in system_server (pid 15586) // third iteration a few seconds later 08:24:43 Zygote: system server (pid 15586) has terminated // the pattern repeats with a new process id each time
程式碼 5. 持續性 crash loop 期間記錄到的重新啟動循環。
圖 2. 重複自動解析所造成的持續性 crash loop。
5. 防禦層面的啟示
最直接的修正方式,是只接受 stored 與 deflated 兩種壓縮方法,並在解析階段拒絕其他所有數值 [1] 。另一種同等有效的強化方式,是統一各程式庫的分類規則,讓未壓縮大小只有在 stored entry 中才會被信任,就像 ZipFileRO 已採用的作法一樣 [1] 。由於異常狀況會直接呈現在檔案結構中,掃描器可以不安裝或執行樣本,就透過靜態檢查標示問題;不過,平台規則不允許第三方工具直接阻止安裝 [1] 。必須處理不受信任封存檔的解析 pipeline,應將解析工作隔離在可隨時丟棄的獨立程序中,因為原生錯誤無法在同一個程序內攔截 [1] 。更廣泛的教訓是:在不同層之間傳遞的每個長度欄位,都是一個信任邊界;共用同一種格式的 Parser,也應共用單一的驗證 routine。