0x00 前言

Java反序列化基础篇的最后一篇,断断续续学习Java反序列化基础快一个月了,所幸学的都懂,基础还行。

参考教程:

类的动态加载

Java类加载机制和对象创建过程 - 个人文章 - SegmentFault 思否

Java反序列化基础篇-05-类的动态加载

类加载器详解(重点)

通俗易懂的双亲委派机制-CSDN博客

干货分享:一文让你入门 Java 字节码! - 知乎

什么是Java字节码?_字节码怎么产生的-CSDN博客

Java 反射之Class类动态加载类的用法

0x01 类加载

引子

学习一个事物,首先要大致了解这个事物的概念和作用,然后在学习中,从原理层面去具体分析其概念和作用。

类加载的概念是什么呢?先看下图,这一张图给出了两个层面上的类加载概念:

image-20241123095835349

利用这张图片举个例子,我们先实例化一个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 的一个重要特性。

加载过程

类加载流程如下二图:

image-20241123102227607

1
2
3
4
5
6
7
8
9
10
11
12
13
      .class 文件

【加载(Loading)】

【验证(Verification)】

【准备(Preparation)】

【解析(Resolution)】

【初始化(Initialization)】

类加载完成

下面简单谈谈这几个过程的作用:

加载(Loading)

在这一阶段,类加载器(ClassLoader)根据类的全限定名找到 .class 文件,并读取其字节内容,转化为 JVM 可以识别的数据结构。

  1. 定位.class文件
  2. 读取.class文件的字节流,将其加载到内存
  3. 最后创建一个代表该类的Class对象(类型是java.lang.Class

简单来说,就是把.class文件里静态的字节码数据转换为JVM内部的Class对象。

验证,准备、解析、初始化

验证(Verification)

验证阶段是为了确保加载的 .class 文件符合 JVM 的规范,保证代码的安全性和稳定性。

准备(Preparation)

在这一阶段,JVM 为类的 静态变量 分配内存并设置默认值(如 int 默认值为 0reference 默认值为 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

  1. BootstrapClassLoader(启动类加载器)

​ 最顶层的加载类,由 C++实现,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库( %JAVA_HOME%/lib目录下的 rt.jarresources.jarcharsets.jar等 jar 包和类)以及被 -Xbootclasspath参数指定的路径下的所有类。

  1. ExtensionClassLoader(扩展类加载器)

​ 主要负责加载 %JRE_HOME%/lib/ext 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类。

  1. 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 双亲委派机制

先看下图:

image-20241123144916326从图中可以看出,BootstrapClassLoaderExtensionClassLoader 的父加载器,ExtensionClassLoaderAppClassLoader 的父加载器。

如果有个类需要被加载,JVM 首先会检查该类是否已经被加载——通常通过当前类加载器的缓存或父加载器的缓存来验证。如果该类没有被加载过,类加载请求会从当前加载器向其父加载器递归委派,即先由父加载器尝试加载,再到当前加载器。如果父加载器如果能够加载这个类,则直接返回结果;如果父加载器无法加载,才由当前类加载器加载。

为什么需要双亲委派机制呢?我们可以做个尝试,修改系统级别的类:String.java

image-20241123150517605

我们明明定义了main方法,为什么这样报错呢?

因为根据双亲委派机制,String进行类加载时,加载请求首先由 BootstrapClassLoader 处理,而该类加载器正好可以加载String类且加载的路径是系统的核心类库,而非我们自定义的类。

到此,我们就大致了解了双亲委派机制,该机制是 Java安全性的一个重要组成部分。通过这一机制,Java 能避免核心类库被篡改的问题。

0x04 代码加载顺序

概述 · 引入

本节主要谈论下面四种代码块:

  • 静态代码块:static{ }
  • 构造代码块:{ }
  • 无参构造器:ClassExample(){ }
  • 有参构造器:ClassExample(String string){ }

给出一个例子:Person.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package com.pax.UnserializeTestOne;

public class Person {
public static int staticVar;
public int instanceVar;
static {
System.out.println("静态代码块");
}

{
System.out.println("构造代码块");
}

Person(){
System.out.println("无参构造器");
}
Person(int instanceVar){
System.out.println("有参构造器");
}

public static void staticAction(){
System.out.println("静态方法");
}
}

下面给出五个场景:

场景一:实例化对象

image-20241123153028376

当使用new关键字实例化对象,先调用静态代码块,再调用构造代码块,最后再调用对应的构造器。

接着往下看

场景二:调用静态方法

image-20241123153315596

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

场景三:赋值静态成员变量

image-20241123153530471

只调用了静态代码块,为什么一直调用它呢?接着往下看

场景四:使用 class 获取类

image-20241123153707795

利用 class 关键字获取类,甚至连静态代码块都没有,是为什么呢?(其实上文有答案)再往下看:

场景五:使用 forName 获取类

forName方法可访问的重载有两种:

image-20241123154033952

下面给出三种情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.pax.UnserializeTestOne;

public class Main {
public static void main(String[] args) throws ClassNotFoundException {
Class c1 = Class.forName("com.pax.UnserializeTestOne.Person");
// 静态代码块
Class c2 = Class.forName("com.pax.UnserializeTestOne.Person", true, ClassLoader.getSystemClassLoader());
// 静态代码块
Class c3 = Class.forName("com.pax.UnserializeTestOne.Person", false, ClassLoader.getSystemClassLoader());
// 无输出

}
}

这里实际上只输出一次静态代码块,因为Person类加载过一次就不会再被加载了。

是类被加载时还是初始化时,才会调用静态代码块呢(上文有答案)?关键就在于forName方法第二个参数,跟进看看:

image-20241123155010787

方法签名写了:第二个参数是一个布尔值,表示类是否要初始化。

所以我们终于得到了答案:当一个类被初始化后,就会调用静态代码块,及之后需要调用的代码块。

这是类加载的最后一步,在这一阶段,JVM 会执行类的静态代码块(static {})以及对静态变量的显式赋值。

所以,我们通过学习中对原理层面进行分析,验证了我们前面所学的内容,进一步加深了印象 !

小结 · 深化

前面几个场景(场景一、二、三)都调用了静态代码块,正是因为该场景下的Person类初始化了,前文也说了,只要进行类加载,其最后一步就是类的初始化。

现在不妨思考一下,为什么场景四没有调用静态代码块呢,是不是Person类没有进行初始化呢?

没错,利用 class 关键字获取类,不会触发类的初始化,这是因为通过 class 关键字获取的是类的 Class 对象的引用,所以如果该类尚未加载,可能会触发该类的加载,但是不会执行该类的初始化。

0x05 动态加载机制

讲了很久,终于到动态加载了。欲知动态,必知静态:

  1. 编译时加载类:静态加载类
  2. 运行时加载类:动态加载类

举一个例子说明静态加载类:

1
2
Student student = new Student();
student.getStart();

编译时就确定了需要加载的类是Student

动态加载类

给出接口、以及实现接口的两个类:

接口:Person

1
2
3
4
5
6
7
package com.pax.UnserializeTestOne;

public interface Person {

void getStart();

}

Teacher类:

1
2
3
4
5
6
7
8
9
10
package com.pax.UnserializeTestOne;

public class Teacher implements Person{

@Override
public void getStart() {
System.out.println("Teacher>>" + Teacher.class.toString());
}

}

Student类:

1
2
3
4
5
6
7
8
9
package com.pax.UnserializeTestOne;

public class Student implements Person{

@Override
public void getStart() {
System.out.println("Student>>" + Student.class.toString());
}
}

执行一下:

image-20241123162351843

上面不难看出动态加载类的灵活性。

0x06 与Java反序列化

这在大节,我们需要讨论Class、字节码与Java安全的关系,我们的目的很纯粹,就是要加载.class文件——也就是字节码文件,进而得到Class对象。

URLClassLoader 加载远程 class 文件

概述

URLClassLoaderAppClassLoader 的父类,所以我们分析一下 URLClassLoader 的工作过程,实际上也是在分析默认的 Java 类加载器的工作流程。

正常情况下,Java会根据配置项 sun.boot.class.pathjava.class.path 中列举到的基础路径(这些路径是经过处理后的 java.net.URL 类)来寻找.class文件来加载,而这个基础路径有分为三种情况:

  1. URL不以斜杠结尾,认为是一个JAR文件,使用 JarLoader 来寻找类,即在Jar包中寻找.class文件
  2. URL以斜杠结尾,且协议名是 file,使用 FileLoader 来寻找类,即在本地文件系统中寻找.class文件
  3. URL以斜杠 / 结尾,且协议名不是 file,使用最基础的 Loader 来寻找类。

下面一个个进行分析:

file协议

先写一个Calc.java文件(内容如下),进行编译后放到E盘:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.pax.UnserializeTestOne;

import java.io.IOException;

public class Calc {

static {
try {
Runtime.getRuntime().exec("calc");
} catch (IOException e){
e.printStackTrace();
}
}
}

Main执行一下,成功弹出计算器:

image-20241123165253742

重点是这一行代码:

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()执行以下代码,成功弹出计算器:

image-20241123175542717

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 文件

  1. 当一个类需要加载时,``loadClass() 的作用是从已加载的类、父加载器位置寻找类,(即双亲委派机制),在前面没有找到的情况下,调用当前ClassLoader的findClass()`方法;

  2. findClass() 根据URL指定的方式来加载类的字节码,其中会调用defineClass()

  3. defineClass 的作用是处理前面传入的字节码,将其处理成真正的 Java 类

那就看看这个方法吧:

image-20241123180203356

name为类名,b为字节码数组,off为偏移量,len为字节码数组的长度。

基于系统的 ClassLoader#defineClass 是一个保护属性,无法直接在外部访问。因此可以反射调用 defineClass() 方法进行字节码的加载,然后实例化之后即可弹 shell:

image-20241123181518627

ClassLoader#defineClass直接加载字节码的优点:不需要出网也可以加载字节码,缺点:需要设置m.setAccessible(true);,这在平常的反射中往往无法调用

在实际场景中,因为 defineClass 方法作用域是不开放的,所以攻击者很少能直接利用到它,但它却是我们常用的一个攻击链 TemplatesImpl 的基石。

Unsafe 加载 class 文件

Unsafe中也存在defineClass()方法,本质上也是 defineClass 加载字节码的方式。先看看该方法:

image-20241123182525150

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

image-20241123182813305

进阶

读者可以自行了解一下: TemplatesImpl 加载字节码利用 BCEL ClassLoader 加载字节码

相关教程:Java反序列化基础篇-05-类的动态加载 | Drunkbaby’s Blog

0x07 结语

希望读者能有收获。