闲谈ClassLoader
闲谈ClassLoader
总算放假了,摆了几天,幻兽帕鲁真上头(x。RWctf的那道shiro凹了半天反弹shell没成功是我没想到的,还是菜了。这篇文章就来讲讲Java的类加载机制吧,类加载机制之前在反射机制那篇文章里提到过,这次就来详细说说。
Java是一个依赖于JVM
(Java虚拟机)实现的跨平台的开发语言。Java程序在运行前需要先编译成class
文件,Java类初始化的时候会调用java.lang.ClassLoader
加载类字节码,ClassLoader
会调用JVM
的native
方法(defineClass0/1/2
)来定义一个java.lang.Class
实例
我们先来看一下JVM
的架构图
ClassLoader
的具体作用就是将class文件加载到jvm
虚拟机中去,程序就可以正确运行了。但是,jvm
启动的时候,并不会一次性加载所有的class
文件,而是根据需要去动态加载。
Java类
先来简单了解下Java
类
接下来我们自己写一个测试类
1 | package com.deicide.sec.classloader; |
然后编译这个类:javac TestHelloWorld.java
可以通过JDK自带的javap
命令反汇编TestHelloWorld.class
文件对应的com.anbai.sec.classloader.TestHelloWorld
类,以及使用Linux自带的hexdump
命令查看TestHelloWorld.class
文件二进制内容,或者我们可以用010Editor
来查看
JVM
在执行TestHelloWorld
之前会先解析class二进制内容,JVM执行的其实就是如上javap
命令生成的字节码。
ClassLoader
一切的Java类都必须经过JVM加载后才能运行,而ClassLoader
的主要作用就是Java类文件的加载。在JVM类加载器中最顶层的是Bootstrap ClassLoader(引导类加载器)
、Extension ClassLoader(扩展类加载器)
、App ClassLoader(系统类加载器)
,AppClassLoader
是默认的类加载器,如果类加载时我们不指定类加载器的情况下,默认会使用AppClassLoader
加载类,ClassLoader.getSystemClassLoader()
返回的系统类加载器也是AppClassLoader
。
从上面的描述来看也可以清楚地知道这三个类加载器的加载顺序是:
- Bootstrap CLassloder
- Extention ClassLoader
- AppClassLoader
Launcher
我们可以查看源码来更好地理解,下面是sun.misc.Launcher
精简后的代码,它是一个 java
虚拟机的入口应用,但是它针对 jdk1.8,jdk9 以后就没有该文件了。
1 | jdk9以后 |
以下针对JDK8
1 | public class Launcher { |
- Launcher初始化了ExtClassLoader和AppClassLoader。
- Launcher中并没有看见BootstrapClassLoader,但通过
System.getProperty("sun.boot.class.path")
得到了字符串bootClassPath
,这个应该就是BootstrapClassLoader加载的jar包路径(可以自己print测试一下)。
ExtClassLoader
下面是它的源码
1 | /* |
我们可以指定-D java.ext.dirs
参数来添加和改变ExtClassLoader的加载路径。接下来测试一下。
1 | System.out.println(System.getProperty("java.ext.dirs")); |
输出因环境而异就不放了。
AppClassLoader
1 | /** |
可以看到AppClassLoader加载的就是java.class.path
下的路径。我们同样打印它的值。
1 | System.out.println(System.getProperty("java.class.path")); |
值得注意的是某些时候我们获取一个类的类加载器时候可能会返回一个null
值,如:java.io.File.class.getClassLoader()
将返回一个null
对象,因为java.io.File
类在JVM初始化的时候会被Bootstrap ClassLoader(引导类加载器)
加载(该类加载器实现于JVM层,采用C++编写),我们在尝试获取被Bootstrap ClassLoader
类加载器所加载的类的ClassLoader
时候都会返回null
。
我们来写个代码测试一下
1 | package com.deicide.sec.classloader; |
这里的classLoader是null,说明Launcher确实是BootstrapClassLoader加载的。
为何classLoader
是null
,说明Launcher
确实是BootstrapClassLoader
加载的?因为java.lang.Class
类的getClassLoader()
方法用于获取此实体的classLoader
,该实体可以是类,数组,接口等。我们知道类加载器类型包括四种,分别是启动类加载器(Bootstrap ClassLoader
)、扩展类加载器(Extension ClassLoader
)、应用程序类加载器(Application ClassLoader
)、用户自定义加载器,如果是后3种类型,一般会打印出具体的信息,而前面代码中的打印2条结果,第二条sun.misc.Launcher$AppClassLoader@19821f
说明应用了程序类加载器,含有关键词AppClassLoader
,与此类似,如果是Ext或自定义类型,也有相关的关键词,而第一条为null
,不是后3种的类型之一,据此反推第一条是Bootstrap ClassLoader
。
其实也不需要反推,根本原因在于java.lang.Class类的getClassLoader()方法实现机制,我们来看下该方法的源码和注释:
1 | //JDK 1.8 |
我们可以看到上面的注释中有这么一句Some implementations may use null to represent the bootstrap class loader
,说明当返回值为null时,表示使用的是bootstrap class loader,还有就是因为BootstrapClassLoader
是C/C++
编写的,它本身是虚拟机的一部分,所以它并不是一个JAVA类,也就是无法在JAVA代码中获取它的引用,所以测试代码中获取launcher
的加载器就会返回null。
另外,ClassLoader
类有如下核心方法:
loadClass
(加载指定的Java类)findClass
(查找指定的Java类)findLoadedClass
(查找JVM已经加载过的类)defineClass
(定义一个Java类)resolveClass
(链接指定的Java类)
方法名挺好记的,嗯。
Java类动态加载方式
Java类加载方式分为显式
和隐式
,显式
即我们通常使用Java反射
或者ClassLoader
来动态加载一个类对象,关于反射我上一篇文章有讲。而隐式
指的是类名.方法名()
或new
类实例。显式
类加载方式也可以理解为类动态加载,我们可以自定义类加载器去加载任意的类。
常用的类动态加载方式:
1 | // 反射加载TestHelloWorld示例 |
Class.forName("类名")
默认会初始化被加载类的静态属性和方法,如果不希望初始化类可以使用Class.forName("类名", 是否初始化类, 类加载器)
,而ClassLoader.loadClass
默认不会初始化类方法。
ClassLoader类加载流程
这里我用HelloWorld
类为例
前面已经讲了大概的一个类加载流程,下面我们来稍微具体地讲一下
下面是com.anbai.sec.classloader.TestHelloWorld
类的源码
1 | package com.deicide.sec.classloader; |
ClassLoader
加载com.anbai.sec.classloader.TestHelloWorld
类loadClass
重要流程如下:
ClassLoader
会调用public Class<?> loadClass(String name)
方法加载com.deicide.sec.classloader.TestHelloWorld
类。- 调用
findLoadedClass
方法检查TestHelloWorld
类是否已经初始化,如果JVM已初始化过该类则直接返回类对象。 - 如果创建当前
ClassLoader
时传入了父类加载器(new ClassLoader(父类加载器)
)就使用父类加载器加载TestHelloWorld
类,否则使用JVM的Bootstrap ClassLoader
加载。 - 如果上一步无法加载
TestHelloWorld
类,那么调用自身的findClass
方法尝试加载TestHelloWorld
类。 - 如果当前的
ClassLoader
没有重写了findClass
方法,那么直接返回类加载失败异常。如果当前类重写了findClass
方法并通过传入的com.anbai.sec.classloader.TestHelloWorld
类名找到了对应的类字节码,那么应该调用defineClass
方法去JVM中注册该类。 - 如果调用loadClass的时候传入的
resolve
参数为true,那么还需要调用resolveClass
方法链接类,默认为false。 - 返回一个被JVM加载后的
java.lang.Class
类对象。
下面是ClassLoader
的部分源码,中文部分注释是我加上去的(非源码内容)
1 | public abstract class ClassLoader { |
也能看懂大概就是上面我说的流程
然后上面的注释中有提到双亲委托,那就插一嘴来简单介绍一下这个。
双亲委托
JVM加载一个class时先查看是否已经加载过,没有则通过父加载器,然后递归下去,直到BootstrapClassLoader,如果BootstrapClassloader找到了,直接返回,如果没有找到,则一级一级返回(查看规定加载路径),最后到达自身去查找这些对象。这种机制就叫做双亲委托。
好处是:
避免重复加载
A和B都需要加载X,各自加载就会导致X加载了两次,JVM中出现两份X的字节码;
防止恶意加载
编写恶意类java.lang.Objcet,自定义加载替换系统原生类;
上面的那段代码就很好地印证了双亲委托模型,先从缓存找,然后根据parent是否为null(BoostrapClassLoader)向上委托加载。
双亲委托是JVM的规范,是可以通过在自定义ClassLoader时重写loadClass方法打破的,而JDK1.2之后不建议直接重写loadClass,不想打破规范只需要重写findClass方法即可。
自定义ClassLoader
java.lang.ClassLoader
是所有类加载器的父类,可以看看下面这张继承关系图
java.lang.ClassLoader
有非常多的自类加载器,比如我们用于加载jar
包的java.net.URLClassLoader
其本身通过继承java.lang.ClassLoader
类,重写了findClass
方法从而实现了加载目录class
文件甚至是远程资源文件
那么接下来我们写一个自己的类加载器来实现加载自定义的字节码,这里我用TestHelloWorld
类为例,调用hello
方法
如果com.deicide.sec.classloader.TestHelloWorld
类存在的情况下,我们可以使用如下代码即可实现调用hello
方法并输出:
1 | TestHelloWorld t = new TestHelloWorld(); |
但是如果com.deicide.sec.classloader.TestHelloWorld
根本就不存在于我们的classpath
,那么我们可以使用自定义类加载器重写findClass
方法,然后在调用defineClass
方法的时候传入TestHelloWorld
类的字节码的方式来向JVM中定义一个TestHelloWorld
类,最后通过反射机制就可以调用TestHelloWorld
类的hello
方法了。
TestClassLoader示例代码:
1 | public class TestClassLoader extends ClassLoader { |
至于如何拿到字节码,emm,我是用010Editor
查看TestHelloWorld.class
,然后把那个十六进制转成十进制就好了(x)。
利用自定义加载器就可以在webshell
中实现加载并调用自己编译的类对象。
比如本地命令执行漏洞调用自定义类字节码的native方法绕过RASP检测,也可以用于加密重要的Java类字节码(只能算弱加密了)。
URLClassLoader
URLClassLoader
继承了ClassLoader
,它提供了一个加载远程资源的能力,在写利用的payload
或者webshell
的时候可以使用这个特性来加载远程的jar来实现远程的类方法调用。这个还是比较常用的一个利用姿势。
TestURLClassLoader.java实例:
1 | package com.deicide.sec.classloader; |
远程的cmd.jar
中就一个CMD.class
文件,对应的编译之前的代码片段如下:
1 | import java.io.IOException; |
就是一个获取shell
的类,前面那篇java反射机制
里面有讲。
类加载隔离
创建类加载器的时候可以指定该类加载的父类加载器,ClassLoader是有隔离机制的,不同的ClassLoader可以加载相同的Class(两者必须是非继承关系),同级ClassLoader跨类加载器调用方法时必须使用反射。
跨类加载器加载
RASP
和IAST
经常会用到跨类加载器加载类的情况,因为RASP/IAST
会在任意可能存在安全风险的类中插入检测代码,因此必须得保证RASP/IAST
的类能够被插入的类所使用的类加载正确加载,否则就会出现ClassNotFoundException,除此之外,跨类加载器调用类方法时需要特别注意一个基本原则:ClassLoader A
和ClassLoader B
可以加载相同类名的类,但是ClassLoader A
中的Class A
和ClassLoader B
中的Class A
是完全不同的对象,两者之间调用只能通过反射`。
那就举个栗子:
1 | package com.deicide.sec.classloader; |
输出
1 | aClass == aaClass:true |
这篇文章就先到这,还有一些比如JSP自定义类加载后门
,BCEL ClassLoader
,Xalan ClassLoader
这些准备分别跟JSP
,BCEL
,Xalan
一块讲,在这里讲有点太早了。
然后也该把RWCTF的那道shiro给他复现一下,感觉挺有意思的。
晚安。