Android插件化、熱補丁中繞不開的Proguard的坑

文章主體部分已經發表於《程序員》雜誌2018年2月期,內容略有改動。html

ProGuard簡介

ProGuard是2002年由比利時程序員Eric Lafortune發佈的一款優秀的開源代碼優化、混淆工具,適用於Java和Android應用,目標是讓程序更小,運行更快,在Java界處於壟斷地位。 主要分爲四個模塊:Shrinker(壓縮器)、Optimizer(優化器)、Obfuscator(混淆器)、Retrace(堆棧反混淆)。java

  • Shrinker 經過引用標記算法,將沒用到的代碼移除掉。
  • Optimizer 經過複雜的算法(Partial Evaluation &Peephole optimization,這部分算法咱們再也不展開介紹)對字節碼進行優化,代碼優化會使部分代碼塊的結構出現變更。 舉幾個例子: -- 某個非靜態方法內部沒有使用this沒有繼承關係,這個方法就能夠改成靜態方法。 -- 某個方法(代碼不是很長)只被調用一次,這個方法就能夠被內聯。 -- 方法中的參數沒有使用到,這個參數能夠被移除掉。 -- 局部變量重分配,好比在if外面初始化了一個變量,可是這個變量只在if內部用到,這樣就能夠將變量移動的if內部去。
  • Obfuscator 經過一個混淆名稱發生器產生a、b、c的毫無心義名稱來替換原來正常的名稱,增長逆向的難度。
  • Retrace 通過ProGuard處理後的字節碼運行的堆棧已經跟沒有處理以前的不同了,除了出現名稱上的變化還伴隨着邏輯上的變化,程序崩潰後,開發者須要藉助Retrace將錯誤堆棧恢復爲沒有通過ProGuard處理的樣子。

背景

在咱們實施插件化、熱補丁修復時,爲了讓插件、補丁和原來的宿主兼容,必須依賴ProGuard的applymapping功能的進行增量混淆,但在使用ProGuard的applymapping時會遇到部分方法混淆錯亂的問題,同時在ProGuard的日誌裏有這些警告信息Warning: ... is not being kept as ..., but remapped to ...,針對這個問題咱們進行了深刻的研究,並找到了解決的方案,本文會對這個問題產生的原因以及修復方案一一介紹。android

現象

下面是在使用-applymapping以後ProGuard輸出的警告信息,同時咱們發如今使用-applymapping獲得的混淆結果中這些方法的名稱都和原來宿主混淆結果的名稱不一致的現象,致使使用-applymapping後的結果和宿主不兼容。git

Printing mapping to [.../mapping.txt]...
...
Warning: com.bumptech.glide.load.resource.gif.GifFrameLoader: method 'void stop()' is not being kept as 'b', but remapped to 'c'
Warning: there were 6 kept classes and class members that were remapped anyway.
         You should adapt your configuration or edit the mapping file.
         (http://proguard.sourceforge.net/manual/troubleshooting.html#mappingconflict1)
...
Warning: com.bumptech.glide.load.resource.gif.GifFrameLoader: method 'void stop()' can't be mapped to 'c' because it would conflict with method 'clear', which is already being mapped to 'c'
Warning: there were 2 conflicting class member name mappings.

複製代碼
applymaping先後的映射關係變化
@@ -1491,7 +1491,7 @@ BitmapRequestBuilder -> com..glide.a:
- 264:265:BitmapRequestBuilder transform(cBitmapTransformation[]) -> a
+ 264:265:BitmapRequestBuilder transform(BitmapTransformation[]) -> b

@@ -3532,7 +3532,7 @@ GifFrameLoader -> com.bumptech.glide.load.r
- 77:78:void stop() -> b
+ 77:78:void stop() -> c_

複製代碼
初次混淆 增量混淆
transform->a transform->b
stop->b stop->c_

stop方法做爲一個公用方法存在的宿主中,而子模塊依賴於宿主中的stop方法。子模塊升級以後依然依賴宿主的接口、公共方法,這要確保stop方法在子模塊升級先後是一致的。當使用-applymapping進行增量編譯時stop由b映射爲c_。升子模塊依賴的stop方法不兼容,形成子模塊沒法升級。程序員

瞭解一下mapping

mapping.txt是代碼混淆階段輸出產物。github

mapping的用途
  1. retrace使用mapping文件和stacktrace進行ProGuard前的堆棧還原。
  2. 使用-applymapping配合mapping文件進行增量混淆。
mapping的組成

->爲分界線,表示原始名稱->新名稱算法

  1. 類映射,特徵:映射以:結束。
  2. 字段映射,特徵:映射中沒有()
  3. 方法映射,特徵:映射中有(),而且左側的擁有兩個數字,表明方法體的行號範圍。
  4. 內聯,特徵:與方法映射相比,多了兩個行號範圍,右側的行號表示原始代碼行,左側表示新的行號。
  5. 閉包,特徵:只有三個行號,它與內聯成對出現。
  6. 註釋,特徵:以#開頭,一般不會出如今mapping中。
一段與-applymapping出錯有關的mapping
GifFrameLoader -> g:
    com.bumptech.glide.load.resource.gif.GifFrameLoader$FrameCallback callback -> a
    60:64:void setFrameTransformation(com.bumptech.glide.load.Transformation) -> a 67:74:void start() -> a 77:78:void stop() -> b 81:88:void clear() -> c 2077:2078:void stop():77:78 -> c 2077:2078:void clear():81 -> c 91:91:android.graphics.Bitmap getCurrentFrame() -> d 95:106:void loadNextFrame() -> e 複製代碼

GifFrameLoader映射爲g。在代碼裏面,每一個類、類成員只有一個新的映射名稱,其中stop出現了兩次不一樣的映射。爲何會出現兩次不一樣的映射?這兩次不一樣的映射對增量混淆有影響嗎?api

ProGuard文檔對於這個問題沒有給出具體的緣由和可靠的解決方案,在-applymapping一節提到若是代碼發生結構性變化可能會輸出上面的警告,建議使用-useuniqueclassmembernames參數來下降衝突的風險,這個參數並不能解決這個問題。bash

爲了解決這個問題,咱們決定探究一下ProGuard源碼來看下爲何會出現這個問題,如何修復這個問題?網絡

從源碼中尋找答案

先看一下ProGuard怎麼表示一個方法:

ProGuard對Class輸入分爲兩類,一類是ProgramClass,另外一類是LibraryClass。前者包含咱們編寫代碼、第三方的SDK,然後者一般是系統庫,不須要編譯到程序中,好比引用的android.jar、rt.jar。 ProgramMember是一個抽象類,擁有ProgramField和ProgramMethod兩個子類,分別表示字段和方法,抽象類內部擁有一個Object visitorInfo的成員,這個字段存放的是混淆後的名稱。

代碼混淆

代碼混淆能夠認爲是一個爲類、方法、字段重命名的過程,可使用-applymapping參數進行增量混淆。使用-applymapping參數時的過程可簡略的分爲mapping複用、名稱混淆、混淆後名稱衝突處理三部分。

流程簡化後以下圖(左右兩個大虛線框表明了對單個類的兩次處理,分別是名稱混淆和衝突處理):

只有使用 -applymapping參數時MappingKeeper纔會執行,不然跳過該步驟。

1. MappingKeeper

它的做用就是複用上次的mapping映射,讓ProgramMember的visitorInfo恢復到上次混淆的狀態。

  • 若是是新加方法,visitorInfo爲null。
  • 若是一個方法存在多份映射,新出現的映射會覆蓋舊的映射並輸出警告Warning: ... is not being kept as ..., but remapped to
public void processMethodMapping(String className, int firstLineNumber, int lastLineNumber, ... int newFirstLineNumber, int newLastLineNumber, String newMethodName) {
    if (clazz != null && className.equals(newClassName))
    {
        String descriptor = ClassUtil.internalMethodDescriptor(methodReturnType,ListUtil.commaSeparatedList(methodArguments));
        Method method = clazz.findMethod(methodName, descriptor);
        if (method != null)
        {
            // Print out a warning if the mapping conflicts with a name that
            // was set before.
            // Make sure the mapping name will be kept.
            MemberObfuscator.setFixedNewMemberName(method, newMethodName);
        }
    }
}
複製代碼
2. 混淆處理

混淆以類爲單位,能夠分爲兩部分,第一部分是收集映射關係,第二部分是名稱混淆。判斷是否存在映射關係,若是不存在的話分配一個新名稱。 第一部分:映射名稱收集 MemberNameCollector收集ProgramMember的visitorInfo,並把相同描述符的方法或字段放入同一個map<混淆後名稱,原始名稱>

String newName = MemberObfuscator.newMemberName(member);//獲取visitorInfo
        if (newName != null)
        {
            String descriptor = member.getDescriptor(clazz);
            Map nameMap = MemberObfuscator.retrieveNameMap(descriptorMap, descriptor);
            String otherName = (String)nameMap.get(newName);
            if (otherName == null                              ||
                MemberObfuscator.hasFixedNewMemberName(member) ||
                name.compareTo(otherName) < 0)
            {
                nameMap.put(newName, name);
            }
        }
複製代碼

若是visitorInfo出現相同名稱,map中的鍵值對會被後出現的方法(以在Class中的順序爲準)覆蓋,可能會致使錯誤映射覆蓋正確映射。

第二部分:名稱混淆

若是visitorInfo爲null的話爲member分配新名稱,第一部分收集的map來確保NameFactory產生的新名稱不會跟現有的衝突,nextName()這個裏面有個計數器,每次產生新名稱都自加,這就是出現a、b、c的緣由。這一步只會保證map裏面出現映射與新產生的映射不會出現衝突。

Map nameMap = retrieveNameMap(descriptorMap, descriptor);
        String newName = newMemberName(member);
        if (newName == null)
        {  nameFactory.reset();
            do{newName = nameFactory.nextName();}
            while (nameMap.containsKey(newName));
            nameMap.put(newName, name);
            setNewMemberName(member, newName);
        }
複製代碼
3. 混淆名稱衝突的處理

混淆衝突處理的第一步同混淆的第一步,先收集ProgramMember的visitorInfo,此時map跟混淆處理過程的狀態同樣。

衝突的判斷代碼:

Map nameMap = MemberObfuscator.retrieveNameMap(descriptorMap, descriptor);
        String newName = MemberObfuscator.newMemberName(member);
        String previousName = (String)nameMap.get(newName);
        if (previousName != null &&!name.equals(previousName))
        {   MemberObfuscator.setNewMemberName(member, null);
            member.accept(clazz, memberObfuscator);
        }
複製代碼

取出當前ProgramMethod中的visitorInfo,用這個visitorInfo做爲key到map裏面取value,若是value跟當前的ProgramMethod不相同話,說明value覆蓋了ProgramMethod映射,認爲當前ProgramMethod映射與map中的映射衝突,當前的映射關係失效,把visitorInfo設爲null,而後再次調用MemberObfuscator爲ProgramMethod產生一個新名稱,NameFactory會爲新名稱加入一個_做爲後綴,這樣會出現某一些方法混淆出現下劃線。

4. 最終的代碼輸出

代碼優化以後再也不對字節碼進行修改,上面的主要是爲類、類成員的名稱進行映射關係分配以及映射衝突的處理, 當衝突解決完以後纔會輸出mapping.txt、修改字節碼、引用修復、生成output.jar。

5. 關於mapping的生成

在mapping生成過程當中,除了生成類、方法、字段的映射關係,還記錄了方法的內聯的信息。

2077:2078:void stop():77:78 -> c 2077:2078:void clear():81 -> c 複製代碼

第一行表示:從右邊的代碼範圍偏移到左側的範圍(方法c中的2077-2087行來自stop方法的),第二行表示偏移來的代碼最終的位置(81行的方法調用修改成2077-2078行代碼)。這兩行並非普通的映射。

代碼優化

剛纔咱們講了,mapping裏面有一段內聯信息,如今看爲何mapping裏面出現一段看起來跟混淆無關的內聯。 上文講到,mapping裏面存在一段內聯信息,之因此mapping裏面出現一段看起來跟混淆無關的內聯,這是由於javac在代碼編譯過程當中並無作太多的代碼優化,只作了一些很簡單的優化,好比字符串連接str1+str2+str3會優化爲StringBuilder,減小了對象分配。

當引入的大量代碼、庫以及某些廢棄的代碼依然停留在倉庫時,這些冗餘的代碼佔用大量的磁盤、網絡、內存。ProGuard代碼優化能夠解決這些問題,移除沒有使用到的代碼、優化指令、邏輯,以及方法內部的局部變量分配和內聯,讓程序運行的更快、佔用磁盤、內存更低。 內聯:在編譯期間的調用內聯的方法進行展開,減小方法調次數,消耗更少的CPU。可是Java中沒有inline這個關鍵字,ProGuard又是怎麼對方法作的內聯呢?

內聯

在代碼優化過程當中,對某一些方法進行內聯(將被內聯的方法體內容Copy到調用方調用被內聯方法處,是一個代碼展開的過程),修改了調用方的代碼結構,因此被內聯的方法Copy到調用方時須要考慮帶來的反作用。當Copy來的代碼發生崩潰時,Java stacktrace沒法體現真實的崩潰堆棧和方法調用關係,它受調用方自身代碼和內聯Copy的代碼相互影響。 內聯主要分爲兩類:unique method 和short method,前者被調用而且只被調用一次,然後者被調用屢次可能,可是這個方法code_length小於8(並不代碼行數)。知足這兩種的方法纔可能被內聯。

以clear調用stop爲例,以下圖:

在clear的81行調用stop,發生內聯,stop的方法內容複製到81行處,很明顯不可使用以前的77-78行,在81行後的新代碼從原來的77-78偏移爲2077-2078。內聯信息對retrace有用:

81:88:void clear() -> c
    2077:2078:void stop():77:78 -> c//stop方法77-78行復制到c中偏移爲2077-2078
    2077:2078:void clear():81 -> c//2077-2078插入到c中的81行後,c爲clear方法
複製代碼

當內聯處發生崩潰,根據2077-2078肯定是stop方法發生崩潰,而stop實際clear的81行調用,根據2077-2078的偏移還原原始的堆棧應該是:clear方法81行調用stop方法(77-78行)發生崩潰。

行號的規則簡化後以下: (被內聯方法的代碼行數+1000後/1000)x1000x內聯發生的次數+offset,offset爲被內聯的起始行號。 Copy的代碼最低行號爲1000+起始行號,若是行數大於1k的話取整以後+起始行號。

對於被內聯的方法還存在嗎?

這個是不必定,可能不存在,也可能存在,若是存在的話mapping就會出現對此方法映射。若是被內聯以後不會有其餘方法調用這個方法不存在,可是該方法若是是由於繼承關係(子類繼承父類),這種方法一般存在。

整個流程是這樣的

這幾個模塊並非沒關聯的,接下來把整個流程串起來。

1. 初始化

ProGuard初始化會讀取咱們配置的proguard-rule.txt和各類輸入類以及依賴的類庫,輸入的類被ClassPool統一管理,咱們的rule.txt配置了keep類的條件,ProGuard會根據keep規則和輸入Classes肯定最終須要被keep的類信息列表,這一份列表就是所謂的seeds.txt(種子),之後全部的操做(混淆、壓縮、優化)都已seeds爲基準,沒有被seeds引用的代碼均可以移除掉。

2. shrink

這部經過引用標記算法,若是沒有被用到的類、類成員支持從ClassPool移除掉,只有第一次調用shrink纔會產生usage.txt記錄了移除掉的類、方法、字段。

3. optimize

代碼優化作的事情比較複雜,這一部分對類進行優化,包括優化邏輯、變量分配、死代碼移除,移除方法中沒用的參數、優化指令、以及方法的內聯,咱們知道內聯發生了代碼Copy,被Copy的代碼不會被當前方法調用。代碼優化完以後會從新執行一次shrink,對於被內聯的方法可能真的沒有引用,這樣就會被移除,可是若是被內聯的方法繼承關係,這種就要保留。

4. obfuscate

混淆以類爲單位,爲類、類成員分配名稱,處理衝突名稱,輸出mapping文件,以後會輸出一份通過優化、混淆後的jar。若是使用`-applymapping參數進行增量編譯會從mapping裏面獲取映射關係,找不到映射關係纔會爲方法、字段分配新名稱。mapping文件記錄了兩類信息:第一類是普通的映射關係,第二類就是內聯關係(這部分源於optimize,跟混淆並無直接關係),對於retrace這兩類信息都須要,可是對於增量混淆只須要映射關係。

再次回到mapping文件

MappingKeeper讀取mapping發生了什麼錯誤?

在執行混淆時,MappingKeeper會把mapping中存在的映射關係爲ProgramMethod的visitorInfo賦值,可是沒有區分普通映射仍是內聯,雖然stop方法最初被正確的賦值爲b,可是由於內聯接下來被錯誤的賦值爲c,此時clear的visitorInfo也是c。

當進入MemberNameCollector收集映射關係。stop和clear方法對應的visitorInfo都是c。由於stop方法排序位於clear以後。雖然stop方法的映射被蒐集了,但收集到clear以後會把stop的映射覆蓋掉,此時map裏面已經沒有了stop的映射,如左上圖。若是stop方法visitorInfo並無被覆蓋此時狀態如右上圖。

進入解決衝突環節

stop的visitorInfo爲c,根據map裏面的c取到爲clear,認爲stop跟map裏面的映射存在衝突,把stop的visitorInfo設爲null,而後從新爲stop分爲一個帶有下劃線的名稱。

假設clear的描述符不是void類型而且被混淆爲f那麼map的狀態以下圖:

由於內聯 stop()->f的干擾,map中stop的visitorInfo由b變爲f,可是名稱爲f的這個方法並不與其餘返回值爲void類型、參數爲空的方法的visitorInfo存在衝突。這個狀況就跟文章開頭例子裏提到的另外一個方法transform同樣雖然錯亂了,可是並不會出現下劃線。

Sample

這個Bug有些項目上很難復現,或者能復現該Bug的項目過於複雜,咱們寫了一個能夠觸發這個Bug的Sample。 下載項目後首先./gradlew assembleDebug產生一個mapping文件,而後把mapping複製到app目錄下,到Proguard rule打開-applymapping選項再次編譯就會出現Warning: ... is not being kept as ..., but remapped to ...

關於ProGuard一些常見問題

除了本文提到的增量混淆方法映射混亂,開發者也會遇到下面這些狀況:

  1. 反射,例如Class clazz=Class.forName("xxxx");clazz.getMethod("method_name").invoke(...)xxxx.class.getMethod("method_name").invoke(...)這兩種寫法效果一不同的,後者混淆的時候能正確處理,而前者method_name可能找不到,須要在rule中keep反射的方法。

  2. 規則混寫會致使配置錯誤如-optimizations !code/** method/**,只容許使用確定或者或者否認規則,!號爲否認規則。

  3. 在6.0以前的版本大量單線程操做,整個處理過程比較耗時,若是時間能夠將-optimizationpasses參數改成1,這樣只進行一次代碼優化,後面的代碼優化帶來的提高不多。

總結

本文主要介紹了Java優化&混淆工具ProGuard的基本原理、ProGuard的幾個模塊之間的相互關係與影響、以及增量混淆使用-applymapping遇到部分方法映射錯亂的Bug,Bug出現的緣由以及修復方案。代碼優化涉及的編譯器理論比較抽象,實現也比較複雜,鑑於篇幅限制咱們只介紹了代碼優化對整個過程帶來的影響,對於代碼優化有興趣的讀者能夠查閱編譯器相關的書籍。

做者簡介

李挺,美團點評技術專家,2014年加入美團。前後負責過多個業務項目和技術項目,致力於推進AOP和字節碼技術在美團的應用。曾獨立負責美團App預裝項目並推進預裝實現自動化。主導了美團插件化框架的設計和開發工做,目前工做重心是美團插件化框架的佈道和推廣。

夏偉,美團點評資深工程師,2017年加入美團。目前從事美團插件化開發,美團平臺的一些底層工具優化,如AAPT、ProGuard等,專一於Hook技術、逆向研究,習慣從源碼中尋找解決方案。

美團平臺客戶端技術團隊,負責美團平臺的基礎業務和移動基礎設施的開發工做。基於海量用戶的美團平臺,支撐了美團點評多條業務線的快速發展。同時,咱們也在移動開發技術方面作了一些積極的探索,在動態化、質量保障、開發模型等方面有必定積累。客戶端技術團隊積極採用開源技術的同時,也把咱們的一些積累回饋給開源社區,但願跟業界一塊兒推進移動開發效率、質量的提高。

若是對咱們團隊感興趣,能夠關注咱們的專欄

相關文章
相關標籤/搜索