摘要

Android 應用程式套件可以經過特殊結構設計,使其完全不必執行、不要求任何權限,卻仍能終止每個讀取它的程序。這篇研究探討兩個相互協作的 Native libraries 為何會對同一個 ZIP entry 產生不同解讀、這種差異如何演變成記憶體錯誤,以及這對 Parser 設計有何啟示。

GhostMap 藉 ZIP 欄位讓 APK 掃描器造成 SIGBUS! | 資訊安全新聞

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] 。

  1. // system/libziparchive/zip_archive.cc
  2. if (data->method == kCompressStored && // only entries labelled "stored" enter this validation branch
  3. data->uncompressed_length > cd_offset - data_offset) { // declared size is larger than the bytes that exist in the file
  4. return kInvalidOffset; // reject the entry as malformed
  5. } // 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] 。

  1. // frameworks/base/libs/androidfw/AssetsProvider.cpp
  2. if (entry.method == kCompressDeflated) { // taken only when the method field equals the deflate value
  3. // map compressed_length, then decompress // mapping size is bounded by bytes actually stored in the file
  4. } else { // every other value, including undefined ones, lands here
  5. // map entry.uncompressed_length directly <-- never bounds-checked // the declared length is trusted blindly
  6. } // end of method classification

程式碼 2. 以「不是 deflated」而非「是 stored」作為分類條件的資源載入器。

sequenceDiagram %% Actor representing the malformed archive entry participant A as Crafted APK entry %% First parser: validates sizes only for stored entries participant Z as libziparchive %% Second parser: loads resources and maps entries participant F as libandroidfw %% The kernel grants mappings and raises faults participant K as Kernel %% The entry declares an unusual method and a huge length A->>Z: method is neither stored nor deflated, length is inflated %% The size check is never reached for this method value Z-->>A: Accepted, no size validation %% A resource read is later requested A->>F: Resource or manifest read %% Anything not deflated is mapped at the declared length F->>K: Map declared uncompressed length %% Sparse mappings larger than the file are allowed K-->>F: Mapping granted %% The first access beyond the real end of the file faults F->>K: Read past end of file %% The unhandled fault kills the reading process K-->>F: SIGBUS

圖 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 紀錄。

  1. // frameworks/base/libs/androidfw/Asset.cpp (_FileAsset::ensureAlignment)
  2. unsigned char* buf = new unsigned char[mLength]; // allocate a buffer sized by the declared, invented length
  3. 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 期間記錄到的重新啟動循環。

sequenceDiagram %% The core service that hosts most platform components participant S as system_server %% A small resource inside an installed app participant R as Malformed resource %% The parent process that restarts the service participant Z as Zygote %% The cycle continues until the file is removed or data is wiped loop every few seconds %% On every start the service scans installed apps S->>R: Parse resource %% The over-mapped read faults R-->>S: SIGBUS %% The service dies without finishing startup S--xZ: Process terminated %% A new instance is spawned and the cycle restarts Z->>S: Restart service end %% Safe mode cannot break the loop Note over S,Z: Safe-mode flag is set but the crash occurs earlier

圖 2. 重複自動解析所造成的持續性 crash loop。

5. 防禦層面的啟示

最直接的修正方式,是只接受 stored 與 deflated 兩種壓縮方法,並在解析階段拒絕其他所有數值 [1] 。另一種同等有效的強化方式,是統一各程式庫的分類規則,讓未壓縮大小只有在 stored entry 中才會被信任,就像 ZipFileRO 已採用的作法一樣 [1] 。由於異常狀況會直接呈現在檔案結構中,掃描器可以不安裝或執行樣本,就透過靜態檢查標示問題;不過,平台規則不允許第三方工具直接阻止安裝 [1] 。必須處理不受信任封存檔的解析 pipeline,應將解析工作隔離在可隨時丟棄的獨立程序中,因為原生錯誤無法在同一個程序內攔截 [1] 。更廣泛的教訓是:在不同層之間傳遞的每個長度欄位,都是一個信任邊界;共用同一種格式的 Parser,也應共用單一的驗證 routine。