本文介绍了一个针对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 interceptors = new ArrayList<>();
interceptors.addAll(client.interceptors());
//.....省略部分代码
return chain.proceed(originalRequest);
}
Dex 插桩
基本流程
Dex文件分析
先要分析Dex文件格式,将其序列化成各种数据结构,Dex文件的结构可以参照官方文档
字节码解析
在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;->(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;->(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