本文介紹了一個針對Dex進行插樁的工具,講解了一下直接修改Dalvik字節碼和Dex文件時遇到的問題和解決方法
作者:字節跳動終端技術—— 李言
背景
線下場景中,我們經常需要在APK中插入一些檢測代碼,來實現一些記錄方法調用耗時,或者增加一些打印日誌的功能。目前的常規做法都是在編譯期修改class字節碼達到,例如byteX提供了方便的修改class框架。
但是,編譯期修改靈活性不足,對於已經編譯好的apk則無能爲力,無法插樁或修改。導致很多業務方都要配置獨立的jenkins打包後,才能觸發進步一步的測試。一次自動化測試任務有將近一半的時間都消耗在打包過程中。
爲了解決這個痛點,我們開發了一套直接針對APK(dex)插樁的工具,DexInjector。主要用來做一些日誌、性能方面的數據採集和注入一些第三方工具,避免業務方二次打包,節省測試時間。
該方案已經用在日誌旁路、網絡數據抓取、第三方庫注入,用戶信息注入、日常調試等。
工具目前可以實現:
- 方法前插樁
- 方法後插樁
- 初始化插樁
技術方案調研
調研了一下市面上現有的字節碼修改方案。
smali
可以通過smali 和baksmali 工具將dex文件轉換成可方便閱讀的smali語法文件,但是smali的工具對smali字節碼的解析是通過語法解析,如果要插入一個新的代碼進去對寄存器等操作沒有辦法實現結構化操作。
redex
redex 支持通過配置在方法前進行插樁,可以通過實現pass來完成自己的插樁功能。但是功能實現有限,使用起來比較複雜,而且在執行之後插入了一些fb自定義的代碼,但Redex 提供了一套強大的字節碼修改能力,後續的版本會基於redex的字節碼修改能力進行完善。 https://github.com/facebook/redex/blob/master/opt/instrument/Instrument.cpp
dexter
https://android.googlesource.com/platform/tools/dexter/+/refs/heads/master
dexter 工具是google開發的一個類似dexdump的工具,但其內部實現了對dex文件結構和字節碼指令的一套完整的操作api,輕量簡潔,對字節碼的操作可以達到ASM的體驗。
綜合,選用dexter對dex進行操作。
方案設計
需求
根據性能防劣化和流量統計的需求,都是在一個方法的方法體內部前後插入對其他方法的調用。以網絡流量統計爲例,需要在 okhttp3.RealCall.getResponseWithInterceptorChain
的方法內部開頭插入一個方法來獲取request請求的詳細數據。
Response getResponseWithInterceptorChain() throws IOException {
com.netflow.inject.hookRealCall(this);//插入的方法
List<Interceptor> interceptors = new ArrayList<>();
interceptors.addAll(client.interceptors());
//.....省略部分代碼
return chain.proceed(originalRequest);
}
Dex 插樁
基本流程
1. Dex文件分析
先要分析Dex文件格式,將其序列化成各種數據結構,Dex文件的結構可以參照官方文檔
2. 字節碼解析
在code 段將二進制的字節碼解析成可處理的數據結構
3. 字節碼構造
按照字節碼規範構造字節碼指令,並插入到現有字節碼的序列中即可完成字節碼的插入。
4. 字節碼序列化
將修改後的Dex結構重新計算Index,然後將各個數據Section序列化爲Dex的文件格式。
功能需求
插樁支持兩種能力,在一個方法的方法體前面和後面插入一個靜態方法調用。
1. 方法體前面插樁
如果被插入的方法爲實例方法,則方法的第一參數爲 this
,隨後的參數和被插入的方法一致 ,如果方法是靜態方法則插入的方法定義需要和被插入的方法參數類型和個數一致,舉例:
public class Tracer{
//被插入的方法,爲實例方法
private void MethodA(int a,int b){
}
//被插入的方法,爲靜態方法
private static void MethodB(int a,int b){
}
}
public class Hooker{
//插入的方法
private static void TestHookA(Tracer this_,int a,int b){
}
private static void TestHookB(int a,int b){
}
}
////////插入後/////////
public class Tracer{
private void MethodA(int a,int b){
Hooker.TestHookA(this,a,b);
//......
}
private static void MethodB(int a,int b){
Hooker.TestHookB(a,b);
//.......
}
}
2. 方法體後面插樁
需要注意的是返回值的處理,插入的方法的返回值需要和被插入方法的返回值類型一致。
參數的處理需要注意,插入的方法需要符合以下規則:
方法名(this,被插入的方法參數,返回值類型)
舉例:
public class Tracer{
//被插入的方法
private void MethodA(int a, int b){
//......
}
private String MethodB(int a, int b){
//......
return str;
}
}
public class Hooker{
private static void TestHookA(Tracer this_,int a,int b){}
private static String TestHookB(Tracer this_,int a,int b,String return_val){
//return_val 參數的值爲原方法的真是返回值
return return_val;
}
}
////////插入後/////////
public class Tracer{
private void MethodA(int a, int b){
//......
Hooker.TestHookA(this, a, b);
}
private String MethodB(int a, int b){
//......
return Hooker.TestHookB(this, a, b, str);
}
}
3. 初始化插樁
一般用來插入一些需要提前初始化的代碼,該功能會解析AndroidManifest.xml
裏application
節點裏定義的Application類。
根據配置在OnCreate
或者 attachBaseContext
方法裏插入代碼。如果沒有定義OnCreate 和 attachBaseContext 方法,插樁工具會生成這兩個方法。
常見問題處理
由於Dex在格式和指令上的一些限制,在修改和插入字節碼的過程中需要符合Dex 和 dalvik指令了一些規則,下面描述了直接操作Dex遇到的一些問題和解決方法。
方法數處理
當代碼量增大後,由於Google早年設計缺陷,一個DEX文件只能容納 65535個方法、方法引用等,插樁本身不可避免會引入新的方法以及方法引用。在某些時候會有如下情況,APP的某個dex文件非常迫近65k,導致無法再插入新的方法調用,這種情況在大多數app中常見。
一種方案是將Dex整體合併在一起,然後進行拆分,此種方法會破壞原有Dex的一些優化,並且需要實現類之間的應用關係計算,計算量比較大,這裏採用一種輕量的解決方法。
Dex 拆包
解決方案1:
通過編譯時增加 --set-max-idx-number
迫使編譯器儘量不要塞滿dex,但是這種方案可能不會生效,如果這個apk被類似redex的工具處理後,dex也有概率會被填滿。
解決方案2:Dex 分拆邏輯
如果當前dex的方法數剩餘量不滿足插入新的方法則將現有dex拆出一部分類出來到一個額外的dex中。
以第一個dex的編譯邏輯爲例,在將maindex list和其引用的類都塞到dex後,一般方法數不會剛好到65535,如果超過了在編譯的過程中就會出現Too many classes in --``main-dex``-list
的錯誤。然後編譯器會將一些引用關係比較小的類填入第一個dex中。這些類就是我們要拆分的目標。
主要找到這個dex裏沒有調用到的類就滿足目標了,通過遍歷所有方法調用
、屬性引用
、類引用
的位置將所有類的引用過濾出來,可以將沒有調用到的類過濾出來,拆分到其他的dex中。
主要邏輯:
- 判斷該dex 的方法數是否可以繼續插樁,如果無法進行插樁則需要進行dex分割邏輯
- 遍歷每個類的每個方法的參數,記錄類型
- 遍歷每個類的屬性,記錄類型
- 遍歷每個方法的字節碼指令,通過方法調用,屬性引用,類型強轉的指令將引用的類型記錄下來
https://source.android.com/devices/tech/dalvik/instruction-formats
字節碼格式ID爲 22c
21c
31c
35c
3rc
的指令在最後的操作數都是一個類的方法或者屬性的引用,就可以將這個方法使用的類獲取到。
- 將所有
interface
annotation
保留在原dex中 - 剩下的class 就是這個dex中沒有使用到的class,可以將其拆分出去而對這個dex的執行不產生影響。
- 將沒有用到的class 單獨打包到一個額外的dex中,比如 現有dex有四個,則創建一個新的dex來保存。
這樣被插入的dex 就會省出一部分方法空間可以繼續插樁。
Dex 合併
-
分割dex合併
在Dex 分割完成後,dex分爲兩部分,我們需要將分割出來的dex合併成一個dex 附加到最後一個dex上面。
如上圖,classes.split.dex、classes3.split.dex、...... classes9.split.dex 會合併成同一個dex 爲classes11.dex
-
插樁dex合併
插樁方法調用的代碼一般不會打包到apk中,需要將代碼merge到apk中。這裏直接將需要插入的dex合併到最後一個dex上,如果最後一個dex無法合併則創建一個新的dex合併進去。
String Jumbo處理
在Dalvik字節碼中從常量池中讀取字符串到寄存器裏有兩個指令,const-string vAA, string@BBBB
和 const-string/jumbo vAA, string@BBBBBBBB
,第一條指令只支持訪問0-0xFFFF範圍的字符串,由於我們插入了新的方法調用,會新增字符串(類名、方法名)進去,在很多情況下會導致字符串總量超過65535,由於Dex格式要求必須使用 UTF-16 代碼點值按字符串內容進行排序,所以在插入新的字符串之後要進行重排序,重新排序之後會導致原先的字符串索引發生變化,引起原本使用 const-string
的指令訪問到高於0xFFFF索引的字符串,引起虛擬機執行異常。
插樁工具對此做了處理,在插樁完成後會掃描所有 const-string vAA, string@BBBB
指令,如果訪問的string index 超過65535 則強制將 const-string
修改爲 const-string/jumbo
指令。
混淆處理
目標方法被混淆
大部分情況下,目標方法都有比較大的概率會被混淆,所以我們在插樁的時候要基於mapping文件找到混淆後的目標函數然後進行插樁。
插入的dex使用了原APK中的類
很多情況下插樁方法調用到我們插入的dex都有可能使用到原來apk裏提供的類,由於原apk裏的類經歷過混淆所以直接通過混淆前的名稱調用會出現類、方法、屬性無法找到的異常。
通過mapping文件將插入的dex裏類名、方法名、屬性名進行一次混淆,將調用方強行修改成混淆後的名稱。
類被刪除,方法內聯/被刪除
- 優先考慮在原apk編譯的過程中增加混淆配置去解決。
- 如果調用的類和原apk邏輯關聯不是很大,則建議將使用到的類包名重命名,然後一起打入到dex中,這樣會表現爲apk中存在相同的類,但是包名不一致,插入的dex只調用自己集成的類,這樣就不用關心這個類的混淆問題。
- 很多情況下是需要使用到原apk的類,無法通過重命名包名來解決,比如通過參數傳入的類,在調用這些類的方法的時候可能會出現這個方法被混淆器刪除掉的情況,有可能是被內聯或者沒有其他位置使用到從而被刪除,那麼在調用過程中儘量避開調用方法。
- 有部分情況一些屬性的get set方法會被內聯成直接訪問屬性的情況
混淆前:
混淆後:
爲了避免這種情況儘量在調用get set方法的時候直接使用屬性訪問。
比如:
如果這個get set方法沒有被內聯掉,那麼會出現調用的屬性是是private 和 protected 則導致fileld驗證不通過,出現java.lang.IllegalAccessError: Field 'xxxx' is inaccessible to class
錯誤,解決方法是強行把被調用的屬性權限改成public
。需要提前指定要修改了哪些屬性的訪問權限。這些配置在一個配置文件裏進行設置,後面會說明如何設置。
類重複問題處理
大部分情況下我們會遇到插入的Dex 與被插入的APK 存在相同類名的類的問題,比如調用了共同的第三方庫,這裏最常見遇到的是使用Kotlin編寫的插入的dex,裏面會存在kotlin 庫。
這裏有兩種解決方法:
1. 剔除插入的Dex裏的重複類
在製作插入Dex的時候使用Dex插樁工具的按包名提取類的功能,將需要的類裁剪出來做成dex,這個時候就可以將一些與APK重複的類剔除出去,插入進去的Dex使用APK自身的庫,這個時候需要將插入的Dex按照mapping進行混淆才能夠正常進行調用。
2. 重命名衝突的第三方庫
將自身調用的重複類按照包名整體重名名調用。比如 kotlin
包重命名成 kotlin_copy
,這樣自己的dex 調用的是kotlin_copy.xxxx
就與原apk 不衝突了。
字節碼插樁
方法前插樁
在方法前面增加一條 invoke-static/range {}
的指令,將原方法的參數透傳到 hook 方法中
.method public static monitorEvent(Ljava/lang/String;Lorg/json/JSONObject;Lorg/json/JSONObject;Lorg/json/JSONObject;)V
.registers 9
//插樁代碼
invoke-static/range {p0 .. p3}, Lcom/bytedance/apm_bypass_tool/monitor/BypassMonitor;->monitorEvent(Ljava/lang/String;Lorg/json/JSONObject;Lorg/json/JSONObject;Lorg/json/JSONObject;)V
const/4 v0, 0x4
....
方法後插樁
1. 查找所有return 指令,在執行前面進行插樁
2. 返回值處理
由於要將返回值通過參數傳遞給hook方法使用,所以需要申請一個寄存器保留返回值的結果然後傳遞過去。
除 return-void
指令以外,其他return指令都附帶一個返回值,如下:
invoke-direct {p2, p0, p1}, Lcom/ss/android/lark/ico$1;-><init>(Lcom/ss/android/lark/ico;Ljava/lang/reflect/Type;)V
return-object v4
將p2 寄存器裏的值保存到一個額外的寄存器裏,然後獲取hook方法的返回值,再返回回去
invoke-direct {p2, p0, p1}, Lcom/ss/android/lark/ico$1;-><init>(Lcom/ss/android/lark/ico;Ljava/lang/reflect/Type;)V
move-result-object v4
invoke-static {p0, p1, p2, v4}, Lcom/netflow/inject/NetFlowHookReceiver;->hookCallServerInterceptor_executeCall_end(Lcom/ss/android/lark/ici;Lcom/ss/android/lark/idj;Lcom/ss/android/lark/icy;Lcom/ss/android/lark/idi;)Lcom/ss/android/lark/idi;
move-result-object v5 //如果不對返回值做修改的話這裏可以直接使用v4
return-object v5
參數寄存器複用問題
在某些情況下,編譯器在返回一個值的時候爲了複用寄存器,會複用參數寄存器來作爲通用寄存器,這就導致我們在方法後面獲取參數的時候,發現這個參數寄存器被複用了,就無法正確獲取到參數的值。
函數中引入的參數命名從p0開始,依次遞增。舉例一個方法會用到v0,v1,p0,p1,p2這五個寄存器,v0和v1表示局部變量寄存器,如果是實例方法的話,p0表示的是被傳入的this對象的引用,p1和p2分別表示兩個傳入的參數。
比如下面,就複用了p1寄存器來保存返回值,導致我們插樁方法無法獲取到正確的p1
參數
invoke-interface {p1, p2}, Lcom/ss/android/lark/idf;->a(Lcom/ss/android/lark/idh;)Lcom/ss/android/lark/idj
move-result-object p1
return-object p1
解決方法:
在原有寄存器數量上面擴展對應參數數量的寄存器即可解決這個問題,比如
一個方法寄存器佈局如下
v0 v1 v2 p0 p1 p2
在當前字節碼中複用了p1寄存器。
擴展當前同參數數量的寄存器之後,寄存器佈局如下:
v0 v1 v2 v3 v4 v5 p0 p1 p2
原字節碼引用p1的位置變成了v4,以上面的例子來說就是字節碼變成了如下形態:
invoke-interface {p1, p2}, Lcom/ss/android/lark/idf;->a(Lcom/ss/android/lark/idh;)Lcom/ss/android/lark/idj
move-result-object v4
return-object v4
這樣就防止參數寄存器被複用
寄存器擴展問題
在擴展寄存器的時候會遇到指令異常的問題,主要原因是寄存器數量擴展過多超過16個導致的,原字節碼的寄存器使用可以保證寄存器的正確使用,在插入的時候也要保證寄存器的正確。
在實踐中,一個方法需要 16 個以上的寄存器不太常見,而需要 8 個以上的寄存器卻相當普遍,因此很多指令僅限於尋址前 16 個寄存器。在合理的可能情況下,指令允許引用最多前 256 個寄存器。此外,某些指令還具有允許更多寄存器的變體,包括可尋址
v0
-v65535
範圍內的寄存器的一對 catch-allmove
指令。如果指令變體不能用於尋址所需的寄存器,寄存器內容會(在運算前)從原始寄存器移動到低位寄存器和/或(在運算後)從低位結果寄存器移動到高位寄存器。
例如,在指令“
move-wide/from16 vAA, vBBBB
”中:
“
move
”爲基礎運算碼,表示基礎運算(移動寄存器的值)。
“
wide
”爲名稱後綴,表示指令對寬(64 位)數據進行運算。
“
from16
”爲運算碼後綴,表示具有 16 位寄存器引用源的變體。
“
vAA
”爲目標寄存器(隱含在運算中;並且,規定目標參數始終在前),取值範圍爲v0
-v255
。
“
vBBBB
”是源寄存器,取值範圍爲v0
-v65535
。
比如在使用超過v16的寄存器的時候,要將move-object vA, vB
指令轉換爲move-object/from16 vAA, vBBBB
或者 move-object/16 vAAAA, vBBBB
插樁Dex製作
插樁 Dex 是我們要額外插入到APK裏的dex,也就是插樁代碼調用到的代碼。
生成Dex
舉個例子,將需要插入的代碼單獨放到一個gradle module中
編譯完成後解壓aar,取出jar包,通過d8
命令將java字節碼轉成dex
mkdir resources
./gradlew inject-dex:clean
./gradlew inject-dex:assembleRelease
d8 inject-dex/build/intermediates/aar_main_jar/release/classes.jar --output resources/
mv resources/classes.dex resources/netflow_caller.dex
mv resources/netflow_caller.dex netflow_caller.dex
方案1:抽取插樁類
由於編譯完成後一般會將一些系統庫和與原APK重複的第三方庫打包進去,所以需要將這些系統庫或者第三方庫過濾掉。
工具提供了一個根據包名抽取類的功能,可以將指定包名的類單獨拆成一個dex。
抽取前:
抽取後:
方案2:將重複的第三方庫重命名
可以將使用的第三方庫使用重命名功能進行重命名,這樣做比使用APK裏類的好處就是可以解決第三方庫的版本問題和混淆問題。
MARS- TALK 04 期來啦!
2月24日晚 MARS TALK 直播間,我們邀請了火山引擎 APMPlus 和美篇的研發工程師,在線爲大家分享「APMPlus 基於 Hprof 文件的 Java OOM 歸因方案」及「美篇基於MARS-APMPlus 性能監控工具的優化實踐」等技術乾貨。現在報名加入活動羣 還有機會獲得最新版VR一體機——Pico Neo3哦!
⏰ 直播時間:2月24日(週四) 20:00-21:30
💡 活動形式:線上直播
🙋 報名方式:掃碼進羣報名
作爲開年首期MARS TALK,本次我們爲大家準備了豐厚的獎品。除了Pico Neo3之外,還有羅技M720藍牙鼠標、筋膜槍及字節周邊禮品等你來拿。千萬不要錯過喲!
👉 點擊這裏,瞭解APMPlus