Java 代码审计 — 1. ClassLoader
阅读原文时间:2021年11月28日阅读:1

参考:

https://www.bilibili.com/video/BV1go4y197cL/

https://www.baeldung.com/java-classloaders

https://mp.weixin.qq.com/s/lX4IrOuCaSwYDtGQQFqseA

以 java 8 为例

Java 是一种混合语言,它既有编译型语言的特性,又有解释型语言的特性。编译特性指所有的 Java 代码都必须经过编译才能运行。解释型指编译好的 .class 字节码需要经过 JVM 解释才能运行。.class 文件中存放着编译后的 JVM 指令的二进制信息。

当程序中用到某个类时,JVM 就会寻找加载对应的 .class 文件,并在内存中创建对应的 Class 对象。这个过程就称为类加载。

理论模型

从一个类的生命周期这个角度来看,一个类(.class) 必须经过加载、链接、初始化三个步骤才能在 JVM 中运行。

当 java 程序需要使用某个类时,JVM 会进行加载、链接、初始化这个类。

加载 Loading

通过类的完全限定名查找类的字节码文件,将类的 .class 文件字节码数据从不同的数据源读取到 JVM 中,并映射成 JVM 认可的数据结构。

这个阶段是用户可以参与的阶段,自定义的类加载器就是在这个过程。

连接 Linking

  • 验证:检查 JVM 加载的字节信息是否符合 java 虚拟机规范。

    确保被加载类的正确性,.class文件的字节流中包含的信息符合当前虚拟机要求,不会危害虚拟机自身安全。

  • 准备:这一阶段主要是分配内存。创建类或接口的静态变量,并给这些变量赋默认值

    只对 static 变量进行处理。而 final static 修饰的变量在编译的时候就会分配。

  • 例如: static int num = 5,此步骤会将 num 赋默认值 0,而 5 的赋值会在初始化阶段完成。

  • 解析:把类中的符号引用转换成直接引用。

    符号引用就是一组符号来描述目标,而直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

初始化 Initialization

执行类初始化的代码逻辑。包括执行 static 静态代码块,给静态变量赋值。

具体实现

java.lang.ClassLoader 是所有的类加载器的父类,java.lang.ClassLoader 有非常多的子类加载器,比如我们用于加载 jar 包的 java.net.URLClassLoader ,后者通过继承 java.lang.ClassLoader 类,重写了findClass 方法从而实现了加载目录 class 文件甚至是远程资源文件。

三种内置的类加载器

  • Bootstrap ClassLoader 引导类加载器

    Java 类被 java.lang.ClassLoader 的实例加载,而 后者本身就是一个 java 类,谁加载后者呢?

    其实就是 bootstrap ClassLoader ,它是最底层的加载器,是 JVM 的一部分,使用 C++ 编写,故没有父加载器,也没有继承 java.lang.ClassLodaer 类,在代码中获取为 null。

    它主要加载 java 基础类。位于 JAVA_HOME/jre/lib/rt.jar 以及sun.boot.class.path 系统属性目录下的类。

    出于安全考虑,此加载器只加载 java、javax、sun 开头的类。

  • Extension ClassLoader 扩展类加载器

    负责加载 java 扩展类。位于是 JAVA_HOME/jre/lib/ext 目录下,以及 java.ext.dirs 系统属性的目录下的类。

    sun.misc.Launcher$ExtClassLoader
    // jdk 9 及之后
    jdk.internal.loader.ClassLoaders$PlatformClassLoader
  • App ClassLoader 系统类加载器

    又称 System ClassLoader ,主要加载应用层的类。位于 CLASS_PATH 目录下以及系统属性 java.class.path 目录下的类。

    它是默认的类加载器,如果类加载时我们不指定类加载器的情况下,默认会使用它来加载类。

    sun.misc.Launcher$AppClassLoader
    // jdk 9 及之后
    jdk.internal.loader.ClassLoaders$AppClassLOader
父子关系

AppClassLoader 父加载器为 ExtClassLoader,ExtClassLoader 父加载器为 null 。

很多资料和文章里说,ExtClassLoader 的父类加载器是 BootStrapClassLoader ,严格来说,ExtClassLoader 的父类加载器是 null,只不过在其的 loadClass 方法中,当 parent 为 null 时,是交给 BootStrap ClassLoader 来处理的。

双亲委派机制

试想几个问题:

  1. 有三种类加载器,如何保证一个类加载器已加载的类不会被另一个类加载器重复加载?

    势必在加载某个类之前,都要检查一下是否已加载过。如果三个内置的类加载器都没加载,则加载。

  2. 某些基础核心类,是可以让所有的加载器加载吗?

    比如 String 类,如果给它加上后门,放到 classpath 下,是让 appclassloader 加载吗?如果是被 appclassloader 加载,那么它需要做什么验证?如何进行验证?

为了解决上面的问题,java 采取的是双亲委派机制来协调三个类加载器。

每个类加载器对它加载的类都有一个缓存。

向上委托查找,向下委托加载。

  • 类的唯一性

    可以避免类的重复加载,当父类加载器已经加载了该类时,就没有必要子 ClassLoader 再加载一次,保证加载的 Class 在内存中只有一份。

    子加载器可以看见父加载器加载的类。而父加载器没办法得知子加载器加载的类。如果 A 类是通过 AppClassLoader 加载,而 B 类通过ExtClassLoader 加载,那么对于 AppClassLoader 加载的类,它可以看见两个类。而对于 ExtClassLoader ,它只能看见 B 类。

  • 安全性

    考虑到安全因素,Java 核心 Api 中定义类型不会被随意替换,假设通过网络传递一个名为 java.lang.Object 的类,通过双亲委派模式传递到启动类加载器,而启动类加载器在核心 JavaAPI 发现这个名字的类,发现该类已被加载,并不会重新加载网络传递过来的 java.lang.Object,而直接返回已加载过的 Object.class,这样可以防止核心API库被随意窜改。

加载步骤及代码细节

public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException

此函数是类加载的入口函数。resolve 这个参数就是表示需不需要进行 连接阶段。

下面是截取的部分代码片段,从这个片段中可以深刻体会双亲委派机制。

Class<?> c = findLoadedClass(name);

在类加载缓存中寻找是否已经加载该类。它最终调用的是 native 方法。

if (parent != null) {
    c = parent.loadClass(name, false);
} else {
    c = findBootstrapClassOrNull(name);
}

如果父加载器不为空,则让递归让父加载器去加载此类。

如果父加载器为空,则调用 Bootstrap 加载器去加载此类。此处也即为何说 ExtClassLoader 的父加载器为 null,而非 Bootstrap 。

c = findClass(name);

如果查询完所有父亲仍未找到,说明此类并未加载,则调用 findClass 方法来寻找并加载此类。我们自定义类加载器,主要重写的就是 findClass 。

总结

ClassLoader类有如下核心方法:

  1. loadClass(加载指定的Java类)
  2. findLoadedClass(查找JVM已经加载过的类)
  3. findClass(查找指定的Java类)
  4. defineClass(定义一个Java类)
  5. resolveClass(链接指定的Java类)

理解Java类加载机制并非易事,这里我们以一个 Java 的 HelloWorld 来学习 ClassLoader

ClassLoader 加载 com.example.HelloWorld 类重要流程如下:

  1. ClassLoader 调用 loadClass 方法加载 com.example.HelloWorld 类。
  2. 调用 findLoadedClass 方法检查 TestHelloWorld 类是否已经加载,如果 JVM 已加载过该类则直接返回类对象。
  3. 如果创建当前 ClassLoader 时传入了父类加载器(new ClassLoader(父类加载器))就使用父类加载器加载 TestHelloWorld 类,否则使用 JVM 的 Bootstrap ClassLoader 加载。
  4. 如果上一步无法加载 TestHelloWorld 类,那么调用自身的 findClass 方法尝试加载TestHelloWorld 类。
  5. 如果当前的 ClassLoader 没有重写了 findClass 方法,那么直接返回类加载失败异常。如果当前类重写了 findClass 方法并通过传入的 com.example.HelloWorld 类名找到了对应的类字节码,那么应该调用 defineClass 方法去JVM中注册该类。
  6. 如果调用 loadClass 的时候传入的 resolve 参数为 true,那么还需要调用 resolveClass 方法链接类,默认为 false。
  7. 返回一个被 JVM 加载后的java.lang.Class类对象。

用途

大多数情况下,内置的类加载器够用了,但是当加载位于磁盘上其它位置,或者位于网络上的类时,或者需要对类做加密等,就需要自定义类加载器。

一些使用场景:通过动态加载不同实现的驱动的 jdbc。以及编织代理可以更改已知的字节码。以及类名相同的多版本共存机制。

具体实现

我们通常实现自定义类加载器,主要就是重写 findClass 方法。

protected Class<?> findClass(String name) throws ClassNotFoundException

从网络或磁盘文件(.class, jar, 等任意后缀文件) 上读取类的字节码。然后将获取的类字节码传给 defineClass 函数来定义一个类。

protected final Class<?> defineClass(String name, byte[] b, int off, int len) throws ClassFormatError

它最终调用也是 native 方法。

示例代码

使用类字节码中加载类
@Test
public void test3(){
    Double salary = 2000.0;
    Double money;
    {
        byte[] b = new byte[]{-54, -2, -70, -66, 0, 0, 0, 52, 0, 32, 10, 0, 7, 0, 21, 10, 0, 22, 0, 23, 6, 63, -15, -103, -103, -103, -103, -103, -102, 10, 0, 22, 0, 24, 7, 0, 25, 7, 0, 26, 1, 0, 6, 60, 105, 110, 105, 116, 62, 1, 0, 3, 40, 41, 86, 1, 0, 4, 67, 111, 100, 101, 1, 0, 15, 76, 105, 110, 101, 78, 117, 109, 98, 101, 114, 84, 97, 98, 108, 101, 1, 0, 18, 76, 111, 99, 97, 108, 86, 97, 114, 105, 97, 98, 108, 101, 84, 97, 98, 108, 101, 1, 0, 4, 116, 104, 105, 115, 1, 0, 26, 76, 67, 108, 97, 115, 115, 76, 111, 97, 100, 101, 114, 47, 83, 97, 108, 97, 114, 121, 67, 97, 108, 101, 114, 49, 59, 1, 0, 3, 99, 97, 108, 1, 0, 38, 40, 76, 106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 68, 111, 117, 98, 108, 101, 59, 41, 76, 106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 68, 111, 117, 98, 108, 101, 59, 1, 0, 6, 115, 97, 108, 97, 114, 121, 1, 0, 18, 76, 106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 68, 111, 117, 98, 108, 101, 59, 1, 0, 10, 83, 111, 117, 114, 99, 101, 70, 105, 108, 101, 1, 0, 17, 83, 97, 108, 97, 114, 121, 67, 97, 108, 101, 114, 49, 46, 106, 97, 118, 97, 12, 0, 8, 0, 9, 7, 0, 27, 12, 0, 28, 0, 29, 12, 0, 30, 0, 31, 1, 0, 24, 67, 108, 97, 115, 115, 76, 111, 97, 100, 101, 114, 47, 83, 97, 108, 97, 114, 121, 67, 97, 108, 101, 114, 49, 1, 0, 16, 106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 79, 98, 106, 101, 99, 116, 1, 0, 16, 106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 68, 111, 117, 98, 108, 101, 1, 0, 11, 100, 111, 117, 98, 108, 101, 86, 97, 108, 117, 101, 1, 0, 3, 40, 41, 68, 1, 0, 7, 118, 97, 108, 117, 101, 79, 102, 1, 0, 21, 40, 68, 41, 76, 106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 68, 111, 117, 98, 108, 101, 59, 0, 33, 0, 6, 0, 7, 0, 0, 0, 0, 0, 2, 0, 1, 0, 8, 0, 9, 0, 1, 0, 10, 0, 0, 0, 47, 0, 1, 0, 1, 0, 0, 0, 5, 42, -73, 0, 1, -79, 0, 0, 0, 2, 0, 11, 0, 0, 0, 6, 0, 1, 0, 0, 0, 3, 0, 12, 0, 0, 0, 12, 0, 1, 0, 0, 0, 5, 0, 13, 0, 14, 0, 0, 0, 1, 0, 15, 0, 16, 0, 1, 0, 10, 0, 0, 0, 64, 0, 4, 0, 2, 0, 0, 0, 12, 43, -74, 0, 2, 20, 0, 3, 107, -72, 0, 5, -80, 0, 0, 0, 2, 0, 11, 0, 0, 0, 6, 0, 1, 0, 0, 0, 5, 0, 12, 0, 0, 0, 22, 0, 2, 0, 0, 0, 12, 0, 13, 0, 14, 0, 0, 0, 0, 0, 12, 0, 17, 0, 18, 0, 1, 0, 1, 0, 19, 0, 0, 0, 2, 0, 20};
        money = calSalary(salary,b);
        System.out.println("money: " + money);
    }
}
private Double calSalary(Double salary,byte[] bytes) {
    Double ret = 0.0;
    try {
        Method method = ClassLoader.class.getDeclaredMethod("defineClass", String.class, byte[].class, int.class, int.class);
        method.setAccessible(true);
        Class<?> clazz = (Class<?>) method.invoke(this.getClass().getClassLoader(), "ClassLoader.SalaryCaler1", bytes, 0, bytes.length);
        System.out.println(clazz.getClassLoader());
        Object object = clazz.getConstructor().newInstance();
        Method cal = clazz.getMethod("cal",Double.class);
        ret = (Double)cal.invoke(object,salary);
    } catch (Exception e) {
        e.printStackTrace();
    }
    return ret;
}
从文件中读取类字节码加载类
@Test
// 自定义类加载器,从 .myclass 文件中中加载类。
public void test4(){
    // 将其它方法全注释,并且 ClassLoader.SalaryCaler 文件更名。
    try {
        Double salary = 2000.0;
        Double money;
        SalaryClassLoader classLoader = new SalaryClassLoader("C:\\Users\\EA\\Desktop\\important_doc\\java\\build\\ideaprojects\\demos\\underlying\\target\\classes\\");
        money = calSalary(salary, classLoader);
        System.out.println("money: " + money);
    } catch (Exception e) {
        e.printStackTrace();
    }
}
private Double calSalary(Double salary, SalaryClassLoader classLoader) throws Exception {

    Class<?> clazz = classLoader.loadClass("ClassLoader.SalaryCaler1");
    System.out.println(clazz.getClassLoader());

    Object object = clazz.getConstructor().newInstance();
    Method cal = clazz.getMethod("cal",Double.class);

    return (Double)cal.invoke(object,salary);
}


package ClassLoader;

import org.apache.commons.io.IOUtils;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.security.SecureClassLoader;

public class SalaryClassLoader extends SecureClassLoader {
    private String classPath;

    public SalaryClassLoader(String classPath) {
        this.classPath = classPath;
    }

    @Override
    protected Class<?> findClass(String name)throws ClassNotFoundException {
        String filePath = this.classPath + name.replace(".", "/").concat(".myclass");
        byte[] b = null;
        Class<?> aClass = null;
        try (FileInputStream fis = new FileInputStream(new File(filePath))) {
            b = IOUtils.toByteArray(fis);
            aClass = this.defineClass(name, b, 0, b.length);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return aClass;
    }
}
从 jar 包中读取类字节码加载类
@Test
//自定义类加载器,从 jar 包中加载 .myclass
public void test5(){
    try {
        Double salary = 2000.0;
        Double money;
        SalaryJarLoader classLoader = new SalaryJarLoader("C:\\Users\\EA\\Desktop\\important_doc\\java\\build\\ideaprojects\\demos\\out\\artifacts\\SalaryCaler\\SalaryCaler.jar");
        money = calSalary(salary, classLoader);
        System.out.println("money: " + money);
    } catch (Exception e) {
        e.printStackTrace();
    }
}
private Double calSalary(Double salary, SalaryJarLoader classLoader) throws Exception {
    Class<?> clazz = classLoader.loadClass("ClassLoader.SalaryCaler1");
    System.out.println(clazz.getClassLoader());

    Object object = clazz.getConstructor().newInstance();
    Method cal = clazz.getMethod("cal",Double.class);

    return (Double)cal.invoke(object,salary);
}


package ClassLoader;

import org.apache.commons.io.IOUtils;

import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
import java.security.SecureClassLoader;

public class SalaryJarLoader extends SecureClassLoader {
    private String jarPath;

    public SalaryJarLoader(String jarPath) {
        this.jarPath = jarPath;
    }

    @Override
    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        Class<?> c = null;
        synchronized (getClassLoadingLock(name)){
            c = findLoadedClass(name);
            if(c == null){
                c = this.findClass(name);
                //                System.out.println(c);
                if( c == null){
                    c = super.loadClass(name,resolve);
                }
            }
        }
        return c;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        Class<?> ret = null;
        try {
            URL jarUrl = new URL("jar:file:\\"+jarPath+"!/"+name.replace(".","/").concat(".myclass"));
            InputStream is = jarUrl.openStream();

            byte[] b = IOUtils.toByteArray(is);
            ret = this.defineClass(name,b,0,b.length);
        } catch (Exception e) {
            //            e.printStackTrace();
        }
        return ret;
    }

}

打破双亲委派机制

重写继承而来的 loadClass 方法。

使其优先从本地加载,本地加载不到再走双亲委派机制。

@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    Class<?> c = null;
    synchronized (getClassLoadingLock(name)){
        c = findLoadedClass(name);
        if(c == null){
            c = this.findClass(name);
            if( c == null){
                c = super.loadClass(name,resolve);
            }
        }
    }
    return c;
}

URLClassLoader

URLClassLoader 提供了加载远程资源的能力,在写漏洞利用的 payload 或者 webshell 的时候我们可以使用它来加载远程的 jar 来实现远程的类方法调用。

在 java.net 包中,JDK提供了一个易用的类加载器 URLClassLoader,它继承了 ClassLoader。

public URLClassLoader(URL[] urls)
//指定要加载的类所在的URL地址,父类加载器默认为 AppClassLoader。
public URLClassLoader(URL[] urls, ClassLoader parent)
//指定要加载的类所在的URL地址,并指定父类加载器。

从本地 jar 包中加载类

@Test
// 从 jar 包中加载类
public void test3() {
    try {
        Double salary = 2000.0;
        Double money;
        URL jarUrl = new URL("file:C:\\Users\\EA\\Desktop\\important_doc\\java\\build\\ideaprojects\\demos\\out\\artifacts\\SalaryCaler\\SalaryCaler.jar");
        try (URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{jarUrl})) {
            money = calSalary(salary, urlClassLoader);
            System.out.println("money: " + money);
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
}
private Double calSalary(Double salary, URLClassLoader classLoader) throws Exception {
    Class<?> clazz = classLoader.loadClass("ClassLoader.SalaryCaler");
    Object object = clazz.getConstructor().newInstance();
    Method cal = clazz.getMethod("cal",Double.class);

    return (Double)cal.invoke(object,salary);
}

从网络 jar 包中加载类

package com.anbai.sec.classloader;

import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.URL;
import java.net.URLClassLoader;

/**
 * Creator: yz
 * Date: 2019/12/18
 */
public class TestURLClassLoader {

    public static void main(String[] args) {
        try {
            // 定义远程加载的jar路径
            URL url = new URL("https://anbai.io/tools/cmd.jar");

            // 创建URLClassLoader对象,并加载远程jar包
            URLClassLoader ucl = new URLClassLoader(new URL[]{url});

            // 定义需要执行的系统命令
            String cmd = "ls";

            // 通过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();
        }
    }

}


import java.io.IOException;

/**
 * Creator: yz
 * Date: 2019/12/18
 */
public class CMD {

    public static Process exec(String cmd) throws IOException {
        return Runtime.getRuntime().exec(cmd);
    }

}

jsp webshell

为什么上传的 jsp webshell 能立即访问,按道理来说 jsp 要经过 servlet 容器处理转化为 servlet 才能执行。而通常开发过程需要主动进行更新资源、或者重新部署、重启 tomcat 服务器。

这是因为 tomcat 的 热加载机制 。而之所以 JSP 具备热更新的能力,实际上借助的就是自定义类加载行为,当 Servlet 容器发现 JSP 文件发生了修改后就会创建一个新的类加载器来替代原类加载器,而被替代后的类加载器所加载的文件并不会立即释放,而是需要等待 GC。