续上一篇Javassist动态修改jar包内java类字节码,有了上一篇的基础之后我们再尝试结合JavaAgent来实现更加强大的AOP功能。

首先提一嘴JavaAgent。在 jdk 1.5 之后引入了 java.lang.instrument 包,该包提供了检测 java 程序的 Api,比如用于监控、收集性能信息、诊断问题,通过 java.lang.instrument 实现的工具我们称之为 Java Agent。

其基本原理可以理解为,当JVM在加载.class类文件的时候会触发ClassFileLoadHook回调JNI执行sun.instrument.InstrumentationImpl#transform方法,如果我们自己实现了ClassFileTransformer的transform方法,则会用transform方法返回的字节码内容替换掉原来读取到的.class文件的字节码。https://www.cnblogs.com/yichengtech/p/15854415.html

接下来我们来自己做一个简单的实现,顺便结合之前文章中的Javassist工具做一点“有意思”的事情。

创建Agent Jar包

创建一个新的工程,代码结构基本如下。一个MyAgent入口类、一个名为MyClassFileTransformer的ClassFileTransformer接口实现类

两个类的对应实现代码,首先是MyAgent类,在这里写一个premain方法,并在这里引入我们下面要实现的MyClassFileTransformer类

package com.myagent;

import java.lang.instrument.Instrumentation;

/**
 * MyAgent<br>
 *
 * @author CheungQ
 * @date 2023/3/15 16:43
 */
public class MyAgent {
    public static void premain(String agentArgs, Instrumentation inst) {
        System.out.println("premain begin");
        inst.addTransformer(new MyClassFileTransformer());
    }
}

而在MyClassFileTransformer类中实现了ClassFileTransformer接口,并在transform方法中实现我们自己的逻辑。先判断一下当前加载的类,如果不需要调整修改的则直接return null即可。此处我们假设需要修改com.cheungq.demo.service.RedisService这个类

package com.myagent;

import javassist.*;
import javassist.expr.ExprEditor;
import javassist.expr.MethodCall;

import java.io.ByteArrayInputStream;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;

/**
 * MyClassFileTransformer<br>
 *
 * @author CheungQ
 * @date 2023/3/15 16:43
 */
public class MyClassFileTransformer implements ClassFileTransformer {

    public MyClassFileTransformer() {
        System.out.println("MyClassFileTransformer construct");
    }

    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
                            ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
        if (!className.startsWith("com/cheungq/demo/service/RedisService")) {
            // 只转换RedisService这一个类,其它类不做处理
            return null;
        }
        String newClassName = className.replace("/", ".");
        ClassPool pool = ClassPool.getDefault();
        CtClass cl = null;
        try {
            pool.insertClassPath(new LoaderClassPath(loader));
            try {
                cl = pool.get(newClassName);
            } catch (NotFoundException e) {
                // Spring会通过字节码对我们的类进行扩展,这种情况下找不到对应的class文件,会报NotFoundException
                // 这时我们可以直接读取classfileBuffer,直接通过类的字节码创建CtClass对象
                ByteArrayInputStream is = null;
                try {
                    is = new ByteArrayInputStream(classfileBuffer);
                    cl = pool.makeClass(is);
                } finally {
                    if (null != is) {
                        is.close();
                        is = null;
                    }
                }
            }
            if (cl.isInterface()) {
                return null;
            }
            CtMethod[] methods = cl.getDeclaredMethods();
            for (CtMethod method : methods) {
                enhance(method);
            }
            return cl.toBytecode();
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        } finally {
            if (null != cl) {
                cl.detach();
            }
        }
    }
}

然后就是和上一篇文章中类似的javassist处理类文件字节码的过程,找到我们需要的某个特定方法,再调用我们自己编写的具体增强方法enhance来修改该方法的字节码内容。此处我们假设需要修改setKeyValue这个方法,同时因为方法中调用了redis的set方法,我们则只对这部分进行修改。

这里用到了javassist.expr.ExprEditor表达式类。方法 instrument() 搜索方法体。 如果它找到一个表达式,如方法调用、字段访问和对象创建,那么它调用给定的 ExprEditor 对象上的 edit() 方法。 edit() 的参数表示找到的表达式。 edit() 可以检查和替换该表达式。调用 edit() 参数的 replace() 方法可以将表达式替换为我们给定的语句。如果给定的语句是空块,即执行replace(“{}”),则将表达式删除。


private void enhance(CtMethod method) throws CannotCompileException {
    if (!method.getLongName().startsWith("com.cheungq.demo.service.RedisService.setKeyValue")) {
        return;
    }
    method.insertBefore("{ System.out.println(\"" + method.getLongName() + " called ...\"); }");
    method.instrument(new ExprEditor() {
        @Override
        public void edit(MethodCall m) throws CannotCompileException {
            String longName = "";
            try {
                longName = m.getMethod().getLongName();
            } catch (NotFoundException e) {
                e.printStackTrace();
            }
            if (!longName.startsWith("org.springframework.data.redis.core.ValueOperations.set(")) {
                return;
            }
            String str = "{" +
                    "long start = System.nanoTime();\n" +
                    "$_ = $proceed($1, \"CheungQ\");\n" +
                    "System.out.print(\"method:[" + longName + "]\");\n" +
                    "System.out.println(\" cost:[\" +(System.nanoTime() -start)+ \"ns]\");" +
                    "}";
            m.replace(str);
        }
    });
}

如果要在表达式之前或之后插入语句(或块),则应该将类似以下的代码传递给 replace()

{ *before-statements;*
  $_ = $proceed($$);
  *after-statements;* }

当然pom文件中记得添加对应依赖

<dependency>
    <groupId>org.javassist</groupId>
    <artifactId>javassist</artifactId>
    <version>3.29.2-GA</version>
</dependency>

打包agent.jar包

完成后我们需要对将这个项目进行打包。这里有两个需要注意的地方,首先打包后的jar包需要指定Premain入口,配置pom文件打包配置项

<plugin>
    <artifactId>maven-jar-plugin</artifactId>
    <version>3.1.2</version>
    <configuration>
        <archive>
            <manifestEntries>
                <Premain-Class>com.myagent.MyAgent</Premain-Class>
                <Can-Redefine-Classes>true</Can-Redefine-Classes>
                <Can-Retransform-Classes>true</Can-Retransform-Classes>
                <Can-Set-Native-Method-Prefix>true</Can-Set-Native-Method-Prefix>
            </manifestEntries>
        </archive>
    </configuration>
</plugin>

这个里面有几个重要的配置:

  • Premain-Class :包含 premain 方法的类(类的全路径名)我们这里就是FirstAgent类
  • Boot-Class-Path :设置打包jar的文件名
  • Can-Redefine-Classes :true表示能重定义此代理所需的类,默认值为 false(可选)
  • Can-Retransform-Classes :true表示能重转换此代理所需的类,默认值为 false (可选)
  • Can-Set-Native-Method-Prefix:true表示能设置此代理所需的本机方法前缀,默认值为 false(可选)

如果没有配置Premain-Class的话在使用这个agent的时候会报出如下错误

Failed to find Premain-Class manifest attribute in agentJar-1.0-SNAPSHOT.jar

但是,还有个但是,现在这样我们打包后,将jar包复制到我们的Spring Boot项目中,直接使用命令执行的时候还是会报错

java -javaagent:agentJar-1.0-SNAPSHOT.jar -jar target/demo-0.0.1-SNAPSHOT.jar
Exception in thread "main" java.lang.reflect.InvocationTargetException
        at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
        at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.lang.reflect.Method.invoke(Method.java:498)
        at sun.instrument.InstrumentationImpl.loadClassAndStartAgent(InstrumentationImpl.java:386)
        at sun.instrument.InstrumentationImpl.loadClassAndCallPremain(InstrumentationImpl.java:401)
Caused by: java.lang.NoClassDefFoundError: javassist/NotFoundException
        at com.myagent.MyAgent.premain(MyAgent.java:14)
        ... 6 more
Caused by: java.lang.ClassNotFoundException: javassist.NotFoundException
        at java.net.URLClassLoader.findClass(URLClassLoader.java:381)
        at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
        at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:331)
        at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
        ... 7 more
FATAL ERROR in native method: processing of -javaagent failed

可以很直接的看到java.lang.NoClassDefFoundError: javassist/NotFoundException的信息,因为我们引入了javassist,而打出来的包里是并不包含javassist的包的,那么我们就需要再打包的时候将javassist一起打进来

所以,这里我们需要对打包的配置再修改一下,将原来的打包配置修改为如下(https://blog.csdn.net/londa/article/details/115098901

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-assembly-plugin</artifactId>
    <configuration>
        <archive>
            <manifestEntries>
                <Premain-class>com.myagent.MyAgent</Premain-class>
                <Can-Redefine-Classes>true</Can-Redefine-Classes>
                <Can-Retransform-Classes>true</Can-Retransform-Classes>
                <Can-Set-Native-Method-Prefix>true</Can-Set-Native-Method-Prefix>
            </manifestEntries>
        </archive>
        <descriptorRefs>
            <descriptorRef>jar-with-dependencies</descriptorRef>
        </descriptorRefs>
    </configuration>
    <executions>
        <execution>
            <id>make-assembly</id>
            <phase>package</phase>
            <goals>
                <goal>single</goal>
            </goals>
        </execution>
    </executions>
</plugin>

执行package后打出了两个包agentJar-1.0-SNAPSHOT-jar-with-dependencies.jar和agentJar-1.0-SNAPSHOT.jar,可以从包文件名和大小上直接看出来区别 ,解压jar包也可以看到包里把javassist的代码也一起打包进来了

将agent包拷贝到和我们的Spring Boot项目打包后的jar包在一起,使用命令启动试试,具体文件路径可以根据自己的目录结构进行调整

java -javaagent:agentJar-1.0-SNAPSHOT-jar-with-dependencies.jar -jar target/demo-0.0.1-SNAPSHOT.jar
premain begin是我在agent包com.myagent.MyAgent#premain方法里输出的内容

可以看到agent包的内容先执行之后,再开始执行Spring Boot包的内容。需要补充一点的内容,在这里我尝试过将agentJar和javassist.jar包分开打包的方式启动,但是依旧没有能成功,报错信息是javassist.expr.ExprEditor类没有加载到。好了,到这里agent包部分的内容就已经结束了,回到我们原来的Spring Boot项目中。

业务应用包中的代码

与之对应的在我们的业务应用包中,我们创建一个RedisService类。内容很简单,实现一个往Redis的Key写值的方法,具体StringRedisTemplate的配置之前在写用Redis结合AOP实现限流的文章中提到过,这里不再做赘述:Redis结合AOP实现限流注解的开发

package com.cheungq.demo.service;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

/**
 * RedisService<br>
 * [在此填写类描述]
 *
 * @author CheungQ
 * @date 2023/3/15 15:32
 */
@Service
public class RedisService {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    public void setKeyValue(String key, String value){
        stringRedisTemplate.opsForValue().set(key,value);
    }
}

同时新增一个Controller来调用这个方法,逻辑也很简单,根据输出的参数值,往redis来写数据

package com.cheungq.demo.controller;

import com.cheungq.demo.service.RedisService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

/**
 * RedisController<br>
 *
 * @author CheungQ
 * @date 2023/3/15 14:33
 */
@RestController
public class RedisController {
    @Autowired
    private RedisService redisService;

    @RequestMapping(value = "val")
    public String setValue(@RequestParam String val) throws Exception {
        redisService.setKeyValue("key",val);
        return "SUCCESS";
    }
}

重新启动后,刷新页面请求当前地址,可以成功请求

再到redis里面看一眼,写入Redis的时候的值被修改了

如果光看代码的话,我们这里在调用了redisService.setKeyValue("key",val);方法之后,redis中这里的值应当是“123”,但是实际上这里调用的

stringRedisTemplate.opsForValue().set(key,value);

代码编译后的.class字节码文件在被加载进入JVM之前,从agent里过了一遍被修改成了

stringRedisTemplate.opsForValue().set(key,"CheungQ");

也就是说无论你传入的值是什么,最终value都会被设置成”CheungQ”字符。事实上这里的逻辑其实比我这边说的还要复杂一些,通过使用工具jclasslib,可以看到方法com.cheungq.demo.service.RedisService#setKeyValue内的字节码内容是这样的

原本只是写了一行的方法内容在编译后会变成图上14行这么多。

第一个aload_0表示从局部变量表中加载第 0 个 slot 中的对象引用到操作数栈的栈顶,这里的 0 表示第 0 个位置,也就是 this。后面aload_1、aload_2即为加载方法参数的两个值。而剩下几个#2、#3、#4的是从常量池获取,分别是获取stringRedisTemplate引用,调用opsForValue方法,最后才是调用set接口方法。关于字节码剖析详细的可以阅读下这篇文章https://blog.csdn.net/Chenhui98/article/details/126740433

所以在上面一开始的javassist.expr.ExprEditor#edit方法中,我们实际上会调用2次。来验证下,第一个[02]CONSTANT_Fieldref_info应该不算,我们在edit方法中新增这么一行

重新打包,并启动项目,可以看到命令行确实对应输出了3个对应方法,第一个是sout的,对应的我们在instrument前调用的一行insertBefore中的内容,第二第三行则分别是对应我们刚刚在jclasslib中分析看到的[03]CONSTANT_Methodref_info和[04]CONSTANT_InterfaceMethodref_info方法引用。

结语

到这里,这部分内容基本就结束了,现在应该已经对JavaAgent有一个基本的了解了。

关于使用场景,JavaAgent可用于实现Java IDE的调试功能、热部署功能、线上诊断⼯具和性能分析⼯具。例如,百度网络攻击防护工具OpenRASP中就使用了Java Agent来对敏感函数进行插桩,以此实现攻击检测。大名鼎鼎的skywalking对于各类应用监测其底层实现也是基于JavaAgent。当然还有一种比较常见的就是JavaAgent内存马了。