APK修改神器:插桩工具 DexInjector
技术干货哪里找?
👆 点击上方蓝字关注我们!
背景
线下场景中,我们经常需要在APK中插入一些检测代码,来实现一些记录方法调用耗时,或者增加一些打印日志的功能。目前的常规做法都是在编译期修改class字节码达到,例如byteX提供了方便的修改class框架。
但是,编译期修改灵活性不足,对于已经编译好的apk则无能为力,无法插桩或修改。导致很多业务方都要配置独立的jenkins打包后,才能触发进步一步的测试。一次自动化测试任务有将近一半的时间都消耗在打包过程中。
为了解决这个痛点,我们开发了一套直接针对APK(dex)插桩的工具,DexInjector。主要用来做一些日志、性能方面的数据采集和注入一些第三方工具,避免业务方二次打包,节省测试时间。
该方案已经用在日志旁路、网络数据抓取、第三方库注入,用户信息注入、日常调试等。
工具目前可以实现:
方法前插桩
方法后插桩
初始化插桩
技术方案调研
smali
redex
redex 支持通过配置在方法前进行插桩,可以通过实现pass来完成自己的插桩功能。但是功能实现有限,使用起来比较复杂,而且在执行之后插入了一些fb自定义的代码,但Redex 提供了一套强大的字节码修改能力,后续的版本会基于redex的字节码修改能力进行完善。
dexter
方案设计
需求
根据性能防劣化和流量统计的需求,都是在一个方法的方法体内部前后插入对其他方法的调用。以网络流量统计为例,需要在 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文件分析
3. 字节码构造
4. 字节码序列化
功能需求
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中。
主要逻辑:
1. 判断该dex 的方法数是否可以继续插桩,如果无法进行插桩则需要进行dex分割逻辑
2. 遍历每个类的每个方法的参数,记录类型
3. 遍历每个类的属性,记录类型
4. 遍历每个方法的字节码指令,通过方法调用,属性引用,类型强转的指令将引用的类型记录下来
Dex 合并
1. 分割dex合并
classes11.dex
2. 插桩dex合并
String Jumbo处理
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
指令。混淆处理
目标方法被混淆
插入的dex使用了原APK中的类
类被删除,方法内联/被删除
1. 优先考虑在原apk编译的过程中增加混淆配置去解决。
2. 如果调用的类和原apk逻辑关联不是很大,则建议将使用到的类包名重命名,然后一起打入到dex中,这样会表现为apk中存在相同的类,但是包名不一致,插入的dex只调用自己集成的类,这样就不用关心这个类的混淆问题。
3. 很多情况下是需要使用到原apk的类,无法通过重命名包名来解决,比如通过参数传入的类,在调用这些类的方法的时候可能会出现这个方法被混淆器删除掉的情况,有可能是被内联或者没有其他位置使用到从而被删除,那么在调用过程中尽量避开调用方法。
4. 有部分情况一些属性的get set方法会被内联成直接访问属性的情况
混淆前:
java.lang.IllegalAccessError: Field 'xxxx' is inaccessible to class
错误,解决方法是强行把被调用的属性权限改成public。需要提前指定要修改了哪些属性的访问权限。这些配置在一个配置文件里进行设置,后面会说明如何设置。类重复问题处理
1. 剔除插入的Dex里的重复类
2. 重命名冲突的第三方库
字节码插桩
方法前插桩
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
....
由于要将返回值通过参数传递给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
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
参数寄存器复用问题
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
举个例子,将需要插入的代码单独放到一个gradle module中
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:将重复的第三方库重命名
参考文献
1.redex:
https://github.com/facebook/redex/blob/master/opt/instrument/Instrument.cpp
2.dexter:
https://android.googlesource.com/platform/tools/dexter/+/refs/heads/master
3. Dalvik 可执行文件格式:
https://source.android.google.cn/devices/tech/dalvik/dex-format?hl=zh-cn
4. --set-max-idx-number:
https://stackoverflow.com/questions/27631500/is-there-a-way-to-limit-method-amount-in-main-dex-file-while-using-multidex-feat/27766126
5. Dalvik 可执行指令格式:
https://source.android.com/devices/tech/dalvik/instruction-formats
MARS- TALK 04 期来啦!
2月24日晚 MARS TALK 直播间,我们邀请了火山引擎 APMPlus 和美篇的研发工程师,在线为大家分享「APMPlus 基于 Hprof 文件的 Java OOM 归因方案」及「美篇基于MARS-APMPlus 性能监控工具的优化实践」等技术干货。现在报名加入活动群 还有机会获得最新版VR一体机——Pico Neo3哦!
作为开年首期MARS TALK,本次我们为大家准备了丰厚的奖品。除了Pico Neo3之外,还有罗技M720蓝牙鼠标、筋膜枪及字节周边礼品等你来拿。千万不要错过哟!
报名赢大奖
惊喜好礼带回家
👇 点击阅读原文,了解APMPlus