Agent 代码:
package test.agent;
import java.lang.instrument.Instrumentation;
public class SimpleAgent {
public static void premain(String agentArgs, Instrumentation instrumentation) throws Exception {
doTest(agentArgs, instrumentation);
}
private static void doTest(String agentArgs, Instrumentation instrumentation) throws Exception{
System.out.println("Agent arguments: " + agentArgs);
JavassistTransformer transformer = new JavassistTransformer();
Class<?> targetClass = Thread.currentThread().getContextClassLoader().loadClass("test.A");
try {
instrumentation.addTransformer(transformer, true);
instrumentation.retransformClasses(targetClass);
} finally {
instrumentation.removeTransformer(transformer);
}
}
}
注意:对于 instrumentation.addTransformer(transformer, true); 如果这里没有带 true 这个参数值,那么 test.A 的字节码不会被修改。
以下为用于修改字节码的转换器:
package test.agent;
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 JavassistTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
try {
ClassPool cp = ClassPool.getDefault();
CtClass ctClass = cp.get(classBeingRedefined.getName());
CtMethod ctMethod = ctClass.getDeclaredMethod("print");
ctMethod.insertBefore("System.out.println(\"Before say hello.\");");
ctMethod.insertAfter("System.out.println(\"After say hello.\");");
byte[] classData = ctClass.toBytecode();
ctClass.detach();
return classData;
} catch (Exception e) {
e.printStackTrace();
}
return classfileBuffer;
}
}
这里使用的是 javassist 来修改字节码,用它的好处是,可以使用文本字符串的方式编写 java 代码,它会帮我们动态编译成字节码。
上面代码的意思是:在 print 方法的头尾各加入一句打印。
如果想了解更多 javassist 的用法,可以访问:
Agent的配置文件:resources/META-INF/MANIFEST.MF
Premain-Class: test.agent.SimpleAgent
Can-Retransform-Classes: true
对于静态连接,使用的属性是 “Can-Retransform-Classes”,如果值不为 true,则 instrumentation.addTransformer(transformer, true); 会报错。
用于构建的 pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>dump-demoartifactId>
<groupId>demogroupId>
<version>1.0version>
parent>
<modelVersion>4.0.0modelVersion>
<groupId>demogroupId>
<artifactId>agentartifactId>
<properties>
<jdk.version>1.8jdk.version>
<javaassist.version>3.21.0-GAjavaassist.version>
properties>
<dependencies>
<dependency>
<groupId>org.javassistgroupId>
<artifactId>javassistartifactId>
<version>${javaassist.version}version>
dependency>
dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.pluginsgroupId>
<artifactId>maven-compiler-pluginartifactId>
<version>3.7.0version>
<configuration>
<fork>truefork>
<source>${jdk.version}source>
<target>${jdk.version}target>
configuration>
plugin>
<plugin>
<groupId>org.apache.maven.pluginsgroupId>
<artifactId>maven-resources-pluginartifactId>
<version>3.1.0version>
<configuration>
<useDefaultDelimiters>falseuseDefaultDelimiters>
<overwrite>trueoverwrite>
<delimiters>
<delimiter>@delimiter>
delimiters>
configuration>
plugin>
<plugin>
<groupId>org.apache.maven.pluginsgroupId>
<artifactId>maven-jar-pluginartifactId>
<version>3.1.0version>
<configuration>
<archive>
<manifestFile>src/main/resources/META-INF/MANIFEST.MFmanifestFile>
archive>
configuration>
plugin>
<plugin>
<artifactId>maven-assembly-pluginartifactId>
<version>3.1.1version>
<configuration>
<appendAssemblyId>falseappendAssemblyId>
<descriptorRefs>
<descriptorRef>jar-with-dependenciesdescriptorRef>
descriptorRefs>
<archive>
<manifestFile>src/main/resources/META-INF/MANIFEST.MFmanifestFile>
archive>
configuration>
<executions>
<execution>
<id>make-assembly-packageid>
<phase>packagephase>
<goals>
<goal>singlegoal>
goals>
execution>
executions>
plugin>
plugins>
build>
project>
配置有点复杂,但是这样才能保证在最终生成的 assembly 包里面包含 Agent 的配置文件: MANIFEST.MF。
使用 maven 打包:
mvn clean package -DskipTests
测试代码(位于另外的 project 或 module):
package test;
public class AgentTest {
public static void main(String[] args) {
new A().print();
}
}
将要被修改字节码的类
package test;
class A {
void print() {
System.out.println("Hello world.");
}
}
运行测试代码,在没有连接 Agent 前,结果为:
Hello world.
加入连接 Agent 的 VM 选项:
通过 java 命令,可以查看到格式为:
-javaagent:[=]
load Java programming language agent, see java.lang.instrument
这里的 options 是最终传递给 Agent 的参数,就是 SimpleAgent#premain(…) 里面的 agentArgs,如果内容含有空格,可以使用双引号;如果想传递复杂的或者结构化的配置信息,可以传递配置文件的URL,再在 premain(…) 方法里面做解析。
再次运行,可以看到结果为:
Agent arguments: These are agent arguments;
Before say hello.
Hello world.
After say hello.
在 SimpleAgent.java 中新增一个方法:
public static void agentmain(String agentArgs, Instrumentation instrumentation) throws Exception {
doTest(agentArgs, instrumentation);
}
在 resources/META-INF/MANIFEST.MF 新增多一行:
Agent-Class: test.agent.SimpleAgent
重新打包 agent-1.0.jar
新增一个类 AgentLoader,用于运行时连接 Agent:
package test;
import com.sun.tools.attach.VirtualMachine;
import java.io.IOException;
public class AgentLoader {
public static void main(String[] args) {
if (args.length < 3) {
System.err.println("Usage: jvmPid agentFilePath options");
System.exit(1);
}
run(args[0], args[1], args[2]);
}
private static void run(String jvmPid, String agentFilePath, String options) {
VirtualMachine jvm = null;
try {
jvm = VirtualMachine.attach(jvmPid);
jvm.loadAgent(agentFilePath, options);
System.out.println("Attached to target JVM and loaded Java agent successfully");
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
if (jvm != null) {
try {
jvm.detach();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
因为使用到的类位于 tools.jar 中,所以需要在 AgentLoader 所在项目的 pom.xml 里加入以下依赖:
<dependencies>
<dependency>
<groupId>com.sungroupId>
<artifactId>toolsartifactId>
<version>1.0version>
<scope>systemscope>
<systemPath>${java.home}/../lib/tools.jarsystemPath>
dependency>
dependencies>
新增测试代码:
package test;
public class AgentTest2 {
public static void main(String[] args) throws Exception {
A a = new A();
for (int i = 0; i < 100; ++i) {
a.print();
System.out.println("============");
Thread.sleep(2000);
}
}
}
运行测试代码,它会一直打印 “Hello world.”
运行以下命令,找出 AgentTest2 的 PID:
$ jps -l | grep AgentTest2
28535 test.AgentTest2
Attached to target JVM and loaded Java agent successfully
再查看 AgentTest2 的输出:
...
============
Hello world.
Agent arguments: These are agent arguments
Before say hello.
Hello world.
After say hello.
============
Before say hello.
Hello world.
After say hello.
可以看到字节码已经被动态修改了。
有人觉得使用 shell 命令去查找 Java 进程的ID,比较麻烦,而且在 Java 代码中集成也不太方便,于是写了以下代码,根据 jvmDisplayName 获取 PID:
private static String getJvmPidByDisplayName(String jvmDisplayName) {
return VirtualMachine.list()
.stream()
.filter(jvm -> jvm.displayName().contains(jvmDisplayName))
.findAny()
.map(VirtualMachineDescriptor::id)
.orElse(null);
}
然而,我多次实验后发现,使用这段代码将会是个悲剧,它有着不稳定的因素,偶尔会返回错误的 PID,导致动态连接失败。
什么叫错误的 PID?就是进程在系统中已经消亡了,你用 ps -ef 是查找不到的,但是以上的代码却还会给你返回那个不存在的进程的 ID。
因此,我强烈建议,不要使用上面这段代码,你可以用 shell 命令查找 PID,也可以在 Java 代码中调用 Runtime.getRuntime().exec(cmd) 来运行 shell,然后根据输出来解析 PID。这是最为保险的做法,至少我到现在为止,没有出现过问题。
对于动态连接的例子,如果多运行几次 AgentLoader,你会发现输出为:
============
Agent arguments: These are agent arguments
Before say hello.
Hello world.
After say hello.
============
Agent arguments: These are agent arguments
Before say hello.
Hello world.
After say hello.
============
Agent arguments: These are agent arguments
Before say hello.
Hello world.
After say hello.
回头看一下 JavassistTransformer 的代码,按照思路,每修改一次字节码,都会在头尾多增加一行打印,但现在的输出明白地告诉我们,多次修改没凑效。
查看 javassist 的源码可以知道问题根源。这里我直接给出解答:
以下为解决办法。
修改 JavassistTransformer 的代码,保存每次变动后的字节码,然后在搜索列表的最前面加入定制的 ClassPath。为什么要在最前面加入?因为如果在末端加入,那么搜索的时候还是会先找到使用 class 文件的 ClassPath,而不是定制的那个。
package test.agent;
import javassist.ClassPath;
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 JavassistTransformer implements ClassFileTransformer {
private static byte[] savedClassData = null;
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
ClassPool cp = ClassPool.getDefault();
ClassPath classPath = null;
if (savedClassData != null) {
classPath = new InMemoryClassPath(savedClassData);
cp.insertClassPath(classPath);
}
try {
CtClass ctClass = cp.get(classBeingRedefined.getName());
CtMethod ctMethod = ctClass.getDeclaredMethod("print");
ctMethod.insertBefore("System.out.println(\"Before say hello.\");");
ctMethod.insertAfter("System.out.println(\"After say hello.\");");
byte[] classData = ctClass.toBytecode();
ctClass.detach();
savedClassData = classData;
return classData;
} catch (Exception e) {
e.printStackTrace();
} finally {
if (classPath != null)
cp.removeClassPath(classPath);
}
return classfileBuffer;
}
}
以下为定制的 ClassPath 类:
package test.agent;
import javassist.ClassPath;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.net.URL;
public class InMemoryClassPath implements ClassPath {
private byte[] currClassData;
public InMemoryClassPath(byte[] currClassData) {
this.currClassData = currClassData;
}
@Override
public InputStream openClassfile(String className) {
return new ByteArrayInputStream(currClassData);
}
@Override
public URL find(String className) {
try {
return new URL("http://fake/" + className);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@Override
public void close() {
}
}
重新运行测试,就可以看到我们期待的输出:
...
============
Before say hello.
Hello world.
After say hello.
============
Agent arguments: These are agent arguments
Before say hello.
Before say hello.
Hello world.
After say hello.
After say hello.
============
Agent arguments: These are agent arguments
Before say hello.
Before say hello.
Before say hello.
Hello world.
After say hello.
After say hello.
After say hello.
============
Agent arguments: These are agent arguments
Before say hello.
Before say hello.
Before say hello.
Before say hello.
Hello world.
After say hello.
After say hello.
After say hello.
After say hello.
Web 服务器的特别之处,在于它可以同时容纳多个 web 应用,每个 web 应用有自己的一套 ClassLoader。同一个 class 文件,由不同的 ClassLoader 加载也会被 JVM 看成是不同的类。如果我们要修改某个 web 应用的字节码,就必须先要获取到它的 ClassLoader。
在继续之前,希望你能阅读以下文章,对如何获取 web 服务器中各应用的 ClassLoader 有充分的了解:
查找WebServer中各个App的ClassLoader
上面链接的文章中介绍了3种方法来获取 web 应用的 ClassLoader,前2种都是通过反射来实现的,因此是跨平台的,而第3种则使用了 JNI 和 JVMTI,对于不同的 OS,需要做额外的本地编译。
使用反射来查找 web 应用的 ClassLoader,需要一个先决条件:拿到方法的调用对象。
在 Jetty 中,这个对象是类 org.eclipse.jetty.runner.Runner 的实例;
在 Tomcat 中,这个对象是类 org.apache.catalina.startup.Bootstrap 的实例。
这个对象有个特点:它是在 web 服务器启动的时候就创建并初始化的,从外部没有直接的手段可以获取到它。但我们可以通过静态或者动态连接的方式来获取它。这以下以 Jetty 为例。
用于记录 Runner 实例的类:
package test.agent;
public class App {
public static volatile Object instance;
}
新的 Agent:
package test.agent;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.Instrumentation;
public class JettyAgent {
public static void premain(String agentArgs, Instrumentation instrumentation) throws Exception {
doTest(agentArgs, instrumentation);
}
private static void doTest(String agentArgs, Instrumentation instrumentation) throws Exception {
new Thread(() ->{
while (App.instance == null) {
System.out.println("========= App.instance is null.");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
}
}
System.out.println("========= App.instance is: " + App.instance + ", class is: " + App.instance.getClass());
}).start();
ClassFileTransformer transformer = new HookJettyRunnerTransformer();
Class<?> targetClass = Class.forName("org.eclipse.jetty.runner.Runner");
try {
instrumentation.addTransformer(transformer, true);
instrumentation.retransformClasses(targetClass);
} finally {
instrumentation.removeTransformer(transformer);
}
}
}
步骤说明:
不要在 agentmain(…) 或者 premain(…) 方法里面编写任何会导致堵塞的逻辑,否则会一直挂死在那里。对于静态连接来说,会导致进程原来的 main(…) 方法无法执行;对于动态连接来说,会导致 AgentLoader 无法返回。因此,要把逻辑放到另外一个线程中去。
接下来是新的转换器:
package test.agent;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtConstructor;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;
public class HookJettyRunnerTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
try {
ClassPool cp = ClassPool.getDefault();
CtClass ctClass = cp.get(classBeingRedefined.getName());
CtConstructor constructor = ctClass.getDeclaredConstructor(new CtClass[0]);
constructor.insertAfter(App.class.getName() + ".instance = this;");
byte[] data = ctClass.toBytecode();
ctClass.detach();
return data;
} catch (Exception e) {
e.printStackTrace();
return classfileBuffer;
}
}
}
说明:只需要在 Runner 类的默认构造函数里面把自身引用赋值给 App.instance 即可。
修改 resources/META-INF/MANIFEST.MF:
Premain-Class: test.agent.JettyAgent
Can-Retransform-Classes: true
重新编译打包,然后启动 Jetty。记得在启动时加入 VM 选项:
-javaagent:/home/helowken/projects/dump-demo/agent/target/agent-1.0.jar
然后就能看到以下输出:
========= App.instance is null.
...
2019-11-19 01:40:21.741:INFO:oejr.Runner:main: Runner
...
2019-11-19 01:40:23.265:INFO:oejs.Server:main: Started @2040ms
========= App.instance is: org.eclipse.jetty.runner.Runner@607235fb, class is: class org.eclipse.jetty.runner.Runner
App.instance 已经成功拿到了 Runner 实例的引用,往后就能用它查找各个 web 应用的 ClassLoader 了。
动态连接的方式虽然方便,但是在连接的时候,Runner 类的实例已经创建并完成了初始化,这时再去修改它的构造函数的字节码,已经没有意义。但我们可以通过 “JNI + JVMTI” 来获取到 Runner 类在 heap 上的所有实例(这里只有一个)。
在继续之前,希望你能先阅读以下文章,对如何使用 “JNI + JVMTI” 有充分了解:
在heap上查找class的对象实例
接下来修改 JettyAgent 的代码,添加 agentmain(…) 方法:
import agent.jvmti.JvmtiUtils;
...
public static void agentmain(String agentArgs, Instrumentation instrumentation) throws Exception {
JvmtiUtils.getInstance().load("/home/helowken/test_jni/jni_jvmti/libagent_jvmti_JvmtiUtils.so");
Class<?> targetClass = Class.forName("org.eclipse.jetty.runner.Runner");
List runnerList = JvmtiUtils.getInstance().findObjectsByClass(targetClass, 1);
if (runnerList.isEmpty())
System.out.println("========= No instance of Runner found on heap.");
else {
Object runner = runnerList.get(0);
System.out.println("========= Runner on heap is: " + runner + ", class is: " + runner.getClass());
}
}
修改 resources/META-INF/MANIFEST.MF:
Premain-Class: test.agent.JettyAgent
Agent-Class: test.agent.JettyAgent
Can-Retransform-Classes: true
重新编译打包。
启动 Jetty,这次不需要 -javaagent 参数。
查找 Jetty 的 PID,再使用之前的 AgentLoader 进行动态连接,就可以看到以下输出:
...
2019-11-19 02:05:08.178:INFO:oejs.Server:main: Started @1657ms
========= Runner on heap is: org.eclipse.jetty.runner.Runner@3a3329ed, class is: class org.eclipse.jetty.runner.Runner