Android外掛化、熱補丁中繞不開的Proguard的坑

美團技術團隊發表於2018-04-27

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

ProGuard簡介

ProGuard是2002年由比利時程式設計師Eric Lafortune釋出的一款優秀的開原始碼優化、混淆工具,適用於Java和Android應用,目標是讓程式更小,執行更快,在Java界處於壟斷地位。 主要分為四個模組:Shrinker(壓縮器)、Optimizer(優化器)、Obfuscator(混淆器)、Retrace(堆疊反混淆)。

  • 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 ...,針對這個問題我們進行了深入的研究,並找到了解決的方案,本文會對這個問題產生的緣由以及修復方案一一介紹。

現象

下面是在使用-applymapping之後ProGuard輸出的警告資訊,同時我們發現在使用-applymapping得到的混淆結果中這些方法的名稱都和原來宿主混淆結果的名稱不一致的現象,導致使用-applymapping後的結果和宿主不相容。

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是程式碼混淆階段輸出產物。

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出現了兩次不同的對映。為什麼會出現兩次不同的對映?這兩次不同的對映對增量混淆有影響嗎?

ProGuard文件對於這個問題沒有給出具體的原因和可靠的解決方案,在-applymapping一節提到如果程式碼發生結構性變化可能會輸出上面的警告,建議使用-useuniqueclassmembernames引數來降低衝突的風險,這個引數並不能解決這個問題。

為了解決這個問題,我們決定探究一下ProGuard原始碼來看下為什麼會出現這個問題,如何修復這個問題?

從原始碼中尋找答案

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

Android外掛化、熱補丁中繞不開的Proguard的坑

ProGuard對Class輸入分為兩類,一類是ProgramClass,另一類是LibraryClass。前者包含我們編寫程式碼、第三方的SDK,而後者通常是系統庫,不需要編譯到程式中,比如引用的android.jar、rt.jar。 ProgramMember是一個抽象類,擁有ProgramField和ProgramMethod兩個子類,分別表示欄位和方法,抽象類內部擁有一個Object visitorInfo的成員,這個欄位存放的是混淆後的名稱。

程式碼混淆

程式碼混淆可以認為是一個為類、方法、欄位重新命名的過程,可以使用-applymapping引數進行增量混淆。使用-applymapping引數時的過程可簡略的分為mapping複用、名稱混淆、混淆後名稱衝突處理三部分。

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

Android外掛化、熱補丁中繞不開的Proguard的坑
只有使用-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為例,如下圖:

Android外掛化、熱補丁中繞不開的Proguard的坑
在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就會出現對此方法對映。如果被內聯之後不會有其他方法呼叫這個方法不存在,但是該方法如果是因為繼承關係(子類繼承父類),這種方法通常存在。

整個流程是這樣的

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

Android外掛化、熱補丁中繞不開的Proguard的坑

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。

Android外掛化、熱補丁中繞不開的Proguard的坑
當進入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的狀態如下圖:

Android外掛化、熱補丁中繞不開的Proguard的坑
因為內聯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技術、逆向研究,習慣從原始碼中尋找解決方案。

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

如果對我們團隊感興趣,可以關注我們的專欄

Android外掛化、熱補丁中繞不開的Proguard的坑

相關文章