Android 字节码插桩

8,282 阅读9分钟

一、为什么要插桩

        我们都知道JAVA是面向对象(继承、封装、多态),而插桩的意义在于面向切面(AOP),可想而知单方面的面向对象开发有许多的局限性,而结合面向切面编程可以说补足了我们的这种局限性。举个例子:在onClick中一般都要做防抖动操作,这样是为了避免多次打开页面的问题。一般实现的话是在每个onClick实现第二次点击的时候加个时间判断。而插桩的话业务端可以不写任何代码通过插桩的方法把这个时间判断插入的字节码里面。

从标题名字看

  • Java字节码:是Java虚拟机执行的一种虚拟指令格式。通过JVM转换生成机器指令
  • 插桩:是在保证被测程序原有逻辑完整性的基础上在程序中插入一些探针(又称为“探测仪”。

二、插桩能带来什么


三、AOP思想

            

       银行系统会有一个取款流程,我们可以把方框里的流程合为一个,另外系统还会有一个查询余额流程,我们先把这两个流程放到一起,有没有发现,这个两者有一个相同的验证流程,我们先把它们圈起来再说下一步,有没有想过可以把这个验证用户的代码是提取出来,不放到主流程里去呢,这就是AOP的作用了,有了AOP,你写代码时不要把这个验证用户步骤写进去,即完全不考虑验证用户。

  • 什么是AOP:把这些横跨并嵌入众多模块里的功能(如监控每个方法的性能) 集中起来,放到一个统一的地方来控制和管理
  • 能给我带来什么:不修改源代码的情况下给程序动态统一添加功能的一种技术,把散落在程序中的公共部分提取出来,做成切面类,这样的好处在于,代码的可重用,一旦涉及到该功能的需求发生变化,只要修改该代码就行,否则,你要到处修改,如果只要修改1、2处那还可以接受,万一有1000处呢。

四、Android打包流程插桩入口

                     

这是app打包流程的整个过程而我把这个打包流程主要分为一下步骤:

  • aapt来打包资源文件,生成R.java文件
  • 处理AIDL,生成对应的.java接口文件
  • 编译Java文件,生成对应的.class文件
  • 把.class文件转化成Davik VM支持的.dex文件
  • 打包生成未签名的.apk文件。

字节码插桩入口:我们知道Android程序从Java源代码到可执行的Apk包主要分析两个环节:

  • javac:将源文件编译成class格式的文件
  • dex:将class格式的文件汇总到dex格式的文件中

我们要想对字节码进行修改,只需要在javac之后,dex之前对class文件进行字节码扫描,并按照一定规则进行过滤及修改就可以了,这样修改过后的字节码就会在后续的dex打包环节被打到apk中,这就是我们的插桩入口。

插桩方式一、:transform api

每个Transform其实都是一个gradle task,Android编译器中的TaskManager将每个Transform串连起来,第一个Transform接收来自javac编译的结果,以及已经拉取到在本地的第三方依赖(jar. aar),还有resource资源,注意,这里的resource并非android项目中的res资源,而是asset目录下的资源。这些编译的中间产物,在Transform组成的链条上流动,每个Transform节点可以对class进行处理再传递给下一个Transform。我们常见的混淆,Desugar等逻辑,它们的实现如今都是封装在一个个Transform中,而我们自定义的Transform,会插入到这个Transform链条的最前面。

对于Android Gradle Plugin 版本在1.5.0及以上的情况,Google官方提供了transformapi用作字节码插桩的入口。

implementation 'com.android.tools.build:gradle:1.5.0'

一般使用方法为:extends Transform重写transform()

插桩方式二:hook dx.jar

需要引入Instrumentation


通过Java Instrumentation机制,为获得插桩入口,对于apk build过程进行了两处插桩(即hook),图中标红部分:

Instrumentation:指的是可以用独立于应用程序之外的代理(agent)程序来监测和协助运行在JVM上的应用程序。这种监测和协助包括但不限于获取JVM运行时状态,替换和修改类定义等。

  • 在build进程,对ProcessBuilder.start()方法进行插桩
    ProcessBuilder类是J2SE 1.5在java.lang中新添加的一个新类,此类用于创建操作系统进程,它提供一种启动和管理进程的方法,start方法就是开始创建一个进程,对它进行插桩,使得通过下面方式启动dx.jar进程执行dex任务时:

    java  dex.jar  com.android.dx.command.Main  --dex …........

    增加参数-javaagent agent.jar,使得dex进程也可以使用Java Instrumentation机制进行字节码插桩

  • 在dex进程
    对我们的目标方法com.android.dx.command.Main.processClasses进行字节码插入,从而实现打入apk的每一个项目中的类都按照我们制定的规则进行过滤及字节码修改。

build进程使用Instrumentation的方式时之前叙述过的VirtualMachine.loadAgent方式(方式二),dex进程中的方式则是-javaagent agent.jar方式(方式一)。

  由此,我们获得了进行字节码插桩的入口,下面我们就使用ASM库的API,对项目中的每一个类进行扫描,过滤,及字节码修改。



五、自定义Gradle插件


1、创建一个Android library Module工程

创建module

2、build.gradle改成groovy方式

apply plugin: 'groovy'

    dependencies {
        compile gradleApi()
        compile localGroovy()
    }

3、新建.groovy类继承 Plugin并实现apply方法,注意:类的后缀不再是.java而是.groovy


4、在main下创建resources目录


5、增加对应的maven deployer发布到本地或远程仓库


6、使用已发布的仓库


六、ASM


ASM 是一个 Java 字节码操控框架。它能被用来动态生成类或者增强既有类的功能。ASM 可以直接产生二进制 class 文件,也可以在类被加载入 Java 虚拟机之前动态改变类行为。Java class 被存储在严格格式定义的 .class 文件里,这些类文件拥有足够的元数据来解析类中的所有元素:类名称、方法、属性以及 Java 字节码(指令)。ASM 从类文件中读入信息后,能够改变类行为,分析类信息,甚至能够根据用户要求生成新类。


为什么选择ASM来进行字节码编织?

Framework First time Later times
Javassist 257 5.2
BCEL 473 5.5
ASM 62.4 1.1

可以使用一个插件[ASM Bytecode Outline]更有效的用ASM编写字节码

ASM(core api) 按照visitor模式按照class文件结构依次访问class文件的每一部分,有如下几个重要的visitor。

操作流程

  1. 需要创建一个 ClassReader 对象,将 .class 文件的内容读入到一个字节数组中
  2. 然后需要一个 ClassWriter 的对象将操作之后的字节码的字节数组回写
  3. 需要事件过滤器 ClassVisitor。在调用 ClassVisitor 的某些方法时会产生一个新的 XXXVisitor 对象,当我们需要修改对应的内容时只要实现自己的 XXXVisitor 并返回就可以了

 ClassReader 类

这个类会将 .class 文件读入到 ClassReader 中的字节数组中,它的 accept 方法接受一个 ClassVisitor 实现类,并按照顺序调用 ClassVisitor 中的方法

 ClassWriter 类

ClassWriter 是一个 ClassVisitor 的子类,是和 ClassReader 对应的类,ClassReader 是将 .class 文件读入到一个字节数组中,ClassWriter 是将修改后的类的字节码内容以字节数组的形式输出。

ClassVisitor 抽象类

  • void visit(int version, int access, String name, String signature, String superName, String[] interfaces)
    该方法是当扫描类时第一个调用的方法,主要用于类声明使用。下面是对方法中各个参数的示意:visit( 类版本 , 修饰符 , 类名 , 泛型信息 , 继承的父类 , 实现的接口)
  • AnnotationVisitor visitAnnotation(String desc, boolean visible)
    该方法是当扫描器扫描到类注解声明时进行调用。下面是对方法中各个参数的示意:visitAnnotation(注解类型 , 注解是否可以在 JVM 中可见)。
  • FieldVisitor visitField(int access, String name, String desc, String signature, Object value)
    该方法是当扫描器扫描到类中字段时进行调用。下面是对方法中各个参数的示意:visitField(修饰符 , 字段名 , 字段类型 , 泛型描述 , 默认值)
  • MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions)
    该方法是当扫描器扫描到类的方法时进行调用。下面是对方法中各个参数的示意:visitMethod(修饰符 , 方法名 , 方法签名 , 泛型信息 , 抛出的异常)
  • void visitEnd()
    该方法是当扫描器完成类扫描时才会调用,如果想在类中追加某些方法

  • MethodVisitor & AdviceAdapter

    MethodVisitor 是一个抽象类,当 ASM 的 ClassReader 读取到 Method 时就转入 MethodVisitor 接口处理。
    AdviceAdapter 是 MethodVisitor 的子类,使用 AdviceAdapter 可以更方便的修改方法的字节码。

    AdviceAdapter

    其中比较重要的几个方法如下:

    1. void visitCode():表示 ASM 开始扫描这个方法
    2. void onMethodEnter():进入这个方法
    3. void onMethodExit():即将从这个方法出去
    4. void onVisitEnd():表示方法扫码完毕



    字节码基础

    • 全限定名即为全类名中的“.”,换为“/”,举例:
      类android.widget.AdapterView.OnItemClickListener的全限定名为:
      android/widget/AdapterView$OnItemClickListener
    • 描述符(descriptors):
      1.类型描述符,如下图所示:


    在class文件中类型 boolean用“Z”描述,数组用“[”描述(多维数组可叠加),那么我们最常见的自定义引用类型呢?“L全限定名;”.例如:
    Android中的android.view.View类,描述符为“Landroid/view/View;”

    2.方法描述符的组织结构为:

    (参数类型描述符)返回值描述符复制代码

    其中无返回值void用“V”代替,举例:

    方法boolean onGroupClick(ExpandableListView parent, View v, int groupPosition, long id)  的描述符如下:
    (Landroid/widget/ExpandableListView;Landroid/view/View;IJ)Z




    对上图中三个步骤的详细说明:

    步骤一:

    ASM的ClassVisitor对所有类的class文件进行扫描,在visitMethod()方法中判断是不是BaseActivity,如果是进行步骤二,否则终止扫描;

    步骤二:

    ClassVisitor每扫描到一个方法时,在visitMethod中进行如下判定:

    1. 是不是要过滤的<init>方法

    如果判定通过,则证明本次扫描到的方法是需要注入字节码的方法,然后将
    将扫描逻辑交给MethodVisitor,进行字节码的修改(步骤三)。

    步骤三:修改扫码到的方法字节码

    假设待修改的方法如下:

    public int test() {
      try {  
          Thread.sleep(1000);
      } catch (InterruptedException e) {
          e.printStackTrace();
      }
    }

    修改之后需要变成:

    public int test() {
       long startTime = System.currentTimeMillis();
       try {  
           Thread.sleep(1000);}
       catch (InterruptedException e){ 
           e.printStackTrace();  
       }
       long timing = System.currentTimeMillis() - startTime;
       BlockManager.timingPage(getLocalClassName(), timing);
    }