第十七课-系统学习代码审计:Java反序列化基础-类加载过程和类加载器讲解

admin 2024年9月26日11:35:26评论16 views字数 9896阅读32分59秒阅读模式

视频教程:( https://www.bilibili.com/video/BV1KT421k7ZE )

主要内容:

1、类加载过程

2、jvm内置的三种类加载器

3、双亲委派机制

4、自定义类加载

5、URLClassloader加载指定字节码文件中的类

类加载过程

https://javaguide.cn/java/jvm/class-loading-process.html#%E7%B1%BB%E7%9A%84%E7%94%9F%E5%91%BD%E5%91%A8%E6%9C%9F

类从被加载到虚拟机内存中开始到卸载出内存为止,它的整个生命周期可以简单概括为 7 个阶段:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)。其中,验证、准备和解析这三个阶段可以统称为连接(Linking)。

第十七课-系统学习代码审计:Java反序列化基础-类加载过程和类加载器讲解

加载

类加载过程的第一步,主要完成下面 3 件事情:

  • 通过全类名获取定义此类的二进制字节流

  • 将字节流所代表的静态存储结构转换为方法区的运行时数据结构。

  • 在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口。

虚拟机规范上面这 3 点并不具体,因此是非常灵活的。比如:"通过全类名获取定义此类的二进制字节流" 并没有指明具体从哪里获取( ZIP、 JAR、EAR、WAR、网络、动态代理技术运行时动态生成、其他文件生成比如 JSP...)、怎样获取。

第十七课-系统学习代码审计:Java反序列化基础-类加载过程和类加载器讲解

ps

一个非数组类的加载阶段(加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,这一步我们可以去完成还可以自定义类加载器去控制字节流的获取方式(重写一个类加载器的 loadClass() 方法)。

  • 从本地系统中直接加载

  • 通过网络下载.class文件

  • 从zip,jar等归档文件中加载.class文件

  • 从专有数据库中提取.class文件

  • 将Java源文件动态编译为.class文件

类的加载

类加载有三种方式:

1、命令行启动应用时候由JVM初始化加载

2、通过Class.forName()方法动态加载 (默认会执行初始化块)但是使用Class.forName()来加载类,并指定ClassLoader,初始化时不执行静态块:Class.forName("Test2", false, loader);

3、通过ClassLoader.loadClass()方法动态加载 (不会执行初始化块 )

   public static void classloadTest() throws ClassNotFoundException {
       // 获取类加载器
       ClassLoader loader = BaseTestClassLoad.class.getClassLoader();
       System.out.println(loader);
       //使用ClassLoader.loadClass()来加载类,不会执行初始化块
//       loader.loadClass("com.llu.classload.Test2");
       //使用Class.forName()来加载类,默认会执行初始化块
//               Class.forName("com.llu.classload.Test2");
       //使用Class.forName()来加载类,并指定ClassLoader,初始化时不执行静态块
               Class.forName("com.llu.classload.Test2", false, loader);
  }
}
class Test2 {
   static {
       System.out.println("静态初始化块执行了!");
  }
}

Class.forName()和ClassLoader.loadClass()区别?

Class.forName(): 将类的.class文件加载到jvm中之外,还会对类进行解释,执行类中的static块;

ClassLoader.loadClass(): 只干一件事情,就是将.class文件加载到jvm中,不会执行static中的内容,只有在newInstance才会去执行static块。

Class.forName(name, initialize, loader)带参函数也可控制是否加载static块。并且只有调用了newInstance()方法采用调用构造函数,创建类的对象

验证

验证是连接阶段的第一步,这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

第十七课-系统学习代码审计:Java反序列化基础-类加载过程和类加载器讲解

准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。

初始化

初始化阶段是执行初始化方法 <clinit> ()方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。

第十七课-系统学习代码审计:Java反序列化基础-类加载过程和类加载器讲解

类卸载

卸载类即该类的 Class 对象被 GC。

卸载类需要满足 3 个要求:

  • 该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象。

  • 该类没有在其他任何地方被引用

  • 该类的类加载器的实例已被 GC

所以,在 JVM 生命周期内,由 jvm 自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的。只要想通一点就好了,JDK 自带的 BootstrapClassLoader, ExtClassLoader, AppClassLoader 负责加载 JDK 提供的类,所以它们(类加载器的实例)肯定不会被回收。而我们自定义的类加载器的实例是可以被回收的,所以使用我们自定义加载器加载的类是可以被卸载掉的。

类加载器

https://javaguide.cn/java/jvm/classloader.html#%E5%9B%9E%E9%A1%BE%E4%B8%80%E4%B8%8B%E7%B1%BB%E5%8A%A0%E8%BD%BD%E8%BF%87%E7%A8%8B

加载器是一个负责加载类的对象。ClassLoader 是一个抽象类。给定类的二进制名称,类加载器应尝试定位或生成构成类定义的数据。典型的策略是将名称转换为文件名,然后从文件系统中读取该名称的“类文件”。每个 Java 类都有一个引用指向加载它的 ClassLoader。不过,数组类不是通过 ClassLoader 创建的, 而是 JVM 在需要的时候自动创建的,数组类通过getClassLoader()方法获取 ClassLoader 的时候和 该数组的元素类型的 ClassLoader 是一致的。

从上面的介绍可以看出:

  • 类加载器是一个负责加载类的对象,用于实现类加载过程中的加载这一步。

  • 每个 Java 类都有一个引用指向加载它的 ClassLoader。

  • 数组类不是通过 ClassLoader 创建的(数组类没有对应的二进制字节流),是由 JVM 直接生成的。

简单来说,类加载器的主要作用就是加载 Java 类的字节码( .class 文件)到 JVM 中(在内存中生成一个代表该类的 Class 对象)。字节码可以是 Java 源程序(.java文件)经过 javac 编译得来,也可以是通过工具动态生成或者通过网络下载得来。

类加载器加载规则

JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载。也就是说,大部分类在具体用到的时候才会去加载,这样对内存更加友好。

对于已经加载的类会被放在 ClassLoader 中。在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。也就是说,对于一个类加载器来说,相同二进制名称的类只会被加载一次

public abstract class ClassLoader {
...
 private final ClassLoader parent;
 // 由这个类加载器加载的类。
 private final Vector<Class<?>> classes = new Vector<>();
 // 由VM调用,用此类加载器记录每个已加载类。
 void addClass(Class<?> c) {
       classes.addElement(c);
  }
...
}

类加载器分类

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 类的字节码( .class 文件)进行加密,加载时再利用自定义的类加载器对其解密。

第十七课-系统学习代码审计:Java反序列化基础-类加载过程和类加载器讲解

public class PrintClassLoaderTree {

   public static void main(String[] args) {

       ClassLoader classLoader = PrintClassLoaderTree.class.getClassLoader();

       StringBuilder split = new StringBuilder("|--");
       boolean needContinue = true;
       while (needContinue){
           System.out.println(split.toString() + classLoader);
           if(classLoader == null){
               needContinue = false;
          }else{
               classLoader = classLoader.getParent();
               split.insert(0, "t");
          }
      }
  }

}

我们编写的 Java 类 PrintClassLoaderTree 的 ClassLoader 是AppClassLoader;

AppClassLoader的父 ClassLoader 是ExtClassLoader;

ExtClassLoader的父ClassLoader是Bootstrap ClassLoader,因此输出结果为 null

双亲委派机制

类加载器有很多种,当我们想要加载一个类的时候,具体是哪个类加载器加载呢?这就需要提到双亲委派模型了。

ClassLoader 类使用委托模型来搜索类和资源。每个 ClassLoader 实例都有一个相关的父类加载器。需要查找类或资源时,ClassLoader 实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器。

虚拟机中被称为 "bootstrap class loader"的内置类加载器本身没有父类加载器,但是可以作为 ClassLoader 实例的父类加载器。

  • ClassLoader 类使用委托模型来搜索类和资源。

  • 双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。ClassLoader 实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器。

注意 ⚠️:双亲委派模型并不是一种强制性的约束,只是 JDK 官方推荐的一种方式。如果我们因为某些特殊需求想要打破双亲委派模型,也是可以的,后文会介绍具体的方法。

双亲委派模型的执行流程

双亲委派模型的实现代码非常简单,逻辑非常清晰,都集中在 java.lang.ClassLoader 的 loadClass() 中,相关代码如下所示。

protected Class<?> loadClass(String name, boolean resolve)
   throws ClassNotFoundException
{
   synchronized (getClassLoadingLock(name)) {
       //首先,检查该类是否已经加载过
       Class c = findLoadedClass(name);
       if (c == null) {
           //如果 c 为 null,则说明该类没有被加载过
           long t0 = System.nanoTime();
           try {
               if (parent != null) {
                   //当父类的加载器不为空,则通过父类的loadClass来加载该类
                   c = parent.loadClass(name, false);
              } else {
                   //当父类的加载器为空,则调用启动类加载器来加载该类
                   c = findBootstrapClassOrNull(name);
              }
          } catch (ClassNotFoundException e) {
               //非空父类的类加载器无法找到相应的类,则抛出异常
          }

           if (c == null) {
               //当父类加载器无法加载时,则调用findClass方法来加载该类
               //用户可通过覆写该方法,来自定义类加载器
               long t1 = System.nanoTime();
               c = findClass(name);

               //用于统计类加载器相关的信息
               sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
               sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
               sun.misc.PerfCounter.getFindClasses().increment();
          }
      }
       if (resolve) {
           //对类进行link操作
           resolveClass(c);
      }
       return c;
  }
}

结合上面的源码,简单总结一下双亲委派模型的执行流程:

  1. 在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载(每个父类加载器都会走一遍这个流程)。

  2. 类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器 loadClass()方法来加载类)。这样的话,所有的请求最终都会传送到顶层的启动类加载器 BootstrapClassLoader 中。

  3. 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(调用自己的 findClass() 方法来加载类)。

  4. 如果子类加载器也无法加载这个类,那么它会抛出一个 ClassNotFoundException 异常

JVM 判定两个 Java 类是否相同的具体规则:JVM 不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样。只有两者都相同的情况,才认为两个类是相同的。即使两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相同。

双亲委派模型的好处

双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。

如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现两个不同的 Object 类。双亲委派模型可以保证加载的是 JRE 里的那个 Object 类,而不是你写的 Object 类。这是因为 AppClassLoader 在加载你的 Object 类时,会委托给 ExtClassLoader 去加载,而 ExtClassLoader 又会委托给 BootstrapClassLoader,BootstrapClassLoader 发现自己已经加载过了 Object 类,会直接返回,不会去加载你写的 Object 类。

打破双亲委派模型方法

自定义类加载器

我们前面也说说了,除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader。如果我们要自定义自己的类加载器,很明显需要继承 ClassLoader抽象类。

ClassLoader 类有两个关键的方法:

  • protected Class loadClass(String name, boolean resolve):加载指定二进制名称的类,实现了双亲委派机制 。name 为类的二进制名称,resolve 如果为 true,在加载时调用 resolveClass(Class<?> c) 方法解析该类。

  • protected Class findClass(String name):根据类的二进制名称来查找类,默认实现是空方法。

如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。

但是,如果想打破双亲委派模型则需要重写 loadClass() 方法。

自定义类加载器实现


public class MyClassLoader extends ClassLoader{

   private String root;

   /**
    * 查找指定类的字节码数据并定义为Class对象
    *
    * @param name 类的全限定名
    * @return 定义的Class对象
    * @throws ClassNotFoundException 如果找不到类的字节码数据,则抛出此异常
    */
   protected Class<?> findClass(String name) throws ClassNotFoundException {
       // 加载指定类的字节码数据 , 直接从文件查找
       byte[] classData = loadClassData(name);
       if (classData == null) {
           // 如果字节码数据为空,说明类未找到,抛出ClassNotFoundException异常
           throw new ClassNotFoundException();
      } else {
           // 使用类的字节码数据定义Class对象并返回
           return defineClass(name, classData, 0, classData.length);
      }
  }


   /**
    * 根据类名加载类数据
    *
    * @param className 类名,用于定位类文件
    * @return 返回加载的类数据,如果加载失败返回null
    *
    * 此方法根据给定的类名构建文件路径,读取对应的.class文件内容,并将其返回为字节数组
    * 主要用于动态加载类文件,以便在运行时获取类的二进制数据
    */
   private byte[] loadClassData(String className) {
       // 构建类文件的完整路径
       String fileName = root + File.separatorChar
               + className.replace('.', File.separatorChar) + ".class";
       try {
           // 打开文件输入流
           InputStream ins = new FileInputStream(fileName);
           // 创建字节输出流,用于将读取的数据组装成字节数组
           ByteArrayOutputStream baos = new ByteArrayOutputStream();
           // 定义读取数据的缓冲区大小
           int bufferSize = 1024;
           // 创建缓冲区
           byte[] buffer = new byte[bufferSize];
           // 定义变量存储每次读取的数据长度
           int length = 0;
           // 循环读取文件数据,直到文件末尾
           while ((length = ins.read(buffer)) != -1) {
               // 将读取的数据写入到输出流中
               baos.write(buffer, 0, length);
          }
           // 返回文件的字节数据
           return baos.toByteArray();
      } catch (IOException e) {
           // 异常处理:打印异常信息
           e.printStackTrace();
      }
       // 如果发生异常,返回null
       return null;
  }


   public String getRoot() {
       return root;
  }

   public void setRoot(String root) {
       this.root = root;
  }

   public static void main(String[] args) {

       MyClassLoader classLoader = new MyClassLoader();
       classLoader.setRoot("C:\Users\it\Desktop\code\代码审计\CodeReviewFromZero\day15-classLoadAndReflcetAndSerial\target\classes");

       Class<?> testClass = null;
       try {
           testClass = classLoader.loadClass("com.llu.classload.Test2");
           Object object = testClass.newInstance();
           System.out.println(object.getClass().getClassLoader());
      } catch (ClassNotFoundException e) {
           e.printStackTrace();
      } catch (InstantiationException e) {
           e.printStackTrace();
      } catch (IllegalAccessException e) {
           e.printStackTrace();
      }
  }
}

URLClassLoader

URLClassLoader 是 Java 中用于从指定的 URL 位置加载类的类加载器。它可以用来动态地从网络或其他位置加载类文件


URLCLassLoader u = new URLClassLoader(new URL[] {nwe URL( "支持http、file、jar协议")} );

Class c = u.loadClass("类名");

#实例化
c.newInstance();

原文始发于微信公众号(安全随心录):第十七课-系统学习代码审计:Java反序列化基础-类加载过程和类加载器讲解

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2024年9月26日11:35:26
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   第十七课-系统学习代码审计:Java反序列化基础-类加载过程和类加载器讲解https://cn-sec.com/archives/3121292.html

发表评论

匿名网友 填写信息