JAVA安全|字节码篇:字节码操作框架-ASM(基本使用)

admin 2023年3月24日23:37:03评论121 views字数 26570阅读88分34秒阅读模式

本文为JAVA安全系列文章第二十六篇,学习ASM的基本使用。

阅读本文前请先阅读:

JAVA安全|字节码篇:字节码文件结构与解读
JAVA安全|字节码篇:常见字节码指令(JVM指令)

JAVA安全|字节码篇:字节码操作框架—ASM(原理)

并且最好已经阅读过ASM Core API的源码了。

本文主要学习使用ASM对Classes和Method执行一些操作。

注:使用ASM是通过修改字节码来对类或方法进行操作,我们将其称之为transform(转换)

0x01  Core API—Classes

一、介绍

1.ClassVisitor各方法访问顺序

用于生成和转换已编译类的 ASM API 是基于 ClassVisitor 抽象类的,这个类在上一篇文章中已经提到过了。该类中有很多visitXxx方法,在它的Javadoc文档中规定了这些方法的访问顺序(参考https://asm.ow2.io/javadoc/org/objectweb/asm/ClassVisitor.html):

visit visitSource? visitOuterClass? ( visitAnnotation | visitAttribute )*
( visitInnerClass | visitField | visitMethod )*
visitEnd

?表示最多一个,*表示任意个。即意味着必须首先调用 visit,然后是对 visitSource 的最多一个调用,接下来是对 visitOuterClass 的最多一个调用 , 然后是可按任意顺序对visitAnnotation 和 visitAttribute 的任意多个访问 , 接下来是可按任意顺序对 visitInnerClass 、 visitField 和 visitMethod 的任意多个调用,最后以一个 visitEnd 调用结束。

2.ClassVisitor API 核心组件

ASM提供了三个基于ClassVisitor API 的核心组件,用于生成和转换类:

(1)ClassReader 类分析以字节数组形式给出的已编译类,并针对在其accept方法参数中传送的ClassVisitor 实例,调用相应的 visitXxx 方法。这个类可以看作一个事件生产者。

(2)ClassWriter 类是 ClassVisitor 抽象类的一个子类,它直接以二进制形式生成编译后的类。它会生成一个字节数组形式的输出,其中包含了已编译类,可以用 toByteArray 方法来提取。这个类可以看作一个事件消费者。

(3)ClassVisitor 类将它收到的所有方法调用都委托给另一个 ClassVisitor 类。这个类可以看作一个事件筛选器 。

二、使用案例

1.解析类

解析现有类所需的唯一组件是 ClassReader 组件,假设我们想以javap工具类似的方式打印Class的内容。

(1)自定义编写访问者类

第一步是编写 ClassVisitor 类的子类,打印有关它访问的类的信息。

下面是一个简化的实现:

import org.objectweb.asm.*;
import static org.objectweb.asm.Opcodes.ASM5;

public class ClassPrinter extends ClassVisitor {
   public ClassPrinter(){
       super(ASM5);
  }

   @Override
   public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
       System.out.println(name + " extends " + superName + " {");
  }

   @Override
   public void visitSource(String source, String debug) {
  }

   @Override
   public void visitOuterClass(String owner, String name, String descriptor) {
  }

   @Override
   public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
       return null;
  }

   @Override
   public void visitAttribute(Attribute attribute) {
  }

   @Override
   public void visitInnerClass(String name, String outerName, String innerName, int access) {
  }

   @Override
   public FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) {
       System.out.println("   " + descriptor + " " + name);
       return null;
  }

   @Override
   public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
       System.out.println("   " + name + descriptor);
       return null;
  }

   @Override
   public void visitEnd() {
       System.out.println("}");
  }
}

注:Opcodes接口中定义了JVM 操作码、访问标志和类型描述符等对应的数值。

(2)结合ClassReader

第二步是将ClassPrinter与ClassReader组件结合起来,这样 ClassReader 产生的事件就被我们的 ClassPrinter消费了:

import org.objectweb.asm.ClassReader;
import java.io.IOException;

public class Test {
   public static void main(String[] args) throws IOException {
       //ClassPrinter继承自ClassVisitor,它是访问者,同时也是消费者
       ClassPrinter cp = new ClassPrinter();
       
       //创建一个ClassReader来解析Human类
       ClassReader cr = new ClassReader("bytecodefile.Human");
       
       //解析Human类字节码并调用 cp 上相应的 visitXxx 方法
       cr.accept(cp,0);
  }
}
(3)代码解说

a.Human类见JAVA安全|字节码篇:字节码文件结构与解读一文。

b.ClassReader的字节码文件可以多种方式传入:

public ClassReader(final byte[] classFile);  二进制文件字节数组
public ClassReader(final String className);  类的全限定名
public ClassReader(final InputStream inputStream);  字节输入流的方式

可以使用 ClassLoader 的 getResourceAsStream 方法获取用于读取类内容的输入流:

cl.getResourceAsStream(classname.replace(., /) + ".class");

c.accept方法处理接收一个访问者,还包括另外一个int类型的parsingOptions参数,选项包括:

SKIP_CODE:跳过已编译代码的访问(如果您只需要类结构,这可能很有用),对应数字1;

SKIP_DEBUG:不访问调试信息,也不为其创建人工标签,对应数字2;

SKIP_FRAMES:跳过堆栈映射帧,对应数字4;

EXPAND_FRAMES:解压缩这些帧,对应数字8;

读过源码的读者会知道accept 方法会按Javadoc中规定的顺序去执行ClassVisitor的 visitXxx方法,由于动态绑定,故而会执行ClassPrinter中重写的那些 visitXxx 方法。

输出结果:

bytecodefile/Human extends java/lang/Object {
   C sex
   Ljava/lang/String; name
   <init>(CLjava/lang/String;)V
   getName()Ljava/lang/String;
   setName(Ljava/lang/String;)V
}

Perfect!!!与我们写的Human类的信息丝毫不差。

2.生成类

生成一个类,唯一必需的组件是ClassWriter组件。假如我们想生成如下的Class:

package pkg;
public interface Comparable extends Mesurable {
int LESS = -1;
int EQUAL = 0;
int GREATER = 1;
int compareTo(Object o);
}

我们可以使用ClassWriter类的visit,visitField,visitMethod,visitEnd来完成:

import org.objectweb.asm.ClassWriter;
import java.io.FileOutputStream;

import static org.objectweb.asm.Opcodes.*;

public class GenerateClass {
   public static void main(String[] args) throws Exception {
       ClassWriter cw = new ClassWriter(0);
       cw.visit(V1_5, ACC_PUBLIC + ACC_ABSTRACT + ACC_INTERFACE, "pkg/Comparable",null, "java/lang/Object", new String[]{"pkg/Mesurable"});
       cw.visitField(ACC_PUBLIC + ACC_FINAL + ACC_STATIC, "LESS", "I",
               null, new Integer(-1)).visitEnd();
       cw.visitField(ACC_PUBLIC + ACC_FINAL + ACC_STATIC, "EQUAL", "I",
               null, new Integer(0)).visitEnd();
       cw.visitField(ACC_PUBLIC + ACC_FINAL + ACC_STATIC, "GREATER", "I",
               null, new Integer(1)).visitEnd();
       cw.visitMethod(ACC_PUBLIC + ACC_ABSTRACT, "compareTo",
               "(Ljava/lang/Object;)I", null, null).visitEnd();
       cw.visitEnd();
       byte[] b = cw.toByteArray();
(1)代码解说

第一行创建一个 ClassWriter 实例,它将实际构建类的字节数组表示(构造器参数后文中说明)

第二行对visit 方法的调用定义了类的头部 ,1_5 参数是一个在Opcodes接口中定义的常量,它指定类版本为Java 1.5;第二个参数为类的访问标志,此处该类是一个接口,并且它是public的和abstract的(因为它不能被实例化);第三个参数以内部名形式指定类名,已编译的类不包含package或import部分,因此所有类名都必须是完全限定的;第四个参数对应于泛型,此处为null,因为接口没有被类型变量参数化;第五个参数是内部名形式的超类(接口类隐式继承自 Object);最后一个参数是继承的接口数组,由它们的内部名指定。

第三到五行对visitField的三个调用是类似的,用于定义三个接口字段。第一个参数是一组对应于 Java 修饰符的标志,此处为public,final,static;第二个参数是字段的名称;第三个参数是字段的类型,用类型描述符表示(I表示int类型);第四个参数对应泛型,此处字段类型没有使用泛型,故而为null;最后一个参数是字段的常量值:这个参数只能用于真正的常量字段,即final static字段。对于其他字段,它必须为null。由于此处没有annotation(注解),故立即调用返回的 FieldVisitor 的 visitEnd 方法,即不调用其 visitAnnotation 或 visitAttribute 方法。

第六行对visitMethod的调用用于定义 compareTo 方法。第一个参数是一组对应于 Java 修饰符的标志;第二个参数是方法名;第三个参数是方法描述符;第四个参数对应于泛型,此处同样为null;最后一个参数是方法可以抛出的异常数组,由它们的内部名指定,此处由于该方法没有声明任何异常,故而为null。visitMethod 方法返回一个 MethodVisitor,它可用于定义方法的annotation(注解)和attribute(属性),最重要的是方法的代码。此处由于没有注解并且方法是抽象的,故立即调用返回的MethodVisitor 的 visitEnd 方法。

最后,对 cw 的 visitEnd 方法的调用用于通知 cw 该类已完成,并且对 toByteArray 的调用用于以字节数组的形式提取它。

(2)使用生成类的方式

a.将字节数组存储在 Comparable.class 文件中以备将来使用

代码如下:

FileOutputStream fos = new FileOutputStream("F:\JAVA\javassist_asm\target\classes\asm\Comparable.class");
fos.write(b);
fos.close();

反编译后得到的class:

package pkg;

public interface Comparable extends Mesurable {
   int LESS = -1;
   int EQUAL = 0;
   int GREATER = 1;

   int compareTo(Object var1);
}

b.使用ClassLoader动态加载

b.1 定义一个 ClassLoader 子类,其 defineClass 方法是public的:

class MyClassLoader extends ClassLoader{
   public Class defineClass(String name, byte[] b) {
       return defineClass(name, b, 0, b.length);
  }
}

加载

Class c = myClassLoader.defineClass("pkg.Comparable", b);

b.2 定义一个 ClassLoader 子类,重写findClass方法以便动态生成请求的类

class StubClassLoader extends ClassLoader {
   @Override
   protected Class<?> findClass(String name) throws ClassNotFoundException {
       if (name.endsWith("_Stub")) {
           ClassWriter cw = new ClassWriter(0);
          ...
           byte[] b = cw.toByteArray();
           return defineClass(name, b, 0, b.length);
      }
       return super.findClass(name);
  }
}

注:使用生成的类的方式取决于上下文,并且超出了 ASM API 的范围。

3.转换类

在上面的例子中,我们都是将ClassReader或ClassWriter单独使用的,而在我们实际的使用中其实是将ClassReader、ClassVisitor(或其子类)、ClassWriter三个组件结合起来使用,这就开始变得有意义和有趣了。

ClassVisitor作为事件筛选器,默认情况下没有筛选任何东西,直接将事件转发,因此我们往往会自定义一个ClassVisitor的子类,重写它的方法,写入筛选的逻辑,从而达到修改类、增加或删除类成员(统称为类的转换)的目的。下面就以具体的例子来说明:

(1)修改类的java版本

我们可以自定义如下一个ClassVisitor的子类—ChangeVersionAdapter:

import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.ClassWriter;

import static org.objectweb.asm.Opcodes.ASM4;
import static org.objectweb.asm.Opcodes.V1_8;

public class ChangeVersionAdapter extends ClassVisitor {
   public static void main(String[] args) {
       byte[] b1 = ...;
       ClassWriter cw = new ClassWriter(0);
       //cv将所有事件转发给cw
       ClassVisitor cv = new ChangeVersionAdapter(cw);
       ClassReader cr = new ClassReader(b1);
       cr.accept(cv, 0);
       byte[] b2 = cw.toByteArray();
  }

   public ChangeVersionAdapter(ClassVisitor cv) {
       super(ASM4, cv);
  }

   @Override
   public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
       super.visit(V1_8, access, name, signature, superName, interfaces);
  }
}

仅重写了 ClassVisitor 类的visit方法,结果是除了对visit 方法的调用时会将类版本号修改为java1.8。其他调用都被不加改变地从cv转发到cw。

上述代码相对应的体系结构可以用如下简图表示,其中的组件用方框表示,事件用箭头表示:

JAVA安全|字节码篇:字节码操作框架-ASM(基本使用)

通过修改visit 方法的其他参数,可以实现其他转换,而不仅仅是修改类的版本。

比如,可以向实现接口的列表中添加一个接口。还可以改变类的名字,但进行这种改变所需要做的工作要多得多,不只是改变 visit 方法的 name 参数了。实际上,类的名字可以出现在一个已编译类的许多不同地方,要真正实现类的重命名,必须修改类中出现的所有这些类名字。

优化

上面的修改其实只修改了原类的四个字节,然而上面的代码在执行时会将整个b1进行分析,并利用相应的时间从头构建b2,显然这种做法效率并不高。ASM中设计了这样一种优化:

如果ClassReader组件检测到作为它的 accept 方法的参数传递的ClassVisitor返回的MethodVisitor来自ClassWriter,这意味着该方法的内容将不会被转换,实际上甚至不会被应用程序看到。

这种情况下ClassReader组件不解析这个方法的内容,不产生相应的事件,只是把这个方法的字节数组表示复制到ClassWriter中

如果ClassReader和ClassWriter组件拥有对对方的引用,则由它们进行这种优化

于是上面的main方法可以优化为:

byte[] b1 = ...
ClassReader cr = new ClassReader(b1);
ClassWriter cw = new ClassWriter(cr, 0);
ChangeVersionAdapter ca = new ChangeVersionAdapter(cw);
cr.accept(ca, 0);
byte[] b2 = cw.toByteArray();

执行这一优化后,以上代码的速度可以达到之前代码的两倍。对于转换部分或全部方法的常见转换,这一速度提升幅度可能要小 一些,但仍然是很可观的:实际上在 10%到 20%的量级。

遗憾的是,这一优化需要将原类中定义的所有常量都复制到转换后的类中。对于那些增加字段、方法或指令的转换来说,这一点不成问题,但对于那些要移除或重命名许多类成员的转换来说,这一优化将导致类文件大于未优化时的情况。因此,建议仅对“增加性”转换应用这一优化。

(2)删除类成员

用于转换类版本的方法也可用于 ClassVisitor 类的其他方法。

比如,通过改变 visitField 和 visitMethod 方法的 access 或 name 参数,可以改变一个字段或一个方 法的访问修饰符或名字。

另外,除了在转发的方法调用中使用经过修改的参数之外,还可以选择根本不转发该调用。其效果就是相应的类元素被移除。

例如,下面编写的类适配器移除了有关外部类及内部类的信息,还删除了一个源文件的名字即编译这个类的源文件(所得到的类仍然具有全部功能,因为删除的这些元素仅用于调试目的)。

这一移除操作是通过在适当的访问方法中不转发任何内容而实现的:

import org.objectweb.asm.ClassVisitor;
import static org.objectweb.asm.Opcodes.ASM4;

public class RemoveDebugAdapter extends ClassVisitor {
   public RemoveDebugAdapter(ClassVisitor cv) {
       super(ASM4, cv);
  }

   @Override
   public void visitSource(String source, String debug) {
  }

   @Override
   public void visitOuterClass(String owner, String name, String descriptor) {
  }

   @Override
   public void visitInnerClass(String name, String outerName, String innerName, int access) {
  }
}

当然,这一策略对于字段和方法是无效的,因为 visitField 和 visitMethod 方法必须返回一个结果。要移除字段或方法,不得转发方法调用,并向调用者返回 null。

例如,下面的类适配器移除了一个方法,该方法由其名字及描述符指明(仅使用名字不足以标识一个方法,因为一个类中可能包含若干个具有不同参数的同名方法,即方法重载):

import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.MethodVisitor;

import static org.objectweb.asm.Opcodes.ASM4;

public class RemoveMethodAdapter extends ClassVisitor {
   private String mName; //要移除的方法名
   private String mDesc; //要移除的方法描述符

   public RemoveMethodAdapter(ClassVisitor cv, String mName, String mDesc) {
       super(ASM4, cv);
       this.mName = mName;
       this.mDesc = mDesc;
  }
   
   @Override
   public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
       if (name.equals(mName) && desc.equals(mDesc)) {
       //不委托给下一个visitor -> 这样就移除方法
           return null;
      }
       return cv.visitMethod(access, name, desc, signature, exceptions);
  }
}
(3)增加类成员

我们可以多“转发”一些调用,也就是发出的调用数多于收到的调用,其效果就是增加了类成员。

新的调用可以插在原方法调用之间的若干位置, 只要遵守各个visitXxx 必须遵循的调用顺序即可。

比如,如果要添加一个字段,则必须在原方法调用之间添加对 visitField 的一个新调用,而且必须将这个新调用放在类适配器的一个访问方法中。

那么这个新调用应放在哪个visitXxx方法中比较合适呢?

第一,不能在 visit 方法, 因为这样可能会导致对 visitField 的调用之后跟有 visitSource、visitOuterClass、visitAnnotation 或 visitAttribute,这是无效的。出于同样的原因,不能将这个新调用放在 visitSource、visitOuterClass、visitAnnotation 或 visitAttribute 方法中 。故仅有的可能位置是 visitInnerClass、visitField、visitMethod 或 visitEnd 方法。

第二,如果将这个新调用放在 visitEnd 方法中,那这个字段将总会被添加(除非增加显式条件),因为这个方法总会被调用。

第三,如果将它放在 visitField 或 visitMethod 中,将会添加几个字段:原类中的每个字段和方法各有一个相应的字段。

这两种解决方案都可能发挥应有的作用,具体取决于需求。比如,可以仅添加一个计数器字段,用于计算对一个对象的调用次数,也可以为每个方法添加一个计数器,用于分别计算对每个方法的调用次数。

注意:事实上,惟一真正正确的解决方案是在visitEnd方法中添加更多调用,以添加新成员。

由于一个类中不得包含重复成员,要确保一个新成员没有重复成员,惟一方法就是将它与所有已有成员进行对比,只有在visitEnd方法中访问了所有这些成员后才能完成这一工作。这种做法是相当受限制的。在实践中,程序员不大可能使用的生成名,比如_counter$或_4B7F_就足以避免重复成员了, 并不需要将它们添加到visitEnd 中。

如下的类适配器,它会向类中添加一个字段,除非这个字段已经存在:

import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.FieldVisitor;

import static org.objectweb.asm.Opcodes.ASM4;

public class AddFieldAdapter extends ClassVisitor {
   private int fAcc; //要添加字段的访问修饰符
   private String fName;//要添加的字段名
   private String fDesc;//要添加的字段的类型描述符
   private boolean isFieldPresent;//表示要添加的字段是否存在

   public AddFieldAdapter(ClassVisitor cv, int fAcc, String fName, String fDesc) {
       super(ASM4, cv);
       this.fAcc = fAcc;
       this.fName = fName;
       this.fDesc = fDesc;
  }

   @Override
   //检测我们希望添加的字段是否已经存在
   public FieldVisitor visitField(int access, String name, String desc,String signature, Object value) {
       if (name.equals(fName)) {
           isFieldPresent = true;
      }
       return cv.visitField(access, name, desc, signature, value);
  }

   @Override
   public void visitEnd() {
       if (!isFieldPresent) {
           FieldVisitor fv = cv.visitField(fAcc, fName, fDesc, null, null);
           if (fv != null) {
               fv.visitEnd();
          }
      }
       cv.visitEnd();
  }
}

字段被添加在 visitEnd 方法中,visitEnd 方法中在调用 fv.visitEnd() 之 前 的 fv != null 检 测是因为一个类访问器可以在visitField中返 回 null。

三、转换链

由上面的使用案例可以看出,使用ASM对Classes进行操作的重点是编写类适配器即ClassVisitor的子类。

在上面的例子中,使用的都是简单的转换链,我们可以使用更为复杂的转换链:将几个类适配器链接在一起。这样就可以组成几个独立的类转换,以完成复杂转换。还要注意,转换链不一定是线性的。我们可以编写一个 ClassVisitor,将接收到的所有方法调用同时转发给几个 ClassVisitor:

import org.objectweb.asm.ClassVisitor;
import static org.objectweb.asm.Opcodes.ASM4;

public class MultiClassAdapter extends ClassVisitor {
   protected ClassVisitor[] cvs;
   public MultiClassAdapter(ClassVisitor[] cvs) {
       super(ASM4);
       this.cvs = cvs;
  }

   @Override
   public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
       for (ClassVisitor cv : cvs) {
           cv.visit(version, access, name, signature, superName, interfaces);
      }
  }
  ...
}

几个类适配器可以委托至同一ClassVisitor(这需要采取一些预防措施,比如确保 visit 和 visitEnd 针对这个 ClassVisitor 恰好仅被调用一次)。如下图所示一个转换链是完全可行的:

JAVA安全|字节码篇:字节码操作框架-ASM(基本使用)

四、工具

除了 ClassVisitor 类和相关的 ClassReader、ClassWriter 组件之外,ASM 还在org.objectweb.asm.util 包中提供了几个工具,这些工具在开发类生成器或适配器时可能非常有用,但在运行时不需要它们。

ASM 还提供了一个实用类,用于在运行时处理内部名、类型描述符和方法描述符

1.Type

一个 Type 对象表示一种 Java 类型,既可以由类型描述符构造,也可以由 Class 对象构建。Type 类还包含表示基元类型的静态变量

Type 对象还可以表示方法类型。这种对象既可以从一个方法描述符构建,也可以由 Method 对象构建

2.TraceClassVisitor

这个类扩展了 ClassVisitor类, 并生成所访问类的文本表示。使用 TraceClassVisitor,可以获得关于实际所生成内容的一个可读轨迹。

除了其默认行为之外,TraceClassVisitor 实际上还可以将对其方法的所有调用委托给另一个访问器

3.CheckClassAdapter

与TraceClassVisitor类似,这个类也扩展了 ClassVisitor 类,并将对其方法的所有调用都委托到另一个ClassVisitor。

但是,这个类并不会打印所访问类的文本表示, 而是验证其对方法的调用顺序是否适当,参数是否有效,然后才会委托给下一个访问器。当发生错误时,会抛出 IllegalStateException 或 IllegalArgumentException

4.ASMifier

这个类为 TraceClassVisitor 工具提供了一种替代后端,这个后端使 TraceClassVisitor 类的每个方法都会打印用于调用它的 Java 代码。

当一个具有 ASMifier 后端的 TraceClassVisitor 访问器访问一个类时,它会打印用 ASM 生成这个类的源代码。如果不知道如何用 ASM 生成某个已编译类,可以编写相应的源代码,用 javac 编译它,并用 ASMifier 来访问这个编译后的类,将会得到生成这个已编译类的 ASM 代码!!!

有关这些工具类的使用可以参考《ASM4-guide》和ASM的javadoc。

0x02  Core API—Method

一、介绍

1.MethodVisitor各方法访问顺序

用于生成和转换已编译方法的 ASM API 是基于 MethodVisitor 抽象类的,它由 ClassVisitor 的 visitMethod 方法返回。同样的,该类中也有很多visitXxx方法,在它的Javadoc文档中规定了这些方法的访问顺序(参考https://asm.ow2.io/javadoc/org/objectweb/asm/MethodVisitor.html):

visitAnnotationDefault?
( visitAnnotation | visitParameterAnnotation | visitAttribute )*
( visitCode
( visitTryCatchBlock | visitLabel | visitFrame | visitXxxInsn |
visitLocalVariable | visitLineNumber )*
visitMaxs )?
visitEnd

这意味着,对于非抽象方法,如果存在注解和属性的话,必须首先访问它们,然后是该方法的字节代码。对于这些方法,其代码必须按顺序访问,位于对 visitCode 的调用(有且仅有一个调用)与对 visitMaxs 的调用(有且仅有一个调用)之间。

故而,visitCode 和 visitMaxs 方法可用于检测该方法的字节代码在一个事件序列中的开始与结束。和Class的情况一样,visitEnd 方法也必须在最后调用,用于检测一个方法在一个事件序列中的结束。

2.MethodVisitor类的相关方法
abstract class MethodVisitor { // public accessors ommited
   MethodVisitor(int api);
   MethodVisitor(int api, MethodVisitor mv);
   AnnotationVisitor visitAnnotationDefault();
   AnnotationVisitor visitAnnotation(String desc, boolean visible);
   AnnotationVisitor visitParameterAnnotation(int parameter,
   String desc, boolean visible);
   void visitAttribute(Attribute attr);
   void visitCode();
   void visitFrame(int type, int nLocal, Object[] local, int nStack,
   Object[] stack);
   void visitInsn(int opcode);
   void visitIntInsn(int opcode, int operand);
   void visitVarInsn(int opcode, int var);
   void visitTypeInsn(int opcode, String desc);
   void visitFieldInsn(int opc, String owner, String name, String desc);
   void visitMethodInsn(int opc, String owner, String name, String desc);
   void visitInvokeDynamicInsn(String name, String desc, Handle bsm,
   Object... bsmArgs);
   void visitJumpInsn(int opcode, Label label);
   void visitLabel(Label label);
   void visitLdcInsn(Object cst);
   void visitIincInsn(int var, int increment);
   void visitTableSwitchInsn(int min, int max, Label dflt, Label[] labels);
   void visitLookupSwitchInsn(Label dflt, int[] keys, Label[] labels);
   void visitMultiANewArrayInsn(String desc, int dims);
   void visitTryCatchBlock(Label start, Label end, Label handler,
   String type);
   void visitLocalVariable(String name, String desc, String signature,
   Label start, Label end, int index);
   void visitLineNumber(int line, Label start);
   void visitMaxs(int maxStack, int maxLocals);
   void visitEnd();
}
3.MethodVisitor API核心组件

ASM 提供了三个基于 MethodVisitor API 的核心组件,用于生成和转换方法:

(1)ClassReader 类分析已编译方法的内容 , 在其 accept 方法的参数中传送了 ClassVisitor ,ClassReader 类将针对这一 ClassVisitor 返回的 MethodVisitor 对象调用相应方法。可以将它看作事件生产者。

(2)ClassWriter 的 visitMethod 方法返回 MethodVisitor 接口的一个实现,它直接以二进制形式生成已编译方法。可以将它看作事件消费者。

(3)MethodVisitor 类将它接收到的所有方法调用委托给另一个 MethodVisitor 方 法。可以将它看作一个事件筛选器

4.ClassWriter构造器参数

JAVA安全|字节码篇:常见字节码指令(JVM指令)一文中介绍过栈映射帧,ClassWriter的构造器参数就是与此有关的,它指定必须计算的内容:

(1)在使用 new ClassWriter(0)时,不会自动计算任何东西。必须自行计算帧、局部变量与操作数栈的大小。

(2)在使用 new ClassWriter(ClassWriter.COMPUTE_MAXS)时,将自动计算局部变量与操作数栈部分的大小。还是必须调用 visitMaxs,但可以使用任何参数:它们将被忽略并重新计算。使用这一选项时,仍然必须自行计算这些帧。

(3)在 new ClassWriter(ClassWriter.COMPUTE_FRAMES)时,一切都是自动计算。不再需要调用 visitFrame,但仍然必须调用 visitMaxs(参数将被忽略并重新计算)

COMPUTE_MAXS 选项使 ClassWriter 的速度降低10%,而使用 COMPUTE_FRAMES 选项则使其降低一半。在特定情况下,自行计算经常会存在一些比 ASM 所用算法更容易、更快速的计算方法,但 ASM 使用的算法必须能够处理所有情况。

二、使用案例

1.生成方法

JAVA安全|字节码篇:常见字节码指令(JVM指令)一文中的getF,setF,checkAndSetF方法为例:

(1)生成getF

我们可以写出如下Demo:

mv.visitCode();
mv.visitVarInsn(ALOAD, 0);
mv.visitFieldInsn(GETFIELD, "pkg/Bean", "f", "I");
mv.visitInsn(IRETURN);
mv.visitMaxs(1, 1);
mv.visitEnd();

其中mv表示MethodVisitor对象

Demo解说

调用 visitCode() 启动字节代码的生成过程

三个 visitXxxInsn 调用,生成这一方法的三条指令(可以看出,字节代码与 ASM API 之间的映射非常简单)

对 visitMaxs 的调用必须在已经访问了所有这些指令后执行,它用于为这个方法的执行帧定义局部变量和操作数栈部分的大小,在JVM指令一文中可以看出,这些大小为每部分 1 个槽

visitEnd() 调用用于结束此方法的生成过程

setF的Demo与此类似

(2)生成checkAndSetF

Demo如下:

mv.visitCode();
mv.visitVarInsn(ILOAD, 1);
Label label = new Label();
mv.visitJumpInsn(IFLT, label);
mv.visitVarInsn(ALOAD, 0);
mv.visitVarInsn(ILOAD, 1);
mv.visitFieldInsn(PUTFIELD, "pkg/Bean", "f", "I");
Label end = new Label();
mv.visitJumpInsn(GOTO, end);
mv.visitLabel(label);
mv.visitFrame(F_SAME, 0, null, 0, null);
mv.visitTypeInsn(NEW, "java/lang/IllegalArgumentException");
mv.visitInsn(DUP);
mv.visitMethodInsn(INVOKESPECIAL,"java/lang/IllegalArgumentException", "<init>", "()V");
mv.visitInsn(ATHROW);
mv.visitLabel(end);
mv.visitFrame(F_SAME, 0, null, 0, null);
mv.visitInsn(RETURN);
mv.visitMaxs(2, 2);
mv.visitEnd();

在 visitCode 和 visitEnd 调用之间,可以看到恰好映射到JVM指令一文中的checkAndSetF的栈映射帧一小节末尾所示字节代码的方法调用:

每条指令、标记或帧分别有个调用(仅有的例外是 label 和 end Label 对象的声明和构造)。

注意:Label 对象规定了跟在这一标记的 visitLabel 之后的指令。比如,end 规定了 RETURN 指令, 而不是随后马上要访问的帧,因为它不是一条指令。用几条标记指定同一指令是完全合法的,但一个标记只能恰好指定一条指令。换句话说,有可能用不同标记对 visitLabel 进行连续调用,但一条指令中的一个标记则必须用 visitLabel 恰好访问一次。最后一条约束是,标记不能共享,每个方法都必须拥有自己的标记。

2.转换方法

根据上面的介绍和类的转换,可以猜到方法的转换就是使用一个方法适配器将它收到的方法调用转发出去,并进行一些修改:

(1)改变参数可用于改变各个具体指令;

(2)不转发某一收到的调用将删除一条指令;

(3)在接收到的调用之间插入调用,将增加新的指令。

MethodVisitor 类提供了这样一种方法适配器的基本实现,它只是转发它接收到的所有方法,而未做任何其他事情。我们可以自定义一个MethodVisitor类的子类,将我们转换的逻辑写在里面,从而实现方法的转换。

案例演示

下面就以一个例子来将方法的转换完整实现一遍:

假设我们要计算一个程序中的每个类所花费的时间,那我们就需要在每个类中添加一个静态计时器字段,并将这个类中每个方法的执行时间添加到这个计时器字段中。

比如,有这样一个类 C:

public class C {
   public void m() throws Exception {
       Thread.sleep(100);
  }
}

我们希望将它转换为:

public class C {
   public static long timer;
   public void m() throws Exception {
       timer -= System.currentTimeMillis();
       Thread.sleep(100);
       timer += System.currentTimeMillis();
}
}

为了了解如何在 ASM 中实现它, 可以编译这两个类,使用javap进行解析,对比字节码指令,或者针对这两个版本比较 TraceClassVisitor 的输出(或者是使用默认的 Textifier 后端,或者是使用ASMifier 后端),得到差异如下(粗体表示):

a.添加指令

可以看到,我们必须在方法的开头增加四条指令,在返回指令之前添加四条其他指令。还需要更新操作数栈的最大尺寸。

方法代码的开头部分用 visitCode 方法访问。因此,可以通过重写方法适配器的visitCode方法,添加前四条指令(其中owner 必须为所转换类的名字):

public void visitCode() {
   mv.visitCode();
   mv.visitFieldInsn(GETSTATIC, owner, "timer", "J");
   mv.visitMethodInsn(INVOKESTATIC, "java/lang/System",
   "currentTimeMillis", "()J");
   mv.visitInsn(LSUB);
   mv.visitFieldInsn(PUTSTATIC, owner, "timer", "J");
}

然后必须在任意 RETURN 之前添加其他四条指令,还要在任何 xRETURN 或 ATHROW 之前添加,它们都是终止该方法执行过程的指令。这些指令没有任何参数,因此在 visitInsn 方法中访问。于是,可以重写visitInsn方法,以增加指令:

public void visitInsn(int opcode) {
   if ((opcode >= IRETURN && opcode <= RETURN) || opcode == ATHROW) {
       mv.visitFieldInsn(GETSTATIC, owner, "timer", "J");
       mv.visitMethodInsn(INVOKESTATIC, "java/lang/System",
       "currentTimeMillis", "()J");
       mv.visitInsn(LADD);
       mv.visitFieldInsn(PUTSTATIC, owner, "timer", "J");
  }
   mv.visitInsn(opcode);
}

b.局部变量、操作数栈、栈映射帧

最后,必须更新操作数栈的最大大小。我们添加的指令压入两个 long 值,因此需要操作数栈中的四个槽。

在方法的开头,操作数栈初始为空,故在开头添加的四条指令需要一个大小为 4 的栈。所插入的代码不会改变栈的状态(因为它弹出的值的数目与压入的数目相同)。因此,如果原代码需要一个大小为 s 的栈,那转换后的方法所需栈的最大大小为 max(4, s)。

但我们还在返回指令前面添加了四条指令,由于并不知道操作数栈恰在执行这些指令之前时的大小。只知道它小于或等于 s。因此只能说,在返回指令之前添加的代码可能要求操作数栈的大小达到 s+4。

这种最糟情景在实际中很少发生:使用常见编译器时,RETURN 之前的操作数栈仅包含返回值,即它的大小最多为 0、1 或 2。但如果希望处理所有可能情景,那就需要考虑最糟情景。必须重写visitMaxs 方法如下:

public void visitMaxs(int maxStack, int maxLocals) {
   mv.visitMaxs(maxStack + 4, maxLocals);
}

那么栈映射帧怎么样呢?原代码不包含任何帧,转换后的代码也没有包含。因为:

(1)插入的代码并没有改变操作数栈

(2)插入代码中没有包含跳转指令

(3)原代码的跳转指令(或者更正式地说,是控制流图)没有被修改。

这意味着原帧没有发生变化,而且不需要为插入代码存储新帧,所以压缩后的原帧也没有发生变化。

c.完整代码

下面将所有元素一起放入相关联的 ClassVisitor 和 MethodVisitor 子类:

import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.FieldVisitor;
import org.objectweb.asm.MethodVisitor;

import static org.objectweb.asm.Opcodes.*;

public class AddTimerAdapter extends ClassVisitor {
   private String owner;
   private boolean isInterface;

   public AddTimerAdapter(ClassVisitor cv) {
       super(ASM4, cv);
  }

   @Override
   public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
       cv.visit(version, access, name, signature, superName, interfaces);
       owner = name;
       isInterface = (access & ACC_INTERFACE) != 0;
  }

   @Override
   public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
       MethodVisitor mv = cv.visitMethod(access, name, desc, signature,
               exceptions);
       if (!isInterface && mv != null && !name.equals("<init>")) {
           mv = new AddTimerMethodAdapter(mv);
      }
       return mv;
  }

   @Override
   public void visitEnd() {
       if (!isInterface) {
           FieldVisitor fv = cv.visitField(ACC_PUBLIC + ACC_STATIC, "timer", "J", null, null);
           if (fv != null) {
               fv.visitEnd();
          }
      }
       cv.visitEnd();
  }

   public class AddTimerMethodAdapter extends MethodVisitor {

       public AddTimerMethodAdapter(MethodVisitor mv) {
           super(ASM4, mv);
      }

       @Override
       public void visitCode() {
           mv.visitCode();
           mv.visitFieldInsn(GETSTATIC, owner, "timer", "J");
           mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J");
           mv.visitInsn(LSUB);
           mv.visitFieldInsn(PUTSTATIC, owner, "timer", "J");
      }

       @Override
       public void visitInsn(int opcode) {
           if ((opcode >= IRETURN && opcode <= RETURN) || opcode == ATHROW) {
               mv.visitFieldInsn(GETSTATIC, owner, "timer", "J");
               mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J");
               mv.visitInsn(LADD);
               mv.visitFieldInsn(PUTSTATIC, owner, "timer", "J");
          }
           mv.visitInsn(opcode);
      }

       @Override
       public void visitMaxs(int maxStack, int maxLocals) {
           mv.visitMaxs(maxStack + 4, maxLocals);
      }
  }
}

类适配器用于实例化方法适配器(构造器除外),还用于添加计时器字段,并将被转换的类的名字存储在一个可以由方法适配器访问的字段中。

写一个测试类Test:

import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.ClassWriter;

import java.io.FileOutputStream;
import java.io.IOException;

public class Test {
   public static void main(String[] args) throws IOException {
       ClassReader cr = new ClassReader("asm.method.transformethod.C");
       ClassWriter cw = new ClassWriter(0);
       ClassVisitor cv = new AddTimerAdapter(cw);

       cr.accept(cv,0);
       byte[] b = cw.toByteArray();
       FileOutputStream fos = new FileOutputStream("F:\JAVA\javassist_asm\src\main\java\asm\method\transformethod\C.class");
       fos.write(b);
       fos.close();
  }
}

运行可得到一个C.class文件,将其反编译可得到代码:

Perfect!!!完美符合预期!!!

3.无状态转换&有状态转换

上面案例中的转换是局部的,不会依赖于在当前指令之前访问的指令:在开头添加的代码总是相同的,而且总会被添加,对于在每个 RETURN 指令之前添加的代码也是如此。这种转换称为无状态转换。它们的实现很简单,但只有最简单的转换具有这一性质。

更复杂的转换需要记忆在当前指令之前已访问指令的状态。比如,有这样一个转换:它将删除所有出现的 ICONST_0 IADD 序列,这个序列的操作就是加入 0,没有什么实际效果。显然,在访问一条IADD 指令时,只有当上一条被访问的指令是 ICONST_0 时,才必须删除该指令。这就要求在方法适配器中存储状态。因此,这种转换被称为有状态转换。

不过对于ASM在安全领域的应用,掌握无状态转换应该就可以了,对于有状态转换有点复杂,感兴趣的读者可以去阅读《ASM4-guide》(https://asm.ow2.io/asm4-guide.pdf)的3.2.5章节

三、转换链

从上面的案例我们可以看出,要进行方法的转换就得将类适配器与方法适配器结合起来,类适配器只是构造一个方法适配器(封装链中下一个类访问器返回的方法访问器), 并返回这个适配器。其效果就是构造了一个类似于类适配器链的方法适配器链。

需要注意,类适配器与方法适配器的相似性并非强制的:完全有可能构造一个与类适配器链不相似的方法适配器链。每种方法甚至还可以有一个不同的方法适配器链。比如,类适配器可以选择仅删除方法中的 NOP, 而不移除构造器中的该指令。

方法适配器链的拓扑结构甚至都可以不同于类适配器。比如,类适配器可能是线性的,而方法适配器链具有分支:

public MethodVisitor visitMethod(int access, String name,String desc, String signature, String[] exceptions) {
   MethodVisitor mv1, mv2;
   mv1 = cv.visitMethod(access, name, desc, signature, exceptions);
   mv2 = cv.visitMethod(access, "_" + name, desc, signature, exceptions);
   return new MultiMethodAdapter(mv1, mv2);
}

四、工具

org.objectweb.asm.commons 包中包含了一些预定义的方法适配器,可用于定义我们自己的适配器

另外,上面介绍的Type、TraceClassVisitor、CheckClassAdapter、ASMifier也可以用于方法的生成或转换。

1.AnalyzerAdapter

这个方法适配器根据 visitFrame 中访问的帧,计算每条指令之前的栈映射帧。之前在JVM指令一文中说过已编译方法中并没有为每条指令包含一个帧,而是仅为那些对应于跳转目标或异常处理器的指令,或者跟在无条件跳转指令之后的指令包含帧,可以容易地由这些帧推断出其他帧。

visitFrame 仅在方法中的一些特定指令前调用,一方面是为了节省空间, 另一方面也是因为“其他帧可以轻松快速地由这些帧推导得出”。这就是这个适配器所做的工作。

当然,它仅对那些包含预计算栈映射帧的类有效,也就是对于用 Java 6 或更高版本编译的有效。

2.LocalVariablesSorter

这个方法适配器将一个方法中使用的局部变量按照它们在这个方法中的出现顺序重新进行编号。

3.AdviceAdapter

这个方法适配器是一个抽象类,可用于在一个方法的开头以及恰在任意 RETURN 或 ATHROW 指令之前插入代码。

它的主要好处就是对于构造器也是有效的,在构造器中,不能将代码恰好插入到构造器的开头,而是插在对父构造器的调用之后。事实上,这个适配器的大多数代码都专门用于检测对这个父构造器的调用。

有关这些工具类的使用可以参考《ASM4-guide》和ASM的javadoc。

0x03  总结

一、简谈ASM Core API使用

对ASM Core API的使用需要明白三点:

首先,要明白ASM的设计思想,它是基于访问者模式来进行设计的;

其次,要明白ClassReader扮演着事件生产者的角色,ClassWriter扮演着事件消费者的角色,ClassVisitor和MethodVisitor扮演着访问者,事件筛选器的角色;

最后,应当明白使用ASM Core API来完成对类和方法的生成和转换,关键就是要会自定义编写适配器,即自定义编写ClassVisitor和MethodVisitor的子类。

二、类、方法的生成和转换

对于类的生成和转换我们只需要会编写ClassVisitor的子类,知道字节码中的访问修饰符、内部名、类型描述符、方法描述符即可;

而对于方法的生成和转换,它除了要会编写MethodVisitor的子类,还要与类适配器相结合起来。此外,重要的是方法的代码,这就得要我们掌握JVM指令了。

三、简谈javassist和ASM

javassist和ASM是两个字节码操作框架,区别在于使用javassist我们不需要了解字节码和JVM指令,只需要调用API即可,对小白很友好;而学习ASM门槛较高,需要了解字节码和JVM指令等相关知识。当然,ASM的优点在于因为它是直接从字节码的层面来进行类的生成和转换,故而比javassist具有更良好的性能。

前面我们在学习反序列化时,如果有去看过Ysoserial或网上的其他代码,会发现在编写POC时用到了javassist,在学习了javassist之后,相信大家都能看懂别人的代码了。

从开发的角度来讲,ASM被用在很多项目中,两种常见的使用场景是AOP和代替反射:

AOP

AOP在学习JDK动态代理时稍有提过,AOP即面向切面编程,在程序开发中主要用来解决一些系统层面上的问题,比如日志,事务,权限等待;其中关键技术就是代理,代理包括动态代理和静态代理,实现的方式也有多种:

  • AspectJ:属于静态织入,原理是静态代理;

  • JDK动态代理:JDK动态代理两个核心类:Proxy和InvocationHandler;

  • Cglib动态代理:封装了ASM,可以在运行期间动态生成新的Class;功能上比JDK动态代理更强大;

其中的Cglib动态代理方式就依赖ASM,上面的案例中我们也看到了ASM的字节码增强功能。

代替反射

FastJson以速度快著称,其中有一项就是使用ASM代替了Java反射;另外还有ReflectASM包专门用来代替Java反射。

ReflectASM 是一个非常小的 Java 类库,通过代码生成来提供高性能的反射处理,自动为 get/set 字段提供访问类,访问类使用字节码操作而不是Java的反射技术,因此非常快。

关于javassist和ASM的应用,相信会在后续的学习中会更多的接触到。

参考:

《ASM4-guide》:https://asm.ow2.io/asm4-guide.pdf

谷歌翻译

https://juejin.cn/post/6972350507100667935


Java安全系列文集

第0篇:JAVA安全|即将开启:java安全系列文章

第1篇:JAVA安全|基础篇:认识java反序列化

第2篇:JAVA安全|基础篇:实战java原生反序列化

第3篇:JAVA安全|基础篇:反射机制之快速入门

第4篇:JAVA安全|基础篇:反射机制之Class类

第5篇:JAVA安全|基础篇:反射机制之类加载

第6篇:JAVA安全|基础篇:反射机制之常见ReflectionAPI使用

第7篇:JAVA安全|Gadget篇:URLDNS链

第8篇:JAVA安全|Gadget篇:TransformedMap CC1链

第9篇:JAVA安全|基础篇:反射的应用—动态代理

第10篇:JAVA安全|Gadget篇:LazyMap CC1链

第11篇:JAVA安全|Gadget篇:无JDK版本限制的CC6链

第12篇:JAVA安全|基础篇:动态字节码加载(一)

第13篇:JAVA安全|基础篇:动态字节码加载(二)

第14篇:JAVA安全|Gadget篇:CC3链及其通杀改造

第15篇:JAVA安全|Gadget篇:CC依赖下为shiro反序列化利用而生的CCK1 CC11链

第16篇:JAVA安全|Gadget篇:CC5 CC7链

第17篇:JAVA安全|Gadget篇:CC2  CC4链—Commons-Collections4.0下的特有链

第18篇:JAVA安全|Gadget篇:CC8 CC9链

第19篇:JAVA安全|Gadget篇:Ysoserial CB1链

第20篇:JAVA安全|Gadget篇:shiro无依赖利用与常见shiro利用工具对比浅析

第21篇:JAVA安全|Gadget篇:JDK原生链—JDK7u21

第22篇:JAVA安全|字节码篇:字节码操作库—javassist

第23篇:JAVA安全|字节码篇:字节码文件结构与解读

第24篇:JAVA安全|字节码篇:常见字节码指令(JVM指令)

第25篇:JAVA安全|字节码篇:字节码操作框架—ASM(原理)


如果喜欢小编的文章,记得多多转发,点赞+关注支持一下哦~,您的点赞和支持是我最大的动力~

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2023年3月24日23:37:03
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   JAVA安全|字节码篇:字节码操作框架-ASM(基本使用)http://cn-sec.com/archives/1625982.html

发表评论

匿名网友 填写信息