类的加载机制
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中对类变量进行初始值设定有两种方式:
- 假如这个类还没有被加载和连接,则程序先加载并连接该类
- 假如该类的直接父类还没有被初始化,则先初始化其直接父类
-
触发类初始化的时机
只有当对类的主动使用的时候才会导致类的初始化,类的主动使用包括以下六种:
使用new关键字实例化对象的时候。
- 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候。
- 调用一个类型的静态方法的时候。
- 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
- 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
以下几种情况不会执行类初始化
- 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
- 定义对象数组,不会触发该类的初始化。
- 常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类。
- 通过类名获取 Class 对象,不会触发类的初始化。
- 通过
Class.forName
加载指定类时,如果指定参数initialize
为false
时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化。 - 通过
ClassLoader
默认的loadClass
方法,也不会触发初始化动作。使用
类访问方法区内的数据结构的接口, 对象是Heap区的数据。卸载
Java虚拟机将结束生命周期的几种情况
- 执行了
System.exit()
方法 - 程序正常执行结束
- 程序在执行过程中遇到了异常或错误而异常终止
-
类加载器
什么是类加载器
虚拟机设计团队把类加载阶段中的通过一个类的全限定名来获取描述此类的二进制字节流这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。 实现这个动作的代码模块称为类加载器。
一切的Java类都必须经过JVM加载后才能运行,而ClassLoader
的主要作用就是Java类文件的加载。
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机的唯一性,每个类加载器都拥有一个独立的类命名空间。也就是说:比较两个类是否「相等」,要在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。类加载器的层次
从Java虚拟机的角度来讲,只存在两种不同的类加载器: 一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的一部分,负责加载JDK中的核心类库,类似于操作系统启动时的boot loader
- 另一种就是所有其他的类加载器,这些类加载器都由Java语言实现,独立于虚拟机外部,并且全都继承自抽象类
java.lang.ClassLoader
。
从Java开发人员的角度来看,类加载器还可以划分得更细致一些,绝大部分Java程序都会使用到以下3种系统提供的类加载器:
启动类加载器(Bootstrap ClassLoader)
- 启动类加载器是最底层的类加载器,是JVM的一部分,它是由C++语言实现的,且没有父加载器,也没有继承
java.lang.ClassLodaer
类。 - 这个类加载器负责将存放在
<JAVA_HOME>/lib
和<JAVA_HOME>/jre/lib
目录中的,或者被-Xbootclasspath
参数所指定的路径中的,并且是虚拟机识别的类库加载到虚拟机内存中。(按照文件名识别,如tools.jar
、rt.jar
,名字不符合的类库即使放在lib目录中也不会被加载) - 处于安全考虑,根类加载器只加载
java
、javax
、sun
开头的类。
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
}
}
我们的应用程序都是由这3种类加载器互相配合进行加载的,如果有必要,还可以加入自己定义的类加载器。
注意
某些时候我们获取一个类的类加载器时候可能会返回一个null
值,如:java.io.File.class.getClassLoader()
将返回一个null
对象,因为java.io.File
类在JVM初始化的时候会被Bootstrap ClassLoader(引导类加载器)
加载(该类加载器实现于JVM层,采用C++编写),我们在尝试获取被Bootstrap ClassLoader
类加载器所加载的类的ClassLoader
时候都会返回null
。
import java.io.File;
public class TestClassLoader {
public static void main(String[] args) {
System.out.println(File.class.getClassLoader());
}
}
类加载的几种方式
Java类加载方式分为显式
和隐式
,显式
即我们通常使用Java反射
或者ClassLoader
来动态加载一个类对象,而隐式
指的是类名.方法名()
或new
类实例。显式
类加载方式也可以理解为类动态加载,我们可以自定义类加载器去加载任意的类。
- 命令行启动应用时候由JVM初始化加载
- 通过
Class.forName()
方法动态加载 - 通过
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()
和ClassLoader.loadClass()
的区别
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,程序的修改才会生效。双亲委派机制
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。类的双亲委派机制
上图展示的类加载器之间的这种层次关系,称为类加载器的双亲委派模型(Parents Delegation Model)。
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。 这里类加载器之间的父子关系一般不会以继承(Inheritance)的关系来实现,而是都使用组合(Composition)关系来复用父加载器的代码。类加载器的双亲委派模型在JDK1.2期间被引入并被广泛应用于之后几乎所有的Java程序中,但它并不是一个强制性的约束模型,而是Java设计者推荐给开发者的一种类加载器实现方式.
双亲委派机制的工作流程
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
举例:
- 当
AppClassLoader
加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader
去完成。 - 当
ExtClassLoader
加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader
去完成。 - 如果
BootStrapClassLoader
加载失败(例如在$JAVA_HOME/jre/lib
里未查找到该class),会使用ExtClassLoader
来尝试加载; - 若
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
函数中
由此也可看出,我们如果要自定义一个类加载器,那么需要重写的就是**findClass()**
方法,而不是**loadClass()**
方法
类加载器的核心方法
loadClass
(加载指定的Java类)findClass
(查找指定的Java类)findLoadedClass
(查找JVM已经加载过的类)defineClass
(定义一个Java类)resolveClass
(链接指定的Java类)自定义类加载器
通常情况下,我们都是直接使用系统类加载器。但是有的时候,我们也需要自定义类加载器。比如应用是通过网络来传输Java类的字节码,为保证安全性,这些字节码经过了加密处理,这时系统类加载器就无法对其进行加载,这样则需要自定义类加载器来实现。利用自定义类加载器我们可以在webshell中实现加载并调用自己编译的类对象,比如本地命令执行漏洞调用自定义类字节码的native方法绕过RASP检测,也可以用于加密重要的Java类字节码(只能算弱加密了)。
自定义类加载器一般都是继承自**ClassLoader**
类,从上面对loadClass
方法来分析来看,我们只需要重写 findClass
方法即可。
注意:
- 这里传递的文件名需要是类的全限定性名称,即
com.test.Test
格式的,因为defineClass
方法是按这种格式进行处理的。 - 最好不要重写
loadClass
方法,因为这样容易破坏双亲委托模式。 - 这类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
加载这个classimport java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
public class TestClassLoader extends ClassLoader {
/**
* 重写了findClass方法
*/
@Override
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
URLClassLoader
继承了ClassLoader
,URLClassLoader
提供了加载远程资源的能力,在写漏洞利用的payload
或者webshell
的时候我们可以使用这个特性来加载远程的jar来实现远程的类方法调用。远程类我们还是使用我们自定义类加载器中编译的
Exploit.class
吧 下方命令给class打包成jar,也可以直接压缩为zip再改后缀
jar cvf Exploit.jar Exploit.class
编写远程加载jar代码
```java
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();
}
}