谈一谈Java动态加载字节码的方式
# ClassLoader
要说Java动态加载字节码,就不得不谈Java的ClassLoader。当然,它也不是我简简单单几段文字能够说得清的,这里对于ClassLoader的介绍姑且算是抛砖引玉,背后的更多东西有兴趣的读者可以自行挖掘。
Java程序运行前需要先编译成为.class文件,在类初始化的时候会调用java.lang.ClassLoader
来加载.class文件,ClassLoader
会调用一些方法把这些字节码文件变成一个java.lang.Class
对象。
graph LR
Java源文件--javac编译-->Java字节码文件--ClassLoader-->JVM
2
3
Java类必须经过JVM的加载之后才能运行,而ClassLoader
的作用就是加载Java类。Java中有三种加载器:
- Bootstarp ClassLoader(引导类加载器)
- Extension ClassLoader(扩展类加载器)
- App ClassLoader (系统类加载器)
其中App ClassLoader
是默认的加载器。也就是说,如果我们在加载类的时候不指定加载器,则会默认使用App ClassLoader
。
下图为JVM的架构图:

# 双亲委派模型
ClassLoader
是基于双亲委派模型来搜索类的,每一个ClassLoader
对象都有一个父类加载器的引用(不是继承关系,而是包含关系),这个父类加载器可以用getParten()
来获取。
对于App ClassLoader
来说,它的父类加载器是Extension ClassLoader
;对于Extension ClassLoader
来说,它的父类加载器是Bootstarp ClassLoader
;而Bootstarp ClassLoader
没有父类加载器。此外,开发人员会编写一些自定义的类加载器,一般来说,这些类加载器的父类加载器是App ClassLoader
。
当一个ClassLoader
加载某个类的时候,会把这个类交给其父类加载器来加载,而整个过程是由上而下的检查的,也就是说首先由最顶层的Bootstarp ClassLoader
加载,如果没加载到,则再由Extension ClassLoader
来加载,如果还是没加载到,再交给App ClassLoader
来加载,如果还是没找到,则会返回到一开始发起这个委托请求的ClassLoader
,由这个加载器到指定的文件系统、网络等URL来获取要加载的类。如果还是没有获取到,则会抛出ClassNotFoundException
异常。
下面是一个简单图示:
其实在loadClass
在调用findClass
之前还会调用一次findLoadedClass
方法来检测JVM是否已经加载过该类了,如果已经加载则会直接返回该类的对象。上图中默认JVM没有加载该类。
在网上找的另外一张:
使用双亲委派模型可以避免类的重复加载,同样增加了安全性。
当父亲已经加载了该类的时候,就没有必要
ClassLoader
再加载一次。考虑到安全因素,我们试想一下,如果不使用这种委托模式,那我们就可以随时使用自定义的String来动态替代java核心api中定义的类型,这样会存在非常大的安全隐患,而双亲委托的方式,就可以避免这种情况,因为String已经在启动时就被引导类加载器(Bootstrcp ClassLoader
)加载,所以用户自定义的ClassLoader
永远也无法加载一个自己写的String,除非你改变JDK中ClassLoader
搜索类的默认算法。
public class Main {
public static void main(String[] args) {
ClassLoader classLoader = Main.class.getClassLoader();
while (classLoader != null) {
System.out.println(classLoader);
classLoader = classLoader.getParent();
}
System.out.println(classLoader);
}
}
2
3
4
5
6
7
8
9
10
我们在尝试被Bootstarp ClassLoader
类所加载的ClassLoader
的时候都会返回null,具体原因下文会解释。
# 到源码中看看
我们前面说到App ClassLoader
的父加载器是Extension ClassLoader
。对于这一点,如果你有足够的好奇心想知道为什么的话,就可以到这两个类的源代码中去看一看。
这两个类都是sun.misc.Launcher
的内部类,我们需要注意的是这几行代码:
var1
是一个Extension ClassLoader
,作为一个参数传入了getAppClassLoader
方法中,在getAppClassLoader
中又调用了AppClassLoader
的构造方法(这种设计方式叫单例模式,目的是为了保证只有一份类的实例):
var0
就是上一步中得到的Extension ClassLoader
。AppClassLoader
是URLClassLoader
的子类,URLClassLoader
间接继承于ClassLoader
,最后一步步调用到ClassLoader
的构造方法,把var0
作为parent
来传入:
至于Extension ClassLoader
并没有对其parent
的明确赋值,而是在构造方法中传入了null
:
但是它的父加载器却是BootStrap ClassLoader
,BootStrap ClassLoader
逻辑采用C++编写,它是JVM的一部分,本身并不是一个Java类,也就无法获得它的引用。Java的一些核心类,比如java.lang.String
、java.lang.Integar
等都有它来加载。它没有父加载器,但是它可以作用于一个父加载器。
# 几个重要方法
ClassLoader#loadClass
这个方法会根据全类名(即包名.类名)来加载类,它的执行过程一般会经历以下几个阶段:
findLoadedClass()
:检测待加载的类是否已经被JVM加载过,如果是,则直接返回该类。parent.loadClass()
:如果没有加载到类的话,则会使用双亲委派机制来递归调用loadClass
来加载类。findClass()
:如果还是没有找到,则会使用findClass
来寻找类,如果找到的话,然后调用defineClass()
来向JVM中注册该类。
源码如下:
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;
}
}
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
28
29
30
31
32
33
34
35
36
37
findClass
方法中明确了一些规则来告诉Java如何寻找的要加载的类,我们可以写一个自己的类加载器来自定义这个规则。
# 自定义加载器
一个自定义加载器一般来说需要满足以下几点:
- 继承于
ClassLoader
- 自定义寻找类的规则,也就是重写
findClass
方法 - 在
findClass
方法中调用defineClass
方法向JVM中注册类
我们下面开始尝试编写一个自定义的ClassLoader
,并且定义它的规则就是根据.class文件的存放路径来加载类。
首先用javassist
编写一个简单的待加载的类Hello
,生成的.class文件反编译之后如下所示:
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
public class Hello {
static {
System.out.println("Hello ClassLoader");
}
public Hello() {
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
然后编写ClassLoader
:
package classloader;
import java.io.*;
public class CustomClassLoader extends ClassLoader {
private final String classpath;
public CustomClassLoader() {
classpath = "./";
}
public CustomClassLoader(String classpath) {
this.classpath = classpath;
}
private String getClassNameWithPaths(String name) {
if (name.endsWith(".class")) {
return name;
}
int index = name.lastIndexOf(".");
if (index == -1) {
return name + ".class";
} else {
return name.substring(index + 1) + ".class";
}
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
String className = getClassNameWithPaths(name);
File file = new File(classpath, className);
try {
FileInputStream fileInputStream = new FileInputStream(file);
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
int length = 0;
byte[] buffer = new byte[1024];
try {
while ((length = fileInputStream.read(buffer)) != -1) {
byteArrayOutputStream.write(buffer, 0, length);
}
} catch (IOException e) {
e.printStackTrace();
}
byte[] bytes = byteArrayOutputStream.toByteArray();
try {
byteArrayOutputStream.close();
fileInputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
return defineClass(name, bytes, 0, bytes.length);
} catch (FileNotFoundException e) {
e.printStackTrace();
}
return super.findClass(name);
}
}
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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
实现的原理也很简单,就是用Java常规的IO操作来读取字节码文件的内容,然后将其转化为字节数组,最后由defineClass
向JVM中注册该类。
package classloader;
public class Main {
public static void main(String[] args) throws Exception {
CustomClassLoader customClassLoader = new CustomClassLoader("D:\\desktop\\JacksonTest");
Class<?> hello = customClassLoader.loadClass("Hello");
System.out.println(hello.newInstance());
}
}
2
3
4
5
6
7
8
9
运行结果:
# 动态加载
Java类的加载可以被分为显示加载和隐式加载两种方式,所谓隐式加载就是直接使用new
关键字来创建一个类的实例,而显示加载又可以称作动态加载,可以使用反射或者ClassLoader
来动态加载类对象。
// 反射
java.lang.Class.forName("ClassName")
// 使用ClassLoader
{ClassLoader}.loadClass()
2
3
4
5
package test;
class Student {
public String name;
public int age;
}
public class Main {
public static void main(String[] args) throws Exception {
Student student = new Student();
System.out.println("-------Load Class by ClassLoader------");
ClassLoader classLoader = student.getClass().getClassLoader();
System.out.println(classLoader);
Class<?> Class = classLoader.loadClass("test.Student");
System.out.println(Class);
System.out.println("------Load Class by Reflection------");
java.lang.Class<?> ClassRef = java.lang.Class.forName("test.Student");
System.out.println(ClassRef);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
在
Student student = new Student()
这条语句执行的时候Student类已经被注册到JVM中了,所以我们用AppClassLoader#loadClass
可以直接获取到类。
如果使用Class.forName("ClassName")
来加载类的话还会触发类的static代码块,其实它有两种形式:
Class.forname(String className)
Class.forName(String name,boolean initialize, ClassLoader loader)
2
这一点在反射中已经学习过了。
# 使用Classloader直接加载字节码
通过ClassLoader#loadClass(String className)
这样使用类名来加载类的时候(默认该类没有被JVM加载过)要经历下面三个方法的调用:
graph LR
loadClass-->findClass-->defineClass
2
3
loadClass
:根据类名使用双亲委派机制从父加载器中寻找类,如果没找到会调用findClass
findClass
:会根据URL所指定的方式来寻找字节码,然后调用defineClass
处理字节码defineClass
:把Java字节码处理成真正的Java类,并且在JVM中注册
所以加载类的核心部分是最后的defineClass
,我们可以写一个Demo来演示如何使用defineClass
来直接加载Java字节码:
package test;
import javassist.ClassPool;
import javassist.CtClass;
import java.lang.reflect.Method;
public class DefineClassTest {
public static void main(String[] args) throws Exception {
// 生成一个Java字节码文件
ClassPool pool = ClassPool.getDefault();
CtClass test = pool.makeClass("Test");
String cmd = "java.lang.System.out.println(\"Hello Test\");";
test.makeClassInitializer().insertBefore(cmd);
byte[] bytes = test.toBytecode();
// 由于defineClass默认属性是protected,所以要用反射的方法来获取该方法
Class<?> Clazz = Class.forName("java.lang.ClassLoader");
Method defineClass = Clazz.getDeclaredMethod("defineClass", String.class, byte[].class, int.class, int.class);
defineClass.setAccessible(true);
Class test1 = (Class) defineClass.invoke(ClassLoader.getSystemClassLoader(), "Test", bytes, 0, bytes.length);
test1.newInstance();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
获取到一个Java字节码文件之后,可以通过ClassLoader#defineClass
来把它变成真正的Java类。但是在defineClass
执行的时候并不会触发static代码块或者类的构造方法的,只有当显式调用其构造函数的时候才会被执行。因为ClassLoader#defineClass
的属性是protected
,所以无法直接在类外部访问,在实际情况中很少直接利用这种方式。
但是com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl
这个类中的内部类TransletClassLoader
重写了defineClass
方法:
static final class TransletClassLoader extends ClassLoader {
······
Class defineClass(final byte[] b) {
return defineClass(null, b, 0, b.length);
}
······
}
2
3
4
5
6
7
8
9
10
11
并且这个方法没有声明作用域,默认为default
,可以在类外部被调用,所以就有了下面这条链:
graph LR
TemplatesImpl#getOutputProperties --> TemplatesImpl#newTransformer -->
TemplatesImpl#getTransletInstance --> TemplatesImpl#defineTransletClasses
--> TransletClassLoader#defineClass
2
3
4
5
6
调试过CC的同学肯定非常熟悉这条利用链,另外在Fastjson和Jackson等反序列化漏洞中都有它的身影。
另外TemplatesImpl
中对于加载的字节码有一定的要求:这个字节码所对应的类必须是com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet
的子类。
# 使用URLClassLoader远程加载字节码
java.net.URLClassLoader
,继承于ClassLoader
并且是App ClassLoader
的父类,它通过重写findClass
方法来实现了加载目录系统中的.class文件或者是远程服务器上的.class文件的功能。

注意继承关系和双亲委派机制中的父加载器的区别:URLClassLoader
虽然是App ClassLoader
的父类,但是在默认情况下它的父加载器却是App ClassLoader
:
package classloader;
import java.net.URL;
import java.net.URLClassLoader;
public class Main {
public static void main(String[] args) throws Exception {
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(systemClassLoader);
URL url = new URL("http://127.0.0.1:8080");
URLClassLoader urlClassLoader = URLClassLoader.newInstance(new URL[]{url});
System.out.println(urlClassLoader);
System.out.println(urlClassLoader.getParent());
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
它可以让我们通过下面几种方式来加载.class文件:
- 从Jar包中加载
- 从文件系统目录中加载
- 从远程服务器进行加载
每种加载方式都对应着一种格式的URL(也叫做基础路径),这里我直接引用P牛文章中的部分内容:
正常情况下,Java会根据配置项
sun.boot.class.path
和java.class.path
中列举的基础路径(这些路径是经过处理之后的java.net.URL
类)来寻找.class文件来加载,这种基础路径分为三种情况:
- URL未以
/
结尾,则认为是一个JAR文件,用JarLoader
来寻找类,即在Jar包中寻找.class文件- URL以
/
结尾,且协议名是file
,则用FileLoader
来寻找类,即在本地文件系统中寻找.class文件- URL以
/
结尾,且协议名不是file
,则使用最基础的Loader
来寻找类
最常见的一种情况就是可以使用http
协议来加载远程服务器上的.class文件,我们可以写一个Demo:
- 编写一个Hello.java文件,然后将其编译成.class文件,最后开启http服务将其托管
public class Hello {
public Hello() {
System.out.println("Say Hello");
}
}
2
3
4
5
- 再使用
URLClassLoader
去加载.class文件
import java.net.URL;
import java.net.URLClassLoader;
public class LoaderTest {
public static void main(String[] args) throws Exception {
URL[] urls = {new URL("http://127.0.0.1:8080/")};
URLClassLoader urlClassLoader = URLClassLoader.newInstance(urls);
Class<?> helloClass = urlClassLoader.loadClass("Hello");
helloClass.newInstance();
}
}
2
3
4
5
6
7
8
9
10
11
所以,如果可以控制ClassLoader的加载类的基础路径的话,则可以使用远程加载恶意类的方式来执行任意代码。
# 使用Unsafe直接加载字节码
除了ClassLoader#defineClass
可以直接加载字节码之外,Java还提供给我们一个类sun.misc.Unsafe
来实现同样的功能。这个类提供了对于底层的内存、线程、类、对象等操作的方法,其中Unsafe#defineClass
就可以直接向JVM中注册类、实现直接加载字节码的功能。
package test.unsafe;
import javassist.ClassPool;
import javassist.CtClass;
import sun.misc.Unsafe;
import java.lang.reflect.Constructor;
public class Main {
public static void main(String[] args) throws Exception {
// 生成一个Java字节码文件
ClassPool pool = ClassPool.getDefault();
CtClass test = pool.makeClass("Test");
String cmd = "java.lang.System.out.println(\"Hello Test\");";
test.makeClassInitializer().insertBefore(cmd);
byte[] bytes = test.toBytecode();
// 利用反射创建Unsafe对象
Constructor<Unsafe> constructor = Unsafe.class.getDeclaredConstructor();
constructor.setAccessible(true);
Unsafe unsafe = constructor.newInstance();
System.out.println(unsafe);
// 调用Unsafe#defineClass
Class aClass = unsafe.defineClass("Test", bytes, 0, bytes.length);
aClass.newInstance();
}
}
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
28
上面的方法完整定义为public native Class defineClass(String var1, byte[] var2, int var3, int var4);
,这个方法仅适用于Java8之前的版本,在Java8中这个方法的定义变成了public native Class<?> defineClass(String var1, byte[] var2, int var3, int var4, ClassLoader var5, ProtectionDomain var6);
,在使用的时候需要传入一个ClassLoader
和ProtectionDomain
,下面是Java8版本中的示例:
package test.unsafe;
import javassist.ClassPool;
import javassist.CtClass;
import sun.misc.Unsafe;
import java.lang.reflect.Constructor;
import java.security.CodeSource;
import java.security.ProtectionDomain;
import java.security.cert.Certificate;
public class Main {
public static void main(String[] args) throws Exception {
// 生成一个Java字节码文件
ClassPool pool = ClassPool.getDefault();
CtClass test = pool.makeClass("Test");
String cmd = "java.lang.System.out.println(\"Hello Test\");";
test.makeClassInitializer().insertBefore(cmd);
byte[] bytes = test.toBytecode();
// 利用反射创建Unsafe对象
Constructor<Unsafe> constructor = Unsafe.class.getDeclaredConstructor();
constructor.setAccessible(true);
Unsafe unsafe = constructor.newInstance();
System.out.println(unsafe);
// Java8中调用Unsafe#defineClass
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
ProtectionDomain protectionDomain = new ProtectionDomain(new CodeSource(null, (Certificate[]) null), null, systemClassLoader, null);
Class aClass = unsafe.defineClass("Test", bytes, 0, bytes.length, systemClassLoader, protectionDomain);
aClass.newInstance();
}
}
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
28
29
30
31
32
33
34
在Java11中Unsafe#defineClass
方法已经被移除,也就不能再使用Unsafe
类来直接加载字节码了,且用且珍惜。
# 使用Proxy来直接加载字节码
java.lang.reflect.Proxy
是Java动态代理中常用的一个类,但是它有个defineClass0
的native方法,也可以和ClassLoader
和Unsafe
类一样直接加载字节码。
package test.proxy;
import javassist.ClassPool;
import javassist.CtClass;
import java.lang.reflect.Method;
public class Demo1 {
public static void main(String[] args) throws Exception {
ClassPool pool = ClassPool.getDefault();
CtClass hello = pool.makeClass("Hello");
String command = "System.out.println('Hello Proxy');".replace("'", "\"");
hello.makeClassInitializer().insertBefore(command);
byte[] bytes = hello.toBytecode();
Method defineClass0 = Class.forName("java.lang.reflect.Proxy").getDeclaredMethod("defineClass0", ClassLoader.class, String.class, byte[].class, int.class, int.class);
defineClass0.setAccessible(true);
Class helloClass = (Class) defineClass0.invoke(null, ClassLoader.getSystemClassLoader(), "Hello", bytes, 0, bytes.length);
Object helloClassInstance = helloClass.newInstance();
System.out.println(helloClass);
System.out.println(helloClassInstance);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 使用BCEL来加载字节码
包含在原生JDK中,但在8u251之后之后的BCEL的ClassLoader被移除了
BCEL提供了两个类Repository
和Utility
:
Repository
用于将一个Java类转换成Java原生字节码,也可以使用javac来编译.java文件来获取字节码Utility
用于将Java原生字节码来转化成BCEL格式的字节码
在ClassLoader#loadClass
中会判断类名是否已$$BCEL$$
开头,如果是的话会对这个字符串进行decode。
Demo如下,先编写一个恶意类Evil
,它的static{}
中会触发计算器:
import java.io.IOException;
public class Evil {
static {
try {
Runtime.getRuntime().exec("calc");
} catch (IOException e) {
e.printStackTrace();
}
}
}
2
3
4
5
6
7
8
9
10
11
然后使用BCEL中的类把这个类转化为符合BCEL格式的字节码,最后进行loadClass
:
import com.sun.org.apache.bcel.internal.Repository;
import com.sun.org.apache.bcel.internal.classfile.JavaClass;
import com.sun.org.apache.bcel.internal.classfile.Utility;
import com.sun.org.apache.bcel.internal.util.ClassLoader;
public class BCELTest {
public static void main(String[] args) throws Exception {
JavaClass javaClass = Repository.lookupClass(Evil.class);
String codes = Utility.encode(javaClass.getBytes(), true);
System.out.println(codes);
ClassLoader loader = new ClassLoader();
loader.loadClass("$$BCEL$$" + codes).newInstance();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 参考
- javasec.org(现在已经被授权到知识盒子,需要付费查看)
- P牛《Java安全漫谈-13.Java中动态加载字节的那些方法》(知识星球,同样需要付费)
- URLClassLoader类加载器_赶路人儿-CSDN博客
- 详细深入分析 Java ClassLoader 工作机制_http://www.54tianzhisheng.cn/-CSDN博客