Java安全之Java Agent
2022-06-19 13:45:43 # Java安全

Java Agent简介

JavaAgent 是 JDK1.5 之后引入的新特性,此特性为用户提供了在 JVM 将字节码文件读入内存之后,JVM 使用对应的字节流在 Java 堆中生成一个 Class 对象之前,用户可以对其字节码进行修改的能力,从而 JVM 也将会使用用户修改过之后的字节码进行 Class 对象的创建。Java Agent可以去实现字节码插桩、动态跟踪分析等。

Java Agent运行模式

共两种运行模式:

  1. 启动Java程序时添加-javaagent(Instrumentation API实现方式)-agentpath/-agentlib(JVMTI的实现方式)参数;
  2. 在1.6版本新增了attach(附加方式)方式,可以对运行中的Java进程插入Agent

方式一中要在启动前去指定需要加载的Agent文件,而方式二可以在Java程序运行后根据进程ID进行动态注入Agent到JVM里面去。

Java Agent概念

Javaagent是java命令的一个参数。参数javaagent可以用于指定一个jar包,并且对该jar包有2个要求:

  1. 这个jar包的MANIFEST.MF文件必须指定Premain-Class项。
  2. Premain-Class指定的那个类必须实现premain()方法。

premain 方法,从字面上理解,就是运行在 main 函数之前的的类。当Java虚拟机启动时,在执行 main 函数之前,JVM会先运行-javaagent所指定jar包内Premain-Class这个类的premain方法 。普通的Java类是以main方法作为程序入口点,而Java Agent则将premain(Agent模式)和agentmain(Attach模式)作为了Agent程序的入口。

在命令行输入java可以看到相应的参数,其中有和java agent相关的:

1
2
3
4
5
6
-agentlib:<libname>[=<选项>] 加载本机代理库 <libname>, 例如 -agentlib:hprof
另请参阅 -agentlib:jdwp=help 和 -agentlib:hprof=help
-agentpath:<pathname>[=<选项>]
按完整路径名加载本机代理库
-javaagent:<jarpath>[=<选项>]
加载 Java 编程语言代理, 请参阅 java.lang.instrument

在上面-javaagent参数中提到了参阅java.lang.instrument,这是在rt.jar中定义的一个包,该路径下有两个重要的类:

image-20220619221436767

该包提供了一些工具帮助开发人员在 Java 程序运行时,动态修改系统中的 Class 类型。其中,使用该软件包的一个关键组件就是 Javaagent。从名字上看,似乎是个 Java 代理之类的,而实际上,他的功能更像是一个Class 类型的转换器,他可以在运行时重新接受外部请求,对Class类型进行修改。

从本质上讲,Java Agent 是一个遵循一组严格约定的常规 Java 类。 上面说到 javaagent命令要求指定的类中必须要有premain()方法,并且对premain方法的签名也有要求,签名必须满足以下两种格式:

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

public static void premain(String agentArgs)

JVM 会优先加载 带Instrumentation签名的方法,加载成功忽略第二种,如果第一种没有,则加载第二种方法。

image-20220619221937740

Instrumentation接口

接口中的方法描述大致如下:

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
public interface Instrumentation {

//增加一个Class 文件的转换器,转换器用于改变 Class 二进制流的数据,参数 canRetransform 设置是否允许重新转换。
void addTransformer(ClassFileTransformer transformer, boolean canRetransform);

//在类加载之前,重新定义 Class 文件,ClassDefinition 表示对一个类新的定义,如果在类加载之后,需要使用 retransformClasses 方法重新定义。addTransformer方法配置之后,后续的类加载都会被Transformer拦截。对于已经加载过的类,可以执行retransformClasses来重新触发这个Transformer的拦截。类加载的字节码被修改后,除非再次被retransform,否则不会恢复。
void addTransformer(ClassFileTransformer transformer);

//删除一个类转换器
boolean removeTransformer(ClassFileTransformer transformer);
//检测是否允许reTransformer
boolean isRetransformClassesSupported();

//在类加载之后,重新定义 Class。这个很重要,该方法是1.6 之后加入的,事实上,该方法是 update 了一个类。
void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;

boolean isRedefineClassesSupported();

//传入字节码,可以重新定义类
void redefineClasses(ClassDefinition... definitions)
throws ClassNotFoundException, UnmodifiableClassException;
//类是否支持reTransformer
boolean isModifiableClass(Class<?> theClass);
//获取所有已经加载的类
@SuppressWarnings("rawtypes")
Class[] getAllLoadedClasses();

//获取类加载器下所有已经初始化的类
@SuppressWarnings("rawtypes")
Class[] getInitiatedClasses(ClassLoader loader);

//获取一个对象的大小
long getObjectSize(Object objectToSize);



void appendToBootstrapClassLoaderSearch(JarFile jarfile);


void appendToSystemClassLoaderSearch(JarFile jarfile);


boolean isNativeMethodPrefixSupported();


void setNativeMethodPrefix(ClassFileTransformer transformer, String prefix);
}

ClassFileTransformer接口

java.lang.instrument.ClassFileTransformer是一个转换类文件的代理接口,我们可以在获取到Instrumentation对象后通过addTransformer方法添加自定义类文件转换器。当有新的类被JVM加载时,JVM会自动回调用我们自定义的Transformer类的transform方法,传入该类的transform信息(类名、类加载器、类字节码等),我们可以根据传入的类信息决定是否需要修改类字节码,修改完字节码后我们将新的类字节码返回给JVMJVM会验证类和相应的修改是否合法,如果符合类加载要求JVM会加载我们修改后的类字节码。

查看一下该接口

image-20220619223902779

只有一个transform方法

1
2
3
4
5
6
ClassLoader loader              	定义要转换的类加载器;如果是引导加载器,则为 null
String className 加载的类名,如:java/lang/Runtime
Class<?> classBeingRedefined 如果是被重定义或重转换触发,则为重定义或重转换的类;如果是类加载,则为 null
ProtectionDomain protectionDomain 要定义或重定义的类的保护域
byte[] classfileBuffer 类文件格式的输入字节缓冲区(不得修改)

重写**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实例。

Java Agent实现

使用javaagent需要几个步骤:

  1. 定义一个 MANIFEST.MF 文件,必须包含 Premain-Class 选项,通常也会加入Can-Redefine-Classes 和 Can-Retransform-Classes 选项。
  2. 创建一个Premain-Class 指定的类,类中包含 premain 方法,方法逻辑由用户自己确定。
  3. 将 premain 的类和 MANIFEST.MF 文件打成 jar 包。
  4. 使用参数 -javaagent: jar包路径 启动要代理的方法。

实现javaagent你需要搭建两个工程,一个工程是用来承载javaagent类,单独的打成jar包;一个工程是javaagent需要去代理的类。

JVM启动前运行

工程目录结构如下:

1
2
3
4
5
6
7
├── java
│   └── org
│   └── agent
│   ├── Agent.java
└── resources
└── META-INF
├── MANIFEST.MF

创建一个包含premain方法的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package org.agent;

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

public class Agent {
public static void premain(String args, Instrumentation inst) {
System.out.println("Agent args is : " + args);
inst.addTransformer(new DefineTransformer(), true);
}

static 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];
}
}
}

上面就是我实现的一个类,实现了带Instrumentation参数的premain()方法。调用addTransformer()方法对启动时所有的类进行拦截。

然后在 resources 目录下新建目录:META-INF,在该目录下新建文件:MANIFREST.MF:

1
2
3
4
5
Manifest-Version: 1.0
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Premain-Class: org.agent.Agent

MANIFREST.MF文件的作用

Premain-Class :包含 premain 方法的类(类的全路径名)

Agent-Class :包含 agentmain 方法的类(类的全路径名)

Boot-Class-Path :设置引导类加载器搜索的路径列表。查找类的特定于平台的机制失败后,引导类加载器会搜索这些路径。按列出的顺序搜索路径。列表中的路径由一个或多个空格分开。路径使用分层 URI 的路径组件语法。如果该路径以斜杠字符(“/”)开头,则为绝对路径,否则为相对路径。相对路径根据代理 JAR 文件的绝对路径解析。忽略格式不正确的路径和不存在的路径。如果代理是在 VM 启动之后某一时刻启动的,则忽略不表示 JAR 文件的路径。(可选)

Can-Redefine-Classes :true表示能重定义此代理所需的类,默认值为 false(可选)

Can-Retransform-Classes :true 表示能重转换此代理所需的类,默认值为 false (可选)

Can-Set-Native-Method-Prefix: true表示能设置此代理所需的本机方法前缀,默认值为 false(可选)

即在该文件中主要定义了程序运行相关的配置信息,程序运行前会先检测该文件中的配置项。

也可以用maven去配置

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
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.1.0</version>
<configuration>
<archive>
<!--自动添加META-INF/MANIFEST.MF -->
<manifest>
<addClasspath>true</addClasspath>
</manifest>
<manifestEntries>
<Premain-Class>com.nice0e3.Agent</Premain-Class>
<Agent-Class>com.nice0e3.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>6</source>
<target>6</target>
</configuration>
</plugin>
</plugins>
</build>

编译成jar包后,再建立一个项目,配置加入-javaagent参数,-javaagent:out\Agent1-1.0-SNAPSHOT.jar

image-20220619233353379

编写一个main方法

1
2
3
4
5
6
7
8
9
10
11
12
13
public class AgentTest {

public static void main(String[] args) throws Exception {
System.out.println("main start");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("main end");
}
}

运行结果,这里可以看到打印了JVM加载的所有类。

image-20220619233501086

上面的输出结果我们能够发现:

  1. 执行main方法之前会加载所有的类,包括系统类和自定义类;
  2. 在ClassFileTransformer中会去拦截系统类和自己实现的类对象;
  3. 如果你有对某些类对象进行改写,那么在拦截的时候抓住该类使用字节码编译工具即可实现。
transform测试
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
import javassist.*;

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

/**
* @author rickiyang
* @date 2019-08-06
* @Desc
*/
public class MyClassTransformer implements ClassFileTransformer {
@Override
public byte[] transform(final ClassLoader loader, final String className, final Class<?> classBeingRedefined,final ProtectionDomain protectionDomain, final byte[] classfileBuffer) {
// 操作Date类
if ("java/util/Date".equals(className)) {
try {
// 从ClassPool获得CtClass对象
final ClassPool classPool = ClassPool.getDefault();
final CtClass clazz = classPool.get("java.util.Date");
CtMethod convertToAbbr = clazz.getDeclaredMethod("convertToAbbr");
//这里对 java.util.Date.convertToAbbr() 方法进行了改写,在 return之前增加了一个 打印操作
String methodBody = "{sb.append(Character.toUpperCase(name.charAt(0)));" +
"sb.append(name.charAt(1)).append(name.charAt(2));" +
"System.out.println(\"sb.toString()\");" +
"return sb;}";
convertToAbbr.setBody(methodBody);

// 返回字节码,并且detachCtClass对象
byte[] byteCode = clazz.toBytecode();
//detach的意思是将内存中曾经被javassist加载过的Date对象移除,如果下次有需要在内存中找不到会重新走javassist加载
clazz.detach();
return byteCode;
} catch (Exception ex) {
ex.printStackTrace();
}
}
// 如果返回null则字节码不会被修改
return null;
}
}

JVM启动后运行

上面介绍的Instrumentation是在JDK1.5中提供的,开发者只能在main加载之前添加手脚,在Java SE 6的Instrumentation当中,提供了一个新的代理操作方法:agentmain,可以在main函数开始运行之后再运行。

跟premain函数一样,开发者可以编写一个含有agentmain函数的Java类:

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

public static void agentmain (String agentArgs)

同样,agentmain方法中带Instrumentation参数的方法也比不带优先级更高。开发者必须在manifest文件里面设置“Agent-Class”来指定包含agentmain函数的类。

在Java JDK6以后实现启动后加载Instrument的是Attach api。存在于com.sun.tools.attach里面有两个重要的类。

来查看一下该包中的内容,这里有两个比较重要的类,分别是VirtualMachineVirtualMachineDescriptor

image-20220620000020624

VirtualMachine

VirtualMachine可以来实现获取系统信息,内存dump、现成dump、类信息统计(例如JVM加载的类)。里面配备有几个方法LoadAgent,Attach 和 Detach 。下面来看看这几个方法的作用

Attach :从 JVM 上面解除一个代理等方法,可以实现的功能可以说非常之强大 。该类允许我们通过给attach方法传入一个jvm的pid(进程id),远程连接到jvm上

loadAgent:向jvm注册一个代理程序agent,在该agent的代理程序中会得到一个Instrumentation实例,该实例可以 在class加载前改变class的字节码,也可以在class加载后重新加载。在调用Instrumentation实例的方法时,这些方法会使用ClassFileTransformer接口中提供的方法进行处理。

Detach:从 JVM 上面解除一个代理(agent)

VirtualMachineDescriptor

则是一个描述虚拟机的容器类,配合VirtualMachine类完成各种功能。

attach实现动态注入的原理

通过VirtualMachine类的attach(pid)方法,便可以attach到一个运行中的java进程上,之后便可以通过loadAgent(agentJarPath)来将agent的jar包注入到对应的进程,然后对应的进程会调用agentmain方法。

image-20220620000746585

既然是两个进程之间通信那肯定的建立起连接,VirtualMachine.attach动作类似TCP创建连接的三次握手,目的就是搭建attach通信的连接。而后面执行的操作,例如vm.loadAgent,其实就是向这个socket写入数据流,接收方target VM会针对不同的传入数据来做不同的处理。

agentmain测试

工程结构和 上面premain的测试一样,编写AgentMainTest,然后使用maven插件打包 生成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
package org.agent;

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

public class Agent {

public static void agentmain(String agentArgs, Instrumentation instrumentation) {
instrumentation.addTransformer(new DefineTransFormer(), true);
}

static class DefineTransFormer implements ClassFileTransformer {

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

}

打包生成jar包,编写测试main方法。的步骤是:从一个attach JVM去探测目标JVM,如果目标JVM存在则向它发送agent.jar。我测试写的简单了些,找到当前JVM并加载agent.jar。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import com.sun.tools.attach.VirtualMachine;
import com.sun.tools.attach.VirtualMachineDescriptor;
import java.util.List;

public class AgentTest {

public static void main(String[] args) throws Exception {
//获取当前系统中所有 运行中的 虚拟机
System.out.println("running JVM start ");
List<VirtualMachineDescriptor> list = VirtualMachine.list();
for (VirtualMachineDescriptor vmd : list) {
//如果虚拟机的名称为 xxx 则 该虚拟机为目标虚拟机,获取该虚拟机的 pid
//然后加载 agent.jar 发送给该虚拟机
System.out.println(vmd.displayName());
if (vmd.displayName().endsWith("AgentTest")) {
VirtualMachine virtualMachine = VirtualMachine.attach(vmd.id());
virtualMachine.loadAgent("D:\\code\\java1\\AgentTest2\\out\\AgentTest-1.0-SNAPSHOT.jar");
virtualMachine.detach();
}
}
}

}

list()方法会去寻找当前系统中所有运行着的JVM进程,通过vmd.displayName()看到当前系统都有哪些JVM进程在运行。因为main函数执行起来的时候进程名为当前类名,所以通过这种方式可以去找到当前的进程id。

执行结果

image-20220620001805482

Instrumentation的局限性

大多数情况下,我们使用Instrumentation都是使用其字节码插桩的功能,或者笼统说就是类重定义(Class Redefine)的功能,但是有以下的局限性:

  1. premain和agentmain两种方式修改字节码的时机都是类文件加载之后,也就是说必须要带有Class类型的参数,不能通过字节码文件和自定义的类名重新定义一个本来不存在的类。
  2. 类的字节码修改称为类转换(Class Transform),类转换其实最终都回归到类重定义Instrumentation#redefineClasses()方法,此方法有以下限制:

参考

https://juejin.cn/post/7017781194481729549

https://www.cnblogs.com/nice0e3/p/14086165.html