0%

Java Agent解读

序言

空山新雨后,天色晚来秋。

整理Java Agent相关知识。

概念

Java Agent

Java Agent是一个运行在目标JVM的特定程序,它的职责是负责从目标JVM中获取数据,然后将数据传递给外部进程。加载Agent的时机可以是目标JVM启动之时,也可以是在目标JVM运行时加载。

image-20210207121651182

JVMTI

==万物起源==

JVMTI(JVM Tool Interface)是Java虚拟机对外提供的Native编程接口,通过JVMTI,外部进程可以获取到运行时JVM的诸多信息,比如线程、GC等。

Instrumentation

在Java SE 5之前,要实现一个Agent只能通过编写Native代码来实现。

从Java SE 5开始,可以使用Java的Instrumentation接口(java.lang.instrument包)来编写Agent。

从而 Java SE 6 的新特性改变了这种情况,通过 Java Tool API 中的 attach 方式,我们可以很方便地在运行过程中动态地设置加载代理类,以达到Instrumentation的目的。

并且从Java SE 6开始,可以向native method插桩。

==无论是通过Native的方式还是通过Java Instrumentation接口的方式来编写Agent,它们的工作都是借助JVMTI来进行完成。==

Instrumentation

这里把Instrumentation单独拿出来细说java.lang.instrument.Instrumentation.

Instrumentation接口设计初衷是为了收集Java程序运行时的数据,用于监控运行程序状态,记录日志,分析代码用的。

image-20210207135925174

目前可以实现(对应JVM方法):

  1. 动态添加或移除自定义的ClassFileTransformeraddTransformer/removeTransformer

    JVM会在类加载时调用Agent中注册的ClassFileTransformer

  2. 动态修改classpathappendToBootstrapClassLoaderSearchappendToSystemClassLoaderSearch

    将Agent程序添加到BootstrapClassLoaderSystemClassLoaderSearch(对应的是ClassLoader类的getSystemClassLoader方法,默认是sun.misc.Launcher$AppClassLoader)中搜索;

  3. 动态获取所有JVM已加载的类(getAllLoadedClasses);

  4. 动态获取某个类加载器已实例化的所有类(getInitiatedClasses)。

  5. 重定义某个已加载的类的字节码(redefineClasses)。

  6. 动态设置JNI前缀(setNativeMethodPrefix),可以实现Hook native方法。

  7. 重新加载某个已经被JVM加载过的类字节码(retransformClasses)。

两种部署模式:

Java Agent支持目标JVM启动时加载,也支持在目标JVM运行时加载,这两种不同的加载模式会使用不同的入口函数。

Java Agent和普通的Java类并没有任何区别,普通的Java程序中规定了main方法为程序入口,而Java Agent则将premain(Agent模式)和agentmain(Attach模式)作为了Agent程序的入口,两者所接受的参数是完全一致的。

Instrumentation类方法如下:

image-20210202230129428

学习API最好的方法就是阅读JavaDoc

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
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
package java.lang.instrument;


该类提供了工具化Java编程语言代码所需的服务。
仪表化是指在方法中添加字节码,以收集数据,供工具使用。
由于这些变化是纯粹的加法,所以这些工具不会修改应用程序的状态或行为。
这种良性工具的例子包括监控代理、剖析器、覆盖率分析器和事件记录器。

下面有两种方式来实例化Instrumentation接口:
1.当JVM启动时,-javaagent:Agent.jar 指示代理类。在这种情况下,premain方法会接收一个Instrumentation实例作为入参。
2.当JVM启动之后,可以Attach目标进程上。在这种情况下,agentmain方法会接收一个Instrumentation实例作为入参。

一旦Agent获得一个Instrumentation实例,代理可以在任何时候调用实例上的方法。

void addTransformer(ClassFileTransformer transformer,boolean canRetransform)
注册所提供的Tranformer。从此以后所有类定义都会被Tranformer看到,但不包括任何已注册的Transformer所依赖的类的定义。
当类被加载时,当类被重新定义时,Tranformer被调用;
如果 canRetransform 为真,当类被重新转换时,变压器被调用。
变换调用的顺序请参见 ClassFileTransformer.transformer。
如果一个Transformer在执行过程中抛出了异常,JVM仍然会依次调用其他注册的Transformer。
同一个Transformer可以被添加一次以上,但强烈不鼓励这样做,可以通过创建一个新的Transformer实例来避免这种情况。

boolean removeTransformer(ClassFileTransformer transformer)
解除所提供的Transformer注册。今后的类定义将不会显示给Transformer。
删除Transformer最近添加的匹配实例。由于类加载的多线程特性,Transformer在被删除后可能会收到调用。
Transformer的编写应考虑到这种情况。

boolean isRetransformClassesSupported()
返回当前JVM配置是否支持类的重新转换。
重构已经加载的类的能力是JVM的一个可选能力,只有当代理JAR中的Can-Retransform-Classes manifest属性被设置为true时,才支持重构。
只有在代理 JAR 文件中 Can-Retransform-Classes manifest 属性被设置为 true(如包规范中所述)且 JVM 支持此能力时,才会支持重构。
在单个JVM的单个实例化过程中,对该方法的多次调用将始终返回相同的答案。

void retransformClasses(Class<?>... classes) throws UnmodifiableClassException
给Class<?> classes 中的若干类进行转换。
该方法便于对已经加载的类进行转换。
当类被初始加载或重新定义时,可以使用ClassFileTransformer对初始类文件字节进行转换。
这个函数会重新运行转换过程(无论之前是否发生过转换)。
重新转换遵循以下步骤:
1. 从初始的类文件字节开始
2. 对于每个添加了 canRetransform false 的Transformer,由 transform 返回的字节将被重新使用,作为变换的输出,不做任何修改;
3. 对于每个添加了 canRetransform true 的Transformer,这些Transformer都会调用 transform 方法。
4.转化后的类文件字节被安装为类的新定义。
变换的顺序在 transform 方法中描述。这个相同的顺序被用于自动重新应用无法变换的变换。

初始类文件的字节代表传递给ClassLoader.defineClass或redefineClasses的字节(在任何转换行为之前),然而它们可能不完全匹配。常量池可能没有相同的布局或内容。常量池可能有更多或更少的条目。常量池条目的顺序可能不同,但是,方法字节码中的常量池索引会对应。一些属性可能不存在。在顺序没有意义的地方,例如方法的顺序,顺序可能不会被保留。
该方法会在一个集合上操作(看入参的形式就是一个不定数组),以允许同时对多个类进行相互依赖的改变(类 A 的重构可能需要类 B 的重构)。
如果一个重构的方法有高频活动的栈帧,这些活动帧将继续运行原方法的字节码。重构后的方法将在新的调用.invoke()中使用。
这个方法不会引起任何初始化.换句话说,重新定义一个类不会导致其初始化。静态变量的值将保持在调用之前的状态。
重构后的类的实例不会受到影响。
重构可以改变方法体、常量池和类属性。重构不能增加、删除或重命名字段或方法,不能改变方法的签名,也不能改变继承。这些限制也许会在未来的版本中被取消。类文件字节的检查、验证和安装直到应用了转换之后才会进行,如果结果字节有错误,这个方法将抛出一个异常。
如果本方法抛出异常,说明没有类被重新转换。

boolean isRedefineClassesSupported()
返回当前JVM配置是否支持类的重新定义。
重新定义已经加载的类的能力是JVM的一个可选能力,只有当代理JAR文件中的Can-Redefine-Classes manifest属性被设置为true时,才会支持重新定义。
只有当代理JAR文件中的Can-Redefine-Classes manifest属性被设置为true时,才会支持重新定义(如包规范中所述),并且JVM支持该能力。在单个JVM的单次实例化过程中,对该方法的多次调用将始终返回相同的答案。


void redefineClasses(ClassDefinition... definitions) throws ClassNotFoundException,
UnmodifiableClassException

使用提供的类文件重新定义提供的类集。
这个方法用于替换类的定义,而不引用现有的类文件字节。
就像从源码重新编译以进行修复和继续调试时一样。当现有的类文件字节要被转换时(例如在字节码instrumentation中),应该使用retransformClasses。
这个方法在一个集合上操作,以便允许同时对多个类进行相互依赖的改变(对类A的重新定义可能需要对类B的重新定义)。


boolean isModifiableClass(Class<?> theClass)
确定一个类是否可以通过retransformClasses或redefineClasses进行修改。如果一个类是可修改的,那么本方法返回true。如果一个类是不可修改的,那么这个方法返回false
要想对一个类进行重构,isRetransformClassesSupported()也必须为真。但是is的值不会影响这个函数返回的值。要想重新定义一个类,isRedefineClassesSupported()也必须为真,但isRedefineClassesSupported()的值不会影响这个函数返回的值。
基元类(例如java.lang.Integer.TYPE)和数组类是永远不能修改的。

Class[] getAllLoadedClasses()
返回JVM当前加载的所有类的数组。

Class[] getInitiatedClasses(ClassLoader loader)
返回一个数组,该数组包含了所有由loader加载的类。如果提供的Loader为空,则返回由bootstrap类Loader加载的类。

long getObjectSize(Object objectToSize)
返回指定对象消耗的存储量的近似值。这个结果可能包括对象的部分或全部开销,因此对于在单独一个实现内进行比较是有用的,但对于实现之间的比较是没有用的。在JVM的单次调用中,估计值可能会改变。

void appendToBootstrapClassLoaderSearch(JarFile jarfile)
指定一个JAR文件(包含插桩类),让它由Bootstrap加载器加载。
当虚拟机内置的类加载器(称为 "bootstrap class loader")搜索某个类不成功时,JAR文件中的条目也会被搜索。
本方法可以多次使用,按照本方法被调用的顺序添加多个JAR文件进行搜索。
Agent应该注意确保JAR文件中除了那些将由引导类加载器定义的类或资源,不包含任何其他类或资源。如果不遵守这个警告,可能会导致难以诊断的意外行为。例如,假设有一个加载器L,L的用于授权的父类是bootstrap类加载器。此外,L定义的类C中的一个方法引用了一个非公共的访问者类C$1,如果JAR文件中包含一个类C$1,那么授权给bootstrap类加载器将导致C$1被bootstrap类加载器定义。在这个例子中,一个IllegalAccessError将被抛出,可能会导致应用程序失败。避免这类问题的一个方法是为插桩类使用一个独特的包名。

void appendToSystemClassLoaderSearch(JarFile jarfile)
指定一个JAR文件(包含插桩类),让它由SystemClassLoader。
这里面的SystemClassLoader其实就是ApplicationClassLoader。
当委托的系统类加载器(参见getSystemClassLoader())搜索一个类不成功时,JarFile中的条目将被搜索。
本方法可以多次使用,按照本方法被调用的顺序添加多个JAR文件进行搜索。

Agent应注意确保JAR中不包含除SystemClassLoader以外的任何类或资源。如果不遵守这个警告,可能会导致难以诊断的意外行为(参见appendToBootstrapClassLoaderSearch)。

如果SystemClassLoader实现了一个名为appendToClassPathForInstrumentation的方法,那么它就支持添加一个要搜索的JAR文件,该方法接收一个类型为java.lang.String的单一参数。该方法不需要公开访问。JAR文件的名称是通过调用jarfile上的getName()方法获得的,并作为参数提供给appendToClassPathForInstrumentation方法。
此方法不会改变java.class.path系统属性的值。

boolean isNativeMethodPrefixSupported()
返回当前JVM配置是否支持设置本地方法前缀。设置本地方法前缀的能力是JVM的一个可选能力。
只有在代理 JAR 文件中 Can-Set-Native-Method-Prefix manifest 属性被设置为 true(如包规范中所述)且 JVM 支持该能力时,才会支持设置本地方法前缀。
在单个JVM的单个实例化过程中,对该方法的多次调用将始终返回相同的答案。

void setNativeMethodPrefix(ClassFileTransformer transformer,String prefix)
设置native method 前缀

ClassFileTransformer

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

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

继续读JavaDoc:

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
通常,一个Agent提供了这个接口的实现,以便转换类文件。转换发生在JVM定义类之前。
请注意,类文件这个术语在Java™虚拟机规范第3.1节中定义,指的是类文件格式的字节序列,无论它们是否驻留在文件中。


byte[] transform(ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer)
throws IllegalClassFormatException

该方法的实现可以对提供的类文件进行转换,并返回一个新的替换类文件。
一旦用addTransformer注册了一个Transformer,那么每一个新的类定义和每一个类的重新定义都会调用这个变换器。能够重构的变换器也会在每次类重构时被调用。
新类定义的请求是通过ClassLoader.defineClass或其原生等价物来实现的。
对类的redefinition的请求是通过Instrumentation.redefineClasses或它的本机等价物提出的。
类的retransformation请求由 Instrumentation.retransformClasses 或其本机等价物发出。
在请求的处理过程中,在类文件字节被验证或应用之前,Transformer被调用。
当有多个Transformer时,Transformer是通过链式变换调用组成的。也就是说,一次变换调用返回的字节数组成为下一次调用的输入(通过classfileBuffer参数)。

对于retransformation,不能重构的变换器不被调用,而是重用之前变换的结果。在所有其他情况下,这个方法都会被调用。在这些分组中,transformers按照注册的顺序被调用。原生变换器由Java虚拟机工具接口中的ClassFileLoadHook事件提供。
参数:
loader 定义要转换的类加载器;如果是引导加载器,则为 null
className 类名,如:java/lang/Runtime
classBeingRedefined 如果是被重定义或重转换触发,则为重定义或重转换的类;如果是类加载,则为 null
protectionDomain 要定义或重定义的类的保护域
classfileBuffer 类文件格式的输入字节缓冲区(不得修改)

return 字节码byte数组

重写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。一定要增加true参数!!!
  7. 卸载transform时需要使用创建时的Instrumentation实例。

Java Agent还限制了我们:

  1. 必须以jar包的形式运行或加载;
  2. 必须包含/META-INF/MANIFEST.MF文件,且该文件中必须定义好Premain-Class(启动前Agent模式)或Agent-Class:(运行中Agent模式)配置;
  3. 如果需要修改已经被JVM加载过的类的字节码,那么还需要设置在MANIFEST.MF中添加Can-Retransform-Classes: trueCan-Redefine-Classes: true

实验部分

接下来进行实验,启动前premain+-javaagent以及启动中Attach 两种模式。

启动前指定Agent位置

如果需要在目标JVM启动的同时加载Agent,实现:

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

JVM将首先寻找[1],如果没有发现[1],再寻找[2]。

实战一:premain方法测试

写一个demo做个实验:

创建Agent类,声明premain方法:

image-20210201222948010

使用maven-jar-plugin,创建MANIFEST.MF:

image-20210201223255740

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.2.0</version>
<configuration>
<archive>
<manifest>
<addClasspath>true</addClasspath>
</manifest>
<manifestEntries>
<Premain-Class>com.0range.Agent</Premain-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
</plugin>

MANIFEST.MF:

image-20210201223426121

mvn clean install生成jar包:java_agent_01-1.0-SNAPSHOT

待插桩类:

image-20210201223908739

image-20210201223920391

同样打包,example01-1.0-SNAPSHOT

image-20210201223953455

终端执行,成功插桩:

image-20210201224546368

实战二:打印加载的类

之前也提到,一旦你addTransformer之后,需要加载的每一个类都会经过transform方法。

一旦用addTransformer注册了一个Transformer,那么每一个新的类定义和每一个类的重新定义都会调用这个变换器。

首先addTranformer:

image-20210209211836621

这里ClassFileTransformerDemo()继承了ClassFileTransformer类,记得最后加上ture:

image-20210209212017484

这里就是简单的执行了打印在Tranformer之后加载的类:

image-20210209212111604

实战三:代码插桩

这里复现一下yz的实验。这个例子写得很好,类似于破解官方软件的一种绕过过程,毕竟白嫖才是最香的。

首先这里有一个校验函数用来判断用户是否已经过了有效期,这个截止日期是硬编码在代码中的,由于是写死的那么就会一直提示已经过期:

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
public class CheckLicense {
private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

private static boolean checkExpiry(String expireDate) throws ParseException {
try {
Date date = DATE_FORMAT.parse(expireDate);

// 检测当前系统时间早于License授权截至时间
if (new Date().before(date)) {
return false;
}
} catch (ParseException e) {
e.printStackTrace();
}

return true;
}

public static void main(String[] args) {
// 设置一个已经过期的License时间
final String expireDate = "2020-10-01 00:00:00";

new Thread(new Runnable() {
@Override
public void run() {
while (true) {
try {
String time = "[" + DATE_FORMAT.format(new Date()) + "] ";
// 检测license是否已经过期
try {
if (checkExpiry(expireDate)) {
System.err.println(time + "您的授权已过期,请重新购买授权!");
} else {
System.out.println(time + "您的授权正常,截止时间为:" + expireDate);
}
} catch (ParseException e) {
e.printStackTrace();
}

// sleep 1秒
TimeUnit.SECONDS.sleep(1);

} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}).start();
}
}

硬编码有效期是一个已经过期的License时间final String expireDate = "2020-10-01 00:00:00";

已经过期:

image-20210209231744276

这里开始编写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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public class ClassFileTransformerDemo  implements ClassFileTransformer {

/**
* 需要被Hook的类
*/
private static final String HOOK_CLASS = "com.sec.CheckLicense";

@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {


// 将目录路径替换成Java类名
String cn = className.replace("/", ".");

// 只处理HOOK_CLASS类的字节码
if (cn.equals(HOOK_CLASS)) {

System.out.println(cn);

try {
ClassPool classPool = ClassPool.getDefault();

// 使用javassist将类二进制解析成CtClass对象
CtClass ctClass = classPool.makeClass(new ByteArrayInputStream(classfileBuffer));

// 使用CtClass对象获取checkExpiry方法,类似于Java反射机制的clazz.getDeclaredMethod(xxx)
CtMethod ctMethod = ctClass.getDeclaredMethod(
"checkExpiry", new CtClass[]{classPool.getCtClass("java.lang.String")}
);

// 在checkExpiry方法执行前插入输出License到期时间代码
ctMethod.insertBefore("System.out.println(\"License到期时间:\" + $1);");

// 修改checkExpiry方法的返回值,将授权过期改为未过期
ctMethod.insertAfter("return false;");

// 修改后的类字节码
return ctClass.toBytecode();

} catch (Exception e) {
e.printStackTrace();
}
}

return classfileBuffer;

}

}

这里相当于将检测日期函数写死return false。

打包,运行:

image-20210210034102611

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
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.2.4</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>MANIFEST.MF</exclude>
<exclude>META-INF/DEPENDENCIES</exclude>
<exclude>META-INF/LICENSE*</exclude>
<exclude>META-INF/NOTICE*</exclude>
</excludes>
</filter>
</filters>

<artifactSet>
<includes>
<include>org.javassist:javassist:jar:*</include>
</includes>
</artifactSet>

<!-- 修改第三方依赖包名称 -->
<relocations>
<relocation>
<pattern>javassist</pattern>
<shadedPattern>com.fxc.deps.javassist</shadedPattern>
</relocation>
</relocations>
</configuration>
</execution>
</executions>
</plugin>

启动中进行Agent Attach

之前的permain方法只能在java程序启动之前执行,而Java SE 6的新特性改变了这种情况,可以通过Java Tool API中的attach方式来达到这种程序启动之后设置代理的效果。

下面来分析一下动态加载Agent的相关技术细节。

AttachListener

Attach机制通过Attach Listener线程来进行相关事务的处理,下面来看一下Attach Listener线程是如何初始化的。

1
2
3
4
5
6
7
8
9
10
11
// Starts the Attach Listener thread
//创建AttachListener线程
void AttachListener::init() {
// 创建线程相关部分代码被去掉了
const char thread_name[] = "Attach Listener";
Handle string = java_lang_String::create_from_str(thread_name, THREAD);
{ MutexLocker mu(Threads_lock);
JavaThread* listener_thread = new JavaThread(&attach_listener_thread_entry);
// ...
}
}

一个线程启动之后都需要指定一个入口来执行代码,Attach Listener线程的入口是attach_listener_thread_entry,下面看一下这个函数的具体实现:

下面看attach_listener_thread_entry的具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static void attach_listener_thread_entry(JavaThread* thread, TRAPS) {
AttachListener::set_initialized();
for (;;) {
//拉取一个需要执行的任务
AttachOperation* op = AttachListener::dequeue();
// find the function to dispatch too
AttachOperationFunctionInfo* info = NULL;
for (int i=0; funcs[i].name != NULL; i++) {
const char* name = funcs[i].name;
if (strcmp(op->name(), name) == 0) {
info = &(funcs[i]); break;
}}
// dispatch to the function that implements this operation
res = (info->func)(op, &st);
//...
}
}

整个函数执行逻辑,大概是这样的:

  • 拉取一个需要执行的任务:AttachListener::dequeue。
  • 查询匹配的命令处理函数。
  • 执行匹配到的命令执行函数。

其中第二步里面存在一个命令函数表,整个表如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
static AttachOperationFunctionInfo funcs[] = {
{ "agentProperties", get_agent_properties },
{ "datadump", data_dump },
{ "dumpheap", dump_heap },
{ "load", load_agent },
{ "properties", get_system_properties },
{ "threaddump", thread_dump },
{ "inspectheap", heap_inspection },
{ "setflag", set_flag },
{ "printflag", print_flag },
{ "jcmd", jcmd },
{ NULL, NULL }
};

对于加载Agent来说,命令就是“load”。

任务从哪来,这个秘密就藏在AttachListener::dequeue这行代码里面,接下来来分析一下dequeue这个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
LinuxAttachOperation* LinuxAttachListener::dequeue() {
for (;;) {
// wait for client to connect
struct sockaddr addr;
socklen_t len = sizeof(addr);
RESTARTABLE(::accept(listener(), &addr, &len), s);
// get the credentials of the peer and check the effective uid/guid
// - check with jeff on this.
struct ucred cred_info;
socklen_t optlen = sizeof(cred_info);
if (::getsockopt(s, SOL_SOCKET, SO_PEERCRED, (void*)&cred_info, &optlen) == -1) {
::close(s);
continue;
}
// peer credential look okay so we read the request
LinuxAttachOperation* op = read_request(s);
return op;
}
}

上面的代码表明,Attach Listener在某个端口监听着,通过accept来接收一个连接,然后从这个连接里面将请求读取出来,然后将请求包装成一个AttachOperation类型的对象,之后就会从表里查询对应的处理函数,然后进行处理。

Attach Listener使用一种被称为“懒加载”的策略进行初始化,也就是说,JVM启动的时候Attach Listener并不一定会启动起来。

具体实现

运行时Attach方法里面的关键是调用VirtualMachine的attach方法进行Agent挂载的功能。

下面分析一下VirtualMachine的attach方法具体是怎么实现的。

Attach模式需要知道我们运行的Java程序进程ID,通过Java虚拟机的进程注入方式实现可以将我们的Agent程序动态的注入到一个已在运行中的Java程序中。

还是之前那个例子,可以使用jps -l命令进行查看:

image-20210210194815301

Java代码实现可以使用com.sun.tools.attach.VirtualMachinelist方法即可获取本机所有运行的Java进程,如:

image-20210210200251210

有了进程ID我们就可以使用Attach API注入Agent了,Attach Java进程注入通用示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
// Java进程ID
String pid = args[0];

// 设置Agent文件的绝对路径
String agentPath = "/xxx/agent.jar";

// 注入到JVM虚拟机进程
VirtualMachine vm = VirtualMachine.attach(pid);

// 注入Agent到目标JVM
vm.loadAgent(agentPath);
vm.detach();

使用Attach模式启动Agent程序时需要使用到JDK目录下的lib/tools.jar,如果没有配置CLASS_PATH环境变量的话需要在运行Agent程序时添加-Xbootclasspath/a:$JAVA_HOME/lib/tools.jar参数,否则无法使用Attach API。

1
java -Xbootclasspath/a:$JAVA_HOME/lib/tools.jar -jar AgentAttach-1.0-SNAPSHOT.jar

首先后台运行监测代码,用我们的jar包获取目标进程:16281

image-20210210222948056

接下来直接注入就完事了:

1
java -Xbootclasspath/a:$JAVA_HOME/lib/tools.jar -classpath $JAVA_HOME/lib/tools.jar:AgentAttach-1.0-SNAPSHOT.jar -jar AgentAttach-1.0-SNAPSHOT.jar 16281

成功修改:

image-20210210223115419

代码:

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
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
public class AttachAgent {

/**
* 需要被Hook的类
*/
private static final String HOOK_CLASS = "com.sec.CheckLicense";

public static void main(String[] args) {
if (args.length == 0) {
List<VirtualMachineDescriptor> list = VirtualMachine.list();
for (VirtualMachineDescriptor desc : list) {
System.out.println("进程ID:" + desc.id() + ",进程名称:" + desc.displayName());
}
}

// Java进程ID
String pid = args[0];

try {
// 注入到JVM虚拟机进程
VirtualMachine vm = VirtualMachine.attach(pid);

// 获取当前Agent的jar包路径
URL agentURL = AttachAgent.class.getProtectionDomain().getCodeSource().getLocation();
String agentPath = new File(agentURL.toURI()).getAbsolutePath();

// 注入Agent到目标JVM
vm.loadAgent(agentPath);
vm.detach();
} catch (Exception e) {
e.printStackTrace();
}
}

public static void agentmain(String args, final Instrumentation inst) {
loadAgent(args, inst);
}

private static void loadAgent(String arg, final Instrumentation inst) {
// 创建ClassFileTransformer对象
ClassFileTransformer classFileTransformer = createClassFileTransformer();

// 添加自定义的Transformer,第二个参数true表示是否允许Agent Retransform,
// 需配合MANIFEST.MF中的Can-Retransform-Classes: true配置
inst.addTransformer(classFileTransformer, true);

// 获取所有已经被JVM加载的类对象
Class[] loadedClass = inst.getAllLoadedClasses();

for (Class clazz : loadedClass) {
String className = clazz.getName();

if (inst.isModifiableClass(clazz)) {
// 使用Agent重新加载HelloWorld类的字节码
if (className.equals(HOOK_CLASS)) {
try {
inst.retransformClasses(clazz);
} catch (UnmodifiableClassException e) {
e.printStackTrace();
}
}
}
}
}


private static ClassFileTransformer createClassFileTransformer() {
return new ClassFileTransformer() {

/**
* 类文件转换方法,重写transform方法可获取到待加载的类相关信息
*
* @param loader 定义要转换的类加载器;如果是引导加载器,则为 null
* @param className 类名,如:java/lang/Runtime
* @param classBeingRedefined 如果是被重定义或重转换触发,则为重定义或重转换的类;如果是类加载,则为 null
* @param protectionDomain 要定义或重定义的类的保护域
* @param classfileBuffer 类文件格式的输入字节缓冲区(不得修改)
* @return 字节码byte数组。
*/

@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) {

// 将目录路径替换成Java类名
className = className.replace("/", ".");

// 只处理HOOK_CLASS类的字节码
if (className.equals(HOOK_CLASS)) {
try {
ClassPool classPool = ClassPool.getDefault();

// 使用javassist将类二进制解析成CtClass对象
CtClass ctClass = classPool.makeClass(new ByteArrayInputStream(classfileBuffer));

// 使用CtClass对象获取checkExpiry方法,类似于Java反射机制的clazz.getDeclaredMethod(xxx)
CtMethod ctMethod = ctClass.getDeclaredMethod(
"checkExpiry", new CtClass[]{classPool.getCtClass("java.lang.String")}
);

// 在checkExpiry方法执行前插入输出License到期时间代码
ctMethod.insertBefore("System.out.println(\"License到期时间:\" + $1);");

// 修改checkExpiry方法的返回值,将授权过期改为未过期
ctMethod.insertAfter("return false;");

// 修改后的类字节码
classfileBuffer = ctClass.toBytecode();
return classfileBuffer;
} catch (Exception e) {
e.printStackTrace();
}
}

return classfileBuffer;
}
};
}
}

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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
</properties>


<dependencies>

<!-- https://mvnrepository.com/artifact/org.javassist/javassist -->
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.25.0-GA</version>
</dependency>


<dependency>
<groupId>com.sun</groupId>
<artifactId>tools</artifactId>
<version>1.8</version>
<scope>system</scope>
<systemPath>${java.home}/../lib/tools.jar</systemPath>
</dependency>

</dependencies>

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

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.2.0</version>
<configuration>
<archive>
<manifest>
<addClasspath>true</addClasspath>
<mainClass>com.fxc.AttachAgent</mainClass>
</manifest>
<manifestEntries>
<Agent-Class>com.fxc.AttachAgent</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-shade-plugin</artifactId>
<version>3.2.4</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>MANIFEST.MF</exclude>
<exclude>META-INF/DEPENDENCIES</exclude>
<exclude>META-INF/LICENSE*</exclude>
<exclude>META-INF/NOTICE*</exclude>
</excludes>
</filter>
</filters>

<artifactSet>
<includes>
<include>org.javassist:javassist:jar:*</include>

</includes>
</artifactSet>

<!-- 修改第三方依赖包名称 -->
<relocations>
<relocation>
<pattern>javassist</pattern>
<shadedPattern>com.fxc.deps.javassist</shadedPattern>
</relocation>
</relocations>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>

坑点

  1. 在打包jar的时候,已经要记得将ASM/javassist打进去,maven-shade-plugin插件。