Java安全基础第二篇之ClassLoader

背景

ClassLoader 顾名思义为类的加载,类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。类的加载的最终形态是位于堆区中的Class对象,Class对象封装了类在方法区内的数据结构,并且向程序员提供了访问方法区内的数据结构的接口。加载之后这个类的信息就会被放到内存中,但是是否对这个类进行初始化就要看这个类实际的定义与使用场景了,后面会一一介绍,先看看ClassLoader的介绍与使用。

ClassLoader概念定义

中文文档中对ClassLoader的定义如下:

img

可以总结出这个类的作用就是根据一个指定的类名,找到对应的Class字节码文件,然后加载成一个java.lang.Class类的一个实例。

ClassLoader分类

大部分java程序会使用以下3中系统提供的类加载器:

启动类加载器(Bootstrap ClassLoader):

这个类加载器负责将\lib目录下的类库加载到虚拟机内存中,用来加载java的核心库,此类加载器并不继承于java.lang.ClassLoader,不能被java程序直接调用,代码是使用C++编写的,是虚拟机自身的一部分。

扩展类加载器(Extendsion ClassLoader):

这个类加载器负责加载\lib\ext目录下的类库,用来加载java的扩展库,开发者可以直接使用这个类加载器。

应用程序类加载器(Application ClassLoader):

这个类加载器负责加载用户类路径CLASSPATH下的类库,一般我们编写的java类都是由这个类加载器加载,这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以也称为系统类加载器。一般情况下这就是系统默认的类加载器。(java.io.File.class.getClassLoader()将返回一个null对象,因为java.io.File类在JVM初始化的时候会被Bootstrap ClassLoader加载,我们在尝试获取被Bootstrap ClassLoader类加载器所加载的类的ClassLoader时候都会返回null

除此之外,我们还可以加入自己定义的类加载器,以满足特殊的需求,需要继承java.lang.ClassLoader类。在实际的攻击场景中,比如本地命令执行漏洞调用自定义类字节码的native方法可绕过RASP检测。

类加载器之间的层次关系如下图:

img

Java类加载分类

上面ClassLoader只是java类加载的一种方法,Java类加载方式可以分为显式和隐式,显式加载即我们通常使用Java反射或者ClassLoader来动态加载一个类对象,而隐式加载指的是类名.方法名()new类实例。显式加载方式也可以理解为类动态加载,我们可以自定义类加载器去加载任意的类。

对于动态加载的2种情况,会存在一些细微的差别:

Class.forName()和ClassLoader.loadClass()区别

Class.forName():除了将类的.class文件加载到jvm中之外,还会对类进行解释,执行类中的staticClass.forName(name, initialize, loader)带参函数也可控制是否加载static

ClassLoader.loadClass():只干一件事情,就是将.class文件加载到jvm中,不会执行static中的内容,只有在newInstance才会去执行static块,实际测试代码如下:

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
/**
* @author chen1sheng
* @date 2020/6/16 20:24
*/
import java.lang.reflect.*;

public class testLoadAndInit{
static int num = 0;
String name = "qqqqqq";
static String name2 = "wwwwwwwwwww";
// static testLoadAndInit parentClass = new testLoadAndInit();
testLoadAndInit(){
System.out.println("这里是构造函数*************");
}
{
System.out.println("name1:" + name);
System.out.println("这里是块1============");
}
static {
num += 1;
System.out.println("name2:" + name2);
System.out.println("这里是静态初始化块*************" + num);
}

}
class TestClass extends ClassLoader {
public static void main(String[] args) throws ClassNotFoundException {
Class testForname = Class.forName("testLoadAndInit");
Class testClassLoader = ClassLoader.getSystemClassLoader().loadClass("testLoadAndInit");
//System.out.println(ClassLoader.getSystemClassLoader());
}

}

Java类加载机制

在之前反射那里学过,类可以通过多种方式进行加载,以一个JavaHelloWorld来学习ClassLoader

1
2
3
4
5
6
7
public class TestHelloWorld {

public String hello() {
return "Hello World~";
}

}

ClassLoader加载TestHelloWorld类重要流程如下:

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

自定义ClassLoader

假设我们需要的类不存在于classpath,那就可以用自定义类加载器重写findClass方法,然后在调用defineClass方法的时候传入TestHelloWorld类的字节码的方式来向JVM中定义一个TestHelloWorld类,最后通过反射机制就可以调用TestHelloWorld类的hello方法了

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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import java.lang.reflect.Method;


public class TestClassLoader extends ClassLoader {

// TestHelloWorld类名
private static String testClassName = "com.anbai.sec.classloader.TestHelloWorld";

// TestHelloWorld类字节码
private static byte[] testClassBytes = new byte[]{
-54, -2, -70, -660005101710040138014701570
16106601051101051166210340418610467111100
1011015761051101017811710998101114849798108101
1051041011081081111020404176106971189747108
9711010347831161141051101035910108311111711499
101701051081011019841011151167210110810811187111
114108100461069711897120506101272101108108111
32871111141081001261040991111094797110989710547
11510199479910897115115108111971001011144784101115
11672101108108111871111141081001016106971189747108
9711010347799810610199116033030400000201
05060107000290101000542, -7301, -79000
1080006010007010901001070002701
010003182, -800001080006010001001011
0002012
};

@Override
public Class<?> findClass(String name) throws ClassNotFoundException {
// 只处理TestHelloWorld类
if (name.equals(testClassName)) {
// 调用JVM的native方法定义TestHelloWorld类
return defineClass(testClassName, testClassBytes, 0, testClassBytes.length);
}

return super.findClass(name);
}

public static void main(String[] args) {
// 创建自定义的类加载器
TestClassLoader loader = new TestClassLoader();

try {
// 使用自定义的类加载器加载TestHelloWorld类
Class testClass = loader.loadClass(testClassName);

// 反射创建TestHelloWorld类,等价于 TestHelloWorld t = new TestHelloWorld();
Object testInstance = testClass.newInstance();

// 反射获取hello方法
Method method = testInstance.getClass().getMethod("hello");

// 反射调用hello方法,等价于 String str = t.hello();
String str = (String) method.invoke(testInstance);

System.out.println(str);
} catch (Exception e) {
e.printStackTrace();
}
}

}

URLClassLoader

使用URLClassLoader可以更灵活的加载远程资源的能力,在写漏洞利用的payload或者webshell的时候我们可以使用这个特性来加载远程的jar来实现远程的类方法调用。

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
47
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.InputStream;
import java.net.URL;
import java.net.URLClassLoader;

/**
* @author chen1sheng
* @date 2020/6/15 21:09
*/
public class TestURLClassLoader {

public static void main(String[] args) {
try {
// read the local file
File file = new File("F:\\JavaProject\\debug\\out\\production\\debug\\CMD.class");
// convert the file:// to URI
URL url = file.toURI().toURL();
// System.out.print(url);

// create the URLClassLoader object by url
URLClassLoader ucl = new URLClassLoader(new URL[]{url});

String cmd = "cmd.exe /c dir";

// load the URLClassLoader Class
Class cmdClass = ucl.loadClass("CMD");

// call the method 'exec' ,it equal 'Process process = CMD.exec("whoami");'
Process process = (Process) cmdClass.getMethod("exec", String.class).invoke(null, cmd);

// read the result
InputStream in = process.getInputStream();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] b = new byte[1024];
int a = -1;
while ((a = in.read(b)) != -1) {
baos.write(b, 0, a);
}
// print the result
System.out.println(baos.toString());
} catch (Exception e) {
e.printStackTrace();
}
}

}

CMD.java编译后放在某个目录下修改上面的path即可,CMD内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.io.IOException;

/**
* @author chen1sheng
* @date 2020/6/15 21:04
*/
public class CMD {
public static Process exec(String cmd) throws IOException {
return Runtime.getRuntime().exec(cmd);
}
public static void main(String[] args) throws IOException {
exec("ipconfig");
}
}

运行截图:

image-20200615211212473

类的加载与初始化

类从被加载到虚拟机内存中开始,到卸载出内存为止,整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载7个阶段。其中验证、准备、解析3个部分统称为连接。

上面说的加载都只是如何将类加载到内存中,完成加载和连接之后是否需要初始化则需要根据当前场景进行判断,对于是否初始化,jvm的规范严格限定了只有5种情况必须对类进行初始化:

1、遇到newgetstaticputstaticinvokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这四条指令最常见的Java场景是:

1)使用关键字new实例化对象的时候

2)读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候

3)调用一个类的静态方法的时候

2、使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化

3、当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化

4、当虚拟机启动的时候,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个类

5、当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStaticREF_putStaticREF_invokeStatic的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。

以上这5种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。如:

1)通过子类引用父类的静态字段,不会导致子类初始化

2)通过数组定义来引用类,不会触发此类的初始化

3)常量(static final 修饰的)在编译阶段会存入调用类的常量池中,因此调用其常量本质上并没有直接引用到定义常量的类,因此不会触发常量的类的初始化

理解这个初始化有什么用呢?以fastjson的利用为例,我们一般需要编译一个命令执行的java文件,这里一般的写法是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.lang.Runtime;
import java.lang.Process;

public class TouchFile {
static {
try {
Runtime rt = Runtime.getRuntime();
String[] commands = {"touch", "/tmp/success"};
Process pc = rt.exec(commands);
pc.waitFor();
} catch (Exception e) {
// do nothing
}
}
}

然后会让服务器来请求远程加载TouchFile类,进而达到远程命令执行的效果。那么这里的写法为什么不是定义一个runtime方法然后在main里面调用呢?

这就牵扯到了加载初始化的过程,因为就这个攻击方法而言,我们只能让服务器来加载并初始化这个类,并不能让服务器去执行某个方法,所以这里的写法是将需要运行的代码片段放在了static里面,然后我们可以看到在fastjson的源码中,加载类的方法都是通过Class.forname进行的,结合上面讲到的Class.forname本身的加载机制也就可以理解这么写的原因了。

image-20200616205425572

总结

了解一下类加载机制的灵活性,在某些限制条件下可以利用这个特性进行绕过,同时学习一下类加载之后的初始化过程,知道为什么fastjsonpayload需要按照使用static去修饰。

参考链接

Java反序列化漏洞学习实践六:类的加载机制和恶意类构造

ClassLoader(类加载机制)

类的加载和初始化的区别

类加载和初始化