写点什么

字节码引用检测原理与实战

  • 2021 年 12 月 07 日
  • 本文字数:7436 字

    阅读完需:约 24 分钟

一、字节码与引用检测


1.1 Java 字节码


本章中的字节码重点研究 Java 字节码,Java 字节码(Java bytecode)是 Java 虚拟机执行的一种指令格式。可以通过 javap -c -v xxx.class(Class 文件路径) 命令来查看一个 Class 对应的字节码文件,如下图所示:



1.2 字节码检测


字节码检测本质就是对.java 或.kt 文件编译后生成的 Class 文件进行相关的分析和检测。在正式介绍字节码分析在引用检测上的原理与实战前,先介绍下字节码引用检测的技术预研背景。


二、字节码检测技术的预研背景


整个预研背景需要先从笔者负责的 APP--内销官网 APP 的软件架构讲起。


2.1 内销官网 APP 软件架构


内销官网 APP 目前共 12 个子仓,子仓分别独立编译成 AAR 文件供 APP 工程使用,软件架构图如下图所示:



APP 以下,上层浅蓝色为业务层,中间绿色为组件层,最下层深蓝色为基础框架层:


  • 业务层:位于架构最上层,根据业务线划分的业务模块(比如商城、社区、服务),与产品业务相对应。


  • 组件层:是 APP 的一些基础功能(比如登录、自升级)和业务公用的组件(比如分享、地址管理、视频播放),提供一定的复用能力。


  • 基础框架层:通过跟业务完全无关的基础组件(比如三方框架、自行封装的通用能力),提供完全的复用能力。


2.2 内销官网 APP 客户端开发模式


  • 官网 APP 目前主要分 3 条业务线,多业务版本并行开发是常态,所以模块化非常必要。


  • 官网 APP 模块化的子仓均已 AAR 形式供 APP 使用,且存在上层 AAR 依赖下层 AAR 的情况。


  • 官网 APP 模块化分仓优化工作穿插在各业务版本中,各业务版本并行开发,底层仓库难免有修改。


  • 官网 APP 各业务版本并行开发时,一般只会新拉取当前版本需要修改代码的仓库,其他仓库均继续依赖老版本的 AAR。


2.3 类、方法、属性引用错误导致的运行时崩溃


假设以下场景:


官网 APP5.0 版本开发过程中,由于 HardWare 仓没有业务修改,所以继续使用上个版本 4.9.0.0 的 HardWare(版本开发过程中一般只会重新拉取需要修改的仓库,无需修改的仓库会继续使用老版本),但 Core 仓有代码修改,所以拉取了新的 5.0 分支,并修改了相关代码,删除了 CoreUtils 类中的某个 fun1 方法,如下图所示:



注:硬件检测模块 v4.9.0.0 版本 AAR 中用到了核心仓 CoreUtils.class 中的 fun1 方法,其他仓包括主 APP 工程均未使用到该 fun1 方法。


请大家思考下,以上场景项目编译是否会有问题?


答:编译无问题


APP 主仓依赖的是 4.9.0.0 版本的 HardWare 仓编译后的 AAR 文件,这个 AAR 文件早在 4.9 版本就编好没动,所以 HardWare 仓没有编译问题;


APP 主仓依赖的是 5.0.0.0 版本的 Core 仓,HardWare 依赖的是 4.9.0.0 版本的 Core 仓,最终编译会取 Core 仓的高版本 5.0.0.0 版本参与 APP 工程编译,App 仓没有使用被删除的 fun1 方法,也不存在编译问题。


以上场景项目编译完成后运行过程中是否会有问题?


答:有问题。


在 APP 运行到 HardWare 仓调用了 CoreUtils 类中 fun1 方法的情况下就会出现运行时崩溃:Method Not Found。


因为最终参与 APP 工程编译的是 5.0.0.0 版本的 Core 仓,该版本已经删除了 fun1 方法,所以会出现运行时错误。


真实案例:


1)找不到方法



2)找不到类



所幸以上问题均在开发、测试阶段发现并及时修复掉了,如果流到线上,就是运行到某功能时的必崩场景,将会非常严重。


如果你负责的 APP 的所有 module 均是源码依赖,一般情况下如果存在引用问题,编译器会进行提示,所以一般情况下无需担心(除非依赖的底层 sdk 存在引用问题),但如果是类似官网这样的软件架构,则需要重点注意。


2.4 现状分析、思考


本地测试过程中已出现过引用问题导致的运行时异常,这种运行时异常的检测只靠人工是不够的,必须要有自动化的检测工具来进行检查。传统的 findBugs、Lint 等是代码静态检测工具,是无法检测出这种潜在的引用问题导致的运行时异常的,静态代码检测无法解决此问题。所以自研自动化的检测工具迫在眉睫!


三、字节码检测的解决方案


如果能在 APK 编译期间,通过自动化工具对所有 JAR、AAR 包中每个类做一遍检测,检测其中调用的方法、属性的使用是否存在引用问题,将检测出疑似问题的地方在编译时进行提示,有必要的情况下直接报错终止编译,并输出错误日志来提醒开发人员检查,防止问题流入线上出现运行时异常。


原理:各子仓的 Java 类(或 Kotlin 类)在编译成 AAR 或 JAR 后,AAR、JAR 中会有所有类的 Class 文件,我们实际上就是需要对编译后生成的 Class 文件进行分析。


如何对 Class 文件进行字节码分析?


这里推荐使用 JavaAssist 或 ASM,我们知道 Android 编译过程主要通过 Gradle 来控制的,要想分析 Class 文件字节码,我们需要实现自己的 Gradle Transform,在 Transform 里对 Class 字节码进行分析,这里我们直接做成 Gradle 插件。


在编译期间自动分析 Class 字节码是否存在方法引用、属性引用、类引用找不到或者当前类无权访问的问题,发现问题停止编译,并输出相关日志,提醒开发人员分析,并支持对插件的配置。


到这里,整个方案的主体框架就比较清晰了,如下图所示:



3.1 方法和属性引用检测原理


方法和属性引用问题的识别:


如何识别一个方法引用存在问题?


  • 该方法被删除,找不到相关方法名;

  • 找不到方法签名相同的方法,主要是指方法的入参数量、入参类型无法匹配;

  • 方法是非 public 方法,当前类无权限访问该方法。


如何识别一个属性(字段)引用存在问题?


  • 该属性被删除,找不到相关属性、字段;

  • 属性是非 public 属性,当前类无权限访问该属性。


权限修饰符说明:



方法和属性引用的字节码检测:我们可以利用 JavaAssist、ASM 等支持字节码操作的库来实现对所有类中方法、属性的扫描,并分析方法调用、属性引用是否存在引用问题。


3.2 方法和属性引用检测实战


以下代码均已 Kotlin 编写,实现 Gradle Plugin、Transform 具体过程省略,直接上检测功能的代码。方法、字段引用检测:


// Gradle Plugin、自定义Transform的部分这里不做赘述// 方法引用检测// 遍历每个类中的 每个方法 (包括构造方法 addBy Qihaoxin)classObj.declaredBehaviors.forEach { ctMethod -> //遍历当前类中所有方法 ctMethod.instrument(object : ExprEditor() { override fun edit(m: MethodCall?) { super.edit(m) //每个方法调用都会回调此方法,在此方法中进行检测 //引用检查功能 try { //这里不是每个方法都需要校验的,过滤掉 我们不需要处理的 系统方法,第三方sdk方法 等等 只校验我们自己的业务逻辑代码 if (ctMethod.declaringClass.name.isNeedCheck()) { return } if (m == null) { throw Exception("MethodCall is null") } //不需要检查的包名 if (m.className.isNotWarn() || classObj.name.isNotWarn()) { return } //method找不到,底层会直接抛异常的,包括方法删除、方法签名不匹配的情况 m.method.instrument(ExprEditor()) //访问权限检测,该方法非public,且对当前调用这个方法的类是不可见的 if (!m.method.visibleFrom(classObj)) { throw Exception("${m.method.name} 对 ${classObj.name} 这个类是不可见的") } } catch (e: Exception) { e.message?.let { errorInfo += "--方法分析 Exception Message: ${e.message} \n" } errorInfo += "--方法分析异常发生在 ${ctMethod.declaringClass.name} 这个类的${m?.lineNumber}行, ${ctMethod.name} 这个方法 \n" errorInfo += "------------------------------------------------\n" isError = true; } } /** * 成员变量调用的分析主要有: * 变量直接被删掉后找不到的问题 * private变量的只能定义该变量的类试用 * protected变量的可被类自己\子类\同包名的访问 * */ override fun edit(f: FieldAccess?) { super.edit(f) try { if (f == null) { throw Exception("FieldAccess is null") } //不需要检查的包名 if (f.className.isNotWarn() || classObj.name.isNotWarn()) { return } //这里不用判空,如果field找不到(这个属性被删掉了),底层会直接抛异常NotFoundException val modifiers = f.field.modifiers if (ctMethod.declaringClass.name == classObj.name) { //只处理定义在本类中的方法,不然基类里的方法也会被处理到--会出现本类实际没访问基类里的private变量但报错的问题 if (ctMethod.declaringClass.name == classObj.name) { if (!f.field.visibleFrom(classObj)) { throw Exception("${f.field.name} 对 ${classObj.name} 这个类是不可见的") } } } } catch (e: Exception) { e.message?.let { errorInfo += "--字段分析 Exception Message: ${e.message} \n" } errorInfo += "--字段分析异常发生在 ${classObj.name} 该类在 ${f?.lineNumber}行,使用 ${f?.fieldName} 这个属性时\n" errorInfo += "------------------------------------------------\n" isError = true } } })}
复制代码


在以上代码实现中,是遍历了所有的方法,对方法内的方法调用、字段访问进行了检测。那么全局变量如何检查呢?



class BillActivity { ... private String mTest1 = CreateNewAddressActivity.TAG; private static String mTest2 = new CreateNewAddressActivity().getFormatProvinceInfo("a","b", "c"); ...}
复制代码


例如以上代码中,mTest1 属性的值以及 mTest2 属性的值应该如何做检测?这个问题困扰笔者良久。在 JavaAssist、ASM 中均未能找到获取属性当前值的相关的 Api、也未能找到 Class 字节码直接分析属性值的相关思路以及资料。


在研究了 Class 字节码相关知识,并做了大量的实验,打了大量的 Log 后,解决思路才慢慢浮出水面。


我们先来看下 BillActivity 的一段字节码:



在这里我们找到了定义的 mTest1 这个全局变量,然后大家可以注意到,右边 Method 中出现了一个 init 方法,实际上 Java 在编译之后会在字节码文件中生成 init 方法,称之为实例构造器,该实例构造器会将语句块,变量初始化,调用父类的构造器等操作收敛到 init 方法中。那我们的 mTest2 这个全局变量呢?


搜索后发现 mTest2 实际上是在 static 代码块中,这里似乎 mTest2 赋值并没有被方法包裹,如下图所示:



实际上通过查阅大量资料后得知,Java 在编译之后会在字节码文件中生成 clinit 方法,称之为类构造器,类构造器会将静态语句块,静态变量初始化,收敛到 clinit 方法中。上图通过 javap 查看 Class 字节码中未显示 clinit 方法是因为 javap 未对此进行相关的适配展示而已。


通过实验 Log 发现 mTest2 的初始化确实出现在 clinit 方法中,且在 ASMPlugin 的 ByteCode 中查看跟上图相同的字节码,展示为带有 clinit 方法标识的字节码,如下图所示:



研究到这里,我们实际也就知道了 mTest1 和 mTest2 的赋值实际都发生在 init 和 clinit 方法中。所以我们前面遍历类中所有方法来检测方法和属性的引用检查是可以覆盖到全局变量的。


问题到这里似乎已经全部完美解决了,但我在全局变量的代码这里看了几眼后,又发现了新的问题:



class BillActivity { ... private String mTest1 = CreateNewAddressActivity.TAG; private static String mTest2 = new CreateNewAddressActivity().getFormatProvinceInfo("a","b", "c"); ...}
复制代码


我们前面只关心了 TAG 这个属性和 getFormatProvinceInfo 这个方法的引用是否存在问题,但我们没有对 CreateNewAddressActivity 这个类本身做引用检查,假设这个类是 private 的,这里依然会有问题。所以我们引用检查不能忘记对类引用的检查。


3.3 类引用检查原理


如何识别一个类引用存在问题?


  • 该类被删除,找不到相关类;

  • 类是非 public 的,当前类无权限访问该类。


3.4 类引用检测实战


类引用检查



//类的引用检查if (classObj.packageName.isNeedCheck()) { classObj.refClasses?.toList()?.forEach { refClassName -> try { if (refClassName.toString().isNotWarn() || classObj.name.isNotWarn()) { return@forEach } //该类被删除,找不到相关类 val refClass = classPool.getCtClass(refClassName.toString()) ?: throw NotFoundException("无法找到该类:$refClassName") //权限检测 //.....省略.....跟方法和属性的权限检测一样,这里不再赘述 } catch (e: Exception) { e.message?.let { errorInfo += "--类引用分析 Exception Message: ${e.message} \n" } errorInfo += "--类引用分析异常 在类:${classObj.name} 中引用了 $refClassName \n" errorInfo += "------------------------------------------------\n" isError = true } }}
复制代码


到这里本次字节码引用检测的原理以及实战就介绍完了。


3.5 解决方案的反思


在内销官网的 buildSrc 中实现了引用检测功能后,得知其他 APP 很多都已做了模块化,联想到其他 APP 可能也采用类似官网的模块化架构,也会存在类似痛点,反思当前技术实现并不具备通用的接入能力,深感这件事其实并没有做完,在解决自身 APP 痛点后需要横向赋能其他 APP,解决大团队所面临的痛点,所有才有了后面的独立 Gradle 插件。


四、独立 Gradle 插件


如果需要在编译期间进行引用检测的 APP 模块,欢迎大家接入我开发的这款字节码引用检测的 Gradle 插件。


4.1 独立 Gradle 插件目标


1)独立 Gradle 插件,方便所有 APP 接入;

2)支持常用的开发配置项,支持插件功能开关、异常跳过等配置;

3)对 Java、Kotlin 编译后的字节码进行引用检查,能在 CI、Jenkins 上编译 APK 包发现引用问题时,编译报错并输出引用问题的具体信息供开发分析、解决。


4.2 插件功能


1)方法引用检测;

2)属性(字段)引用检测;

3)类引用检测;

4)插件支持常用配置,可开可关。


比如能检测出 Class Not Found \Method Not Found 或者 Field Not Found 的问题。整个插件在编译期间运行时间很短,以内销官网 APP 为例,该插件在 APP 编译期间运行时间在 2.3 秒左右,速度很快,不必担心会增加编译耗时。


4.3 插件接入


在主工程根目录 build.gradle 中添加依赖:



dependencies { ... classpath "com.byteace.refercheck:byteace-refercheck:35-SNAPSHOT" //目前是试运行版本,版本还需迭代;欢迎大家体验并提建议和问题,帮助不断完善插件功能}
复制代码


在 APP 工程的 build.gradle 中使用插件并设置配置信息:


//官网自研的字节码引用检查插件apply plugin: 'com.byteace.refercheck'//官网自研的字节码引用检查插件-配置项referCheckConfig {        enable true //是否打开引用检查功能        strictMode true // 控制是否发现问题时停止构建,        check "com.abc.def" //需要检查的类的包名,因为工程中会使用很多sdk或者第三方库我们一般不做检查,只检查我们需要关注的类的包名        notWarn "org.apache.http,com.core.videocompressor.VideoController" //人工检查确认后不需要报错的包名}
复制代码


4.4 插件配置项说明


Enable:是否打开引用检查功能,如果为 false,则不进行引用检查


StrictMode:严苛模式开启时,发现引用异常直接中断编译(严苛模式关闭时,只会将异常信息打在编译过程的日志中,发现引用问题不会终止编译)。


建议:Jekins 或 CI 上打 Release 包时 build.gradle 中配置的 enable 和 strictMode 都设置为 true。


Check:需要检测的包名,一般只配置检查当前 APP 包名即可,如需对依赖的第三方 sdk 等做检查,可根据需要进行配置。


NotWarn:发现引用问题不报错的白名单,在开发人员检查插件报错的问题并认定实际不会导致崩溃后,可将当前引用不到的类名配置在这里,可跳过检查。如 A 类引用不到 B 类中的某个方法,可将 B 类的类名配置在这里,将不会报错。


4.5 内销官网 APP 中 NotWarn 配置项说明


内销官网 APP 将 org.apache.http 以及 com.core.videocompressor.VideoController 加入到了不报错白名单中。org.apache.http 实际用的是 Android 系统中的包,该包并没有参与 APK 编译,如果不加该配置项,则会报错,但实际运行不会出错。



com.core.videocompressor.VideoController 该项不加的话会报错:FileProcessFactory 中引用不到 CompressProgressListener 类。排查下 FileProcessFactory 代码,FileProcessFactory 类的 138 行 调用了 convertVideo 方法,最后一个 listner 参数传的 null。



该类的字节码 Class 文件如下,会自动对 converVideo 最后一个入参 null 进行强制类型转换:



而这个 CompressProgressListener 并不是 public 的,是默认的 package。而且 FileProcessFactory 类与 CompressProgressListener 不在同一个 package 下,所以会报错。但实际运行时并不会崩溃,所以需要将其类名加入到不报错的白名单中。


如果在插件使用过程中遇到不应报错的案例,可以通过白名单控制进行跳过,同时希望将案例反馈给我,我这边对案例进行分析并对插件进行迭代更新。


五、总结


预研过程中由于字节码知识较深,且网络上类似字节码插桩、进行代码生成的的教程较多,但做字节码分析的资料太少,所以需要熟悉字节码知识并在实践中慢慢实验和摸索,细节也需慢慢打磨。


在预研过程中积极思考解决方案的通用性和可配置性,最终开发出通用的 Gradle 插件,积极推动其他模块接入,借此次宝贵的机会进行横向技术赋能,争取大团队的成功。


目前已有两个 APP 接入插件,插件会持续维护并迭代,等插件稳定后规划集成到 CI、Jenkins 上。欢迎有需求的 APP 接入引用检测的 Gradle 插件,希望能帮助到存在引用检测痛点的 APP 和团队。


作者:vivo 官网商城客户端团队-Qi Haoxin

发布于: 11 小时前阅读数: 6
用户头像

官方公众号:vivo互联网技术,ID:vivoVMIC 2020.07.10 加入

分享 vivo 互联网技术干货与沙龙活动,推荐最新行业动态与热门会议。

评论

发布
暂无评论
字节码引用检测原理与实战