使用Instrumentation和Javassist修改web应用字节码

前言

  1. Java Instrumentation 允许我们使用静态或者动态的方式连接JVM,从而在运行时修改类的字节码;
  2. Javassist 是一个不错的字节码修改库,可以通过文本方式编写要修改的代码,而不需要懂底层字节码的运行机制;
  3. 对于复杂的应用场景,例如部署了多应用的 web 服务器,需要额外的手段来辅助实现字节码的动态修改。

静态连接

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 的用法,可以访问:

  1. 官网:https://www.javassist.org
  2. 教程:https://www.javassist.org/tutorial/tutorial.html

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

<?xml version="1.0" encoding="UTF-8"?>
<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-demo</artifactId>
        <groupId>demo</groupId>
        <version>1.0</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <groupId>demo</groupId>
    <artifactId>agent</artifactId>

    <properties>
        <jdk.version>1.8</jdk.version>
        <javaassist.version>3.21.0-GA</javaassist.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.javassist</groupId>
            <artifactId>javassist</artifactId>
            <version>${javaassist.version}</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.7.0</version>
                <configuration>
                    <fork>true</fork>
                    <source>${jdk.version}</source>
                    <target>${jdk.version}</target>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-resources-plugin</artifactId>
                <version>3.1.0</version>
                <configuration>
                    <useDefaultDelimiters>false</useDefaultDelimiters>
                    <overwrite>true</overwrite>
                    <delimiters>
                        <delimiter>@</delimiter>
                    </delimiters>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.1.0</version>
                <configuration>
                    <archive>
                        <manifestFile>src/main/resources/META-INF/MANIFEST.MF</manifestFile>
                    </archive>
                </configuration>
            </plugin>
            <plugin>
                <artifactId>maven-assembly-plugin</artifactId>
                <version>3.1.1</version>
                <configuration>
                    <appendAssemblyId>false</appendAssemblyId>
                    <descriptorRefs>
                        <descriptorRef>jar-with-dependencies</descriptorRef>
                    </descriptorRefs>
                    <archive>
                        <manifestFile>src/main/resources/META-INF/MANIFEST.MF</manifestFile>
                    </archive>
                </configuration>
                <executions>
                    <execution>
                        <id>make-assembly-package</id>
                        <phase>package</phase>
                        <goals>
                            <goal>single</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

配置有点复杂,但是这样才能保证在最终生成的 assembly 包里面包含 Agent 的配置文件: MANIFEST.MF。

使用 maven 打包:

mvn clean package -DskipTests

之后可以看到生成了 agent-1.0.jar:
agent.jar

测试代码(位于另外的 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 选项:
javaagent vm options
通过 java 命令,可以查看到格式为:

-javaagent:<jarpath>[=<options>]
                  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.sun</groupId>
            <artifactId>tools</artifactId>
            <version>1.0</version>
            <scope>system</scope>
            <systemPath>${java.home}/../lib/tools.jar</systemPath>
        </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

运行 AgentLoader,对应的3个参数为:
AgentLoader args
输出为:

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.

可以看到字节码已经被动态修改了。

两种连接方式的对比

静态连接的特点:
  1. JVM 启动时便开启,因此可用于运行时间较短的应用
  2. 需要在 JVM 启动时添加 -javaagent VM 选项
  3. 在类加载之前,可以做字节码修改,这在某些场景下非常有用。譬如在类中添加静态方法,记录实例的引用,然后在需要的时候通过引用查找实例。
动态连接的特点:
  1. 不适用于运行时间较短的应用,因为可能还没连接成功,应用就已经退出了
  2. 不需要在启动 JVM 时添加 -javaagent VM 选项
  3. 可以在应用运行期间的任一时刻进行连接
  4. 连接的时间点往往是在应用初始化之后,因此某些类的实例可能已经创建并初始化,如果想通过修改字节码来获取这些实例的引用,就会比较困难,需要通过另外的办法来实现。

部分答疑

问题1:该如何查找 PID

有人觉得使用 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。这是最为保险的做法,至少我到现在为止,没有出现过问题。

问题2:多次字节码的修改无效

对于动态连接的例子,如果多运行几次 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 的源码可以知道问题根源。这里我直接给出解答:

  1. javassist 有几种不同的 ClassPath 用于查找 class,ClassPool#inserrtClassPath(…) 会把 ClassPath 放到搜索列表的最前面,ClassPool#appendClassPath(…) 则会把 ClassPath 放到搜索列表的最末端;
  2. 默认情况下(ClassPool 没有任何定制的 ClassPath),javassist 会通过 Class 或者 ClassLoader 的 getResourceXXX 方法来获取 Resource;
  3. 通过 Resource 的一系列方法,javassist 可以获取到 class 文件包含的内容,也就是字节码;
  4. JavassistTransformer#transform() 的实现中,没有添加过 ClassPath, 因此 ClassPool#get(className) 的每次调用,都会从 class 文件中重新读取内容,于是获取到的字节码都是一样的。

以下为解决办法。
修改 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 应用,每个 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 实例

用于记录 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);
        }
    }
}

步骤说明:

  1. 在 premain(…) 方法中运行,表示使用的是静态方式
  2. 启动了一个线程间隔2s来测试 App.instance 的值
  3. 使用新的转换器来修改字节码
注意!!!

不要在 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 实例

动态连接的方式虽然方便,但是在连接的时候,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
  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值