本文介绍了一个针对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 插桩
根本流程
Dex文件剖析
先要剖析Dex文件格式,将其序列化成各种数据结构,Dex文件的构造能够参照官网文档
Dalvik 可执行文件格局
字节码解析
在code 段将二进制的字节码解析成可解决的数据结构
字节码结构
依照字节码标准结构字节码指令,并插入到现有字节码的序列中即可实现字节码的插入。
字节码序列化
将批改后的Dex构造从新计算Index,而后将各个数据Section序列化为Dex的文件格式。
性能需要
插桩反对两种能力,在一个办法的办法体后面和前面插入一个静态方法调用。
办法体后面插桩
如果被插入的办法为实例办法,则办法的第一参数为 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); //....... }}
办法体前面插桩
须要留神的是返回值的解决,插入的办法的返回值须要和被插入方法的返回值类型统一。
参数的解决须要留神,插入的办法须要合乎以下规定:
办法名(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); }}
初始化插桩
个别用来插入一些须要提前初始化的代码,该性能会解析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](https://my.oschina.net/u/205605)
和 const-string/jumbo vAA, string[@BBBBBBBB](https://my.oschina.net/u/2326784)
,第一条指令只反对拜访0-0xFFFF范畴的字符串,因为咱们插入了新的办法调用,会新增字符串(类名、办法名)进去,在很多状况下会导致字符串总量超过65535,因为Dex格局要求必须应用 UTF-16 代码点值按字符串内容进行排序,所以在插入新的字符串之后要进行重排序,从新排序之后会导致原先的字符串索引发生变化,引起本来应用 const-string
的指令拜访到高于0xFFFF索引的字符串,引起虚拟机执行异样。
插桩工具对此做了解决,在插桩实现后会扫描所有 const-string vAA, string[@BBBB](https://my.oschina.net/u/205605)
指令,如果拜访的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 库。
这里有两种解决办法:
剔除插入的Dex里的反复类
在制作插入Dex的时候应用Dex插桩工具的按包名提取类的性能,将须要的类裁剪进去做成dex,这个时候就能够将一些与APK反复的类剔除进来,插入进去的Dex应用APK本身的库,这个时候须要将插入的Dex依照mapping进行混同才可能失常进行调用。
重命名抵触的第三方库
将本身调用的反复类依照包名整体重名名调用。比方 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 ....
办法后插桩
- 查找所有return 指令,在执行后面进行插桩
- 返回值解决
因为要将返回值通过参数传递给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;)Vreturn-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;)Vmove-result-object v4invoke-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 //如果不对返回值做批改的话这里能够间接应用v4return-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/idjmove-result-object v4return-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:assembleReleased8 inject-dex/build/intermediates/aar_main_jar/release/classes.jar --output resources/mv resources/classes.dex resources/netflow_caller.dexmv 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