在项目中使用到了Java Agent技术开发RASP检测框架,本文主要记录对Java Agent的学习过程

Java Agent介绍

Java Agent 是一种基于 java.lang.instrument 包的技术,可以在 JVM 启动时或运行时加载代理代码,修改目标类的字节码。它主要用于 AOP(面向切面编程)、性能监控、日志收集、测试工具、应用安全等场景。

Java Agent 可以在两种模式下运行:

  • 启动时代理 (premain) :在 JVM 启动时加载。
  • 运行时代理 (agentmain) :在 JVM 启动后动态加载。

基本结构

一个 Java Agent (已打包好的Jar文件)包含以下几个关键部分:

  • Manifest 文件:定义了代理类的入口方法。
  • 代理类:包含 premainagentmain 方法,用于执行字节码操作。
1
2
3
4
5
6
7
import java.lang.instrument.Instrumentation;
public class SimpleAgent {
public static void premain(String agentArgs, Instrumentation inst) {
System.out.println("SimpleAgent premain called with args: " + agentArgs);
// 你可以在这里添加对类的字节码修改逻辑
}
}

premain 方法是代理类的入口,Instrumentation 接口提供了修改字节码的能力。

在 Java Agent 的 JAR 包中,META-INF/MANIFEST.MF 文件需要包含以下内容来指定代理类:

1
Premain-Class: SimpleAgent

工作原理

Intrumentation接口

Java Agent 的核心在于 Instrumentation 接口,它提供了修改类定义和字节码的能力。JVM 在加载类时,Java Agent 可以通过 ClassFileTransformer 接口动态修改类的字节码。

Instrumentation 接口的关键功能包括:

  • 添加字节码转换器: addTransformer(ClassFileTransformer transformer) 方法允许代理添加自定义的字节码转换器。
  • 重新定义类: redefineClasses(ClassDefinition... definitions) 方法允许重新定义已经加载的类。
  • 查看类信息: 提供方法来查看 JVM 中加载的所有类、获取类的大小、获取类加载器等信息。

ClassFileTransformer接口

其中ClassFileTransformer 接口用于定义字节码转换器,它包含一个方法 transform,在类加载时调用:

1
2
3
4
5
6
7
8
9
public interface ClassFileTransformer {
byte[] transform(
ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) throws IllegalClassFormatException;
}

  • classfileBuffer:类的字节码数组。
  • transform 方法返回修改后的字节码,如果不需要修改,返回 null 即可。

类加载过程中的字节码转换

当 JVM 加载一个类时,ClassFileTransformertransform 方法会被调用。开发者可以在这个方法中修改类的字节码,例如增加日志、性能监控代码,甚至可以在类中注入新的方法或字段。

启动时和运行时代理

  • 启动时代理 (premain) :代理在 JVM 启动时加载,允许修改所有即将加载的类。
  • 运行时代理 (agentmain) :代理在 JVM 运行过程中通过 Attach API 动态加载,允许修改已加载的类。

agentmain 方法的使用方式与 premain 类似,不同的是它在 JVM 运行时被调用。

Byte Buddy

在Java Agent技术的框架下,常用的框架有以下几个:

  1. Byte Buddy: 这是一个强大的库,用于在运行时创建和修改Java类。Byte Buddy提供了一个简单易用的API,用于生成、修改和加载Java字节码。它支持Java 5及更高版本,并且与Java Agent技术非常配合。
  2. ASM: ASM是一个Java字节码操控框架。它能直接生成或以二进制形式修改已有类或者核心类的字节码。ASM可以直接生成字节码,而不需要了解Java虚拟机指令。ASM比其他的Java字节码操控框架(例如Javassist, BCEL, CGLIB)更快更小。
  3. Javassist: Javassist是一个开源的分析、编辑和创建Java字节码的库。它已经被许多其他的Java类库和工具使用,包括Hibernate和Spring。Javassist是分析字节码的工具,并且提供了一个简单的API来操作和生成字节码。
  4. Instrumentation API: 这是Java Agent技术的核心API,用于在运行时修改类的字节码。使用这个API,你可以实现自己的类加载器,并在类被加载到JVM时修改其字节码。
  5. HotSwapAgent: HotSwapAgent是一个Java类重新加载器,它支持在不停止和重启JVM的情况下重新加载已修改的类。HotSwapAgent基于Java Instrumentation API,并提供了更多的功能,如条件断点、类变量查看和修改等。

Byte Buddy语法

任何一个由 Byte Buddy 创建/增强的类型都是通过 ByteBuddy 类的实例来完成的

1
2
3
4
5
6
DynamicType.Unloaded<?> dynamicType = new ByteBuddy()
// 生成 Object的子类
.subclass(Object.class)
// 生成类的名称为"com.itheima.Type"
.name("com.itheima.Type")
.make();

动态增强代码

Byte Buddy 动态增强代码总共有三种方式:

  • subclass:对应 ByteBuddy.subclass() 方法。这种方式比较好理解,就是为目标类(即被增强的类)生成一个子类,在子类方法中插入动态代码。
  • rebasing:对应 ByteBuddy.rebasing() 方法。当使用 rebasing 方式增强一个类时,Byte Buddy 保存目标类中所有方法的实现,也就是说,当 Byte Buddy 遇到冲突的字段或方法时,会将原来的字段或方法实现复制到具有兼容签名的重新命名的私有方法中,而不会抛弃这些字段和方法实现。从而达到不丢失实现的目的。这些重命名的方法可以继续通过重命名后的名称进行调用。
  • redefinition:对应 ByteBuddy.redefine() 方法。当重定义一个类时,Byte Buddy 可以对一个已有的类添加属性和方法,或者删除已经存在的方法实现。如果使用其他的方法实现替换已经存在的方法实现,则原来存在的方法实现就会消失。
subclass

利用以下代码创建一个目标类Object的子类

1
2
3
4
5
6
7
new ByteBuddy()
.subclass(Object.class)
.method(ElementMatchers.named("toString"))
.intercept(FixedValue.value("Hello World!"))
.make()
.saveIn(new File("result"));

上述代码创建了一个Object的子类并且创建了toString方法输出Hello World! 通过找到保存的输出类我们可以看到最后的类是这样的:

1
2
3
4
5
6
7
8
9
10
11
package net.bytebuddy.renamed.java.lang;

public class Object$ByteBuddy$tPSTnhZh {
public String toString() {
return "Hello World!";
}

public Object$ByteBuddy$tPSTnhZh() {
}
}

可以看到虽然创建了一个类,但是我们没有为这个类取名,通过结果得知最后的类名是 net.bytebuddy.renamed.java.lang.Object$ByteBuddy$tPSTnhZh

在ByteBuddy中如果没有指定类名,他会调用默认的NamingStrategy策略来生成类名,一般情况下为

父类的全限定名 + $ByteBuddy$ + 随机字符串
例如: org.example.MyTestByteBuddyByteBuddyNsT9pB6w

如果父类是java.lang目录下的类,例如Object,那么会变成

net.bytebuddy.renamed. + 父类的全限定名 + $ByteBuddy$ + 随机字符串
例如: net.bytebuddy.renamed.java.lang.Object$ByteBuddy$2VOeD4Lh

rebiasing

首先定义一个类

1
2
3
4
5
6
7
8
package org.example.bytebuddy.test;

public class MyClassTest {
public String test() {
return "my test";
}
}

rebasing代码

1
2
3
4
5
6
7
Class<?> dynamicType = new ByteBuddy()
.rebase(MyClassTest.class)
.method(ElementMatchers.named("test"))
.intercept(FixedValue.value("Hello World!"))
.make()
.load(String.class.getClassLoader()).getLoaded();

rebasing后

可以看到原先的方法被重命名后保留了下来,并且变成了私有方法。

redefinition

rebasing操作和redefinition操作最大的区别就是rebasing操作不会丢失原先的类的方法信息。大致的实现原理是在变基操作的时候把所有的方法实现复制到重新命名的私有方法(具有和原先方法兼容的签名)中,这样原先的方法就不会丢失。

1
2
3
4
5
6
7
Class<?> dynamicType = new ByteBuddy()
.redefine(MyClassTest.class)
.method(ElementMatchers.named("test"))
.intercept(FixedValue.value("Hello World!"))
.make()
.load(String.class.getClassLoader()).getLoaded();

redefine后

注意redefinition和rebasing不能修改已经被jvm加载的类,不然会报错Class already loaded

类的加载

通过上述三种方式完成类的增强之后,我们得到的是 DynamicType.Unloaded 对象,表示的是一个未加载的类型,我们可以使用 ClassLoadingStrategy 加载此类型。Byte Buddy 提供了几种类加载策略,这些加载策略定义在 ClassLoadingStrategy.Default 中,其中:

  • WRAPPER:这个策略会创建一个新的ByteArrayClassLoader,并使用传入的类加载器为父类。
  • WRAPPER_PERSISTENT:该策略和WRAPPER大致一致,只是会将所有的类文件持久化到类加载器中
  • CHILD_FIRST:这个策略是WRAPPER的改版,其中动态类型的优先级会比父类加载器中的同名类高,即在此种情况下不再是类加载器通常的父类优先,而是“子类优先”
  • CHILD_FIRST_PERSISTENT:该策略和CHILD_FIRST大致一致,只是会将所有的类文件持久化到类加载器中
  • INJECTION:这个策略最为特殊,他不会创建类加载器,而是通过反射的手段将类注入到指定的类加载器之中。这么做的好处是用这种方法注入的类对于类加载器中的其他类具有私有权限,而其他的策略不具备这种能力。
1
2
3
4
5
6
7
8
9
10
11
Class<?> dynamicClazz = new ByteBuddy()
// 生成 Object的子类
.subclass(Object.class)
// 生成类的名称为"com.itheima.Type"
.name("com.itheima.Type")
.make()
.load(Demo.class.getClassLoader(),
//使用WRAPPER 策略加载生成的动态类型
ClassLoadingStrategy.Default.WRAPPER)
.getLoaded();

类的重载

rebase和redefine通常没办法重新加载已经存在的类,但是由于jvm的热替换(HotSwap)机制的存在,使得ByteBuddy可以在加载后也能够重新定义类。

1
2
3
4
5
6
7
8
class Foo {
String m() { return "foo"; }
}

class Bar {
String m() { return "bar"; }
}

通过ByteBuddy的ClassRelodingsTrategy即可完成热替换。

1
2
3
4
5
6
7
8
ByteBuddyAgent.install();
Foo foo = new Foo();
new ByteBuddy()
.redefine(Bar.class)
.name(Foo.class.getName())
.make()
.load(Foo.class.getClassLoader(), ClassReloadingStrategy.fromInstalledAgent());

需要注意的是热替换机制必须依赖Java Agent才能使用

未加载类

ByteBuddy除了可以处理已经加载完的类,他也具备处理尚未被加载的类的能力。

ByteBuddy对java的反射api做了抽象,例如Class实例就被表示成了TypeDescription实例。事实上,ByteBuddy只知道如何通过实现TypeDescription接口的适配器来处理提供的 Class。这种抽象的一大优势是类信息不需要由类加载器提供,可以由任何其他来源提供。

ByteBuddy中可以通过TypePool获取类的TypeDescription,ByteBuddy提供了TypePool的默认实现TypePool.Default。这个类可以帮助我们把java字节码转换成TypeDescription

Java的类加载器只会在类第一次使用的时候加载一次,因此我们可以在java中以如下方式安全的创建一个类:

1
2
3
package foo;
class Bar { }

但是通过如下的方法,我们可以在Bar这个类没有被加载前就提前生成我们自己的Bar,因此后续jvm就只会使用到我们的Bar

1
2
3
4
5
6
7
8
9
TypePool typePool = TypePool.Default.ofSystemLoader();
Class bar = new ByteBuddy()
.redefine(typePool.describe("foo.Bar").resolve(),
ClassFileLocator.ForClassLoader.ofSystemLoader())
.defineField("qux", String.class)
.make()
.load(ClassLoader.getSystemClassLoader(), ClassLoadingStrategy.Default.INJECTION)
.getLoaded();

Java Agent项目记录

项目地址:https://github.com/JAgentSphere/bytebuddy-agent-quickstart

主要分为agent、core以及spy三个模块。

Java Agent入口

Java Agent入口位于agent/src/main/java/com/jas/quickstart/agent/AgentLauncher.java

启动时代理与运行时代理均从调用launch方法开始

1
2
3
4
5
6
7
public static void premain(String args, Instrumentation inst) throws Throwable {
launch(args, inst);
}

public static void agentmain(String args, Instrumentation inst) throws Throwable {
launch(args, inst);
}

agent模块主要完成的任务包括

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//com.jas.quickstart.spy.Spy类加载
private static void addSpyJarToBootstrapClassLoader() throws Throwable {
try {
Class.forName(SPY_CLASS_NAME);
} catch (ClassNotFoundException e) {
String spyJarPath = agentJarFolderPath + File.separator + SPY_JAR_FILE_NAME;
instrumentation.appendToBootstrapClassLoaderSearch(new JarFile(spyJarPath));
}
}

//调用com.jas.quickstart.core.AgentMain(core模块)中的install方法;
private static void launchCore() throws Throwable {
String coreJarPath = agentJarFolderPath + File.separator + CORE_JAR_FILE_NAME;
coreClassLoader = new CoreClassLoader(new URL[] {new File(coreJarPath).toURI().toURL()});

Class<?> mainClass = coreClassLoader.loadClass(CORE_CLASS_NAME);
mainInstance = mainClass.newInstance();
Method installMethod = mainClass.getMethod(CORE_INSTALL_METHOD_NAME, Instrumentation.class);
installMethod.invoke(mainInstance, instrumentation);
}

Agent Core

理解AOP

AgentBuilder

初始化

AgentBuilder类负责与Instrumentation进行对接,通过注册不同的Listener来对处理不同类的加载过程

1
2
3
4
5
6
7
8
9
agentBuilder = new AgentBuilder.Default().disableClassFormatChanges()
.ignore(elementMatcher) //放行规则匹配
.with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION) //transform 策略
.with(new CustomTransformListener(dumpClass, dumpFolder));
nativeAgentBuilder = new AgentBuilder.Default()
.ignore(none())
.enableNativeMethodPrefix(NATIVE_METHOD_PREFIX)
.with(AgentBuilder.TypeStrategy.Default.REBASE)
.with(new ErrorTransformListener());

不清楚这里是按栈顺序执行还是默认native方法先执行

通过重写注册Listener的以下方法以控制类的加载过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private static class ErrorTransformListener implements AgentBuilder.Listener {
private static final Logger log = LoggerFactory.getLogger(ErrorTransformListener.class);

private ErrorTransformListener() {
}
//类初次加载
public void onDiscovery(String typeName, ClassLoader classLoader, JavaModule module, boolean loaded) {
}
//类transform
public void onTransformation(TypeDescription typeDescription, ClassLoader classLoader, JavaModule module, boolean loaded, DynamicType dynamicType) {
}
//匹配到放行规则
public void onIgnored(TypeDescription typeDescription, ClassLoader classLoader, JavaModule module, boolean loaded) {
}
//加载错误
public void onError(String typeName, ClassLoader classLoader, JavaModule module, boolean loaded, Throwable throwable) {
log.error("transform class: {} failed", typeName, throwable);
}
//无论类是否加载成功最后均执行
public void onComplete(String typeName, ClassLoader classLoader, JavaModule module, boolean loaded) {
}
}
transform

类加载的堆栈执行顺序如下图

在intrumentation接口后对应执行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
43
44
45
46
private byte[] transform(@MaybeNull JavaModule module, @MaybeNull ClassLoader classLoader, @MaybeNull String internalTypeName, @MaybeNull Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] binaryRepresentation) {
if (internalTypeName != null && this.lambdaInstrumentationStrategy.isInstrumented(classBeingRedefined)) {
String name = internalTypeName.replace('/', '.');

try {
if (this.resubmissionEnforcer.isEnforced(name, classLoader, module, classBeingRedefined)) {
return AgentBuilder.Default.NO_TRANSFORMATION;
}
} catch (Throwable var28) {
try {
this.listener.onDiscovery(name, classLoader, module, classBeingRedefined != null);
} finally {
this.listener.onError(name, classLoader, module, classBeingRedefined != null, var28);
}

throw new IllegalStateException("Failed transformation of " + name, var28);
}

byte[] var11;
try {
this.listener.onDiscovery(name, classLoader, module, classBeingRedefined != null);
ClassFileLocator classFileLocator = new ClassFileLocator.Compound(new ClassFileLocator[]{this.classFileBufferStrategy.resolve(name, binaryRepresentation, classLoader, module, protectionDomain), this.classFileLocator, this.locationStrategy.classFileLocator(classLoader, module)});
TypePool typePool = this.classFileBufferStrategy.typePool(this.poolStrategy, classFileLocator, classLoader, name);

try {
byte[] var10 = this.doTransform(module, classLoader, name, classBeingRedefined, classBeingRedefined != null, protectionDomain, typePool, classFileLocator);
return var10;
} catch (Throwable var25) {
if (classBeingRedefined == null || !this.descriptionStrategy.isLoadedFirst() || !this.fallbackStrategy.isFallback(classBeingRedefined, var25)) {
throw var25;
}
}

var11 = this.doTransform(module, classLoader, name, AgentBuilder.Default.NOT_PREVIOUSLY_DEFINED, true, protectionDomain, typePool, classFileLocator);
} catch (Throwable var26) {
this.listener.onError(name, classLoader, module, classBeingRedefined != null, var26);
throw new IllegalStateException("Failed transformation of " + name, var26);
} finally {
this.listener.onComplete(name, classLoader, module, classBeingRedefined != null);
}

return var11;
} else {
return AgentBuilder.Default.NO_TRANSFORMATION;
}
}

doTransform首先匹配类是否位于agentbuilder的放行规则,如果不在则进一步查找有无相应的transformer,如果没有匹配的transformer,则同样调用onIgnored方法;反之则分别调用匹配的transform进行字节码转换处理,最后调用onTransformation方法

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
private byte[] doTransform(@MaybeNull JavaModule module, @MaybeNull ClassLoader classLoader, String name, @MaybeNull Class<?> classBeingRedefined, boolean loaded, ProtectionDomain protectionDomain, TypePool typePool, ClassFileLocator classFileLocator) {
TypeDescription typeDescription = this.descriptionStrategy.apply(name, classBeingRedefined, typePool, this.circularityLock, classLoader, module);
List<Transformer> transformers = new ArrayList();
if (!this.ignoreMatcher.matches(typeDescription, classLoader, module, classBeingRedefined, protectionDomain)) {
Iterator var11 = this.transformations.iterator();

while(var11.hasNext()) {
Transformation transformation = (Transformation)var11.next();
if (transformation.getMatcher().matches(typeDescription, classLoader, module, classBeingRedefined, protectionDomain)) {
transformers.addAll(transformation.getTransformers());
if (transformation.isTerminal()) {
break;
}
}
}
}

if (transformers.isEmpty()) {
this.listener.onIgnored(typeDescription, classLoader, module, loaded);
return AgentBuilder.Default.Transformation.NONE;
} else {
DynamicType.Builder<?> builder = this.typeStrategy.builder(typeDescription, this.byteBuddy, classFileLocator, this.nativeMethodStrategy.resolve(), classLoader, module, protectionDomain);
InitializationStrategy.Dispatcher dispatcher = this.initializationStrategy.dispatcher();

Transformer transformer;
for(Iterator var13 = transformers.iterator(); var13.hasNext(); builder = transformer.transform(builder, typeDescription, classLoader, module, protectionDomain)) {
transformer = (Transformer)var13.next();
}

DynamicType.Unloaded<?> dynamicType = dispatcher.apply(builder).make(net.bytebuddy.dynamic.TypeResolutionStrategy.Disabled.INSTANCE, typePool);
dispatcher.register(dynamicType, classLoader, protectionDomain, this.injectionStrategy);
this.listener.onTransformation(typeDescription, classLoader, module, loaded, dynamicType);
return dynamicType.getBytes();
}
}

Java Agent调试

Agent构建

gradle构建第3小节生成的项目后,在release目录下会生成三个jar包

Agent注入

这里我随便找了一个项目来辅助调试

将生成的三个jar包以及conf目录拷贝到应用目录下

右键每个jar包add as library即可查看源码并添加断点,同时运行构建configuration添加VM Option

1
-javaagent:xxxxx\Hotel-java-demo\agent.jar

参考资料

  1. https://juejin.cn/post/7110113494447423518
  2. Java字节码 - ByteBuddy原理与使用(上)
  3. Java字节码 - ByteBuddy原理与使用(下)
  4. https://blog.csdn.net/undergrowth/article/details/86493336
  5. 双亲委派
  6. Java Agent Debug
  7. Java Agent 深入解析:原理、应用及实践
  8. Byte Buddy 教程 Alpha
  9. 使用AOP - Java教程 - 廖雪峰的官方网站
  10. http://www.enmalvi.com/2022/05/16/java-javaagent/#!