0x00 前言
Java反序列化基础篇的最后一篇,断断续续学习Java反序列化基础快一个月了,所幸学的都懂,基础还行。
参考教程:
Java类加载机制和对象创建过程 - 个人文章 - SegmentFault 思否
0x01 类加载
引子
学习一个事物,首先要大致了解这个事物的概念和作用,然后在学习中,从原理层面去具体分析其概念和作用。
类加载的概念是什么呢?先看下图,这一张图给出了两个层面上的类加载概念:

利用这张图片举个例子,我们先实例化一个Car对象car:
Car car = new Car();
首先,JVM利用Class Loader(类加载器)加载Car.class文件,从而得到Car类的Class对象,最后才能把Class实例化(new)为car。
上述的内容还是不够深入,我们再深入思考一下:
字节码与.class文件
字节码
- 概念
字节码是程序的一种低级表示,可以运行于Java虚拟机上。将程序抽象成字节码可以保证Java程序在各种设备上的运行,是Java跨平台特性的一个重要组成部分。
.class文件
- 概念
.class 文件是 Java 编程语言编译器生成的字节码文件。它是 Java 源代码(通常是 .java 文件)经过编译后生成的中间形式,是虚拟机可以直接执行的文件格式。
通过javac命令可以将.java文件 编译为.class文件:
javac MyClass.java
然后使用java命令运行.class文件,JVM就会加载.class文件,并解释或即时编译为机器码后执行:
java MyClass
- 作用
.class 文件是 Java 类加载过程中的核心,其直接影响类加载器的工作。Java 的 类加载机制 是通过加载 .class 文件,将其内容解析并转换为 JVM 能识别的结构,最终在运行时实例化和使用类。
.class 文件是 Java 实现跨平台性、模块化和高效性的核心。它的存在让 Java 程序可以不受操作系统和硬件限制,同时享有良好的安全性和运行性能,是 Java 的一个重要特性。
加载过程
类加载流程如下二图:

1 | .class 文件 |
下面简单谈谈这几个过程的作用:
加载(Loading)
在这一阶段,类加载器(
ClassLoader)根据类的全限定名找到.class文件,并读取其字节内容,转化为 JVM 可以识别的数据结构。
- 定位.class文件
- 读取.class文件的字节流,将其加载到内存
- 最后创建一个代表该类的Class对象(类型是
java.lang.Class)
简单来说,就是把.class文件里静态的字节码数据转换为JVM内部的Class对象。
验证,准备、解析、初始化
验证(Verification)
验证阶段是为了确保加载的 .class 文件符合 JVM 的规范,保证代码的安全性和稳定性。
准备(Preparation)
在这一阶段,JVM 为类的 静态变量 分配内存并设置默认值(如 int 默认值为 0,reference 默认值为 null)。
解析(Resolution)
解析阶段是将 .class 文件中的 符号引用 转换为 直接引用 的过程。
初始化(Initialization)
这是类加载的最后一步,在这一阶段,JVM 会执行类的静态代码块(static {})以及对静态变量的显式赋值。
类加载的概念
细心的读者可能在思考本文出现了两个加载的含义:
- 广义的类加载
广义的类加载包含以下五个阶段:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution) 和 初始化(Initialization)。这五个阶段共同完成了一个类从磁盘上的 .class 文件到 JVM 中可用的完整生命周期过程。
- 狭义的类加载
狭义的类加载仅指 加载(Loading) 阶段,也就是从 .class 文件中读取字节码并生成 Class 对象的过程。
终于在花费了许久时间之后,我们知道了类加载是什么。
0x02 类加载器
在上一大节我们学习了类加载的概念和作用,现在我们要深入了解类加载具体的实现。
讨论类加载器,主要是对狭义类加载(也就是广义类加载的加载(Loading)阶段)进行讨论。
概述
先看看官方API文档的介绍:
类加载器是一个负责加载类的对象。
ClassLoader是一个抽象类。给定类的二进制名称,类加载器应尝试定位或生成构成类定义的数据。典型的策略是将名称转换为文件名,然后从文件系统中读取该名称的“类文件”。每个 Java 类都有一个引用指向加载它的
ClassLoader。不过,数组类不是通过ClassLoader创建的,而是 JVM 在需要的时候自动创建的,数组类通过getClassLoader()方法获取ClassLoader的时候和该数组的元素类型的ClassLoader是一致的。
提取一下内容:
- 类加载器是一个负责加载类的对象,用于实现类加载过程中的加载这一步。
- 每个 Java 类都有一个引用指向加载它的
ClassLoader。 - 数组类不是通过
ClassLoader创建的(数组类没有对应的二进制字节流),是由JVM直接生成的。
简单来说,类加载器的功能就是把Java类的字节码(.class文件)到JVM中(在内存中生成一个代表该类的Class对象)。
你品,你细品,这不就是狭义类加载的概念和作用吗!所以我们这样认为:
类加载器(Class Loader)用于实现类加载过程中的加载(Loading)这一步。
分类
JVM 中内置了三个重要的 ClassLoader:
- BootstrapClassLoader(启动类加载器):
最顶层的加载类,由 C++实现,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库( %JAVA_HOME%/lib目录下的 rt.jar、resources.jar、charsets.jar等 jar 包和类)以及被 -Xbootclasspath参数指定的路径下的所有类。
- ExtensionClassLoader(扩展类加载器):
主要负责加载 %JRE_HOME%/lib/ext 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类。
- AppClassLoader(应用程序类加载器):
面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类,用户自定义的类一般会被该类加载器加载。
🌈 拓展一下:
- **
rt.jar**:rt 代表“RunTime”,rt.jar是 Java 基础类库,包含 Java doc 里面看到的所有的类的类文件。也就是说,我们常用内置库java.xxx.*都在里面,比如java.util.*、java.io.*、java.nio.*、java.lang.*、java.sql.*、java.math.*。- Java 9 引入了模块系统,并且略微更改了上述的类加载器。扩展类加载器被改名为平台类加载器(platform class loader)。Java SE 中除了少数几个关键模块,比如说
java.base是由启动类加载器加载之外,其他的模块均由平台类加载器所加载。
除此之外,我们还可以自定义类加载器来满足自身的特殊需求。
这么多类加载器,我们在进行类加载时JVM会选择哪一个进行类加载呢?这就涉及到Java的双亲委派机制了。
流程
0x03 双亲委派机制
先看下图:
从图中可以看出,BootstrapClassLoader 是 ExtensionClassLoader 的父加载器,ExtensionClassLoader 是 AppClassLoader 的父加载器。
如果有个类需要被加载,JVM 首先会检查该类是否已经被加载——通常通过当前类加载器的缓存或父加载器的缓存来验证。如果该类没有被加载过,类加载请求会从当前加载器向其父加载器递归委派,即先由父加载器尝试加载,再到当前加载器。如果父加载器如果能够加载这个类,则直接返回结果;如果父加载器无法加载,才由当前类加载器加载。
为什么需要双亲委派机制呢?我们可以做个尝试,修改系统级别的类:String.java:

我们明明定义了main方法,为什么这样报错呢?
因为根据双亲委派机制,String进行类加载时,加载请求首先由 BootstrapClassLoader 处理,而该类加载器正好可以加载String类且加载的路径是系统的核心类库,而非我们自定义的类。
到此,我们就大致了解了双亲委派机制,该机制是 Java安全性的一个重要组成部分。通过这一机制,Java 能避免核心类库被篡改的问题。
0x04 代码加载顺序
概述 · 引入
本节主要谈论下面四种代码块:
- 静态代码块:
static{ } - 构造代码块:
{ } - 无参构造器:
ClassExample(){ } - 有参构造器:
ClassExample(String string){ }
给出一个例子:Person.class
1 | package com.pax.UnserializeTestOne; |
下面给出五个场景:
场景一:实例化对象

当使用new关键字实例化对象,先调用静态代码块,再调用构造代码块,最后再调用对应的构造器。
接着往下看
场景二:调用静态方法

不实例化对象,所以没有调用构造代码块和构造器,只调用了静态代码块和静态方法。这其中有什么规律吗?我们接着往下看
场景三:赋值静态成员变量

只调用了静态代码块,为什么一直调用它呢?接着往下看
场景四:使用 class 获取类

利用 class 关键字获取类,甚至连静态代码块都没有,是为什么呢?(其实上文有答案)再往下看:
场景五:使用 forName 获取类
forName方法可访问的重载有两种:

下面给出三种情况:
1 | package com.pax.UnserializeTestOne; |
这里实际上只输出一次静态代码块,因为Person类加载过一次就不会再被加载了。
是类被加载时还是初始化时,才会调用静态代码块呢(上文有答案)?关键就在于forName方法第二个参数,跟进看看:

方法签名写了:第二个参数是一个布尔值,表示类是否要初始化。
所以我们终于得到了答案:当一个类被初始化后,就会调用静态代码块,及之后需要调用的代码块。
这是类加载的最后一步,在这一阶段,
JVM会执行类的静态代码块(static {})以及对静态变量的显式赋值。
所以,我们通过学习中对原理层面进行分析,验证了我们前面所学的内容,进一步加深了印象 !
小结 · 深化
前面几个场景(场景一、二、三)都调用了静态代码块,正是因为该场景下的Person类初始化了,前文也说了,只要进行类加载,其最后一步就是类的初始化。
现在不妨思考一下,为什么场景四没有调用静态代码块呢,是不是Person类没有进行初始化呢?
没错,利用 class 关键字获取类,不会触发类的初始化,这是因为通过 class 关键字获取的是类的 Class 对象的引用,所以如果该类尚未加载,可能会触发该类的加载,但是不会执行该类的初始化。
0x05 动态加载机制
讲了很久,终于到动态加载了。欲知动态,必知静态:
- 编译时加载类:静态加载类
- 运行时加载类:动态加载类
举一个例子说明静态加载类:
1 | Student student = new Student(); |
编译时就确定了需要加载的类是Student。
动态加载类
给出接口、以及实现接口的两个类:
接口:Person
1 | package com.pax.UnserializeTestOne; |
Teacher类:
1 | package com.pax.UnserializeTestOne; |
Student类:
1 | package com.pax.UnserializeTestOne; |
执行一下:

上面不难看出动态加载类的灵活性。
0x06 与Java反序列化
这在大节,我们需要讨论Class、字节码与Java安全的关系,我们的目的很纯粹,就是要加载.class文件——也就是字节码文件,进而得到Class对象。
URLClassLoader 加载远程 class 文件
概述
URLClassLoader 是 AppClassLoader 的父类,所以我们分析一下 URLClassLoader 的工作过程,实际上也是在分析默认的 Java 类加载器的工作流程。
正常情况下,Java会根据配置项 sun.boot.class.path 和 java.class.path 中列举到的基础路径(这些路径是经过处理后的 java.net.URL 类)来寻找.class文件来加载,而这个基础路径有分为三种情况:
- URL不以斜杠结尾,认为是一个JAR文件,使用
JarLoader来寻找类,即在Jar包中寻找.class文件 - URL以斜杠结尾,且协议名是
file,使用FileLoader来寻找类,即在本地文件系统中寻找.class文件 - URL以斜杠 / 结尾,且协议名不是
file,使用最基础的Loader来寻找类。
下面一个个进行分析:
file协议
先写一个Calc.java文件(内容如下),进行编译后放到E盘:
1 | package com.pax.UnserializeTestOne; |
Main执行一下,成功弹出计算器:

重点是这一行代码:
Class calc = urlClassLoader.loadClass("com.pax.UnserializeTestOne.Calc");
代码解释:
这行代码创建了一个 URLClassLoader 对象,能够从 E盘加载类和资源文件。
HTTP协议
有点麻烦,在Java项目根目录起一个Calc.class,该文件的全限定名必须是Calc,而不是com.pax.xxxxxx.Calc。然后复制到E盘,接着在E盘起个HTTP服务:
1 | py -m http.server 7777 --bind 127.0.0.1 |
然后Main()执行以下代码,成功弹出计算器:

file/HTTP+jar 协议
将源目录下(不是E盘)的文件Calc.class打包为jar文件:
1 | jar -cvf Calc.jar Clac.class |
再复制到E盘,对于HTTP要再起一次服务
Main()执行代码只需要变成文件路径:
file+jar:
1 | URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{new URL("jar:file:///E:\\Calc.jar!/")}); |
HTTP+jar:
1 | URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{new URL("jar:http://127.0.0.1:9999/Calc.jar!/")}); |
ClassLoader#defineClass 直接加载 class 文件
当一个类需要加载时,``loadClass()
的作用是从已加载的类、父加载器位置寻找类,(即双亲委派机制),在前面没有找到的情况下,调用当前ClassLoader的findClass()`方法;findClass()根据URL指定的方式来加载类的字节码,其中会调用defineClass();defineClass的作用是处理前面传入的字节码,将其处理成真正的 Java 类
那就看看这个方法吧:

name为类名,b为字节码数组,off为偏移量,len为字节码数组的长度。
基于系统的 ClassLoader#defineClass 是一个保护属性,无法直接在外部访问。因此可以反射调用 defineClass() 方法进行字节码的加载,然后实例化之后即可弹 shell:

ClassLoader#defineClass直接加载字节码的优点:不需要出网也可以加载字节码,缺点:需要设置m.setAccessible(true);,这在平常的反射中往往无法调用
在实际场景中,因为
defineClass方法作用域是不开放的,所以攻击者很少能直接利用到它,但它却是我们常用的一个攻击链TemplatesImpl的基石。
Unsafe 加载 class 文件
Unsafe中也存在defineClass()方法,本质上也是 defineClass 加载字节码的方式。先看看该方法:

这里不能直接调用该方法,还是采用反射:

进阶
读者可以自行了解一下: TemplatesImpl 加载字节码和利用 BCEL ClassLoader 加载字节码
相关教程:Java反序列化基础篇-05-类的动态加载 | Drunkbaby’s Blog
0x07 结语
希望读者能有收获。