插件化、热补丁中绕不开的Proguard的坑
文章主體部分已經發表于《程序員》雜志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的用途
mapping的組成
以->為分界線,表示原始名稱->新名稱。
一段與-applymapping出錯有關的mapping
GifFrameLoader -> g:com.bumptech.glide.load.resource.gif.GifFrameLoader$FrameCallback callback -> a60:64:void setFrameTransformation(com.bumptech.glide.load.Transformation) -> a67:74:void start() -> a77:78:void stop() -> b81:88:void clear() -> c2077:2078:void stop():77:78 -> c2077:2078:void clear():81 -> c91:91:android.graphics.Bitmap getCurrentFrame() -> d95:106:void loadNextFrame() -> eGifFrameLoader映射為g。在代碼里面,每個類、類成員只有一個新的映射名稱,其中stop出現了兩次不同的映射。為什么會出現兩次不同的映射?這兩次不同的映射對增量混淆有影響嗎?
ProGuard文檔對于這個問題沒有給出具體的原因和可靠的解決方案,在-applymapping一節提到如果代碼發生結構性變化可能會輸出上面的警告,建議使用-useuniqueclassmembernames參數來降低沖突的風險,這個參數并不能解決這個問題。
為了解決這個問題,我們決定探究一下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。
2. 混淆處理
混淆以類為單位,可以分為兩部分,第一部分是收集映射關系,第二部分是名稱混淆。判斷是否存在映射關系,如果不存在的話分配一個新名稱。
第一部分:映射名稱收集
MemberNameCollector收集ProgramMember的visitorInfo,并把相同描述符的方法或字段放入同一個map<混淆后名稱,原始名稱>。
String newName = MemberObfuscator.newMemberName(member);//獲取visitorInfoif (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 -> c2077: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() -> c2077:2078:void stop():77:78 -> c//stop方法77-78行復制到c中偏移為2077-20782077: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一些常見問題
除了本文提到的增量混淆方法映射混亂,開發者也會遇到下面這些情況:
總結
本文主要介紹了Java優化&混淆工具ProGuard的基本原理、ProGuard的幾個模塊之間的相互關系與影響、以及增量混淆使用-applymapping遇到部分方法映射錯亂的Bug,Bug出現的原因以及修復方案。代碼優化涉及的編譯器理論比較抽象,實現也比較復雜,鑒于篇幅限制我們只介紹了代碼優化對整個過程帶來的影響,對于代碼優化有興趣的讀者可以查閱編譯器相關的書籍。
作者簡介
- 李挺,美團點評技術專家,2014年加入美團。先后負責過多個業務項目和技術項目,致力于推動AOP和字節碼技術在美團的應用。曾獨立負責美團App預裝項目并推動預裝實現自動化。主導了美團插件化框架的設計和開發工作,目前工作重心是美團插件化框架的布道和推廣。
- 夏偉,美團點評資深工程師,2017年加入美團。目前從事美團插件化開發,美團平臺的一些底層工具優化,如AAPT、ProGuard等,專注于Hook技術、逆向研究,習慣從源碼中尋找解決方案。
美團平臺客戶端技術團隊,負責美團平臺的基礎業務和移動基礎設施的開發工作。基于海量用戶的美團平臺,支撐了美團點評多條業務線的快速發展。同時,我們也在移動開發技術方面做了一些積極的探索,在動態化、質量保障、開發模型等方面有一定積累。客戶端技術團隊積極采用開源技術的同時,也把我們的一些積累回饋給開源社區,希望跟業界一起推動移動開發效率、質量的提升。
總結
以上是生活随笔為你收集整理的插件化、热补丁中绕不开的Proguard的坑的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Spring Cloud Alibaba
- 下一篇: Java动态追踪技术探究