什么是"Java Agent" ?
在Java中,“Agent”(代理)是指一个可以附加到Java虚拟机(JVM)上的程序,它可以监控、修改或扩展JVM执行的应用程序的行为。这个术语的使用源于它的工作方式:它像一个代理一样在JVM和应用程序之间进行工作,而不需要改变应用程序本身的代码。
java Agent主要有2种方式
- 静态Agent:在JVM启动时通过
-javaagent
参数加载。它必须定义一个premain
方法,JVM会在应用程序的main
方法执行之前调用这个方法。 - 动态Agent:在JVM已经运行的情况下附加。它必须定义一个
agentmain
方法,当Agent被动态附加到JVM时,此方法被调用。
静态Agent
创建代理类
import java.lang.instrument.Instrumentation;
public class MyAgent {
public static void premain(String agentArgs, Instrumentation inst){
System.out.println("premain method called......");
}
public static void agentmain(String agentArgs, Instrumentation inst) {
System.out.println("agentmain method called......");
}
}
Instrumentation
对象是Java Agent的核心,提供了一系列强大的工具来控制和监视JVM的运行时行为,它允许Java agent访问和修改类和对象的信息。Instrumentation
对象通常在代理初始化时通过premain
方法或agentmain
方法传递给Java代理。
premain
方法在启动 Java 应用程序时,在 main
方法之前调用 premain
方法。这是 Java Agent API 的一个约定。premain
方法有两个参数:
String agentArgs
: 这是传递给代理的参数。这些参数是在启动 JVM 时与代理一起指定的。Instrumentation inst
: 这是Instrumentation
的一个实例,它提供了各种用于修改和检查类和对象的方法。
在 Java Agent 中,除了 premain
方法之外,还可以定义一个名为 agentmain
的方法。这个方法允许你的代理代码在 JVM 启动之后的某个时刻被动态地加载和执行。这通常用于那些不能在 JVM 启动时就加载的场景,或者用于那些需要在运行时动态附加到 JVM 的代理。
premain和agentmain方法的必须是静态方法,且必须满足String,Instrumentation的参数规范
创建清单文件
MANIFEST.MF
Manifest-Version: 1.0
Premain-Class: MyAgent
Agent-Class: MyAgent
Premain-Class用来指定静态代理类,这个类将被查找并在JVM启动之前调用其 agentmain
方法。
Agent-Class用来指定动态代理类,这个类将被查找并在JVM启动之后调用其agentmain
方法。
有以下几点需要注意:
- MANIFEST文件必须以一个空行结束。
- 清单文件中的属性按照需求来写即可,不需要每个都包含,如果你只需要静态agent,那就只写Premain-Class,反之亦然。
- 类名必须是完整类名
打包代理jar
进入包含manifest以及class文件的目录下,输入jar命令进行打包
jar cmf MANIFEST.MF myAgent.jar *.class
编写主程序
public class Main {
public static void main(String[] args) {
System.out.println("Hello world!");
}
}
执行的程序用字节码文件或者打包成jar都可以
代理执行
java -javaagent:myAgent.jar -cp Main_path Main
这里注意要添加classpath,否则会抛出NoClassDefFoundError
执行结果
这里是以静态agent的方式使用,所以在JVM启动之前只执行了premain方法
动态Agent
编写主程序
因为动态agent是以附加到别的jvm上的工作形式,所以我们需要写一个能持续运行的程序
import static java.time.LocalTime.now;
public class Main {
public static void main(String[] args) throws InterruptedException {
while(true) {
String time = String.valueOf(now());
System.out.println(time.substring(0,8));
Thread.sleep(1000);
}
}
}
编译为Main.class文件
使用Attach API 附加Agent
在刚才的静态Agent编写步骤里Agent JAR文件已经准备好了,现在只需要在另一个Java应用程序中使用Attach API来将这个Agent附加到目标JVM上。
首先,查找你要附加的JVM进程ID。
先运行刚才的Main程序
使用jps来查找进程PID
C:\>jps
17572
32532 Launcher
21928 Jps
29288 Main
然后,使用Attach API来加载你的Agent。
Attach
import com.sun.tools.attach.VirtualMachine;
public class AttachAgent {
public static void main(String[] args) throws Exception {
String targetPid = ""; // 目标JVM的进程ID
VirtualMachine vm = VirtualMachine.attach(targetPid);
vm.loadAgent("myAgent.jar");
vm.detach();//释放attach进程
}
}
执行attach,返回Main程序查看结果,动态agent附着成功
有一个权限问题,attach api的JVM权限必须 ≥ Main程序的JVM权限,也就是说,你不能用管理员权限运行Main而用普通用户权限去attach,否则会抛出“拒绝访问”的IO异常
阻塞性探究
将MyAgent的premain与agentamin方法修改如下
import java.lang.instrument.Instrumentation;
public class MyAgent {
static int count=5;
public static void premain(String agentArgs, Instrumentation inst) throws InterruptedException {
for(int i=0;i<count;i++) {
System.out.println("premain method called......");
Thread.sleep(1000);
}
}
public static void agentmain(String agentArgs, Instrumentation inst) throws InterruptedException {
for(int i=0;i<count;i++) {
System.out.println("agentmain method called......");;
Thread.sleep(1000);
}
}
}
静态Agent执行结果
在premain
方法执行完毕之前,Main程序不会执行,因此静态Agent
具有阻塞性
动态Agent执行结果
动态Agent的本质是将attach JVM连接到主程序JVM的运行环境中,相当于2个JVM共享同一片内存区域,因此动态Agent不具有阻塞性
常见应用
字节码增强(静态)
主程序Main类
public class Main {
public static void main(String[] args) {
sayHello();
}
public static void sayHello(){
System.out.println("hello");
}
}
Agent类
import java.lang.instrument.Instrumentation;
public class Agent {
public static void premain(String agentArgs, Instrumentation inst) {
inst.addTransformer(new ClassModifier());
}
}
inst.addTransformer
方法是 Java Agent中的一个关键方法,用于添加一个类文件转换器(ClassFileTransformer
)到 JVM中。这是用来实现字节码增强的一种方式。
ClassFileTransformer实现类
import javassist.*;
import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;
public class ClassModifier implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) {
if (className.equals("Main")) {
try {
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.makeClass(new java.io.ByteArrayInputStream(classfileBuffer));
CtMethod m = cc.getDeclaredMethod("sayHello");
m.insertBefore("{ System.out.println(\"Before hello\"); }");
m.insertAfter("{ System.out.println(\"After hello\"); }", true);
byte[] byteCode = cc.toBytecode();
cc.detach();
return byteCode;
} catch (Exception e) {
e.printStackTrace();
}
}
return null;
}
}
ClassModifier
- 这是一个实现了
ClassFileTransformer
接口的对象。 - 当 JVM 加载或者重新转换(retransformClasses)一个类时,会回调这个对象的
transform
方法,允许我们修改类的字节码。
有一点很重要,javassist版本要对应JDK的适用范围。比如我用的是JDK 17,所以我用的是最新的3.29.2的javassist。如果你的JDK版本很高,那么javassist对应的版本也要更新才对,否则可能出现各种错误。
清单文件
Manifest-Version: 1.0
Premain-Class: Agent
打包测试
javac -cp .;./lib/* *.java
jar cvmf MANIFEST.MF myAgent.jar *.class
java -javaagent:myAgent.jar -cp .;./lib/* Main
成功在sayHello方法执行前与执行后执行insert中的代码
字节码增强(动态)
主程序Main类
public class Main {
public static void main(String[] args) throws InterruptedException {
while (true){
Thread.sleep(2000);
hello();
}
}
public static void hello(){
System.out.println("hello");
}
}
Agent类
这里我将上文使用的ClassFileTransformer实现类
换成了实现ClassFileTransformer接口的匿名类
,快捷一些
import javassist.*;
import java.io.ByteArrayInputStream;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.lang.instrument.Instrumentation;
import java.lang.instrument.UnmodifiableClassException;
import java.security.ProtectionDomain;
public class Agent {
public static void agentmain(String agentArgs, Instrumentation inst) throws UnmodifiableClassException {
//获取所有已经加载到JVM中的类
Class[] classes = inst.getAllLoadedClasses();
Class cls = null;
//从中获取到Main类
for (Class tempcls: classes
) {
if(tempcls.getName().equals("Main")) {
cls = tempcls;
System.out.println("catch class:"+tempcls.getName());
break;
}
}
//注册一个类转换器
inst.addTransformer(new ClassFileTransformer() {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
ClassPool classPool = ClassPool.getDefault();
if (classBeingRedefined != null) {
System.out.println("Class Transformed: " + className);
/*
这里为什么需要将classBeingRedefined作为javassist的classpath传入,而静态agent却不用呢
在下文会有一个探究
*/
ClassClassPath ccp = new ClassClassPath(classBeingRedefined);
classPool.insertClassPath(ccp);
}
CtClass ctClass;
try {
ctClass = classPool.makeClass(new ByteArrayInputStream(classfileBuffer));
//获取hello方法
CtMethod m = ctClass.getDeclaredMethod("hello");
//替换代码
m.setBody("{ System.out.println(\" the method has been modified! \"); }");
ctClass.detach();
return ctClass.toBytecode();
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException(e);
}
}
},true);
//重新转换Main类,使其触发注册过的ClassFileTransformer实现字节码增强
inst.retransformClasses(cls);
}
}
Attach API
上述内容中有Attach API,不再赘述
清单文件
Manifest-Version: 1.0
Class-Path: ./lib/javassist-3.29.2-GA.jar
Agent-Class: Agent
Can-Redefine-Classes: true
Can-Retransform-Classes: true
打包测试
在上个例子中,在主程序的classpath中指定了javaassist依赖项,但在实战中的环境不一定具备此条件,同时更多的主动权在攻击者手中。所以这次我们将javaassist依赖项直接打包在Agent Jar中。
jar cvmf MANIFEST.MF myAgent.jar *.class ./lib/*
已添加清单
正在添加: Agent$1.class(输入 = 2162) (输出 = 1105)(压缩了 48%)
正在添加: Agent.class(输入 = 1600) (输出 = 861)(压缩了 46%)
正在添加: lib/javassist-3.29.2-GA.jar(输入 = 794137) (输出 = 739165)(压缩了 6%)
运行Main,查看PID,最终Attach 一条龙
成功通过动态Agent实现字节码增强
补充
transform 方法的调用时机
类加载时调用:最常见的情况是,在 JVM 加载类时,如果已经通过 addTransformer
方法注册了 ClassFileTransformer
,那么对于每个被加载的类,JVM 都会调用这个 ClassFileTransformer
的 transform
方法。这允许你在类实际被使用前修改其字节码,对应的是上述中静态Agent修改字节码的情况。
类重新转换时调用:当你调用 inst.retransformClasses
方法请求重新转换一个或多个已加载的类时,JVM 也会调用 ClassFileTransformer
的 transform
方法(前提是canRetransform
参数为true
),即使这个类已经被加载。这是为了应用在运行时的字节码修改。
字节码增强的本质
1. 运行时字节码修改
字节码增强发生在类的字节码级别,通常是在类被加载到 JVM 之前(静态增强)或者在类已经加载之后(动态增强)。这意味着你可以在不改变原始源代码的情况下,改变类的行为。
2. 不重新加载类
与重新编译或替换类文件不同,字节码增强并不涉及类的重新加载过程。即便是对于已加载的类,通过 retransformClasses
方法触发的增强操作只是动态替换内存中的类定义,而不会产生 JVM 完全重新加载一次类的行为。
retransformClasses方法并不会触发被重新转换类的static代码块
javassist的ClassClassPath问题
在前面的例子中,静态增强与动态增强在转换方法的不同上,本质的区别就只有动态增强比静态增强多两行代码
ClassClassPath ccp = new ClassClassPath(classBeingRedefined);
classPool.insertClassPath(ccp);
- ClassPool的classpath是由Javassist这类库在Java应用程序运行时动态管理的。ClassPool是一个自定义的类加载和管理机制,它独立于JVM的标准类加载器。
- 通过ClassPool的classpath,你可以动态地添加
(insertClassPath)
、移除或修改类路径。这允许在运行时进行更复杂的操作,如动态地修改类的结构或行为。 - ClassPool可以访问JVM的classpath中的类。当你在ClassPool中查找类时,如果该类在JVM的classpath中,ClassPool可以加载和使用它。但是,如果你在ClassPool中添加新的类路径或修改类,这些变化不会反映到JVM的标准类加载器中
如果没有这两行代码,会报错
javassist.CannotCompileException: [source error] no such class: System.out
......
Caused by: compile error: no such class: System.out
at javassist.compiler.MemberResolver.searchImports(MemberResolver.java:479)
... 14 more
那么问题来了既然ClassPool可以访问JVM的classpath中的类,那为什么会显示no such class: System.out
呢?
开始探索,
首先跟进到searchImports
方法,
不难看出,因为classPool并没有成功获取到System.out这个类(java.lang.System.out也测试过),所以才会抛出compile error: no such class: System.out
但正常情况下java.lang包是默认包含在classpath中的。
接下来,我们需要找到初始ClassPool的classpath,看看里面的情况
回到Agent,跟进ClassPool.getDefault()方法,
该方法会添加系统类路径(包括 Java 标准库和其他基础类路径
),佐证了上述观点
跟进appendSystemPath方法,
继续跟进,
以JAVA 9为分界线,做了2种不同的添加系统类classpath方法的适配
跟进appendClassPath方法,
appendClassPath
方法在ClassPoolTail
类中用于将新的ClassPath
添加到类路径列表的末尾。
当前类ClassPoolTail
下的toString方法刚好能够打印当前ClassPool实例的classpath
而ClassPool
类的toString恰好调用了上述方法,
之后通过打印classPool来查看其classpath
在setBody上一行增加一行代码
System.out.println("classPool's cp: \n"+classPool+"\n");
m.setBody("{System.out.println(\" the method has been modified! \"); }");
有insertClassPath
[class path: Main.class;<null>;]
无insertClassPath
[class path: <null>;]
从这里似乎并不能找到为什么会出现System.out无法识别的原因。因为Main.class和java.lang.System.class,一个是自定义类,一个是java标准库,二者毫无关系。
额外补充一个,静态Agent的
[class path: jdk.internal.loader.ClassLoaders$AppClassLoader@4dc63996;]
让我们有的放矢地更改一下,既然javaassist找不到System.class,那我们就手动给他System的类路径
if (classBeingRedefined != null) {
System.out.println("Class Transformed: " + classBeingRedefined);
//这里把classBeingRedefined改为System.class
ClassClassPath ccp = new ClassClassPath(System.class);
classPool.insertClassPath(ccp);
}
执行结果
这次我们显式地将System.class加入到javaassist的classpath中,发现执行成功,并且效果和之前一样都实现了字节码增强。
通过以上的探究,加之一些资料的查询,我目前的推测如下(不一定准确):
- 在 Java 中,
java.lang
和其他标准库的类通常由 “引导类加载器”(Bootstrap ClassLoader)加载,而由于引导类加载器是 Java 运行时的一部分,它通常不会出现在由应用程序代码打印的类路径列表中。所以,在静态Agent的环境中,既然javaassist能够默认的加载应用类加载器(AppClassLoader), 那么Bootstrap ClassLoader也应该能够被默认加载,从而加载java.lang这样的标准库,但对于我们来说是透明的。并且,javaassist可以共享使用JVM 的classpath(针对静态)。 - 在
动态运行
的JVM环境中,不像静态Agent的ClassPool.getDefault()方法会创建一个包含系统类路径
的ClassPool
,在JVM正在运行时,ClassPool.getDefault()由于某种原因无法获取到系统类路径(在内存中无法找到classpath?)
。 - 当我们去手动地为ClassPool去insertClassPath一个class时,javaassist知道去哪里(内存中)找到该类的字节码 ,之后会调用相应的类加载器,在加载该class时,会产生一系列的蝴蝶效应,自动完成其他类的加载。(这一点没想明白,有点牵强)
现执行成功,并且效果和之前一样都实现了字节码增强。
通过以上的探究,加之一些资料的查询,我目前的推测如下(不一定准确):
- 在 Java 中,
java.lang
和其他标准库的类通常由 “引导类加载器”(Bootstrap ClassLoader)加载,而由于引导类加载器是 Java 运行时的一部分,它通常不会出现在由应用程序代码打印的类路径列表中。所以,在静态Agent的环境中,既然javaassist能够默认的加载应用类加载器(AppClassLoader), 那么Bootstrap ClassLoader也应该能够被默认加载,从而加载java.lang这样的标准库,但对于我们来说是透明的。并且,javaassist可以共享使用JVM 的classpath(针对静态)。 - 在
动态运行
的JVM环境中,不像静态Agent的ClassPool.getDefault()方法会创建一个包含系统类路径
的ClassPool
,在JVM正在运行时,ClassPool.getDefault()由于某种原因无法获取到系统类路径(在内存中无法找到classpath?)
。 - 当我们去手动地为ClassPool去insertClassPath一个class时,javaassist知道去哪里(内存中)找到该类的字节码 ,之后会调用相应的类加载器,在加载该class时,会产生一系列的蝴蝶效应,自动完成其他类的加载。(这一点没想明白,有点牵强)