Serialization in Java: Understanding the Risks and Best Practices
Written on
Serialization in Java refers to the process of converting objects into a byte stream, facilitating their storage in files or transmission over networks. Essentially, serialization encodes the state of an object, allowing for its reconstruction later through deserialization.
Imagine serialization as packing your belongings into a box. You wrap and arrange each item, sealing the box for transport. Upon arrival, the recipient simply unpacks it, restoring everything to its original condition.
Java simplifies serialization with the Serializable interface. By implementing this interface in your class, you enable serialization. Here’s a basic illustration:
import java.io.Serializable;
public class Person implements Serializable {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "Person{name='" + name + "', age=" + age + "}";}
}
In this example, the Person class can be serialized because it implements Serializable. You can serialize its instances and later deserialize them to retrieve the same objects.
Serialization proves beneficial in various scenarios, such as saving an object’s state to a file for later restoration or transferring it between machines in distributed applications. While it appears straightforward and powerful, there are significant challenges and risks associated with Java serialization, which we will discuss further.
Biggest Challenge: Security
Security is one of the foremost concerns related to Java serialization. When deserializing an object, you must trust that the incoming data is safe. However, deserialization can be manipulated by attackers to execute arbitrary code on your system. This risk arises because deserialization can invoke methods in unintended ways.
A major security issue is deserializing data from untrusted sources. If you deserialize data from an unknown origin, you expose your application to possible attacks. For example, an attacker may design a serialized byte stream that executes malicious code upon deserialization.
The attack surface for serialization is extensive. Any class that undergoes deserialization becomes part of this attack surface, meaning any serializable class could be exploited if not managed properly. Securing serialized objects, especially in large applications with numerous dependencies, poses significant challenges.
Security researchers have identified numerous vulnerabilities in popular libraries and applications stemming from unsafe deserialization practices. These vulnerabilities can lead to remote code execution (RCE), denial-of-service (DoS) attacks, and various exploits. For instance, an attacker could leverage deserialization flaws to create a payload that chains multiple method calls from different classes to execute harmful actions.
Given these dangers, exercising caution when employing serialization is paramount, particularly with untrusted data. Avoiding deserialization of such data is a fundamental security measure. Additionally, there are safer alternatives and best practices to mitigate these risks, which we will explore in subsequent sections.
Serialization Bombs
Serialization bombs are maliciously crafted serialized objects that consume excessive resources during deserialization. These attacks can lead to denial-of-service (DoS) situations by exhausting memory, CPU cycles, or other resources, effectively incapacitating the application or server.
A classic example of a serialization bomb is a nested data structure that grows exponentially during deserialization. For instance, a small serialized stream can trigger the creation of a vast number of objects, leading to out-of-memory errors or excessive CPU utilization.
Consider this serialization bomb featuring nested HashSet objects:
import java.io.*;
import java.util.HashSet;
import java.util.Set;
public class SerializationBomb {
public static byte[] createBomb() throws IOException {
Set<Object> root = new HashSet<>();
Set<Object> s1 = root;
Set<Object> s2 = new HashSet<>();
for (int i = 0; i < 100; i++) {
Set<Object> t1 = new HashSet<>();
Set<Object> t2 = new HashSet<>();
t1.add("foo"); // Make t1 unequal to t2
s1.add(t1);
s1.add(t2);
s2.add(t1);
s2.add(t2);
s1 = t1;
s2 = t2;
}
return serialize(root);
}
public static byte[] serialize(Object obj) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(obj);
return baos.toByteArray();
}
public static void main(String[] args) {
try {
byte[] bomb = createBomb();
// Deserialize the bomb
ByteArrayInputStream bais = new ByteArrayInputStream(bomb);
ObjectInputStream ois = new ObjectInputStream(bais);
Object obj = ois.readObject();
ois.close();
System.out.println("Deserialized object: " + obj);
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();}
}
}
In this code, the createBomb method generates a deeply nested HashSet structure. The resulting byte array, when deserialized, will consume a massive amount of resources. The serialized form is compact, but deserializing it will create a large, intricate object graph.
When deserializing this bomb, the program attempts to reconstruct the entire nested structure, resulting in high memory and CPU usage, potentially causing a denial-of-service attack and making the system unresponsive.
Serialization bombs are particularly hazardous as they are often challenging to identify and mitigate. They exploit the complexity and resource demands of the deserialization process, which can be overlooked. Defending against such attacks requires implementing deserialization filters and avoiding the deserialization of data from untrusted sources whenever feasible.
By understanding serialization bombs and their implications, developers can take proactive measures to fortify their applications against these threats. In the following sections, we will delve into safer alternatives to Java serialization and best practices to minimize these risks.
Safer Alternatives to Java Serialization
Given the security concerns and pitfalls associated with Java serialization, it is wise to seek safer alternatives. Several methods provide more secure and flexible ways to serialize and deserialize objects. These alternatives not only reduce the risks tied to Java’s native serialization but also offer benefits such as improved performance and cross-platform compatibility.
One popular alternative is utilizing JSON for serialization. JSON is a lightweight data-interchange format that is easy for humans to read and write, and simple for machines to parse and generate. Libraries such as Jackson and Gson are commonly employed for converting Java objects to JSON and vice versa.
Here’s a straightforward example using Jackson:
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
public class JsonExample {
public static class Person {
private String name;
private int age;
// Default constructor needed for Jackson
public Person() {}
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "Person{name='" + name + "', age=" + age + "}";}
}
public static void main(String[] args) {
ObjectMapper objectMapper = new ObjectMapper();
try {
// Serialize object to JSON
Person person = new Person("Alice", 30);
String jsonString = objectMapper.writeValueAsString(person);
System.out.println("Serialized JSON: " + jsonString);
// Deserialize JSON to object
Person deserializedPerson = objectMapper.readValue(jsonString, Person.class);
System.out.println("Deserialized object: " + deserializedPerson);
} catch (IOException e) {
e.printStackTrace();}
}
}
In this example, the Person class is serialized into a JSON string and subsequently deserialized back into a Person object using Jackson. JSON serialization is less risky as it does not depend on Java's native serialization mechanism and its vulnerabilities.
Another robust alternative is Protocol Buffers (protobuf), a language-neutral and platform-neutral mechanism for serializing structured data, developed by Google. Protobuf offers compact, swift, and reliable serialization.
Here’s a simple example using protobuf:
First, define your data structure in a .proto file:
syntax = "proto3";
message Person {
string name = 1;
int32 age = 2;
}
Next, generate Java classes from the .proto file using the protobuf compiler.
Then, use the generated classes for serialization and deserialization:
import com.example.PersonOuterClass.Person;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
public class ProtobufExample {
public static void main(String[] args) {
try {
// Serialize object to protobuf
Person person = Person.newBuilder().setName("Bob").setAge(25).build();
FileOutputStream fos = new FileOutputStream("person.ser");
person.writeTo(fos);
fos.close();
// Deserialize object from protobuf
FileInputStream fis = new FileInputStream("person.ser");
Person deserializedPerson = Person.parseFrom(fis);
fis.close();
System.out.println("Deserialized object: " + deserializedPerson);
} catch (IOException e) {
e.printStackTrace();}
}
}
In this protobuf example, we define a Person message in a .proto file, compile it to generate Java classes, and then utilize these classes to serialize and deserialize a Person object. Protobuf's binary format is compact and efficient, making it ideal for high-performance applications.
By opting for these alternatives, you gain greater control over the serialization process, minimize security risks, and enhance interoperability with other systems. In the following sections, we will further explore cross-platform data representations and how they improve the safety and functionality of your applications.
Cross-Platform Data Representations
Cross-platform data representations like JSON and Protocol Buffers offer significant advantages over Java serialization, particularly in security, performance, and interoperability. These formats are designed to work seamlessly across different programming languages and platforms, making them ideal for distributed systems and web services.
JSON (JavaScript Object Notation)
JSON is a lightweight data-interchange format that is easy for humans to read and write and simple for machines to parse and generate. It is language-independent and widely used in web development for exchanging data between clients and servers. JSON’s simplicity and flexibility make it a popular choice for serialization.
Here’s an example using Gson for JSON serialization and deserialization:
import com.google.gson.Gson;
public class JsonExample {
public static class Person {
private String name;
private int age;
public Person() {}
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "Person{name='" + name + "', age=" + age + "}";}
}
public static void main(String[] args) {
Gson gson = new Gson();
// Serialize object to JSON
Person person = new Person("Alice", 30);
String jsonString = gson.toJson(person);
System.out.println("Serialized JSON: " + jsonString);
// Deserialize JSON to object
Person deserializedPerson = gson.fromJson(jsonString, Person.class);
System.out.println("Deserialized object: " + deserializedPerson);
}
}
In this example, we utilize Gson to convert a Person object to a JSON string and back to a Person object. JSON’s text-based format is human-readable, simplifying debugging and logging of serialized data.
Protocol Buffers (protobuf)
Protocol Buffers, developed by Google, provide a language-neutral and platform-neutral mechanism for serializing structured data. Protobuf is efficient in size and speed, making it suitable for high-performance applications. It employs a schema to define the structure of the data, ensuring consistency and backward compatibility.
Here’s how to use Protocol Buffers:
Define the data structure in a .proto file:
syntax = "proto3";
message Person {
string name = 1;
int32 age = 2;
}
Generate Java classes from the .proto file using the protobuf compiler.
Use the generated classes to serialize and deserialize data:
import com.example.PersonOuterClass.Person;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
public class ProtobufExample {
public static void main(String[] args) {
try {
// Serialize object to protobuf
Person person = Person.newBuilder().setName("Bob").setAge(25).build();
FileOutputStream fos = new FileOutputStream("person.ser");
person.writeTo(fos);
fos.close();
// Deserialize object from protobuf
FileInputStream fis = new FileInputStream("person.ser");
Person deserializedPerson = Person.parseFrom(fis);
fis.close();
System.out.println("Deserialized object: " + deserializedPerson);
} catch (IOException e) {
e.printStackTrace();
}
}
}
In this example, we define a Person message in a .proto file, compile it to generate Java classes, and then utilize these classes to serialize and deserialize a Person object. Protobuf’s binary format is compact and efficient, making it ideal for high-performance applications.
Both JSON and Protocol Buffers provide safer and more efficient methods for serialization and deserialization compared to Java’s native serialization. They support data interchange across different platforms and languages, enhancing the flexibility and robustness of your applications. In the next section, we will discuss the importance of avoiding deserialization of untrusted data and the best practices to follow.
Avoiding Deserialization of Untrusted Data
A critical security practice in serialization is to refrain from deserializing data from untrusted sources. Deserializing such data can expose your application to various attacks, including remote code execution, denial-of-service, and data tampering. To mitigate these risks, it is essential to adhere to best practices and employ defensive programming techniques.
First and foremost, never accept serialized data from untrusted or unknown sources. This is the most effective way to prevent deserialization vulnerabilities. If your application must deserialize data from external sources, ensure that those sources are thoroughly vetted and trusted.
In cases where deserialization of untrusted data is unavoidable, Java provides mechanisms to mitigate risks. One such mechanism is the use of ObjectInputFilter, which allows you to define rules governing what types of objects are permissible for deserialization.
Here’s an example utilizing ObjectInputFilter:
import java.io.*;
public class DeserializationFilterExample {
public static class Person implements Serializable {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "Person{name='" + name + "', age=" + age + "}";}
}
public static void main(String[] args) {
try {
// Serialize the object
Person person = new Person("Alice", 30);
FileOutputStream fos = new FileOutputStream("person.ser");
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(person);
oos.close();
// Set up the deserialization filter
ObjectInputFilter filter = info -> {
if (info.serialClass() != null && info.serialClass() != Person.class) {
return ObjectInputFilter.Status.REJECTED;}
return ObjectInputFilter.Status.ALLOWED;
};
// Deserialize the object with the filter
FileInputStream fis = new FileInputStream("person.ser");
ObjectInputStream ois = new ObjectInputStream(fis);
ois.setObjectInputFilter(filter);
Person deserializedPerson = (Person) ois.readObject();
ois.close();
System.out.println("Deserialized object: " + deserializedPerson);
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();}
}
}
In this example, the ObjectInputFilter is configured to permit only the deserialization of Person objects. Any attempts to deserialize other types will be rejected, reducing the risk of executing malicious code.
Another best practice is to utilize libraries that support safer serialization methods, such as JSON or Protocol Buffers, as previously discussed. These formats do not rely on Java’s native serialization mechanism, and thus do not suffer from the same vulnerabilities.
If your application necessitates deserializing data, consider implementing a whitelisting approach rather than a blacklisting one. Whitelisting specifies the exact classes that are allowed for deserialization, while blacklisting identifies classes that should not be deserialized. Whitelisting is generally safer because it restricts deserialization to known, trusted classes.
Here’s an example of a basic whitelisting approach:
import java.io.*;
import java.util.HashSet;
import java.util.Set;
public class DeserializationWhitelistExample {
public static class Person implements Serializable {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "Person{name='" + name + "', age=" + age + "}";}
}
public static void main(String[] args) {
try {
// Serialize the object
Person person = new Person("Bob", 25);
FileOutputStream fos = new FileOutputStream("person.ser");
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(person);
oos.close();
// Set up the whitelist
Set<Class<?>> allowedClasses = new HashSet<>();
allowedClasses.add(Person.class);
// Deserialize the object with the whitelist check
FileInputStream fis = new FileInputStream("person.ser");
ObjectInputStream ois = new ObjectInputStream(fis);
Class<?> objectClass = ois.readObject().getClass();
if (!allowedClasses.contains(objectClass)) {
throw new InvalidObjectException("Unauthorized deserialization attempt: " + objectClass.getName());}
Person deserializedPerson = (Person) ois.readObject();
ois.close();
System.out.println("Deserialized object: " + deserializedPerson);
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();}
}
}
In this code, a whitelist checks the class of the object being deserialized. If the class does not appear in the whitelist, an exception is thrown, preventing unauthorized deserialization.
By avoiding the deserialization of untrusted data and implementing strict controls like filters and whitelists, you can significantly enhance your application's security, protecting it from potential exploits. In the next section, we will discuss how to create a safe serializable class, focusing on techniques and best practices to ensure security and integrity.
Creating a Safe Serializable Class
When making a class serializable in Java, following best practices is vital for ensuring security and maintainability. Creating a safe serializable class involves careful design, controlling its serialized form, and implementing necessary checks.
Firstly, avoid using the default serialization mechanism for complex objects. Instead, define a custom serialized form by implementing the writeObject and readObject methods. This approach allows you to control what gets serialized and deserialized, minimizing the risk of exposing internal state and potential vulnerabilities.
Here’s an example of a class with custom serialization:
import java.io.*;
public class SafePerson implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private transient int age; // Marking as transient to exclude from serialization
public SafePerson(String name, int age) {
this.name = name;
this.age = age;
}
private void writeObject(ObjectOutputStream oos) throws IOException {
oos.defaultWriteObject(); // Serialize the default fields
oos.writeInt(age); // Serialize the transient field manually
}
private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
ois.defaultReadObject(); // Deserialize the default fields
age = ois.readInt(); // Deserialize the transient field manually
}
@Override
public String toString() {
return "SafePerson{name='" + name + "', age=" + age + "}";}
public static void main(String[] args) {
try {
// Serialize the object
SafePerson person = new SafePerson("Charlie", 28);
FileOutputStream fos = new FileOutputStream("safePerson.ser");
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(person);
oos.close();
// Deserialize the object
FileInputStream fis = new FileInputStream("safePerson.ser");
ObjectInputStream ois = new ObjectInputStream(fis);
SafePerson deserializedPerson = (SafePerson) ois.readObject();
ois.close();
System.out.println("Deserialized object: " + deserializedPerson);
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();}
}
}
In this example, the SafePerson class features a transient age field excluded from the default serialization. The writeObject and readObject methods manually handle the serialization and deserialization of the age field, ensuring that only necessary data is serialized.
Additionally, validating the state of the object during deserialization is crucial. By adding validation checks in the readObject method, you can prevent the creation of invalid objects, which is vital for maintaining the integrity and security of your application.
Here’s an example with validation:
import java.io.*;
public class ValidatedPerson implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private int age;
public ValidatedPerson(String name, int age) {
if (name == null || name.isEmpty() || age < 0) {
throw new IllegalArgumentException("Invalid name or age");}
this.name = name;
this.age = age;
}
private void writeObject(ObjectOutputStream oos) throws IOException {
oos.defaultWriteObject();}
private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
ois.defaultReadObject();
if (name == null || name.isEmpty() || age < 0) {
throw new InvalidObjectException("Invalid name or age");}
}
@Override
public String toString() {
return "ValidatedPerson{name='" + name + "', age=" + age + "}";}
public static void main(String[] args) {
try {
// Serialize the object
ValidatedPerson person = new ValidatedPerson("Diana", 22);
FileOutputStream fos = new FileOutputStream("validatedPerson.ser");
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(person);
oos.close();
// Deserialize the object
FileInputStream fis = new FileInputStream("validatedPerson.ser");
ObjectInputStream ois = new ObjectInputStream(fis);
ValidatedPerson deserializedPerson = (ValidatedPerson) ois.readObject();
ois.close();
System.out.println("Deserialized object: " + deserializedPerson);
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();}
}
}
In this example, the ValidatedPerson class ensures that its state is valid during both construction and after deserialization. The readObject method checks the integrity of the name and age fields, throwing an InvalidObjectException if they are not valid.
By implementing these practices, you can prevent security vulnerabilities and ensure that your serialized classes are robust and reliable. In the next section, we will explore instance control and the use of the readResolve method to maintain the singleton property during deserialization.
Instance Control and the ReadResolve Method
In some cases, particularly when implementing singletons or other instance-controlled classes, it is essential to ensure that deserialization does not create new instances of a class. Instead, deserialization should return the same instance that was originally serialized. This can be achieved using the readResolve method.
The readResolve method enables you to replace the deserialized object with another object during the deserialization process. This is particularly useful for maintaining the singleton property of a class. When an object is deserialized, the readResolve method is invoked, returning the instance that should be used instead of the newly deserialized object.
Here’s an example illustrating the use of readResolve in a singleton class:
import java.io.*;
public class Singleton implements Serializable {
private static final long serialVersionUID = 1L;
private static final Singleton INSTANCE = new Singleton();
private Singleton() {
// Private constructor to prevent instantiation}
public static Singleton getInstance() {
return INSTANCE;}
// Implementing readResolve to return the singleton instance
private Object readResolve() throws ObjectStreamException {
return INSTANCE;}
@Override
public String toString() {
return "Singleton instance";}
public static void main(String[] args) {
try {
// Serialize the singleton instance
Singleton instance1 = Singleton.getInstance();
FileOutputStream fos = new FileOutputStream("singleton.ser");
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(instance1);
oos.close();
// Deserialize the singleton instance
FileInputStream fis = new FileInputStream("singleton.ser");
ObjectInputStream ois = new ObjectInputStream(fis);
Singleton instance2 = (Singleton) ois.readObject();
ois.close();
// Verify that both instances are the same
System.out.println("Instance 1: " + instance1);
System.out.println("Instance 2: " + instance2);
System.out.println("Same instance: " + (instance1 == instance2));
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();}
}
}
In this example, the Singleton class ensures that only one instance exists. The readResolve method returns the same INSTANCE during deserialization, maintaining the singleton property. When you deserialize the singleton, instance1 and instance2 will refer to the same object.
The use of readResolve is not limited to singletons. It can also manage instances of classes requiring special handling during deserialization. For instance, if you have a cache of instances that should be reused, readResolve can ensure that the correct instance is returned.
Consider a class representing database connections, where you want to ensure that deserialization returns a previously created instance if it exists:
import java.io.*;
import java.util.HashMap;
import java.util.Map;
public class DatabaseConnection implements Serializable {
private static final long serialVersionUID = 1L;
private static final Map<String, DatabaseConnection> instances = new HashMap<>();
private String connectionString;
private DatabaseConnection(String connectionString) {
this.connectionString = connectionString;}
public static DatabaseConnection getInstance(String connectionString) {
return instances.computeIfAbsent(connectionString, DatabaseConnection::new);}
private Object readResolve() throws ObjectStreamException {
return getInstance(connectionString);}
@Override
public String toString() {
return "DatabaseConnection{connectionString='" + connectionString + "'}";}
public static void main(String[] args) {
try {
// Serialize the database connection instance
DatabaseConnection conn1 = DatabaseConnection.getInstance("jdbc:mysql://localhost:3306/mydb");
FileOutputStream fos = new FileOutputStream("dbConnection.ser");
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(conn1);
oos.close();
// Deserialize the database connection instance
FileInputStream fis = new FileInputStream("dbConnection.ser");
ObjectInputStream ois = new ObjectInputStream(fis);
DatabaseConnection conn2 = (DatabaseConnection) ois.readObject();
ois.close();
// Verify that both instances are the same
System.out.println("Connection 1: " + conn1);
System.out.println("Connection 2: " + conn2);
System.out.println("Same connection: " + (conn1 == conn2));
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();}
}
}
In this example, the DatabaseConnection class utilizes a cache to manage instances. The readResolve method ensures that deserialization returns an existing instance from the cache if it is available, maintaining consistency and preventing duplicate instances.
By employing readResolve, you can control the instances of your classes during deserialization, guaranteeing that the appropriate instance is always returned and preserving the integrity of your objects. In the next section, we will examine the serialization proxy pattern, a powerful technique for enhancing the security and robustness of serializable classes.
Serialization Proxy Pattern
The serialization proxy pattern is an effective technique for enhancing the security and robustness of serializable classes. Instead of permitting an object to serialize and deserialize itself, a separate proxy class handles the serialization process. This approach minimizes the risk of serialization vulnerabilities and maintains the integrity of the object’s invariants.
The essence of the serialization proxy pattern is to create a private static nested class within your main class that functions as a proxy for serialization. This proxy class should contain only the data necessary to recreate the main class. During serialization, the main class writes an instance of this proxy class to the stream. During deserialization, the proxy class reconstructs the main class instance.
Here’s a simple example to illustrate the serialization proxy pattern:
import java.io.*;
public class Person implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
// Serialization proxy class
private static class SerializationProxy implements Serializable {
private static final long serialVersionUID = 1L;
private final String name;
private final int age;
SerializationProxy(Person person) {
this.name = person.name;
this.age = person.age;
}
private Object readResolve() {
return new Person(name, age);}
}
private Object writeReplace() {
return new SerializationProxy(this);}
}
In this example, we define a Person class that implements the serialization proxy pattern. The SerializationProxy class contains the essential fields for reconstructing the Person instance. The writeReplace method of the Person class returns an instance of SerializationProxy during serialization, while the readResolve method in the proxy creates a new Person instance during deserialization.
By adopting the serialization proxy pattern, you can enhance the security and robustness of your classes while mitigating the risks associated with serialization. In the following sections, we will further explore best practices for safe serialization and the implications of these techniques in real-world applications.