点击上方“凌天实验室”,“星标或置顶公众号”
漏洞、技术还是其他,我都想第一时间和你分享
前 言
JDK 1.5 开始,Java新增了 Instrumentation ( Java Agent API )和 JVMTI ( JVM Tool Interface )功能,允许JVM在加载某个 class 文件之前对其字节码进行修改,同时也支持对已加载的 class (类字节码)进行重新加载( Retransform )。
开发者可以在一个普通 Java 程序(带有 main 函数的 Java 类)运行时,通过 –javaagent 参数指定一个特定的 jar 文件(包含 Instrumentation 代理)来启动 Instrumentation 的代理程序。在类的字节码载入 jvm 前会调用 ClassFileTransformer 的 transform 方法,从而实现修改原类方法的功能,实现 AOP 。
在字节码加载前进行注入,一般有两种写法,重写 ClassLoader 或利用 Instrumentation,而如果重写 ClassLoader,仍然对现有代码进行了修改,而 Instrumentation 则可以做到完全无侵入,利用这种特性,衍生出了诸多新型技术和产品,RASP 就是其中之一。
本篇为相关学习笔记以及个人的一些理解。
源码简介
代码位于包 java.lang.instrument
下,共包含如下类和接口:
此异常为非法的字节码格式化异常,由ClassFileTransformer.transform
的实现抛出。
抛出此异常的原因是由于初始类文件字节无效,或者由于以前应用的转换损坏了字节码。
当程序无法修改制定的类时,会抛出该异常。由 Instrumentation.redefineClasses
的实现抛出。
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;
}
}
此接口为转换类文件的代理接口。提供了 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()
方法需要注意以下事项:
ClassLoader 如果是被 Bootstrap ClassLoader (引导类加载器)所加载那么 loader 参数的值是空。
修改类字节码时需要特别注意插入的代码在对应的 ClassLoader 中可以正确的获取到,否则会报 ClassNotFoundException ,比如修改 java.io.FileInputStream (该类由 Bootstrap ClassLoader 加载)时插入了我们检测代码,那么我们将必须保证 FileInputStream 能够获取到我们的检测代码类。
JVM类名的书写方式路径方式:java/lang/String
而不是我们常用的类名方式:java.lang.String
。
类字节必须符合 JVM 校验要求,如果无法验证类字节码会导致 JVM 崩溃或者 VerifyError (类验证错误)。
如果修改的是 retransform 类(修改已被 JVM 加载的类),修改后的类字节码不得新增方法、修改方法参数、类成员变量。
addTransformer 时如果没有传入 retransform 参数(默认是 false ),就算 MANIFEST.MF 中配置了 Can-Redefine-Classes: true
而且手动调用了retransformClasses()
方法也一样无法retransform。
卸载 transform 时需要使用创建时的 Instrumentation 实例。
还需要理解的是,在以下三种情况下 ClassFileTransformer.transform()
会被执行:
新的 class 被加载。
Instrumentation.redefineClasses 显式调用。
addTransformer 第二个参数为 true 时,Instrumentation.retransformClasses 显式调用。
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 语言编写的插桩服务提供支持的代理。
用 法
可以看到,是非常简单和清晰的一个包,有了这些方法之后我们就可以通过代理,在main函数运行前或后动态的改变类的定义和其他处理操作。
接下来我们来看用法,首先定义一个类,这个类就是我们将要修改的类:
package org.su18;public class MyClass {
public static void sayNice() {
System.out.println("Nice!");
}
}
毫无疑问,此类的 sayNice()
方法在运行时将打印出字符串 “Nice!” 。
然后接下来进行定义自己的 Transformer,如下代码,我这里使用判断如果类名为指定类的名称,则使用ClassHandler.replaceBytes()
方法进行字节码的替换。
package org.su18;import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;
public class Transformer implements ClassFileTransformer {
public byte[] transform(ClassLoader loader, String className,
Class<?> classBeingRedefined, ProtectionDomain protectionDomain,
byte[] classfileBuffer) throws IllegalClassFormatException {
// 将常用的类名转换为 JVM 认识的类名
className = className.replace("/", ".");
// 如果类名为我们指定的类
if (className.equals("org.su18.MyClass")) {
// 进一步进行处理,替换掉输出字符串
return ClassHandler.replaceBytes(className, classfileBuffer);
}
return classfileBuffer;
}
}
字节处理代码:
package org.su18;import java.util.Arrays;
public class ClassHandler {
public static byte[] replaceBytes(String className, byte[] classBuffer) {
// 将类字节码转换成byte字符串
String bufferStr = Arrays.toString(classBuffer);
System.out.println(className + "类替换前的字节码:" + bufferStr);
bufferStr = bufferStr.replace("[", "").replace("]", "");
// 查找需要替换的Java二进制内容
byte[] findBytes = "Nice!".getBytes();
// 把搜索的字符串byte转换成byte字符串
String findStr = Arrays.toString(findBytes).replace("[", "").replace("]", "");
// 二进制替换后的byte值,注意这个值需要和替换的字符串长度一致,不然会破坏常量池
byte[] replaceBytes = "Fxxk!".getBytes();
// 把替换的字符串byte转换成byte字符串
String replaceStr = Arrays.toString(replaceBytes).replace("[", "").replace("]", "");
bufferStr = bufferStr.replace(findStr, replaceStr);
// 切割替换后的byte字符串
String[] byteArray = bufferStr.split("\\s*,\\s*");
// 创建新的byte数组,存储替换后的二进制
byte[] bytes = new byte[byteArray.length];
// 将byte字符串转换成byte
for (int i = 0; i < byteArray.length; i++) {
bytes[i] = Byte.parseByte(byteArray[i]);
}
System.out.println(className + "类替换后的字节码:" + Arrays.toString(bytes));
// 返回修改后的二进制
return bytes;
}
}
可以看到,这里将类字节码转换为byte字符串,并进行字符串查找,替换后再转回 byte,这里为了演示是一种取巧的方式,在实际项目中将使用 ASM 或 javassist 等对类字节码进行处理。
接下来定义 Premain,类名随意,类中定义了 premain()
方法添加自己的 Transformer。
其中参数 agentArgs 是 premain 函数得到的程序参数,随同 “-javaagent”一起传入。与 main 函数不同的是,这个参数是一个字符串而不是一个字符串数组,如果程序参数有多个,程序将自行解析这个字符串。
Inst 是一个 java.lang.instrument.Instrumentation 的实例,由 JVM 自动传入。
package org.su18;import java.lang.instrument.Instrumentation;
import java.lang.instrument.UnmodifiableClassException;
public class Premain {
public static void premain(String agentArgs, Instrumentation inst) throws ClassNotFoundException, UnmodifiableClassException {
inst.addTransformer(new Transformer());
}
}
最后需要在 MANIFEST.MF 中修改 :Premain-Class: org.su18.Premain
。
并且在 pom.xml 中加入如下配置:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>2.3.2</version>
<configuration>
<archive>
<manifestFile>src/main/resources/MANIFEST.MF</manifestFile>
</archive>
</configuration>
</plugin>
然后使用 maven 构建 jar 包:mvn clean install
。
打包之后,在运行程序时加入如下参数:
-javaagent:/Users/phoebe/IdeaProjects/AgentTest/target/AgentTest-1.0.jar org.su18.MyClass
运行 MyClass 程序,输出如下:
可以看到,输出的内容已经成功被修改。
JDK 1.6 新增了attach (附加方式)方式,可以对运行中的 Java 进程附加 Agent 。
这就是我们说的 agentmain ,使用方式和 permain 十分相似,包括编写 MANIFEST.MF 和生成代理 Jar 包。但是,它并不需要通过-javaagent
命令行形式引入代理 Jar ,而是在运行时通过 attach 工具激活指定代理即可。
同样的,我们简单修改下 MyClass,使程序每过三秒打印一次 “Nice!” 字符串。
package org.su18;public class MyClass {
public static void sayNice() {
System.out.println("Nice!");
}
public static void main(String[] args) throws InterruptedException {
while (true) {
sayNice();
Thread.sleep(1000 * 3);
}
}
}
Transformer 和程序处理逻辑不变,将 Premain 修改为 AgentMain。
package org.su18;import java.lang.instrument.Instrumentation;
import java.lang.instrument.UnmodifiableClassException;
public class AgentMain {
public static void agentmain(String agentArgs, Instrumentation inst) throws UnmodifiableClassException,
ClassNotFoundException {
inst.addTransformer(new Transformer(), true);
inst.retransformClasses(Class.forName("org.su18.MyClass"));
}
}
这里可以看到和 premain 的区别在于,我们在 addTransformer 的参数中指定了 true,而且使用了 retransformClasses 重新加载了指定的类。
然后我们再编写 AttachTest 类用来将我们的程序 attach 进去。
package org.su18;import com.sun.tools.attach.*;
import java.io.IOException;
import java.util.List;
public class AttachTest {
public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException,
AgentInitializationException, InterruptedException {
// 获取正在运行 JVM 列表
List<VirtualMachineDescriptor> list = VirtualMachine.list();
// 遍历列表
for (VirtualMachineDescriptor descriptor : list) {
// 根据进程名字获取进程ID, 并使用 loadAgent 注入进程
if (descriptor.displayName().endsWith("MyClass")) {
VirtualMachine virtualMachine = VirtualMachine.attach(descriptor.id());
virtualMachine.loadAgent("/Users/phoebe/IdeaProjects/AgentTest/target/AgentTest-1.0.jar", "arg1");
virtualMachine.detach();
}
}
}
}
别忘了修改 MANIFEST.MF 文件:Agent-Class: org.su18.AgentMain
。
然后同样进行打包,如果找不到 tools 的话可以指定 classpath,还有一种简单粗暴的方式:
<dependency>
<groupId>com.sun</groupId>
<artifactId>tools</artifactId>
<version>1.8.0</version>
<scope>system</scope>
<systemPath>/Library/Java/JavaVirtualMachines/jdk1.8.0_131.jdk/Contents/Home/lib/tools.jar</systemPath>
</dependency>
打包后先运行 MyClass,然后运行 AttachTest 进行注入,可看到效果。
可以看到,使用 attach 进行附加进程的方式可以在程序无需重启的情况下进行注入和修改,是更加方便的方式,两种方式可以看情况选择。
但是使用 attach 方式进行进程注入时,需要注意的点为:
java agent 中的所有依赖,在原进程中的 classpath 中都要能找到,否则在注入时原进程会报错NoClassDefFoundError。
java agent 的 pom 文件中包含如下内容,以在 jar 包中包含 MANIFEST.MF 并设置 Agent-Class 和 Can-Retransform-Classes 属性。
agent 进程的 classpath 中必须有 tools.jar(提供 VirtualMachine attach api ),jdk 默认有 tools.jar,jre 默认没有。
如果我们需要在虚拟机启动之后来加载某些 jar 进入 bootclasspath,可以使用 appendToBootstrapClassLoaderSearch/appendToSystemClassLoaderSearch 方法进行动态添加,或使用配置文件在 agent 启动时进行添加。
由于博主发量有限,这部分暂不涉及。
以上案例中,我们均使用了 retransform 来重新进行类加载,而 Instrumentation 还提供了 redefine,这两者有什么异同呢?
以下节选自参考文章:Java 5就提供了 Class Redifine 的能力,而 Java 6 才支持 Class Retransform ,可以认为 Retransform 是 Redifine 的一种升级版本,更加方便使用,两者能实现的功能是一致的,只是调用方式有些区别。
参考链接
https://javasec.org/javase/JavaAgent/
https://www.cnblogs.com/yelao/p/9841810.html
http://throwable.coding.me/2019/06/29/java-understand-instrument-first/
https://blog.csdn.net/warren288/article/details/82828989
https://www.ibm.com/developerworks/cn/java/j-lo-jse61/index.html
https://github.com/anbai-inc/javaweb-expression
https://javaweb.org/?p=1862
凌天实验室,是安百科技旗下针对应用安全领域进行攻防研究的专业技术团队,其核心成员来自原乌云创始团队及社区知名白帽子,团队专业性强、技术层次高且富有实战经验。实验室成立于2016年,发展至今团队成员已达35人,在应用安全领域深耕不辍,向网络安全行业顶尖水平攻防技术团队的方向夯实迈进。