0x01 JVM安全问题
0x02 正文剖析讲解
类的加载机制
Java 是一个依赖于 JVM(Java 虚拟机)实现的跨平台的开发语言。Java 程序在运行前需要先编译成 class 文件,Java 类初始化的时候会调用 java.lang.ClassLoader 加载类字节码,ClassLoader 会调用 JVM 的 native 方法(defineClass0/1/2)来定义一个 java.lang.Class 实例。Java 虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这个过程被称作虚拟机的类加载机制。
类的生命周期
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载,校验,准备,解析,初始化,使用,卸载这 7 个阶段.其中其中验证、准备、解析 3 个部分统称为链接。
加载、校验、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 语言的运行时绑定特性(也称为动态绑定或晚期绑定)注意,这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。
在加载阶段,虚拟机需要完成以下 3 件事情:
●1)通过一个类的全限定名来获取定义此类的二进制字节流。
●2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
●3)在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
验证是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致会完成 4 个阶段的检验动作:
● 文件格式验证: 验证字节流是否符合 Class 文件格式的规范;例如: 是否以 0xCAFEBABE 开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。
● 元数据验证:对字节码描述的信息进行语义分析(注意: 对比 javac 编译阶段的语义分析),以保证其描述的信息符合 Java 语言规范的要求;例如: 这个类是否有父类,除了 java.lang.Object 之外。
● 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
● 符号引用验证:确保解析动作能正确执行。
验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用-Xverifynone 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。
该阶段的注意事项:
● 这时候进行内存分配的仅包括类变量(被 static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。
● 这里所设置的初始值通常情况下是数据类型默认的零值(如 0、0L、null、false 等),而不是被在 Java 代码中被显式地赋予的值。
比如:假设一个类变量的定义为: public static int value = 3;那么变量 value 在准备阶段过后的初始值为 0,而不是 3,因为这时候尚未开始执行任何 Java 方法,而把 value 赋值为 3 的 put static 指令是在程序编译后,存放于类构造器()方法之中的,所以把 value 赋值为 3 的动作将在初始化阶段才会执行。
● 对基本数据类型来说,对于类变量(static)和全局变量,如果不显式地对其赋值而直接使用,则系统会为其赋予默认的零值,而对于局部变量来说,在使用前必须显式地为其赋值,否则编译时不通过。
● 对于同时被 static 和 final 修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过;而只被 final 修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,总之,在使用前必须为其显式地赋值,系统不会为其赋予默认零值。
● 对于引用数据类型 reference 来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值,即 null。
● 如果在数组初始化时没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的零值。
● 如果类字段的字段属性表中存在 ConstantValue 属性,即同时被 final 和 static 修饰,那么在准备阶段变量 value 就会被初始化为 ConstValue 属性所指定的值。
假设上面的类变量 value 被定义为: public static final int value = 3;编译时 Javac 将会为 value 生成 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值为 3。我们可以理解为 static final 常量在编译期就将其结果放入了调用它的类的常量池中
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行。符号引用就是一组符号来描述目标,可以是任何字面量。
直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
初始化,为类的静态变量赋予正确的初始值,JVM 负责对类进行初始化,主要对类变量进行初始化。在 Java 中对类变量进行初始值设定有两种方式:
1 声明类变量是指定初始值
2 使用静态代码块为类变量指定初始值
● 假如这个类还没有被加载和连接,则程序先加载并连接该类
● 假如该类的直接父类还没有被初始化,则先初始化其直接父类
● 假如类中有初始化语句,则系统依次执行这些初始化语句
只有当对类的主动使用的时候才会导致类的初始化,类的主动使用包括以下六种:
● 使用 new 关键字实例化对象的时候。
● 读取或设置一个类型的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)的时候。
● 调用一个类型的静态方法的时候。
● 使用 java.lang.reflect 包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
● 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
● 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main()方法的那个类),虚拟机会先初始化这个主类。
1 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
2 定义对象数组,不会触发该类的初始化。
3 常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类。
4 通过类名获取 Class 对象,不会触发类的初始化。
5 通过 Class.forName 加载指定类时,如果指定参数 initialize 为 false 时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化。
6 通过 ClassLoader 默认的 loadClass 方法,也不会触发初始化动作。
类访问方法区内的数据结构的接口, 对象是 Heap 区的数据。
Java 虚拟机将结束生命周期的几种情况
● 执行了 System.exit()方法
● 程序正常执行结束
● 程序在执行过程中遇到了异常或错误而异常终止
● 由于操作系统出现错误而导致 Java 虚拟机进程终止
虚拟机设计团队把类加载阶段中的通过一个类的全限定名来获取描述此类的二进制字节流这个动作放到 Java 虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为类加载器。
一切的 Java 类都必须经过 JVM 加载后才能运行,而 ClassLoader 的主要作用就是 Java 类文件的加载。
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机的唯一性,每个类加载器都拥有一个独立的类命名空间。也就是说:比较两个类是否「相等」,要在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
public class TestClassLoader {
public static void main(String[] args) {
System.out.println(java.lang.String.class.getClassLoader()); // null
}
}
扩展类加载器(Extension ClassLoader)
● 这个加载器由 sun.misc.Launcher$ExtClassLoader 实现,它负责加载 <JAVA_HOME>/lib/ext 和 <JAVA_HOME>/jre/lib/ext 目录中的,或者被 java.ext.dirs 系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
public class TestClassLoader {
public static void main(String[] args) {
System.out.println(com.sun.nio.zipfs.ZipFileStore.class.getClassLoader()); // sun.misc.Launcher$ExtClassLoader@6bc168e5
}
}
应用程序类加载器(Application ClassLoader)这个类加载器由 sun.misc.Launcher$AppClassLoader 来实现。由于应用程序类加载器是 ClassLoader 类中的 getSystemClassLoader()方法的返回值,所以有些场合中也称它为“系统类加载器”。
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
它负责加载用户类路径(ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
public class TestClassLoader {
public static void main(String[] args) {
System.out.println(TestClassLoader.class.getClassLoader()); // sun.misc.Launcher$AppClassLoader@18b4aac2
}
}
import java.io.File;
public class TestClassLoader {
public static void main(String[] args) {
System.out.println(File.class.getClassLoader());
}
}
Java 类加载方式分为显式和隐式,显式即我们通常使用 Java 反射或者 ClassLoader 来动态加载一个类对象,而隐式指的是类名.方法名()或 new 类实例。显式类加载方式也可以理解为类动态加载,我们可以自定义类加载器去加载任意的类。
1 命令行启动应用时候由 JVM 初始化加载
2 通过 Class.forName()方法动态加载
3 通过 ClassLoader.loadClass()方法动态加载
public class TestClassLoader {
public static void main(String[] args) throws ClassNotFoundException {
// 默认会执行初始化静态代码块
Class.forName("Test");
// 使用应用程序类加载器来加载类Test,不会执行初始化静态代码块
ClassLoader appClassLoader = ClassLoader.getSystemClassLoader();
appClassLoader.loadClass("Test");
//forName指定了classLoader,initialize为false不会执行初始化静态代码块,为true则会执行
Class.forName("Test", false, appClassLoader);
}
}
class Test {
static {
System.out.println("静态方法被执行了");
}
}
●Class.forName(): 将类的.class 文件加载到 jvm 中之外,还会对类进行解释,执行类中的 static 块;
●ClassLoader.loadClass(): 只干一件事情,就是将.class 文件加载到 jvm 中,不会执行 static 中的内容,只有在 newInstance()才会去执行 static 块;
●Class.forName(name, initialize, loader)带参函数也可控制是否加载 static 块。并且只有调用了 newInstance()方法采用调用构造函数,创建类的对象。
JVM 类加载机制
当一个类加载器负责加载某个 Class 时,该 Class 所依赖的和引用的其他 Class 也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入。
先让父类加载器试图加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类。
缓存机制将会保证所有加载过的 Class 都会被缓存,当程序中需要使用某个 Class 时,类加载器先从缓存区寻找该 Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成 Class 对象,存入缓存区。这就是为什么修改了 Class 后,必须重启 JVM,程序的修改才会生效。
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承(Inheritance)的关系来实现,而是都使用组合(Composition)关系来复用父加载器的代码。
类加载器的双亲委派模型在 JDK1.2 期间被引入并被广泛应用于之后几乎所有的 Java 程序中,但它并不是一个强制性的约束模型,而是 Java 设计者推荐给开发者的一种类加载器实现方式.
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
举例:
1 当 AppClassLoader 加载一个 class 时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器 ExtClassLoader 去完成。
2 当 ExtClassLoader 加载一个 class 时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给 BootStrapClassLoader 去完成。
3 如果 BootStrapClassLoader 加载失败(例如在 $JAVA_HOME/jre/lib 里未查找到该 class),会使用 ExtClassLoader 来尝试加载;
4 若 ExtClassLoader 也加载失败,则会使用 AppClassLoader 来加载,如果 AppClassLoader 也加载失败,则会报出异常 ClassNotFoundException。
代码举例:
/**
* 输出结果:
* sun.misc.Launcher$AppClassLoader@18b4aac2
* sun.misc.Launcher$ExtClassLoader@61064425
* null
*/
public class TestClassLoader {
public static void main(String[] args) {
ClassLoader loader= TestClassLoader.class.getClassLoader();
while(loader!=null){
System.out.println(loader);
loader=loader.getParent();
}
System.out.println(loader);
}
}
使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。
例如类 java.lang.Object,它存放在 rt.jar 之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为 java.lang.Object 的类,并放在程序的 ClassPath 中,那系统中将会出现多个不同的 Object 类,Java 类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。
所以它的优点
● 系统类防止内存中出现多份同样的字节码
● 保证 Java 程序安全稳定运行
代码实现主要在 ClassLoader 类的 loadClass 函数中
1loadClass(加载指定的 Java 类)
2findClass(查找指定的 Java 类)
3findLoadedClass(查找 JVM 已经加载过的类)
4defineClass(定义一个 Java 类)
5resolveClass(链接指定的 Java 类)
通常情况下,我们都是直接使用系统类加载器。但是有的时候,我们也需要自定义类加载器。比如应用是通过网络来传输 Java 类的字节码,为保证安全性,这些字节码经过了加密处理,这时系统类加载器就无法对其进行加载,这样则需要自定义类加载器来实现。
利用自定义类加载器我们可以在 webshell 中实现加载并调用自己编译的类对象,比如本地命令执行漏洞调用自定义类字节码的 native 方法绕过 RASP 检测,也可以用于加密重要的 Java 类字节码(只能算弱加密了)。
自定义类加载器一般都是继承自 ClassLoader 类,从上面对 loadClass 方法来分析来看,我们只需要重写 findClass 方法即可。
注意:
1 这里传递的文件名需要是类的全限定性名称,即 com.test.Test 格式的,因为 defineClass 方法是按这种格式进行处理的。
2 最好不要重写 loadClass 方法,因为这样容易破坏双亲委托模式。
3 这类 Test 类本身可以被 AppClassLoader 类加载,因此我们不能把 com/test/Test 放在类路径下。否则,由于双亲委托机制的存在,会直接导致该类由 AppClassLoader 加载,而不会通过我们自定义类加载器来加载。
举例:此处我通过本地 class 文件的字节码来加载 class
● 需要加载的 class 源码
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
public class Exploit{
public Exploit() throws Exception {
Process p = Runtime.getRuntime().exec(new String[]{"open", "-na", "Calculator"});
InputStream is = p.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(is));
String line;
while((line = reader.readLine()) != null) {
System.out.println(line);
}
p.waitFor();
is.close();
reader.close();
p.destroy();
}
public static void main(String[] args) throws Exception {
}
}
编译成 class 文件
javac Exploit.java
JVM 执行的其实就是 javap 命令生成的字节码(ByteCode)。
编写 TestClassLoader 加载这个 class
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
public class TestClassLoader extends ClassLoader {
/**
* 重写了findClass方法
*/
protected Class findClass(String name) throws ClassNotFoundException {
byte[] bytes = new byte[0];
try {
bytes = loadClassData();
} catch (IOException e) {
e.printStackTrace();
}
if (bytes == null) {
throw new ClassNotFoundException(name);
} else {
return defineClass("Exploit", bytes, 0, bytes.length);
}
}
/**
* 给class文件以字节码的形式返回
*/
private byte[] loadClassData() throws IOException {
String fileName = "/Users/d4m1ts/d4m1ts/tools/exp/exphub/fastjson/Exploit.class";
try {
InputStream ins = new FileInputStream(fileName);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 1024;
byte[] buffer = new byte[bufferSize];
int length = 0;
while ((length = ins.read(buffer)) != -1) {
baos.write(buffer, 0, length);
}
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {
TestClassLoader testClassLoader = new TestClassLoader();
// loadClass的时候上层的ClassLoader都找不到对应的类,所以会调用它自己的findClass去加载类
Class test = testClassLoader.loadClass("Exploit");
System.out.println(test.getClassLoader());
// 申请实例
test.newInstance();
}
}
URLClassLoader 继承了 ClassLoader,URLClassLoader 提供了加载远程资源的能力,在写漏洞利用的 payload 或者 webshell 的时候我们可以使用这个特性来加载远程的 jar 来实现远程的类方法调用。
远程类我们还是使用我们自定义类加载器中编译的 Exploit.class
jar cvf Exploit.jar Exploit.class
编写远程加载 jar 代码
import java.io.IOException;
import java.net.URL;
import java.net.URLClassLoader;
public class TestClassLoader {
public static void main(String[] args) throws IOException, ClassNotFoundException, IllegalAccessException, InstantiationException {
// 也可以搭建个web服务器用http协议来远程加载
URL url = new URL("file:/Users/d4m1ts/d4m1ts/tools/exp/exphub/fastjson/Exploit.jar");
URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{url});
Class exploit = urlClassLoader.loadClass("Exploit");
exploit.newInstance();
}
}
0x03 总结
0x05 知识星球
原文始发于微信公众号(狐狸说安全):浅谈JVM类加载机制与安全性问题
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论