共计 9094 个字符,预计需要花费 23 分钟才能阅读完成。
本文介绍了一个针对 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;)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