如何通過 Mapping 文件反混淆

本文已同步到 如何通過 Mapping 文件反混淆 ,提供更好的閱讀體驗


寫在前邊

做過 Android 開發的應該或多或少都知道“混淆”這個技術點,它不僅可以幫助我們增加三方逆向的難度,還可以有效減少包體積,瘦身 APK

其實這些能力都來自於 Proguard 這個程序,Proguard 能利用字典文件,在編譯時將我們的類名,方法名,字段名都替換掉,最後生成一份非常反人類的編譯產物。Proguard 在每次運行時都會創建一個 mapping.txt 文件,其中列出了經過混淆處理的類、方法和字段名稱與原始名稱的映射關係。此映射文件還包含用於將行號映射回原始源文件行號的信息

這篇文章的目的就是要解析生成的 Mapping 文件

正文

Mapping 文件的來源與用途

以下就是 Mapping 文件的生成過程

在這裏插入圖片描述

這個 Mapping 文件是由 Proguard 程序自動生成的,會存放在 output 目錄下,與 release 包放在一起。需要謹記的是,Mapping 文件可能在每次 Proguard 運行後都會不同,所以發佈給用戶的包一定要留存好 Mapping 文件,方便後續跟蹤解決問題

瞭解 Mapping 文件的好處

瞭解 Mapping 文件最直觀的好處在於我們跟蹤線上的經過混淆之後的 Crash 信息時,可以從 Mapping 文件逆向推出原始的堆棧信息,更快更方便的定位問題,但不只這些,我們還可以通過 Mapping 文件處理內存快照文件 Hprof 的反混淆,處理 Systrace 的文件的反混淆,還有 Nanoscope 文件的反混淆等

如何解析 Mapping 文件

注意:Android 在新版中啓用了 R8 編譯器,沒有使用 Proguard 工具,雖然兼容 Proguard 的配置和字典等,但是編譯出來的 Mapping 文件格式還是有一點不同。我們會在最後一個小節講一下其中的不同

下面我們詳細來看 Mapping 文件的格式

classline
    fieldline *
    methodline *

Mapping 文件的正式部分由多個 Class 塊組成,每個 Class 塊中包含混淆前後的類信息,字段信息,方法信息。每個 Class 塊由頂格的類信息開頭,後邊跟着開頭帶有4個空格的字段信息與方法信息
每個 Class 塊中詳細格式如下:

類信息:

originalclassname -> obfuscatedclassname:

混淆之前的全限定類名與混淆後的全限定類名通過 -> 分隔符分割,以 : 標識當前類信息的結束,標識類內字段,方法信息的開始

備註:全限定類名,是指帶有包名限定的類名,可以完全定位一個類

字段信息:

originalfieldtype originalfieldname -> obfuscatedfieldname

混淆之前的字段信息與混淆之後的字段信息同樣通過 -> 分隔符分割,值得注意的是,混淆前的字段包含了字段類型和字段名稱,而混淆之後只有字段名稱

方法信息:

[startline:endline:]originalreturntype [originalclassname.]originalmethodname(originalargumenttype,...)[:originalstartline[:originalendline]] -> obfuscatedmethodname

備註:標識着 * 的行,意味着可能出現任意多次;
[] 表示內容是可選的;
表示可能會出現任意多個前邊指定的item;
:->都是分隔符

方法信息同樣通過 -> 分隔符分割,但是方法信息比類信息和字段信息更復雜一點,因爲方法還額外包含了行號表,參數,返回類型等信息

  • originalreturntype:原始返回類型,全限定類名,或者基本類型,或者無類型 void
  • originalmethodname:原始方法名稱
  • originalclassname:可選,當方法不屬於所在的類塊時,需要特別通過全限定類名引用
  • originalargumenttype:原始參數類型,全限定類名或者基本類型,多個參數按順序通過 , 分割
  • obfuscatedmethodname:混淆後的名稱

剩下的行號信息,稍微複雜一點要根據方法有沒有做內聯優化分成兩種情況:

無內聯優化:

  • [startline:endline:]:和 Jvm 字節碼中的 LineNumberTable 對應,表示原始代碼的行號範圍
  • [:originalstartline[:originalendline]]:無內聯優化,這個字段不存在

有內聯優化

  • [startline:endline:]:是一個編譯器給出的一個類似於源碼中的行號,爲什麼說類似於源碼行號,我們後邊通過示例來說明
  • [:originalstartline[:originalendline]]:這個字段中 originalendline 又是可選的,所以也分兩種情況
    • [:originalstartline]:只有起始行號,表示這是異常的某個中間調用(???大問號?後邊通過示例來說明吧,這裏不好理解)
    • [:originalstartline:originalendline]:有行號範圍,表示在源碼中的真實行號範圍,對應源碼的方法範圍

有一些要注意的點:

  1. 方法的行號唯一標識了這個方法,這個在我們從堆棧中反推原始信息時特別有用
  2. 如果方法的行號沒有存在,那麼我們只能通過方法的簽名(或者描述符)來反混淆代碼,但是這種匹配不是絕對準確的,可能會出現匹配到多個相同的方法或字段

Mapping 示例分析

簡單示例

com example.application.ArgumentWordReader -> com.example.a.a:
    java.lang.String[] arguments -> a
    int index -> a
    36:57:void <init>(java.lang.String[],java.io.File) -> <init>
    64:64:java.lang.String nextLine() -> a
    72:72:java.lang.String lineLocationDescription() -> b

com example.application.ArgumentWordReader 被混淆爲 com.example.a.a ,其中字符 a 來源於混淆文件中所配置的字典文件
字段 arguments 和 index 會在混淆中丟掉類型信息,同時轉換爲混淆字符 a

如果多個方法或者字段的簽名(或者說描述符)不同,那麼混淆之後的名稱可能是相同的

方法的實例構造函數 <init> 和靜態類構造函數 <clinit> ,名稱不會被混淆,只會丟棄其參數列表和返回類型

方法 nextLine 和 lineLocationDescription 都有自己的源碼行號範圍,但是返回類型和參數列表是相同的,如果在混淆的配置文件中配置保留了 LineNumberTable,那麼在報錯堆棧中就可以看到行號,也就可以通過行號定位到具體的方法,而如果沒有在混淆的配置文件中配置保留 LineNumberTable,那麼報錯堆棧中也就不會打印出行號,僅僅通過返回類型和參數列表是無法區分二者的,所以這就是爲什麼這兩個方法的混淆之後的名稱是不同的

以上的示例比較簡單,我們來看一下複雜的示例

複雜示例

com.example.application.Main -> com.example.application.Main:
    com.example.application.Configuration configuration -> a
    50:66:void <init>(com.example.application.Configuration) -> <init>
    74:228:void execute() -> a
    2039:2056:void com.example.application.GPL.check():39:56 -> a
    2039:2056:void execute():76 -> a
    2236:2252:void printConfiguration():236:252 -> a
    2236:2252:void execute():80 -> a
    3040:3042:java.io.PrintWriter com.example.application.util.PrintWriterUtil.createPrintWriterOut(java.io.File):40:42 -> a
    3040:3042:void printConfiguration():243 -> a
    3040:3042:void execute():80 -> a
    3260:3268:void readInput():260:268 -> a
    3260:3268:void execute():97 -> a

com.example.application.Main 類配置了 keep 屬性,所以類信息沒有被混淆掉,一般我們會把可能需要被反射使用的類保留,防止在 release 包中類名變化導致混淆使用出錯

configuration 字段信息同上,混淆後丟棄類型
實例構造函數 <init> 和方法 execute 同上解析方式

剩下的方法都比較奇怪,開頭的行號都是特別大的數字,且有幾個方法行號是相同的,明顯不是正常的行號,
這是因爲經過了方法內聯處理,在混淆處理的過程中,可能會內聯方法到其他方法中,甚至進行遞歸的內聯

方法內聯

簡單來說,就是將互相調用的多個方法合併爲一個方法,這樣減少程序方法調用的次數,從而減少程序調用過程中的棧幀的創建銷燬等額外的消耗,提升性能
例如

class A:
    def a():
        print("a")
        B.b()
class B:
    def b():
        print("from B")
        print("b")

做方法內聯優化之後:

class A:
    def a():
        print("a")
        print("from B")
        print("b") // inner line from B()

瞭解了方法內聯之後,我們再來看方法內聯對混淆的影響,方法內聯之後,堆棧中原來 B.b() 方法已經被內聯到 A.a() 方法中,混淆之後的方法信息也自然指向了 A.a(),那麼堆棧中出現的錯誤信息也是指向 A.a(),但是我們源碼中的調用是來自方法 B.b() 的,所以內聯前後的優化信息我們是需要知道的,方便在後續堆棧信息追蹤時反推源碼信息。

下邊我們就看一下具體的解析方法

2039:2056:void com.example.application.GPL.check():39:56 -> a
2039:2056:void execute():76 -> a

方法最前邊的行號範圍如果相同,就代表一個內聯鏈中的方法調用鏈,比如以上兩句表示,方法 check 被內聯到了 execute 方法中,內聯的位置是原 execute 方法的第76行,如果末尾是行號範圍,那麼對應的就是最終的內聯方法體

開頭的行號是內聯函數調用鏈最底層的行號範圍和編譯器給予的一定的偏移量加和的結果,偏移量是
1000的倍數,偏移量的目的是避免與其他的正常的代碼範圍產生衝突,所以2039:2056是來自 check 方法的源碼行號範圍 39:56 與 2000 的偏移量相加得出的結果

另外,因爲 check 方法因爲不屬於類 com.example.application.Main,所以使用了類全限定符標識,標明 check 所處的類

2236:2252:void printConfiguration():236:252 -> a
2236:2252:void execute():80 -> a
3040:3042:java.io.PrintWriter com.example.application.util.PrintWriterUtil.createPrintWriterOut(java.io.File):40:42 -> a
3040:3042:void printConfiguration():243 -> a
3040:3042:void execute():80 -> a

以上 Mapping 文件的分析方法和之前的一致,唯一需要說明的是這其中的關聯
execute 方法在80行內聯了方法 printConfiguration,後者的行號範圍是 236:252,其中,printConfiguration 又在 243 行內聯了方法 createPrintWriterOut,後者的行號範圍是 40:42。

至此,我們分析完了 Mapping 文件的所有情況的格式,最後的兩行交由讀者自己嘗試分析一下。

R8 編譯器

當使用 Android Gradle 插件 3.4.0 或更高版本構建項目時,該插件不再使用 ProGuard 來執行編譯時代碼優化,而是與 R8 編譯器協同工作來處理編譯時任務,所以可以通過 Gradle 插件版本來查看具體使用了 Proguard 還是 R8 編譯器。

R8 編譯器一定程度上兼容 Proguard 規則,但是還是略有不同。

詳情可以參看官網:https://developer.android.com/studio/build/shrink-code?hl=zh-cn

註釋

Mapping 文件以 # 開頭的行作爲註釋,標識 R8 程序的格式,日期等信息,但是在 Proguard 中還未發現這樣的規範

例如以下:

# compiler: R8
# compiler_version: 1.6.82
# min_api: 19
# pg_map_id: 6af58cc
# common_typos_disable

與 Proguard 區別

R8 中的行號的表示方式和 Proguard 還不太一樣,以下的解析方式是基於 Proguard 新版的規範和源碼相印證的結果,在 R8 的官方文檔中,是直接導向 Proguard 官網的,並沒有自己的格式的說明(這點在 Hprof 格式也是),所以有誰找到對應的官方文檔,可以幫忙附到評論中,感謝。

androidx.appcompat.app.AppCompatActivity -> androidx.appcompat.app.AppCompatActivity:
    1:1:void <init>():77:77 -> <init>
    2:2:void <init>(int):92:92 -> <init>
    1:1:void addContentView(android.view.View,android.view.ViewGroup$LayoutParams):176:176 -> addContentView
    1:2:void attachBaseContext(android.content.Context):97:98 -> attachBaseContext
    1:4:void closeOptionsMenu():609:612 -> closeOptionsMenu
    1:2:boolean dispatchKeyEvent(android.view.KeyEvent):552:553 -> dispatchKeyEvent
    3:3:boolean dispatchKeyEvent(android.view.KeyEvent):555:555 -> dispatchKeyEvent
    4:4:boolean dispatchKeyEvent(android.view.KeyEvent):558:558 -> dispatchKeyEvent
    1:1:android.view.View findViewById(int):214:214 -> findViewById

這是一個 R8 編譯完成的 Mapping 文件示例,因爲都使用了 keep 屬性,所以沒有被混淆之後名稱沒有被字典中的字符替換掉,但這點對於分析 Mapping 格式沒有什麼影響。

和 Proguard 規範有所不同的是:

  1. 許多方法並沒有用一個連續的行號範圍標識,而是被拆分成了不同的子塊,每個子塊都有自身對應的行號範圍,方法前邊的是虛擬行號,對應後邊的真實行號範圍

例如:

1:1:void <init>():77:77 -> <init>
2:2:void <init>(int):92:92 -> <init>

......

1:2:boolean dispatchKeyEvent(android.view.KeyEvent):552:553 -> dispatchKeyEvent
3:3:boolean dispatchKeyEvent(android.view.KeyEvent):555:555 -> dispatchKeyEvent
4:4:boolean dispatchKeyEvent(android.view.KeyEvent):558:558 -> dispatchKeyEvent
  1. 各個方法的虛擬行號的範圍是有所重疊的,但是所對應的混淆之後的名稱是不同的,所以在區分不同方法上來說是沒有歧義的

例如:

1:2:void attachBaseContext(android.content.Context):97:98 -> attachBaseContext
1:4:void closeOptionsMenu():609:612 -> closeOptionsMenu

虛擬行號範圍重疊了,但實際的行號範圍是不一樣的,而且混淆後的名稱也是不同的

  1. R8 編譯出的文件中並未找到內聯方法相關的編譯優化,不確定是沒有開對應的優化項還是說根本就沒有這項優化,所以不會出現 Proguard 之前的內聯相關的調用棧的 Mapping 信息

後記

基於以上的 Mapping 文件的解析規則,我們可以做很多事情,比如反混淆 Trace 文件,反混淆 Nanoscope 文件,反混淆 Hprof 文件等等,我基於這個規則,開發了一個 ReProguard 的程序,可以供大家參考,歡迎交流提意見

項目地址:https://github.com/lixiao123/ReProguard

如果後續有時間,我會基於收集的資料寫一個 Hprof 文件格式的解析教程,歡迎評論交流。_

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章