Java Instrumentation

时间:2022-11-15 18:06:30

JDK 1.5 开始,Java新增了 Instrumentation ( Java Agent API )和 JVMTI ( JVM Tool Interface )功能,允许JVM在加载某个 class 文件之前对其字节码进行修改,同时也支持对已加载的 class (类字节码)进行重新加载( Retransform )。

在1.6版本新增了attach(附加方式),可以对运行中的Java进程插入Agent,instrumentation包被赋予了更强大的功能:启动后的 instrument、本地代码(native code)instrument,以及动态改变 classpath 等等。这些改变,意味着 Java 具有了更强的动态控制、解释能力,它使得 Java 语言变得更加灵活多变。

java.lang.instrument包的具体实现,依赖于 JVMTI。JVMTI(Java Virtual Machine Tool Interface)是一套由 Java 虚拟机提供的,为 JVM 相关的工具提供的本地编程接口集合。JVMTI 是从 Java SE 5 开始引入,整合和取代了以前使用的 Java Virtual Machine Profiler Interface (JVMPI) 和 the Java Virtual Machine Debug Interface (JVMDI),而在 Java SE 6 中,JVMPI 和 JVMDI 已经消失了。JVMTI 提供了一套”代理”程序机制,可以支持第三方工具程序以代理的方式连接和访问 JVM,并利用 JVMTI 提供的丰富的编程接口,完成很多跟 JVM 相关的功能

Java Agent可以去实现字节码插桩、动态跟踪分析等,比如RASP产品和Java Agent内存马。

源码

java.lang.instrument包位于rt.jar共包含如下类和接口:
Java Instrumentation

1.IllegalClassFormatException 异常类

此异常为非法的字节码格式化异常,由ClassFileTransformer.transform 的实现抛出。

抛出此异常的原因是由于初始类文件字节无效,或者由于以前应用的转换损坏了字节码。

2. UnmodifiableClassException 异常类

当程序无法修改制定的类时,会抛出该异常。由 Instrumentation.redefineClasses 的实现抛出。

3. ClassDefinition 绑定/定义类

public final class ClassDefinition {
    /**
     *  要重定义的类
     */
    private final Class<?> mClass;

    /**
     *  用于替换的本地 class ,为 byte 数组
     */
    private final byte[]   mClassFile;

    /**
     *  构造方法,使用提供的类和类文件字节创建一个新的 ClassDefinition 绑定
     */
    public ClassDefinition( Class<?> theClass, byte[]  theClassFile) {
        if (theClass == null || theClassFile == null) {
            throw new NullPointerException();
        }
        mClass      = theClass;
        mClassFile  = theClassFile;
    }

    /**
     * 以下为 getter 方法
     */
    public Class<?>  getDefinitionClass() {
        return mClass;
    }

    public byte[] getDefinitionClassFile() {
        return mClassFile;
    }
}

4. ClassFileTransformer 接口

此接口为转换类文件的代理接口。提供了 transform() 方法用于修改原类的注入。
我们可以在获取到 Instrumentation对象后通过 addTransformer() 方法添加自定义类文件转换器。

public interface ClassFileTransformer {


    /**
     * 类文件转换方法,重写transform方法可获取到待加载的类相关信息
     *
     * @param loader              定义要转换的类加载器;如果是引导加载器,则为 null
     * @param className           类名,如:java/lang/Runtime
     * @param classBeingRedefined 如果是被重定义或重转换触发,则为重定义或重转换的类;如果是类加载,则为 null
     * @param protectionDomain    要定义或重定义的类的保护域
     * @param classfileBuffer     类文件格式的输入字节缓冲区(不得修改)
     * @return 返回一个通过ASM修改后添加了防御代码的字节码byte数组。
     */
        byte[] transform(  ClassLoader loader, 
                String className,
                Class<?> classBeingRedefined,
                ProtectionDomain protectionDomain,
                byte[] classfileBuffer)
        throws IllegalClassFormatException;
}

重写 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。
  7. 卸载 transform 时需要使用创建时的 Instrumentation 实例。

在以下三种情形下 ClassFileTransformer.transform() 会被执行:

  1. 新的 class 被加载。
  2. Instrumentation.redefineClasses 显式调用。
  3. addTransformer 第二个参数为 true 时,Instrumentation.retransformClasses 显式调用。

5. Instrumentation 接口

java.lang.instrument.Instrumentation 是 Java 提供的监测运行在 JVM 程序的 API 。利用 Instrumentation 我们可以实现如下功能:

类方法 功能
void addTransformer(ClassFileTransformer transformer, boolean canRetransform) 添加一个 Transformer,是否允许 reTransformer
void addTransformer(ClassFileTransformer transformer) 添加一个 Transformer
boolean removeTransformer(ClassFileTransformer transformer) 移除一个 Transformer
boolean isRetransformClassesSupported() 检测是否允许 reTransformer
void retransformClasses(Class<?>... classes) 重加载(retransform)类
boolean isModifiableClass(Class<?> theClass) 确定一个类是否可以被 retransformation 或 redefinition 修改
Class[] getAllLoadedClasses() 获取 JVM 当前加载的所有类
Class[] getInitiatedClasses(ClassLoader loader) 获取指定类加载器下所有已经初始化的类
long getObjectSize(Object objectToSize) 返回指定对象大小
void appendToBootstrapClassLoaderSearch(JarFile jarfile) 添加到 BootstrapClassLoader 搜索
void appendToSystemClassLoaderSearch(JarFile jarfile) 添加到 SystemClassLoader 搜索
boolean isNativeMethodPrefixSupported() 是否支持设置 native 方法 Prefix
void setNativeMethodPrefix(ClassFileTransformer transformer, String prefix) 通过允许重试,将前缀应用到名称,此方法修改本机方法解析的失败处理
boolean isRedefineClassesSupported() 是否支持类 redefine
void redefineClasses(ClassDefinition... definitions) 重定义(redefine)类

原理

这部分由于参考作者 throwable 总结较好,直接引用。

instrument 的底层实现依赖于 JVMTI ,也就是 JVM Tool Interface ,它是 JVM 暴露出来的一些供用户扩展的接口集合, JVMTI 是基于事件驱动的, JVM 每执行到一定的逻辑就会调用一些事件的回调接口(如果有的话),这些接口可以供开发者去扩展自己的逻辑。 JVMTIAgent 是一个利用 JVMTI 暴露出来的接口提供了代理启动时加载(agent on load)、代理通过 attach 形式加载(agent on attach)和代理卸载(agent on unload)功能的动态库。而 instrument agent 可以理解为一类 JVMTIAgent 动态库,别名是 JPLISAgent (Java Programming Language Instrumentation Services Agent),也就是专门为 Java 语言编写的插桩服务提供支持的代理

使用

Java agent的使用方式有两种:
实现premain方法,在JVM启动前加载。
实现agentmain方法,在JVM启动后加载
premainagentmain函数声明如下,方法名相同情况下,拥有Instrumentation inst参数的方法优先级更高:

public static void agentmain(String agentArgs, Instrumentation inst) {
    ...
}

public static void agentmain(String agentArgs) {
    ...
}

public static void premain(String agentArgs, Instrumentation inst) {
    ...
}

public static void premain(String agentArgs) {
    ...
}

JVM 会优先加载带 Instrumentation 签名的方法,加载成功忽略第二种;如果第一种没有,则加载第二种方法。

  • 第一个参数String agentArgs就是Java agent的参数。

  • Inst 是一个 java.lang.instrument.Instrumentation 的实例,可以用来类定义的转换和操作等等。

premain方式

JVM启动时 会先执行 premain 方法,大部分类加载都会通过该方法,注意:是大部分,不是所有。遗漏的主要是系统类,因为很多系统类先于 agent 执行,而用户类的加载肯定是会被拦截的。也就是说,这个方法是在 main 方法启动前拦截大部分类的加载活动,既然可以拦截类的加载,就可以结合第三方的字节码编译工具,比如ASM,javassist,cglib等等来改写实现类。

大致分为以下逻辑:

  1. 编写一个Agent类,其中定义premain方法并调用Instrumentation#addTransformer方法添加一个自定义的Transformer
  2. 自定义一个Transformer类,实现Instrumentation接口,在transform方法中写入自己想要的AOP逻辑
  3. 创建MANIFEST.MF文件,可以手动写也可以通过Maven的插件(pom.xml)
  4. 打包Agent的jar包
  5. 在需要使用JavaAgent的项目添加JVM启动参数-javaagent并指定我们打包好的jar

这里需要2个项目,1个为javaagent的jar包,另1个为被javaagent代理的类。最终在被代理类的main方法执行前先执行我们Agent中的premain方法

编写javaagent相关代码
创建一个Maven项目,其中创建一个Premain类,里面需要包含premain方法

package org.gk0d;

import java.lang.instrument.Instrumentation;

public class Premain {
    public static void premain(String agentArgs, Instrumentation inst){
        System.out.println("agentArgs"+agentArgs);
        inst.addTransformer(new DefineTransformer(),true);//调用addTransformer添加一个Transformer
    }

}

创建DefineTransformer类,实现ClassFileTransformer接口

package org.gk0d;

import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;

public class DefineTransformer implements ClassFileTransformer {
    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
        System.out.println("premain load class"+className); //打印加载的类
        return new byte[0];
    }
}

创建MANIFEST.MF文件
此时项目如果打成jar包,缺少入口main文件,所以需要自己定义一个MANIFEST.MF文件,用于指明premain的入口在哪里:

手动创建的话需要在resources/META-INF目录下创建MANIFEST.MF文件,内容如下:注意多留一行空行

Manifest-Version: 1.0
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Premain-Class: com.gk0d.Premain

或者通过pom.xml中调用Maven的插件去创建该文件

   <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.1.0</version>
                <configuration>
                    <archive>
                        <!--自动添加META-INF/MANIFEST.MF -->
                        <manifest>
                            <addClasspath>true</addClasspath>
                        </manifest>
                        <manifestEntries>
                            <Premain-Class>org.gk0d.Premain</Premain-Class>
                            <Agent-Class>org.gk0d.Premain</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-compiler-plugin</artifactId>
                <configuration>
                    <source>6</source>
                    <target>6</target>
                </configuration>
            </plugin>
        </plugins>
    </build>

以下是MANIFEST.MF的其他选项

Premain-Class :包含 premain 方法的类(类的全路径名)
Agent-Class :包含 agentmain 方法的类(类的全路径名)
Boot-Class-Path :设置引导类加载器搜索的路径列表。查找类的特定于平台的机制失败后,引导类加载器会搜索这些路径。按列出的顺序搜索路径。列表中的路径由一个或多个空格分开。路径使用分层 URI 的路径组件语法。如果该路径以斜杠字符(“/”)开头,则为绝对路径,否则为相对路径。相对路径根据代理 JAR 文件的绝对路径解析。忽略格式不正确的路径和不存在的路径。如果代理是在 VM 启动之后某一时刻启动的,则忽略不表示 JAR 文件的路径。(可选)
Can-Redefine-Classes :true表示能重定义此代理所需的类,默认值为 false(可选)
Can-Retransform-Classes :true 表示能重转换此代理所需的类,默认值为 false (可选)
Can-Set-Native-Method-Prefix: true表示能设置此代理所需的本机方法前缀,默认值为 false(可选)

打包
设置完后然后使用 maven 构建agent jar 包:mvn clean install
编写测试类
为了更直观,起了个新项目

public class Test {
    public static void main(String[] args) {
        System.out.println("main Method");
    }
}

-javaagent模式启动
JVM启动参数添加,这里使用绝对路径,否则容易出问题
-javaagent:D:/vul/agent/target/agent-1.0-SNAPSHOT.jar
Java Instrumentation
Java Instrumentation

执行main方法之前会加载所有的类,包括系统类和自定义类。而在ClassFileTransformer中会去拦截系统类和自己实现的类对象,逻辑则是在ClassFileTransformer实现类的transform方法中定义。
而在这里transform感觉是类似于一个filter会去拦截/遍历一些要在JVM中加载的类,而在transform方法中我们可以定义一些逻辑,比如if className== xxx时走入一个逻辑去实现AOP。而其中就可以利用如javassist技术修改字节码并作为transform方法的返回值,这样就在该类在JVM中加载前(-javaagent模式)修改了字节码

使用javassist修改字节码

这里在之前Test类中新添加一个方法,并在Agent里我们自定义的Transformerttransform添加一个逻辑,使用javassist去修改我们Test类中新添加的方法

Test类中新加一个call方法

public class Test {
    public static void main(String[] args) {
        System.out.println("main Method");
        call();
    }
    public static void call(){
        System.out.println("say hello ...");
    }
}

DefineTransformer 类

package org.gk0d;

import javassist.*;

import java.io.IOException;
import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;

public class DefineTransformer implements ClassFileTransformer {
    @Override
    public byte[] transform(final ClassLoader loader, final String className, final Class<?> classBeingRedefined,final ProtectionDomain protectionDomain, final byte[] classfileBuffer) {
        // System.out.println("premain load class"+className); //打印加载的类
        if ("org/example/Test".equals(className)) {
            try {
                // 从ClassPool获得CtClass对象
                 ClassPool classPool = ClassPool.getDefault();
                 CtClass ctclass = classPool.get("org.example.Test");
                CtMethod call= ctclass.getDeclaredMethod("call");
                // 打印后加了一个弹计算器的操作
                String MethodBody = "{System.out.println(\"say hello ...\");" +
                        "java.lang.Runtime.getRuntime().exec(\"calc.exe\");}";
                call.setBody(MethodBody);
                byte[] bytes = ctclass.toBytecode();

                // 返回字节码,并且detachCtClass对象
                byte[] byteCode = ctclass.toBytecode();
                //detach的意思是将内存中曾经被javassist加载过的Test对象移除,如果下次有需要在内存中找不到会重新走javassist加载
                ctclass.detach();
                return byteCode;
            } catch (Exception ex) {
                ex.printStackTrace();
            }
        }
        // 如果返回null则字节码不会被修改
        return null;
    }
}

permain方式总结

Java Instrumentation
这种方法存在一定的局限性——只能在启动时使用-javaagent参数指定。在实际环境中,目标的JVM通常都是已经启动的状态,无法预先加载premain。相比之下,agentmain更加实用。

agentmain

JDK 1.6 新增了attach (附加方式)方式,可以对运行中的 Java 进程附加 Agent 。

这就是我们说的 agentmain ,使用方式和 permain 十分相似,包括编写 MANIFEST.MF 和生成代理 Jar 包。但是,它并不需要通过-javaagent 命令行形式引入代理 Jar ,而是在运行时通过 attach 工具激活指定代理即可
premain函数一样, 开发者可以编写一个含有agentmain函数的 Java 类:

//采用attach机制,被代理的目标程序VM有可能很早之前已经启动,当然其所有类已经被加载完成,这个时候需要借助Instrumentation#retransformClasses(Class<?>... classes)让对应的类可以重新转换,从而激活重新转换的类执行ClassFileTransformer列表中的回调
public static void agentmain (String agentArgs, Instrumentation inst)

public static void agentmain (String agentArgs)

同样,agentmain 方法中带Instrumentation参数的方法也比不带优先级更高。开发者必须在 manifest 文件里面设置“Agent-Class”来指定包含 agentmain 函数的类。
在Java6 以后实现启动后加载的新实现是Attach apiAttach API 很简单,只有 2 个主要的类,都在 com.sun.tools.attach 包里面(需要手动导入):
Java Instrumentation

  1. VirtualMachine 字面意义表示一个Java 虚拟机,也就是程序需要监控的目标虚拟机,提供了获取系统信息(比如获取内存dump、线程dump,类信息统计(比如已加载的类以及实例个数等), loadAgent,Attach 和 Detach (Attach 动作的相反行为,从 JVM 上面解除一个代理)等方法,可以实现的功能可以说非常之强大 。该类允许我们通过给attach方法传入一个jvm的pid(进程id),远程连接到jvm上 。
    代理类注入操作只是它众多功能中的一个,通过loadAgent方法向jvm注册一个代理程序agent,在该agent的代理程序中会得到一个Instrumentation实例,该实例可以 在class加载前改变class的字节码,也可以在class加载后重新加载。在调用Instrumentation实例的方法时,这些方法会使用ClassFileTransformer接口中提供的方法进行处理。

  2. VirtualMachineDescriptor 则是一个描述虚拟机的容器类,配合 VirtualMachine 类完成各种功能。

attach 实现注入的原理如下:
通过VirtualMachine类的attach(pid)方法,便可以attach到一个运行中的java进程上,之后便可以通过loadAgent(agentJarPath)来将agent的jar包注入到对应的进程,然后对应的进程会调用agentmain方法
Java Instrumentation

Attach模式使用

AgentMain类

package org.gk0d;

import java.lang.instrument.Instrumentation;
import java.lang.instrument.UnmodifiableClassException;

import static java.lang.Class.forName;

public class AgentMain {
    public static void agentmain(String agentArgs, Instrumentation inst) throws UnmodifiableClassException,
            ClassNotFoundException {
        inst.addTransformer(new DefineTransformer(), true);
        inst.retransformClasses(Class.forName("org.example.Test"));
    }

}

和 premain 的区别在于,我们在 addTransformer 的参数中指定了 true,而且使用了 retransformClasses 重新加载了指定的类。

新建一个自定义的Transformer

package org.gk0d
import javassist.*;

import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;

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

        if ("org.gk0d.Test".equals(className)) {
            try {
                ClassPool classPool = ClassPool.getDefault();
                CtClass ctClass = classPool.get("org.gk0d.Test");
                CtMethod call = ctClass.getDeclaredMethod("call");
                // 打印后加了一个弹计算器的操作
                String MethodBody = "{java.lang.Runtime.getRuntime().exec(\"calc.exe\");" +
                        "System.out.println(\"say hello ...\");}";
                call.setBody(MethodBody);
                byte[] bytes = ctClass.toBytecode();
                return bytes;
                //detach的意思是将内存中曾经被javassist加载过的Test对象移除,如果下次有需要在内存中找不到会重新走javassist加载
                //  ctClass.detach();


            } catch (Exception e) {
                e.printStackTrace();
                return classfileBuffer;
            }
        }else {
            return classfileBuffer;
        }


    }


}

测试AgentMainTest类
将jar通过jvm pid注入进来,使其修改Test类中call方法的字节码

package org.gk0d;

import com.sun.tools.attach.*;

import java.io.IOException;
import java.util.List;

public class AgentMainTest {
    public static void main(String[] args) {
        System.out.println("running JVM start ");
        List<VirtualMachineDescriptor> list = VirtualMachine.list(); // 寻找当前系统中所有运行着的JVM进程
        for (VirtualMachineDescriptor vmd : list) {
            //如果虚拟机的名称为 xxx 则 该虚拟机为目标虚拟机,获取该虚拟机的 pid
            //然后加载 agent.jar 发送给该虚拟机
            System.out.println(vmd.displayName()); //vmd.displayName()看到当前系统都有哪些JVM进程在运行
            if (vmd.displayName().endsWith("org.gk0d.AgentMainTest")) {
                VirtualMachine virtualMachine = null;
                try {
                    virtualMachine = VirtualMachine.attach(vmd.id());
                    virtualMachine.loadAgent("D:/vul/agent/agent-1.0-SNAPSHOT.jar");

                    virtualMachine.detach();

                } catch (AttachNotSupportedException e) {
                    e.printStackTrace();
                } catch (IOException e) {
                    e.printStackTrace();
                } catch (AgentLoadException e) {
                    e.printStackTrace();
                } catch (AgentInitializationException e) {
                    e.printStackTrace();
                }

            }
        }
    }

}