什么是”Java Agent” ?

在Java中,”Agent”(代理)是指一个可以附加到Java虚拟机(JVM)上的程序,它可以监控修改或扩展JVM执行的应用程序的行为。这个术语的使用源于它的工作方式:它像一个代理一样在JVM应用程序之间进行工作,而不需要改变应用程序本身的代码。

java Agent主要有2种方式

  • 静态Agent:在JVM启动时通过 -javaagent 参数加载。它必须定义一个 premain 方法,JVM会在应用程序的 main 方法执行之前调用这个方法。
  • 动态Agent:在JVM已经运行的情况下附加。它必须定义一个 agentmain 方法,当Agent被动态附加到JVM时,此方法被调用。

静态Agent

创建代理类

1
2
3
4
5
6
7
8
9
10
11
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

1
2
3
4
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命令进行打包

1
jar cmf MANIFEST.MF myAgent.jar *.class

编写主程序

1
2
3
4
5
public class Main {
public static void main(String[] args) {
System.out.println("Hello world!");
}
}

执行的程序用字节码文件或者打包成jar都可以

代理执行

1
java -javaagent:myAgent.jar -cp Main_path Main

这里注意要添加classpath,否则会抛出NoClassDefFoundError

执行结果

这里是以静态agent的方式使用,所以在JVM启动之前只执行了premain方法

动态Agent

编写主程序

因为动态agent是以附加到别的jvm上的工作形式,所以我们需要写一个能持续运行的程序

1
2
3
4
5
6
7
8
9
10
11
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

1
2
3
4
5
C:\>jps
17572
32532 Launcher
21928 Jps
29288 Main

然后,使用Attach API来加载你的Agent。

Attach

1
2
3
4
5
6
7
8
9
10
11
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方法修改如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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类

1
2
3
4
5
6
7
8
9
public class Main {
public static void main(String[] args) {
sayHello();
}
public static void sayHello(){
System.out.println("hello");
}
}

Agent类

1
2
3
4
5
6
7
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实现类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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对应的版本也要更新才对,否则可能出现各种错误。

清单文件

1
2
3
Manifest-Version: 1.0
Premain-Class: Agent

打包测试

1
2
3
4
5
javac -cp .;./lib/* *.java

jar cvmf MANIFEST.MF myAgent.jar *.class

java -javaagent:myAgent.jar -cp .;./lib/* Main

成功在sayHello方法执行前与执行后执行insert中的代码

字节码增强(动态)

主程序Main类

1
2
3
4
5
6
7
8
9
10
11
12
13
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接口的匿名类,快捷一些

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
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,不再赘述

清单文件

1
2
3
4
5
6
7
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中。

1
2
3
4
5
6
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 都会调用这个 ClassFileTransformertransform 方法。这允许你在类实际被使用前修改其字节码,对应的是上述中静态Agent修改字节码的情况。

类重新转换时调用:当你调用 inst.retransformClasses 方法请求重新转换一个或多个已加载的类时,JVM 也会调用 ClassFileTransformertransform 方法(前提是canRetransform参数为true),即使这个类已经被加载。这是为了应用在运行时的字节码修改。

字节码增强的本质

1. 运行时字节码修改

字节码增强发生在类的字节码级别,通常是在类被加载到 JVM 之前(静态增强)或者在类已经加载之后(动态增强)。这意味着你可以在不改变原始源代码的情况下,改变类的行为。

2. 不重新加载类

与重新编译或替换类文件不同,字节码增强并不涉及类的重新加载过程。即便是对于已加载的类,通过 retransformClasses 方法触发的增强操作只是动态替换内存中的类定义,而不会产生 JVM 完全重新加载一次类的行为。

retransformClasses方法并不会触发被重新转换类的static代码块

javassist的ClassClassPath问题

在前面的例子中,静态增强与动态增强在转换方法的不同上,本质的区别就只有动态增强比静态增强多两行代码

1
2
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的标准类加载器中

如果没有这两行代码,会报错

1
2
3
4
5
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上一行增加一行代码

1
2
System.out.println("classPool's cp: \n"+classPool+"\n");
m.setBody("{System.out.println(\" the method has been modified! \"); }");

有insertClassPath

1
[class path: Main.class;<null>;]

无insertClassPath

1
[class path: <null>;]

从这里似乎并不能找到为什么会出现System.out无法识别的原因。因为Main.class和java.lang.System.class,一个是自定义类,一个是java标准库,二者毫无关系。

额外补充一个,静态Agent的

1
[class path: jdk.internal.loader.ClassLoaders$AppClassLoader@4dc63996;]

让我们有的放矢地更改一下,既然javaassist找不到System.class,那我们就手动给他System的类路径

1
2
3
4
5
6
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时,会产生一系列的蝴蝶效应,自动完成其他类的加载。(这一点没想明白,有点牵强)