Java反序列化基础篇-05-类的动态加载
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 结语
希望读者能有收获。