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支持了在运行时对类定义的修改。

  1. 在JVM启动时,通过JVM参数-javaagent,传入agent jar,Instrument Agent被加载,调用其Agent_OnLoad函数;
  2. 在Instrument Agent 初始化时,注册了JVMTI初始化函数eventHandlerVMinit;
  3. 在JVM启动时,会调用初始化函数eventHandlerVMinit,启动了Instrument Agent;
  4. 用sun.instrument.instrumentationImpl类里的方法loadClassAndCallPremain方法去初始化Premain-Class指定类的premain方法。初始化函数eventHandlerVMinit,注册了class解析的ClassFileLoadHook函数;
  5. 调用应用程序的main开始执行,准备解析;
  6. 解析Class之前,JVM调用JVMTI的ClassFileLoadHook函数,钩子函数调用sun.instrument.instrumentationImpl类里的transform方法,通过TransformerManager的transformer方法最终调用我们自定义的Transformer类的transform方法;
  7. 因为字节码在解析Class之前改的,直接使用修改后的字节码的数据流替代,最后进入Class解析,对整个Class解析无影响;
  8. 重新加载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包,有以下特性:

  1. 必须 META-INF/MANIFEST.MF中指定Premain-Class 设定启agent启动类;
  2. 在启动类需写明启动方法public static void main(String arg,)
  3. 不可直接运行,只能通过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
2
public static void premain(String agentArgs,Instrumentation inst);
public static void premain(String agentArgs);

注意,第一种定义方式优先执行于第二种定义方式。

两个参数解释:

  • 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
2
public static void premain(String agentArgs, Instrumentation inst);  [1]
public static void premain(String agentArgs); [2]

其中,[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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main.java.mi1k7eatest;

import java.io.PrintStream;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;

public class PreMainTraceAgent{
public static void premain(String agentArgs, Instrumentation inst)
{
System.out.println("agentArgs : " + agentArgs);
inst.addTransformer(new DefineTransformer(), true);
}

static class DefineTransformer implements ClassFileTransformer{
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException{
System.out.println("premain load Class:" + className);
return classfileBuffer;
}
}
}

配置文件META-INF/MANIFEST.MF:

1
2
3
4
Manifest-Version: 1.0
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Premain-Class: main.java.mi1k7eatest.PreMainTraceAgent

Premain-Class用于指定上面的premain函数所在的Class。

然后在启动java服务的时候添加启动参数:

1
-javaagent:mi1k7ea.jar=123

使用agentmain()在主程序运行之后代理

1
2
3
4
5
6
7
public static void agentmain(String args,Instrumentation inst){
Class<?>[] classes = inst.getAllLoadedClasses();
for(Class<?>[] cls:classes){
System.out.println(cls.getName());
}
System.out.println("Finished");
}

在程序运行后加载,编写加载agent类的程序。因为如果选择agentmain的写法,运行时主程序已经加载了,所以我们不能再在程序中编写加载的代码,只能另写程序。

那么另写程序如何与主程序进行通信?

这里用到的机制就是attach机制,它可以将JVM A连接至JVM B,并发送指令给JVM B执行,JDK自带常用工具如jstack,jps等就是使用该机制来实现的。

这里我们先用tomcat启动一个程序我们称为主程序B

然后再来写A程序代码如下:

1
2
3
4
5
6
7
8
public static void main(String[] args){
try{
VirtualMachine vm = VirtualMachine.attach("78256");
vm.loadAgent("/home/mi1k7ea.jar");
}catch(Exception e){
e.printStackTrace();
}
}

上述代码将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
2
3
4
5
6
7
8
9
10
11
12
13
package com.dumpclass;

import java.lang.instrument.Instrumentation;

public class MainAgent {
public static void main(String[] args) {

}

public static void premain(String args, Instrumentation instrumentation) {
instrumentation.addTransformer(new DumpClassTransformer());
}
}

DumpClassTransformer.java,实现instrument提供的ClassFileTransformer接口,定义了一个transform()方法,该方法会在类文件被加载时调用,而在该方法中会将已经解码的class文件字节码写入目标文件中保存:

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
package com.dumpclass;

import java.io.File;
import java.io.FileOutputStream;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;

public class DumpClassTransformer implements ClassFileTransformer {
private static final String DUMP_PACKAGE = System.getProperty("dump_package");
private static final String OUT_FOLDER = System.getProperty("dump_out_folder");


public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
if (className == null || className.isEmpty()) {
return classfileBuffer;
}

if (classfileBuffer == null) {
return classfileBuffer;
}

String tmpClassName = className.replace("/", ".");
if (tmpClassName.startsWith(DUMP_PACKAGE)) {
try {
writeClass(className, classfileBuffer);
} catch (Exception e) {
e.printStackTrace();
}
}

return classfileBuffer;
}

private boolean writeClass(String className, byte[] classfileBuffer) {
File file = null;
FileOutputStream fileOutputStream = null;

try {
String folder = OUT_FOLDER;
if (!folder.endsWith(File.separator)) {
folder = folder + File.separator;
}

String classPath = className.substring(0, className.lastIndexOf("/"));
className = className.substring(className.lastIndexOf("/") + 1, className.length());

String path = OUT_FOLDER + File.separator + classPath;
file = new File(path);
if (!file.exists()) {
file.mkdirs();
}
file = new File(path + File.separator + className + ".class");

fileOutputStream = new FileOutputStream(file);
fileOutputStream.write(classfileBuffer);

fileOutputStream.flush();
} catch (Exception e) {
e.printStackTrace();
return false;
} finally {
try {
if (fileOutputStream != null) {
fileOutputStream.close();
fileOutputStream = null;
}
} catch (Exception e) {
e.printStackTrace();
}
}

return true;
}
}

配置文件META-INF/MANIFEST.MF,Premain-Class用于指定上面的premain()函数所在的Class,注意最后必须空一行出来:

1
2
3
4
Manifest-Version: 1.0
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Premain-Class: com.dumpclass.MainAgent

打包成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文件的内容了:

0x06 参考

java agent技术原理及简单实现

字节码插桩技术

插桩技术在Java安全中的应用简述

Java SE 6 新特性:Instrumentation 新功能