续上一篇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
可以看到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内存马了。
发表评论