Java Agent 入门

本文简要介绍 Java Agent,快速入门 Java Agent

建议先学习 Javassist 和 Java ASM 后再来学习 Java Agent,这两部分在我的博客上都有介绍

什么是 Java Agent

Java Agent 本质上可以理解为一个插件,该插件就是一个精心提供的 Jar 包。只是启动方式和普通 Jar 包有所不同,对于普通的 Jar 包,通过指定类的 main 函数进行启动。但是 Java Agent 并不能单独启动,必须依附在一个 Java 应用程序运行,在面向切面编程方面应用比较广泛

Java Agent 的 Jar 包通过 JVMTI(JVM Tool Interface)完成加载,最终借助 JPLISAgent(Java Programming Language Instrumentation Services Agent)完成对目标代码的修改。主要功能如下:

  1. 可以在加载 Java 文件之前做拦截把字节码做修改
  2. 可以在运行期将已经加载的类的字节码做变更

在 JDK1.5 版本开始,Java 增加了 Instrumentation(Java Agent API)和 JVMTI(JVM Tool Interface)功能,该功能可以实现 JVM 在加载某个 class 文件对其字节码进行修改,也可以对已经加载的字节码进行一个重新的加载。而在 1.6 版本新增了 Attach(附加)方式,可以对运行中的 Java 进程插入 Agent。Java Agent 可以去实现字节码插桩、动态跟踪分析等,比如 RASP 产品和 Java Agent 内存马

Java Agent 有两种模式:

启动 Java 程序时添加-javaagent(Instrumentation API 实现方式)或-agentpath/-agentlib(JVMTI 的实现方式)参数,如:

1
java -javaagent:XXX.jar Test

JDK1.6 新增了 Attach(附加)方式,可以对运行中的 Java 进程附加 Agent

这两种运行方式的最大区别在于第一种方式只能在程序启动时指定 Agent 文件,而 Attach 方式可以在 Java 程序运行后根据进程 ID 动态注入 Agent 到 JVM,所以类似于想要注入 Agent 型内存马,一般会用 Attach 的方式

Java Agent 简介

Java Agent 是 java 命令的一个参数,参数 Java Agent 可以用于指定一个 Jar 包,Java Agent 和普通的 Java 类并没有任何区别,普通的 Java 程序中规定了 main 方法为程序入口,而 Java Agent 则将 premain(Agent 模式)和 agentmain(Attach 模式)作为了 Agent 程序的入口,两者所接受的参数是完全一致的,如下:

1
2
public static void premain(String args, Instrumentation inst)
public static void agentmain(String args, Instrumentation inst)

而在 Attach 模式下的premain()方法有两种写法,如下:

1
2
public static void premain(String agentArgs, Instrumentation inst)
public static void premain(String agentArgs)

JVM 会去优先加载带 Instrumentation 签名的方法,加载成功忽略第二种,如果第一种没有,则加载第二种方法,Java Agent 还限制了我们必须以 Jar 包的形式运行或加载,我们必须将编写好的 Agent 程序打包成一个 Jar 文件。除此之外,Java Agent 还强制要求了所有的 Jar 文件中必须包含/META-INF/MANIFEST.MF文件,且该文件中必须定义好Premain-ClassAgent-Class配置,如:

1
2
Premain-Class: com.anbai.sec.agent.CrackLicenseAgent
Agent-Class: com.anbai.sec.agent.CrackLicenseAgent

如果我们需要修改已经被 JVM 加载过的类的字节码,那么还需要设置在MANIFEST.MF中添加:

1
2
3
Can-Retransform-Classes: true
// 或者下面这种
Can-Redefine-Classes: true

以下是几个主要的概念:

1.ClassFileTransformer

ClassFileTransformer 是一个转换类文件的代理接口,我们可以在获取到 Instrumentation 对象后通过 addTransformer 方法添加自定义类文件转换器

使用addTransformer方法可以注册一个我们自定义的 Transformer 到 Java Agent,当有新的类被 JVM 加载时 JVM 会自动回调用我们自定义的 Transformer 类的 transform 方法,传入该类的 transform 信息(类名、类加载器、类字节码等),我们可以根据传入的类信息决定是否需要修改类字节码,修改完字节码后我们将新的类字节码返回给 JVM,JVM 会验证类和相应的修改是否合法,如果符合类加载要求 JVM 会加载我们修改后的类字节码

重写 transform 方法需要注意以下事项:

  1. ClassLoader 如果是被 Bootstrap ClassLoader(引导类加载器)所加载那么 loader 参数的值是空
  2. 修改类字节码时需要特别注意插入的代码在对应的 ClassLoader 中可以正确的获取到,否则会报 ClassNotFoundException,比如修改java.io.FileInputStream(该类由 Bootstrap ClassLoader 加载)时插入了我们检测代码,那么我们将必须保证 FileInputStream 能够获取到我们的检测代码类
  3. JVM 类名的书写方式路径方式:java/lang/String而不是我们常用的类名方式:java.lang.String
  4. 类字节必须符合 JVM 校验要求,如果无法验证类字节码会导致 JVM 崩溃或者 VerifyError(类验证错误)
  5. 如果修改的是 retransform 类(修改已被 JVM 加载的类),修改后的类字节码不得新增方法、修改方法参数、类成员变量
  6. addTransformer 时如果没有传入 retransform 参数(默认是 false)就算MANIFEST.MF中配置了Can-Redefine-Classes: true而且手动调用了retransformClasses方法也一样无法 retransform
  7. 卸载 transform 时需要使用创建时的 Instrumentation 实例

2.Instrumentation

java.lang.instrument.Instrumentation是监测运行在 JVM 程序的 Java API,利用 Instrumentation 我们可以实现如下功能:

  1. 动态添加或移除自定义的 ClassFileTransformer(addTransformer、removeTransformer),JVM 会在类加载时调用 Agent 中注册的 ClassFileTransformer
  2. 动态修改 classpath(appendToBootstrapClassLoaderSearch、appendToSystemClassLoaderSearch),将 Agent 程序添加到 BootstrapClassLoader 和 SystemClassLoaderSearch(对应的是 ClassLoader 类的 getSystemClassLoader 方法,默认是sun.misc.Launcher$AppClassLoader)中搜索
  3. 动态获取所有 JVM 已加载的类(getAllLoadedClasses)
  4. 动态获取某个类加载器已实例化的所有类(getInitiatedClasses)
  5. 重定义某个已加载的类的字节码(redefineClasses)
  6. 动态设置 JNI 前缀(setNativeMethodPrefix),可以实现 Hook native 方法
  7. 重新加载某个已经被 JVM 加载过的类字节码(retransformClasses)

Java Agent 实例

写几个小 Demo 来实际体验下 Java Agent,我们需要新建两个项目,一个用来写我们的 Agent(起名:JavaAgent),另一个用来测试 Agent(起名:JavaAgentTest)

Agent 模式

先来写一个最简单的 Demo,创建我们的DefineTransformer

1
2
3
4
5
6
7
8
9
10
11
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;

public class DefineTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
System.out.println("Premain load class: " + className);
return new byte[0];
}
}

我们的主类Agent

1
2
3
4
5
6
7
8
9
import java.lang.instrument.Instrumentation;

public class Agent {
public static void premain(String agentArgs, Instrumentation inst) {
System.out.println("agentArgs: " + agentArgs);
// 调用 addTransformer 添加一个 Transformer
inst.addTransformer(new DefineTransformer(), true);
}
}

然后需要添加一些依赖来打包和生成MANIFEST.MF

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
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<!-- 自动添加 META-INF/MANIFEST.MF -->
<manifest>
<addClasspath>true</addClasspath>
</manifest>
<manifestEntries>
<Premain-Class>Agent</Premain-Class>
<Agent-Class>Agent</Agent-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
</plugin>

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>8</source>
<target>8</target>
</configuration>
</plugin>
</plugins>
</build>

然后能生成这样的MANIFEST.MF

1
2
3
4
5
6
7
8
9
10
Manifest-Version: 1.0
Premain-Class: Agent
Archiver-Version: Plexus Archiver
Built-By: xxx
Agent-Class: Agent
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Created-By: Apache Maven 3.5.4
Build-Jdk: 1.8.0_341
...

一些可能会用到的参数说明:

  1. Premain-Class :包含 premain 方法的类(类的全路径名)
  2. Agent-Class :包含 agentmain 方法的类(类的全路径名)
  3. Boot-Class-Path :设置引导类加载器搜索的路径列表。查找类的特定于平台的机制失败后,引导类加载器会搜索这些路径。按列出的顺序搜索路径。列表中的路径由一个或多个空格分开。路径使用分层 URI 的路径组件语法。如果该路径以斜杠字符/开头,则为绝对路径,否则为相对路径。相对路径根据代理 Jar 文件的绝对路径解析。忽略格式不正确的路径和不存在的路径。如果代理是在 VM 启动之后某一时刻启动的,则忽略不表示 JAR 文件的路径(可选)
  4. Can-Redefine-Classes :true 表示能重定义此代理所需的类,默认值为 false(可选)
  5. Can-Retransform-Classes :true 表示能重转换此代理所需的类,默认值为 false (可选)
  6. Can-Set-Native-Method-Prefix: true 表示能设置此代理所需的本机方法前缀,默认值为 false(可选)

接下来编写我们的测试类:

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

先使用 Maven 进行打包(打包为 Jar 文件),再添加对应的 VM 选项:

1
-javaagent:E:\Zodog\Temp\javassist\JavaAgent\target\JavaAgent-1.0-SNAPSHOT.jar

运行测试类,可以看见:

1
2
3
4
5
6
7
agentArgs: null
Premain load class: java/util/concurrent/ConcurrentHashMap$ForwardingNode
...
Hello, world!
Premain load class: sun/misc/FloatingDecimal$PreparedASCIIToBinaryBuffer
...
Premain load class: java/lang/Shutdown$Lock

执行 main 方法之前会加载所有的类,包括系统类和自定义类。而在ClassFileTransformer中会去拦截系统类和自己实现的类对象,逻辑则是在ClassFileTransformer实现类的transform方法中定义

而在这里transform类似于一个filter会去拦截、遍历一些要在 JVM 中加载的类,而在transform方法中我们可以定义一些逻辑,比如if className == xxx时走入一个逻辑去实现 AOP。而其中就可以利用如 Javassist 技术修改字节码并作为transform方法的返回值,这样就在该类在 JVM 中加载前(-javaagent模式)修改了字节码

再来看另一个例子,使用 Javassist 修改字节码,首先需要在所有项目里添加依赖:

1
2
3
4
5
6
7
<dependencies>
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.27.0-GA</version>
</dependency>
</dependencies>

我们的DefineTransformer

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
import javassist.*;

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

public class DefineTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
// 打印加载的类
// System.out.println("premain load class: " + className);

// 使用 / 代替 .
if ("Main".equals(className)) {
try {
ClassPool classPool = ClassPool.getDefault();
// 这里使用 .
CtClass ctClass = classPool.get("Main");
CtMethod call = ctClass.getDeclaredMethod("call");

// 打印后加了一个弹计算器的操作
String MethodBody = "{System.out.println(\"say hello ...\");" + "java.lang.Runtime.getRuntime().exec(\"calc\");}";

call.setBody(MethodBody);
byte[] bytes = ctClass.toBytecode();

// detach 的意思是将内存中曾经被 javassist 加载过的 Main 对象移除,如果下次有需要在内存中找不到会重新走 javassist 加载
ctClass.detach();
return bytes;

} catch (NotFoundException | CannotCompileException | IOException e) {
e.printStackTrace();
}
}
return new byte[0];
}
}

其它的类不用变化,运行就可以发现打印了:

1
2
3
agentArgs: null
Hello, world!
say hello ...

然后弹出了计算器

Attach API

在 Java SE 6 以后在Instrumentation接口中提供了新的方法agentmain可以在 main 函数开始运行之后再运行

1
2
3
4
5
// 采用 attach 机制,被代理的目标程序 VM 有可能很早之前已经启动,当然其所有类已经被加载完成,这个时候需要借助
// Instrumentation#retransformClasses(Class<?>... classes)
// 让对应的类可以重新转换,从而激活重新转换的类执行 ClassFileTransformer 列表中的回调
public static void agentmain (String agentArgs, Instrumentation inst)
public static void agentmain (String agentArgs)

同样,agentmain 方法中带 Instrumentation 参数的方法也比不带优先级更高。开发者必须在MANIFEST.MF文件里面设置Agent-Class来指定包含 agentmain 函数的类,在 Java6 以后实现启动后加载的新实现是 Attach API。Attach API 很简单,只有 2 个主要的类,都在com.sun.tools.attach包里面:

  1. VirtualMachine 字面意义表示一个 Java 虚拟机,也就是程序需要监控的目标虚拟机,提供了获取系统信息,比如获取内存 dump、线程 dump,类信息统计,比如已加载的类以及实例个数等, loadAgent,Attach 和 Detach (Attach 动作的相反行为,从 JVM 上面解除一个代理)等方法,可以实现的功能可以说非常之强大 。该类允许我们通过给 attach 方法传入一个 JVM 的 PID(进程 id),远程连接到 JVM上 。代理类注入操作只是它众多功能中的一个,通过 loadAgent 方法向 JVM 注册一个代理程序 agent,在该 agent 的代理程序中会得到一个 Instrumentation 实例,该实例可以在 class 加载前改变 class 的字节码,也可以在 class 加载后重新加载。在调用 Instrumentation 实例的方法时,这些方法会使用 ClassFileTransformer 接口中提供的方法进行处理
  2. VirtualMachineDescriptor 则是一个描述虚拟机的容器类,配合 VirtualMachine 类完成各种功能

Attach 实现动态注入的原理如下:通过 VirtualMachine 类的attach(pid)方法,便可以 attach 到一个运行中的 java 进程上,之后便可以通过loadAgent(agentJarPath)来将 agent 的 Jar 包注入到对应的进程,然后对应的进程会调用 agentmain 方法

Attach 模式

在 JavaAgent 项目中新编写一个 AgentMain 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.lang.instrument.Instrumentation;
import java.lang.instrument.UnmodifiableClassException;

public class AgentMain {
public static void agentmain(String agentArgs, Instrumentation instrumentation) {
instrumentation.addTransformer(new AgentMainTransformer(), true);
System.out.println("Transformer 添加成功");
try {
// 重新加载指定类
instrumentation.retransformClasses(Class.forName("Main"));
System.out.println("retransformClasses 已运行");
} catch (UnmodifiableClassException | ClassNotFoundException e) {
e.printStackTrace();
}
}
}

我们需要编写一个 AgentMainTransformer 类,去修改方法:

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
import javassist.*;

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

public class AgentMainTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
System.out.println("在类 " + className + " 执行 transform");
if ("Main".equals(className)) {
try {
System.out.println("运行 AgentMainTransformer#transform 改变方法");

ClassPool classPool = ClassPool.getDefault();
CtClass ctClass = classPool.get("Main");
CtMethod call = ctClass.getDeclaredMethod("call");
// 为了系统考虑,还是别启动计算器了
String MethodBody = "{System.out.println(\"Say goodbye...\");}";

call.setBody(MethodBody);
return ctClass.toBytecode();

// detach 的意思是将内存中曾经被 javassist 加载过的 Main 对象移除
// 如果下次有需要在内存中找不到会重新走 javassist 加载
// ctClass.detach();
} catch (Exception e) {
e.printStackTrace();
return classfileBuffer;
}
} else {
return classfileBuffer;
}
}
}

在 Agent 项目里编写 AgentMainTest,用于注入 Agent:

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
import com.sun.tools.attach.*;
import java.io.IOException;
import java.util.List;

public class AgentMainTest {
public static void main(String[] args) {
// 寻找当前系统中所有运行着的 JVM 进程
List<VirtualMachineDescriptor> list = VirtualMachine.list();
for (VirtualMachineDescriptor vmd : list) {
// 如果虚拟机的名称为 xxx 则该虚拟机为目标虚拟机,获取该虚拟机的 PID
// 然后加载 JavaAgent.jar 发送给该虚拟机

// vmd.displayName() 看到当前系统都有哪些 JVM 进程在运行
System.out.println(vmd.displayName());
if (vmd.displayName().endsWith("Main")) {
System.out.println("----------");
System.out.println("Choose Main...");
VirtualMachine virtualMachine;
try {
virtualMachine = VirtualMachine.attach(vmd.id());
virtualMachine.loadAgent("E:\\Zodog\\Temp\\javassist\\JavaAgent\\target\\JavaAgent-1.0-SNAPSHOT.jar");
virtualMachine.detach();
} catch (AttachNotSupportedException | IOException | AgentLoadException | AgentInitializationException e) {
e.printStackTrace();
}
}
}
}
}

注意修改 Jar 的路径,另外修改 Agent 的pom.xml

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
<dependencies>
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.27.0-GA</version>
</dependency>

<dependency>
<groupId>jdk.tools</groupId>
<artifactId>jdk.tools</artifactId>
<version>1.0.0</version>
<scope>system</scope>
<systemPath>${java.home}/../lib/tools.jar</systemPath>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<!-- 自动添加 META-INF/MANIFEST.MF -->
<manifest>
<addClasspath>true</addClasspath>
</manifest>
<manifestEntries>
<Agent-Class>AgentMain</Agent-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
</plugin>

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>8</source>
<target>8</target>
</configuration>
</plugin>
</plugins>
</build>

还需要去除我们在前面为测试 Agent 而添加的 VM 选项,改变测试 Agent 项目以前的 Main 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Main {
public static void main(String[] args) throws Exception {
System.out.println("Hello, world!");
for (;;) {
Thread.sleep(1000);
call();
}
}

public static void call() {
System.out.println("Say hello...");
}
}

然后先运行测试 Agent 的类,再运行 Agent 的类,就会出现:

Agent

这样就成功了,还有些要注意的内容:

  1. 已加载的 Java 类是不会再被 Agent Attach 处理的,所以我们需要调用retransformClasses
  2. premain 和 agentmain 两种方式修改字节码的时机都是类文件加载之后,也就是说必须要带有 Class 类型的参数,不能通过字节码文件和自定义的类名重新定义一个本来不存在的类
  3. 类的字节码修改称为类转换(Class Transform),类转换其实最终都回归到类重定义Instrumentation#redefineClasses()方法,此方法有以下限制:
    • 新类和老类的父类必须相同
    • 新类和老类实现的接口数也要相同,并且是相同的接口
    • 新类和老类访问符必须一致。 新类和老类字段数和字段名要一致
    • 新类和老类新增或删除的方法必须是private static/final修饰的
    • 可以修改方法体
  4. Java Agent 中的所有依赖,在原进程中的 classpath 中都要能找到,否则在注入时原进程会报错 NoClassDefFoundError
  5. Agent 进程的 classpath 中必须有tools.jar(提供 VirtualMachine Attach API ),JDK 默认有tools.jar,JRE 默认没有。并且 Linux 和 Windows 之间是存在一个适配问题

到此介绍完毕


本文参考链接:

初探Java安全之JavaAgent