浅析Java Instrument插桩技术
/0x01 Java Instrument
Instrument简介
利用 Java 代码,即 java.lang.instrument 做动态 Instrumentation 是 Java SE 5 的新特性,它把 Java 的 instrument 功能从本地代码中解放出来,使之可以用 Java 代码的方式解决问题。使用 Instrumentation,开发者可以构建一个独立于应用程序的代理程序(Agent),用来监测和协助运行在 JVM 上的程序,甚至能够替换和修改某些类的定义。有了这样的功能,开发者就可以实现更为灵活的运行时虚拟机监控和 Java 类操作了,这样的特性实际上提供了一种虚拟机级别支持的 AOP 实现方式,使得开发者无需对 JDK 做任何升级和改动,就可以实现某些 AOP 的功能了。
要想使用Java插桩,需要用到两个技术JavaAgent与Javassist 。前者用于拦截ClassLoad装载,后者用于操作修改class文件。
在应用启动时,通过-javaagent
参数来指定一个代理程序。
详细介绍见:Java SE 6 新特性:Instrumentation 新功能
Instrument整体流程
Instrument是JVM提供的一个可以修改已加载类的类库,专门为Java语言编写的插桩服务提供支持。它需要依赖JVMTI的Attach API机制实现。在JDK 1.6以前,Instrument只能在JVM刚启动开始加载类时生效,而在JDK 1.6之后,Instrument支持了在运行时对类定义的修改。
- 在JVM启动时,通过JVM参数-javaagent,传入agent jar,Instrument Agent被加载,调用其Agent_OnLoad函数;
- 在Instrument Agent 初始化时,注册了JVMTI初始化函数eventHandlerVMinit;
- 在JVM启动时,会调用初始化函数eventHandlerVMinit,启动了Instrument Agent;
- 用sun.instrument.instrumentationImpl类里的方法loadClassAndCallPremain方法去初始化Premain-Class指定类的premain方法。初始化函数eventHandlerVMinit,注册了class解析的ClassFileLoadHook函数;
- 调用应用程序的main开始执行,准备解析;
- 解析Class之前,JVM调用JVMTI的ClassFileLoadHook函数,钩子函数调用sun.instrument.instrumentationImpl类里的transform方法,通过TransformerManager的transformer方法最终调用我们自定义的Transformer类的transform方法;
- 因为字节码在解析Class之前改的,直接使用修改后的字节码的数据流替代,最后进入Class解析,对整个Class解析无影响;
- 重新加载Class依然重新走6-7步骤;
JavaAgent
简介
JavaAgent本质上可以理解为一个插件,该插件就是一个精心提供的jar包,这个jar包通过JVMTI(JVM Tool Interface)完成加载,最终借助JPLISAgent(Java Programming Language Instrumentation Services Agent)完成对目标代码的修改。
通过JavaAgent技术进行类的字节码修改最主要使用的就是Java Instrumentation API。
JavaAgent技术的主要功能如下:
- 可以在加载Java文件之前做拦截把字节码做修改;
- 可以在运行期将已经加载的类的字节码做变更;
- 还有其他的一些小众的功能:
- 获取所有已经被加载过的类
- 获取所有已经被初始化过了的类
- 获取某个对象的大小
- 将某个jar加入到bootstrapclasspath里作为高优先级被bootstrapClassloader加载
- 将某个jar加入到classpath里供AppClassloard去加载
- 设置某些native方法的前缀,主要在查找native方法的时候做规则匹配
下图说明了是否使用JavaAgent的时候的区别。当使用JavaAgent之后,加载的class都会被拦截,就可以在拦截的过程中进行修改:
JavaAgent最后展现形式是一个Jar包,有以下特性:
- 必须 META-INF/MANIFEST.MF中指定Premain-Class 设定启agent启动类;
- 在启动类需写明启动方法
public static void main(String arg,)
; - 不可直接运行,只能通过JVM参数
-javaagent:xxx.jar
附着于其它JVM进程运行;
启动时修改
启动时修改主要是在JVM启动时,执行native函数的Agent_OnLoad方法,在方法执行时,执行如下步骤:
- 创建InstrumentationImpl对象
- 监听ClassFileLoadHook事件
- 调用InstrumentationImpl的loadClassAndCallPremain方法,在这个方法里会去调用JavaAgent里MANIFEST.MF里指定的Premain-Class类的premain方法
运行时修改
运行时修改主要是通过JVM的attach机制来请求目标JVM加载对应的agent,执行native函数的Agent_OnAttach方法,在方法执行时,执行如下步骤:
- 创建InstrumentationImpl对象
- 监听ClassFileLoadHook事件
- 调用InstrumentationImpl的loadClassAndCallAgentmain方法,在这个方法里会去调用javaagent里MANIFEST.MF里指定的Agentmain-Class类的agentmain方法
ClassFileLoadHook和TransFormClassFile
从前面可以看出整体流程中有两个部分是具有共性的,分别为:
- ClassFileLoadHook
- TranFormClassFile
ClassFileLoadHook是一个JVMTI事件,该事件是Instrument Agent的一个核心事件,主要是在读取字节码文件回调时调用,内部调用了TransFormClassFile函数。
TransFormClassFile的主要作用是调用java.lang.instrument.ClassFileTransformer的tranform方法,该方法由开发者实现,通过instrument的addTransformer方法进行注册。
通过以上描述可以看出在字节码文件加载的时候,会触发ClassFileLoadHook事件,该事件调用TransFormClassFile,通过经由instrument的addTransformer注册的方法完成整体的字节码修改。
对于已加载的类,需要调用retransformClass函数,然后经由redefineClasses函数,在读取已加载的字节码文件后,若该字节码文件对应的类关注了ClassFileLoadHook事件,则调用ClassFileLoadHook事件。后续流程与类加载时字节码替换一致。
0x02 常用字节码操作工具
Javaassist
Javaassist是一个开源的分析、编辑和创建Java字节码的类库。性能消耗较大,但容易使用。
特点:简单,性能比ASM低。
ASM
ASM是一个轻量级的Java字节码操作框架,直接涉及到JVM底层的操作和指令。性能高,功能丰富。
特点:复杂,性能高,一般更为常用。
BCEL
BCEL这是Apache Software Fundation的Jakarta项目的一部分。BCEL可以让你深入JVM汇编语言进行类的操作的细节。
0x03 Instrument的基本功能和用法
基本功能
java.lang.instrument包的具体实现,依赖于 JVMTI。JVMTI(Java Virtual Machine Tool Interface)是一套由 Java 虚拟机提供的,为 JVM 相关的工具提供的本地编程接口集合。JVMTI 提供了一套”代理”程序机制,可以支持第三方工具程序以代理的方式连接和访问 JVM,并利用 JVMTI 提供的丰富的编程接口,完成很多跟 JVM 相关的功能。事实上,java.lang.instrument 包的实现,也就是基于这种机制的:在 Instrumentation 的实现当中,存在一个 JVMTI 的代理程序,通过调用 JVMTI 当中 Java 类相关的函数来完成 Java 类的动态操作。除开 Instrumentation 功能外,JVMTI 还在虚拟机内存管理,线程控制,方法和变量操作等等方面提供了大量有价值的函数。
Instrumentation 的最大作用,就是类定义动态改变和操作。在 Java SE 5 及其后续版本当中,开发者可以在一个普通 Java 程序(带有 main 函数的 Java 类)运行时,通过-javaagent
参数指定一个特定的 jar 文件(包含 Instrumentation 代理)来启动 Instrumentation 的代理程序。
函数说明
premain()
在主程序运行之前的代理程序使用premain()。
有如下两种方式编写premain函数:
1 | public static void premain(String agentArgs,Instrumentation inst); |
注意,第一种定义方式优先执行于第二种定义方式。
两个参数解释:
- agentArgs是函数得到的程序参数,随同”-javaagent”一起传入,传入的是一个字符串
- Inst是一个java.lang.instrument.Instrumentation的实例,由JVM自动传入
agentmain()
在主程序运行之后的代理程序使用agentmain()。
定义方式和premain类似:
1 | public static void agentmain(String agentArgs,Instrumentation inst) |
addTransformer()
增加一个Class文件的转换器,该转换器用于改变class二进制流的数据,参数canRetransform设置是否允许重新转换。
redefineClasses()
类加载之前,重新定义class文件,ClassDefinition表示一个类新的定义,如果在类加载之后,需要用retransformClasses方法重新定义。
retransformClasses()
在类加载之后,重新定义class。事实上,该方法update了一个类。
appendToBootstrapClassLoaderSearch()
添加jar文件到BootstrapClassLoader中。
appendToSystemClassLoaderSearch()
添加jar文件到system class loader。
getAllLoadedClasses()
获取加载的所有类数组。
Javassist的特殊语法
基本步骤
编写premain函数
编写一个 Java 类,包含如下两个方法当中的任何一个:
1 | public static void premain(String agentArgs, Instrumentation inst); [1] |
其中,[1] 的优先级比 [2] 高,将会被优先执行([1] 和 [2] 同时存在时,[2] 被忽略)。
在这个 premain 函数中,开发者可以进行对类的各种操作。
agentArgs 是 premain 函数得到的程序参数,随同 “– javaagent”一起传入。与 main 函数不同的是,这个参数是一个字符串而不是一个字符串数组,如果程序参数有多个,程序将自行解析这个字符串。
Inst 是一个 java.lang.instrument.Instrumentation 的实例,由 JVM 自动传入。
java.lang.instrument.Instrumentation 是 instrument 包中定义的一个接口,也是这个包的核心部分,集中了其中几乎所有的功能方法,例如类定义的转换和操作等等。
jar文件打包
将这个 Java 类打包成一个 jar 文件,并在其中的 manifest 属性当中加入” Premain-Class”来指定步骤 1 当中编写的那个带有 premain 的 Java 类。
运行
用如下方式运行带有 Instrumentation 的 Java 程序:
1 | java -javaagent:jar 文件的位置 [= 传入 premain 的参数 ] |
0x04 Demo
使用premain()在主程序运行之前代理
要使用instrument的类修改功能,我们需要实现它提供的ClassFileTransformer接口,定义一个类文件转换器。接口中的transform()方法会在类文件被加载时调用,而在transform方法里,我们可以利用上文中的ASM或Javassist对传入的字节码进行改写或替换,生成新的字节码数组后返回。
每当加载一个class文件时输出当前class文件名:
1 | package main.java.mi1k7eatest; |
配置文件META-INF/MANIFEST.MF:
1 | Manifest-Version: 1.0 |
Premain-Class用于指定上面的premain函数所在的Class。
然后在启动java服务的时候添加启动参数:
1 | -javaagent:mi1k7ea.jar=123 |
使用agentmain()在主程序运行之后代理
1 | public static void agentmain(String args,Instrumentation inst){ |
在程序运行后加载,编写加载agent类的程序。因为如果选择agentmain的写法,运行时主程序已经加载了,所以我们不能再在程序中编写加载的代码,只能另写程序。
那么另写程序如何与主程序进行通信?
这里用到的机制就是attach机制,它可以将JVM A连接至JVM B,并发送指令给JVM B执行,JDK自带常用工具如jstack,jps等就是使用该机制来实现的。
这里我们先用tomcat启动一个程序我们称为主程序B
然后再来写A程序代码如下:
1 | public static void main(String[] args){ |
上述代码将mi1k7ea.jar连接到tomcat的78256进程上
查看tomcat的控制台,就会发现已经执行了mi1k7ea.jar的代码,有相应的输出内容。
0x05 实例——Dump加密class源码
现在假设有个ClassEncode_encrypt.jar文件,其中的com.mi1k7ea包下的class文件都被加密处理了,直接用反编译工具是没办法反编译成功的。但由于该jar文件在运行时需要加载特定的so文件来在加密的class文件中字节码执行之前先进行解码操作,因此我们可以使用JavaAgent来实现在目标class文件内容被解码后且执行前将其class文件源码dump下来。
具体场景参考:Java代码反反编译对抗思路
这里我们选择在主程序运行之前进行代理,即编写premain()函数。
MainAgent.java,定义了premain()函数,其中调用了Instrumentation类的addTransformer()函数来添加一个类文件转换器实例,该实例类型为后面定义的DumpClassTransformer类:
1 | package com.dumpclass; |
DumpClassTransformer.java,实现instrument提供的ClassFileTransformer接口,定义了一个transform()方法,该方法会在类文件被加载时调用,而在该方法中会将已经解码的class文件字节码写入目标文件中保存:
1 | package com.dumpclass; |
配置文件META-INF/MANIFEST.MF,Premain-Class用于指定上面的premain()函数所在的Class,注意最后必须空一行出来:
1 | Manifest-Version: 1.0 |
打包成DumpClass.jar。
通过以下命令,指定JavaAgent的jar包,然后在目标jar包主执行类方法执行之前先执行DumpClass.jar中的premain()方法,从而从内存将加密的目标jar类的字节码Dump下来:
1 | java -Ddump_package=com.mi1k7ea -Ddump_out_folder=/tmp -agentlib:decrypt -javaagent:DumpClass.jar -jar ClassEncode_encrypt.jar |
下载下来,此时就能从成功反编译获取到加密class文件的内容了: