Java反序列化基础4——类的动态加载
参考文章
首先简单介绍一下类加载过程
类加载过程:加载->连接->初始化
连接过程又可分为三步:验证->准备->解析
类加载器
简单来说,类加载器的主要作用就是动态加载 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 包和类。
除了这三种类加载器之外,用户还可以加入自定义的类加载器来进行拓展,以满足自己的特殊需求
举个例子
package org.example;
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 是PlatformClassLoader
;PlatformClassLoader
的父ClassLoader是Bootstrap ClassLoader
,因此输出结果为 null(因为BootstrapClassLoader
由 C++ 实现,由于这个 C++ 实现的类加载器在 Java 中是没有与之对应的类的,所以拿到的结果是 null。)
注:PlatformClassLoader 是 Java 9 引入的模块化系统(Jigsaw)后新增的重要类加载器,取代了原来的扩展类加载器(Extension ClassLoader)。
双亲委派机制
ClassLoader
类通过委托模型来搜索类和资源双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。
ClassLoader实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载。
下图展示的各种类加载器之间的层次关系被称为类加载器的“双亲委派模型(Parents Delegation Model)”。
执行流程
双亲委派模型的实现代码非常简单,逻辑非常清晰,都集中在 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;
}
}
每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载。
各场景下代码块加载顺序
这里的代码块主要指的是这四种
静态代码块:static{}
构造代码块:{}
无参构造器:ClassName()
有参构造器:ClassName(String name)
场景一:实例化对象
// 存放代码块
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("静态方法");
}
}
public class Main {
public static void main(String[] args) {
Person person = new Person();
}
}
运行结果:
通过 new 关键字实例化的对象,先调用静态代码块,然后调用构造代码块,最后根据实例化方式不同,调用不同的构造器
场景二:调用静态方法
public class Main {
public static void main(String[] args) {
Person.staticAction();
}
}
不实例化对象直接调用静态方法,会先调用类中的静态代码块,然后调用静态方法
场景三:对类中的静态成员变量赋值
public class Main {
public static void main(String[] args) {
Person.staticVar = 1;
}
}
在对静态成员变量赋值前,会调用静态代码块
场景四:使用class获取类
public class Main {
public static void main(String[] args) {
Class c = Person.class;
}
}
利用 class 关键字获取类,并不会加载类,也就是什么也不会输出。
场景五:使用forName获取类
public class Main {
public static void main(String[] args) throws ClassNotFoundException{
Class.forName("org.example.Person");
}
}
public class Main {
public static void main(String[] args) throws ClassNotFoundException{
Class.forName("org.example.Person", false, ClassLoader.getSystemClassLoader());
}
}//无输出
如果将第二个参数设置为false,那么就不会调用静态代码
场景六:使用 ClassLoader.loadClass() 获取类
public class Main {
public static void main(String[] args) throws ClassNotFoundException{
Class.forName("org.example.Person", false, ClassLoader.getSystemClassLoader());
}
}//无输出
ClassLoader.loadClass()方法不会进行类的初始化
利用URLClassLoader加载远程class文件
正常情况下java会根据配置项sun.boot.class.path
和java.class.path
中列举的基础路径来寻找.class文件来加载,而这个基础路径又分为三种情况
URL未以斜杠 / 结尾,则认为是一个JAR文件,使用 JarLoader 来寻找类,即为在Jar包中寻找.class文件
URL以斜杠 / 结尾,且协议名是 file ,则使用 FileLoader 来寻找类,即为在本地文件系统中寻找.class文件
URL以斜杠 / 结尾,且协议名不是 file ,则使用最基础的 Loader 来寻找类。
file协议
// URLClassLoader 的 file 协议
public class Calc {
static {
try {
Runtime.getRuntime().exec("calc");
} catch (IOException e){
e.printStackTrace();
}
}
}
编译后会生成.class文件
package org.example;
import java.net.URL;
import java.net.URLClassLoader;
public class FileRce {
public static void main(String[] args) throws Exception{
URLClassLoader urlClassLoader=new URLClassLoader
(new URL[]{new URL("file:///D:\\text\\codeqltext2\\untitled\\target\\classes\\org\\example\\")});
//Calc.class文件地址
Class calc =urlClassLoader.loadClass("org.example.Calc");
calc.newInstance();
}
}
运行之后弹出计算器
HTTP协议
在Calc.class文件目录下执行python3 -m http.server 9999
,起一个http服务
package org.example;
import java.net.URL;
import java.net.URLClassLoader;
public class HTTPRce {
public static void main(String[] args) throws Exception{
URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{new URL("http://127.0.0.1:9999")});
Class calc = urlClassLoader.loadClass("org.example.Calc");
calc.newInstance();
}
}
file+jar协议
先将Calc.class打包为jar文件
package org.example;
import java.net.URL;
import java.net.URLClassLoader;
public class JarRce {
public static void main(String[] args) throws Exception{
URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{new URL("jar:file:///D:\\text\\codeqltext2\\untitled\\target\\classes\\org\\example\\Calc.jar!/")});
Class calc = urlClassLoader.loadClass("org.example.Calc");
calc.newInstance();}
}
HTTP + jar 协议
package org.example;
import java.net.URL;
import java.net.URLClassLoader;
public class HTTPJarRce {
public static void main(String[] args) throws Exception {
URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{new URL("jar:http://127.0.0.1:9999/Calc.jar!/")});
Class calc = urlClassLoader.loadClass("org.example.Calc");
calc.newInstance();
}
}
利用ClassLoader#defineClass直接加载字节码
不管是加载远程class文件,还是本地的class或jar文件,Java都经历的是下面三个方法的调用
loadClass()的作用是从已加载的类、父加载器位置寻找类(即双亲委派机制),在前面没有找到的情况下,调用当前ClassLoader的
findClass()
方法findClass()
根据URL指定的方式来加载类的字节码,其中会调用defineClass()
;defineClass
的作用是处理前面传入的字节码,将其处理成真正的Java类,所以可见,真正的核心部分其实是defineClass()
,它决定了如何将一段字节流转变成一个Java类。
protected final Class<?> defineClass(String name, byte[] b, int off, int len)
name
为类名,b
为字节码数组,off
为偏移量,len
为字节码数组的长度
因为系统的ClassLoader#defineClass是一个保护属性,所以我们无法直接在外部访问。因此可以反射调用defineClass()
方法进行字节码的加载,然后实例化之后即可弹shell。
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Paths;
// 利用 ClassLoader#defineClass 直接加载字节码
public class DefineClassRce {
public static void main(String[] args) throws Exception{
ClassLoader classLoader = ClassLoader.getSystemClassLoader();
Method method = ClassLoader.class.getDeclaredMethod("defineClass", String.class, byte[].class, int.class, int.class);
method.setAccessible(true);
byte[] code = Files.readAllBytes(Paths.get("D:\\text\\codeqltext2\\untitled\\target\\classes\\org\\example\\Calc.class")); // 字节码的数组
Class c = (Class) method.invoke(classLoader, "org.example.Calc", code, 0, code.length);
c.newInstance();
}
}
这里首先遇到了一个报错,gpt查一下说是由于 Java 9 及以上版本引入的模块系统(JPMS) 导致的。默认情况下,java.base 模块不对外开放 java.lang 包,而某些库(如 CGLIB、ASM 或旧版反射工具)试图通过反射访问 ClassLoader.defineClass,从而触发此错误。
所以要添加JVM参数
--add-opens=java.base/java.lang=ALL-UNNAMED
报错解决,成功弹出计算器。
使用ClassLoader#defineClass直接加载字节码有个优点就是不需要出网也可以加载字节码,但是它也是有缺点的,就是需要设置m.setAccessible(true);,这在平常的反射中是无法调用的。
在实际场景中,因为 defineClass 方法作用域是不开放的,所以攻击者很少能直接利用到它,但它却是我们常用的一个攻击链 TemplatesImpl 的基石。