java_javaassist&ognl

9 min read

1 javaassist

javaassist是jvm字节码操作的库,通过以下方式引入maven依赖

<!-- https://mvnrepository.com/artifact/org.javassist/javassist -->
<dependency>
    <groupId>org.javassist</groupId>
    <artifactId>javassist</artifactId>
    <version>3.29.2-GA</version>
</dependency>

通过下面代码即可读取某个类的字节码。

ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("org.example.A");
byte[] bytes = cc.toBytecode();

1.1 概念介绍

上面代码中我们看到了ClassPoolCtClassClassPool本质是一个Map用来存储类的字符串名例如"org.example.A"和这个类型本身CtClass的相关信息。

ClassPool可以通过getDefault方法获取,此时是以当前的classpath进行创建的,当创建完成后我们可以debug查看pool中只有8个基础类型和一个void类型。

image

当运行pool.get("org.example.A")时,会从当前的classpath下去寻找A的class文件,将其信息处理封装成CtClass,同时把相关的CtClassType存到pool中。

image

pool存储太多会占用一些内存,如果不是频繁使用可以在get之后通过cc.detach()方法,将其从pool中删除。

CtClass对应的就是javaassist中对于一个类的描述,与java的Class有点像,但是也不是完全的对应关系,可以理解为一个CtClass可能对应多个Class,因为CtClass本质就是字节码byte[],而jvm的Class除了字节码,还有ClassLoader,同一组字节码,被不同的加载器加载的Class是不同的。

除了加载已有的类,pool还可以直接创建新的类,用makeClass方法,也可以给新的类添加列和方法如下,实现无中生有。

// 创建一个新的类,如果pool中已经有这个类,则会替换成新的
CtClass cc = pool.makeClass("org.example.A");

// 追加一个field
CtField cf = CtField.make("public String name;", cc);
cc.addField(cf);

// 追加一个方法
CtMethod cm = CtMethod.make("public String getName() { return this.name; }", cc);
cc.addMethod(cm);

// 追加一个构造函数,这里用了setBody,CtMethod也有该方法
CtConstructor ccs = new CtConstructor(null, cc);
ccs.setBody("{this.name =\"佚名\";}");
cc.addConstructor(ccs);

// 将字节码写入class文件
cc.writeFile();

查看当前目录下多出了./org/example/A.class文件,用idea反编译后能看到代码如下,构造方法里的name=佚名,直接在字段上复制了,这是反编译的结果,效果和上面一样。

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package org.example;

public class A {
    public String name = "佚名";

    public String getName() {
        return this.name;
    }

    public A() {
    }
}

1.2 展开说下CtMethod的功能

与java的class类似,CtClass也能通过getDeclaredMethodgetField来获取类中的CtMethodCtField,这样我们就能够对已有的类的方法进行修改。

ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("org.example.A");

CtMethod cm = c.getDeclaredMethod("sayHi");

// setBody方法直接修改整个方法体内容
cm.setBody("{ System.out.println(\"HiHiHi\"); }");

// insertBefore在方法第一行插入代码块
cm.insertBefore("System.out.println(\"start\");");
// insertAfter在方法最后插入代码块,不会影响return的结果
cm.insertAfter("System.out.println(\"after\");");
// insertAt在方法指定的第几行插入
cm.insertAt(10, "System.out.println(\"line10\");");

// instrument是当前方法中,运行到某些时间点的时候的修改,例如MethodCall就是调用其他函数时候的修改
// NewExpr是创建异常的时候,最常用的是MethodCall其他的简单看一下即可。
cm.instrument(new ExprEditor() {
            public void edit(NewExpr e) throws CannotCompileException {}
            public void edit(NewArray a) throws CannotCompileException {}
            public void edit(MethodCall m) throws CannotCompileException {}
            public void edit(ConstructorCall c) throws CannotCompileException {}
            public void edit(FieldAccess f) throws CannotCompileException {}
            public void edit(Instanceof i) throws CannotCompileException {}
            public void edit(Cast c) throws CannotCompileException {}
            public void edit(Handler h) throws CannotCompileException {}
        });

一些关键字,在上面的方法中,因为无法获取到当前的入参和返回值等,所以提供了一些内定的变量,下面是一部分。

image

还有$proceed,用法是

//修改第一个入参的值,然后运行
$1 = 0;
$_ = $proceed($$);

注意这些预留变量并不是每个方法中都能使用的,例如$proceed就只有在instrument方法中才能使用,而$_也不能在setBody中使用,详情参考https://www.javassist.org/tutorial/tutorial2.html,基本准则就是入参的`$args $0 $1等,类型的$w $r等,返回值$_的除了setBody`是都可以使用。

1.3 retransform

javaassist本质是创建或修改类的字节码,有了字节码,其实还没法生效,字节码要通过ClassLoader加载成Class才能被拿来使用。

通过toClass方法可以用当前类加载器加载该字节码,但是如果这个类已经被加载过了,那么就会报错。duplicate class definition for name: "xxxx"

此时可以用javaassist提供的Loader来加载该字节码,这样就不会报错了,但是问题就是下面的c1的类加载器是一个专门的,因而不会影响该类已经加载到内存的运行的部分,这样的作用就变小了。

Class c = cc.toClass();

Class c1 = cc.toClass(new Loader());

如果想要替换已经在内存中的类的字节码,就需要借助java Instrumentation API的retransform方法,我们在之前的java agent中已经有相关介绍,这里就不展开了,除了agent/attach,想要快速验证也可以用ByteBuddyAgent这个库可以直接在当前进程中拿到Instrumentation,但是一般用于测试,千万不要在prod代码用byte-buddy-agent。

<dependency>
    <groupId>net.bytebuddy</groupId>
    <artifactId>byte-buddy-agent</artifactId>
    <version>1.14.10</version>
</dependency>

例如修改一个已有的A类。

// 这是A类
public class A {
    public void sayHi() {
        System.out.println("hi");
    }
}

// 这是主类
public class Main {
    public static void main(String[] args) throws Exception {
        A a = new A();
        ClassPool pool = ClassPool.getDefault();
        CtClass cc = pool.get("org.example.A");
        cc.getDeclaredMethod("sayHi").setBody("{System.out.println(\"No hi\");}");
        byte[] bytes = cc.toBytecode();
        Instrumentation inst = ByteBuddyAgent.install();
        inst.addTransformer(new ClassFileTransformer() {
            @Override
            public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
                return bytes;
            }
        }, true);
        a.sayHi(); // 打印hi
        inst.retransformClasses(A.class);
        a.sayHi(); // retransform后打印No hi
    }
}

2 ognl

ognl是一种表达式或者叫dsl,可以用ognl引擎去运行表达式的内容,该表达式依赖反射去执行,也依赖了javaassist库,例如我们要运行a.sayHi()

// #root.sayHi是表达式内容,是说要运行根节点的sayHi方法
// 第二个参数就是根节点
A a = new A();
Object result = Ognl.getValue("#root.sayHi", a); // 打印hi,并返回null,因为没有返回值void
System.out.println(result); // null

ognl用#xx表示变量,一个表达式如果运行多段功能需要用,隔开,如下

Point point = new Point(1, 2);
Object result = Ognl.getValue("#root.x=100, #root.y=200, #root.toString()", point);
System.out.println(result); // 打印java.awt.Point[x=100,y=200]

ognl可以用new 语句来创建新对象,对于对象的方法就用普通的a.sayHi()就可以调用像之前看到的,但是对于静态方法,则需要用@

Object result = Ognl.getValue("@java.util.UUID@randomUUID()", null);
System.out.println(result); //打印一个uuid