Java 是一种强类型语言,在运行之前必须将其编译成.class 字节码,然后再交给 JVM 处理运行。Java Agent 就是一种能在不影响正常编译的前提下,修改 Java 字节码,进而动态地修改已加载或未加载的类、属性和方法的技术。
实际上,平时较为常见的技术如热部署、一些诊断工具等都是基于 Java Agent 技术来实现的。那么 Java Agent 技术具体是怎样实现的呢?对于 Agent(代理)来讲,其大致可以分为两种,一种是在 JVM 启动前加载的 premain-Agent,另一种是 JVM 启动之后加载的 agentmain-Agent。这里我们可以将其理解成一种特殊的 Interceptor(拦截器),如下图
premain-Agent

agentmain-Agent

通过这俩张图我们能清楚的看出俩者的不同在于一个是必须在 JVM 启动前加载,而另一种相对,是在 JVM 加载后再进行加载。
premain 和 agentmain 的简单 Demo
这部分通过代码执行实际展示俩者区别。
先写一个 premain-agent
package org.example;
import java.lang.instrument.Instrumentation;
public class Agent_Premain {
public static void premain(String args, Instrumentation inst){
System.out.println("调用执行了premain_agent");
}
}再写一个常规类
package org.example;
public class Hello_agent {
public static void main(String[] args)
{
System.out.println("Hello, agent!");
}
}然后再在项目的 main 文件夹下找到 resource 文件夹 (没有则新建),在 resource 文件夹下新建 META-INF 文件夹,在该文件夹下新建 MANIFEST.MF 文件,具体文件结构如图。

MANIFEST.MF 内的内容如下,注意结尾的换行符不能少,不然会标红
Manifest-Version: 1.0
Premain-Class: org.example.Agent_Premain在 pom.xm 里面找准位置添加下述代码,我的是加在 下一行
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>2.6</version> <!-- 确保使用最新版本 -->
<configuration>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
<archive>
<manifestFile>
src/main/resources/META-INF/MANIFEST.MF
</manifestFile>
</archive>
</configuration>
</plugin>
</plugins>
</build>然后再在该页面右键选择 maven 重新加载 pom.xml

安装完插件后点击你 IDEA 右边的 Maven,然后选择 assembly:assembly 进行打包

打包完成后修改 Hello_agent 的运行配置,在修改配置里面选添加虚拟机选项,然后在框框里面加入代码,注意我们选择的是尾巴为 with-dependencies.jar 的 jar 包
-javaagent:你的打包好的包的绝对路径
//-javaagent:C:\Users\agent-demo\target\agent-demo-1.0-SNAPSHOT-jar-with-dependencies.jar
配置好配置后选择执行,执行效果如下,可以发现 Agent_Premain 的 premain 执行在 jvm 加载执行 Hello_agent 前。

再继续尝试 agentmain,同样的先写一个 agentmain
package org.example;
import java.lang.instrument.Instrumentation;
import static java.lang.Thread.sleep;
public class Agent_Agentmain {
public static void agentmain(String args, Instrumentation inst) throws InterruptedException {
System.out.println("调用执行了agentmain");
}
}然后更改 MF 文件内容为
Manifest-Version: 1.0
Agent-Class: org.example.Agent_Agentmain因为 agentmain 是可以在 JVM 加载后进行插入,因此我们这次写一个循环的类
package org.example;
import static java.lang.Thread.sleep;
public class Helloo_Agent {
public static void main(String[] args) throws InterruptedException {
while (true){
System.out.println("Helloo!");
sleep(5000);
}
}
}因为 agentmain 和 premain 不同是在 JVM 加载后插入执行的,因此我们需要写一个注入代码实现插入操作
package org.example;
import com.sun.tools.attach.*;
import java.io.IOException;
import java.util.List;
public class Inject_Agent {
public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException, AttachNotSupportedException, AgentLoadException, AgentInitializationException, AgentLoadException, AgentInitializationException, AttachNotSupportedException, AgentLoadException, AgentInitializationException, AgentLoadException, AgentInitializationException, AgentLoadException, AgentInitializationException {
// 调用 VirtualMachine.list () 获取正在运行的 JVM 列表
List<VirtualMachineDescriptor> list = VirtualMachine.list();
for(VirtualMachineDescriptor vmd : list){
System.out.println(vmd.displayName());
// 遍历每一个正在运行的 JVM,如果 JVM 名称为 Sleep_Hello 则连接该 JVM 并加载特定 Agent
if(vmd.displayName().equals("org.example.Helloo_Agent")){
// 连接指定 JVM
VirtualMachine virtualMachine = VirtualMachine.attach(vmd.id());
// 加载 Agent
virtualMachine.loadAgent("C:\\Users\\agent-demo\\target\\agent-demo-1.0-SNAPSHOT-jar-with-dependencies.jar");// 这个包的路径和之前添加虚拟机选项选择的包是一个包
// 断开 JVM 连接
virtualMachine.detach();
}
}
}
}先执行 Helloo_Agent 然后再执行 Inject_Agent 进行注入。你可以发现当你执行 Inject_Agent 后,Helloo_Agent 的运行终端会出现调用了 agentmain,这就是执行成功了。

Instrumentation
Instrumentation 是 JVMTIAgent(JVM Tool Interface Agent)的一部分,Java agent 通过这个类和目标 JVM 进行交互,从而达到修改数据的效果。

其是 JAVA 中的接口有以下常用方法 (注释取自该文章)
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);
// 在类加载之后,重新定义 Class。这个很重要,该方法是 1.6 之后加入的,事实上,该方法是 update 了一个类。
void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;
// 判断一个类是否被修改
boolean isModifiableClass(Class<?> theClass);
// 获取目标已经加载的类。
@SuppressWarnings("rawtypes")
Class[] getAllLoadedClasses();
// 获取一个对象的大小
long getObjectSize(Object objectToSize);
}其中 addTransformer 的作用是增加一个 Class 文件的转换器,转换器用于改变 Class 二进制流的数据,参数 var2 设置是否允许重新转换。
在 Instrumentation 中增加了名叫 transformer 的 Class 文件转换器,转换器可以改变二进制流的数据,transformer 可以对未加载的类进行拦截,同时也可对已加载的类进行重新拦截,所以根据这个特性我们能够实现动态修改字节码。
public synchronized void addTransformer(ClassFileTransformer var1, boolean var2) {
if (var1 == null) {
throw new NullPointerException("null passed as 'transformer' in addTransformer");
} else {
if (var2) {
if (!this.isRetransformClassesSupported()) {
throw new UnsupportedOperationException("adding retransformable transformers is not supported in this environment");
}
if (this.mRetransfomableTransformerManager == null) {
this.mRetransfomableTransformerManager = new TransformerManager(true);
}
this.mRetransfomableTransformerManager.addTransformer(var1);
if (this.mRetransfomableTransformerManager.getTransformerCount() == 1) {
this.setHasRetransformableTransformers(this.mNativeAgent, true);
}
} else {
this.mTransformerManager.addTransformer(var1);
}
}
}而 retransformClasses 的作用是在类加载之后,重新定义 Class。该方法是 1.6 之后加入的,事实上,该方法是 update 了一个类。
private native void retransformClasses0(long var1, Class<?>[] var3);
public void retransformClasses(Class<?>... var1) {
if (!this.isRetransformClassesSupported()) {
throw new UnsupportedOperationException("retransformClasses is not supported in this environment");
} else {
this.retransformClasses0(this.mNativeAgent, var1);
}
}这里是调用了 retransformClasses0 方法,不过其是 native 方法要去 gitee 上下载源码。该方法在后面的 agent 内存马中有重要作用,这里就先不赘述。
ClassFileTransformer 也是一个接口,不过该接口里只有一个方法,返回一个 bytes 数组
public interface ClassFileTransformer {
byte[] transform(ClassLoader var1, String var2, Class<?> var3, ProtectionDomain var4, byte[] var5) throws IllegalClassFormatException;
}看了这 3 个源码你是不是已经有注入的思路了,这不就有点类似我们之前学的三大基础的内存马,往正常程序里面夹杂私货吗。那我们就可以写一个新类 Agent_transform,通过获取 org.example.Sleep_Hello 然后向里面添加我们的恶意 transform,然后通过 retransformClasses 触发加载。
package org.example;
import java.lang.instrument.Instrumentation;
import java.lang.instrument.UnmodifiableClassException;
public class Agent_transform {
public static void agentmain(String args, Instrumentation inst) throws UnmodifiableClassException {
Class [] classes = inst.getAllLoadedClasses();
// 获取目标 JVM 加载的全部类
for(Class cls:classes){
if(cls.getName().equals("org.example.Helloo_Agent")){
// 添加一个 transformer 到 Instrumentation,并重新触发目标类加载
inst.addTransformer(new Htransform(), true);
inst.retransformClasses(cls);
}
}
}
}恶意类 Htransform
package org.example;
import javassist.ClassClassPath;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;
public class Htransform implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
try {
// 获取 CtClass 对象的容器 ClassPool
ClassPool classPool = ClassPool.getDefault();
// 添加额外的类搜索路径
if (classBeingRedefined != null) {
ClassClassPath ccp = new ClassClassPath(classBeingRedefined);
classPool.insertClassPath(ccp);
}
// 获取目标类
CtClass ctClass = classPool.get("org.example.Helloo_Agent");
System.out.println(ctClass);
// 获取目标方法
CtMethod ctMethod = ctClass.getDeclaredMethod("hello");
// 设置方法体
String body = "{System.out.println(\"Hacker!\");}";
ctMethod.setBody(body);
// 返回目标类字节码
byte[] bytes = ctClass.toBytecode();
return bytes;
}catch (Exception e){
e.printStackTrace();
}
return null;
}
}修改 Helloo_Agent,添加 hello 方法,并且将 printf 输出改为调用 hello
package org.example;
import static java.lang.Thread.sleep;
public class Helloo_Agent {
public static void hello(){
System.out.println("Hello World");
}
public static void main(String[] args) throws InterruptedException {
while (true){
hello();
sleep(5000);
}
}
}不过注意要更改 MF 文件,把允许重新加载打开
Manifest-Version: 1.0
Agent-Class: org.example.Agent_transform
Can-Redefine-Classes: true
Can-Retransform-Classes: true然后按照之前的方法进行构建 jar 包,然后先后运行 Helloo_Agent 和 Inject_Agent,运行结果如下。可以发现成功实现了注入,更改了原本的 hello 方法。

Instrumentation 的局限性
大多数情况下,我们使用 Instrumentation 都是使用其字节码插桩的功能,简单来说就是类重定义功能(Class Redefine),但是有以下局限性: premain 和 agentmain 两种方式修改字节码的时机都是类文件加载之后,也就是说必须要带有 Class 类型的参数,不能通过字节码文件和自定义的类名重新定义一个本来不存在的类。 类的字节码修改称为类转换 (Class Transform),类转换其实最终都回归到类重定义 Instrumentation#redefineClasses 方法,此方法有以下限制:
- 新类和老类的父类必须相同
- 新类和老类实现的接口数也要相同,并且是相同的接口
- 新类和老类访问符必须一致。 新类和老类字段数和字段名要一致
- 新类和老类新增或删除的方法必须是 private static/final 修饰的
- 可以修改方法体
上述来自介绍来自https://www.cnblogs.com/F12-blog/p/18122545
其实我还想复现注入 doFilter 的,但是不会调 spring,导致一直找不到可以注入 attach 的进程,所以就没写了,不过思路和上面的大相径庭,不过把要修改的类改成了 doFilter,想复现的可以看这个作者的博客
结语
这篇文章几乎都是照着别的师傅博客的流程写的,算是一个记录复现的笔记吧。其实我之前还是参考了蛮多博客的,不过忘记存链接了,就只能加一点个人的理解,算一个小笔记吧。建议想更加清晰了解的直接看这个师傅的博客咯
