Skip to content

CWE-502: Insecure Deserialization - Java

Overview

Insecure deserialization occurs when untrusted data is used to create objects, potentially allowing attackers to execute arbitrary code, manipulate application logic, or achieve denial of service. Java's native serialization is particularly dangerous because it can invoke methods during deserialization.

Primary Defence: Use JSON (Jackson, Gson) instead of Java serialization, or if Java serialization is required, implement ObjectInputFilter (Java 9+) to whitelist allowed classes.

Common Vulnerable Patterns

ObjectInputStream with Untrusted Data

// VULNERABLE - Deserializing user input
import java.io.*;

public class UserController {
    public User loadUser(byte[] userData) throws Exception {
        ByteArrayInputStream bis = new ByteArrayInputStream(userData);
        ObjectInputStream ois = new ObjectInputStream(bis);
        return (User) ois.readObject();  // DANGEROUS!
    }
}

Why this is vulnerable:

  • Deserializes arbitrary serializable classes from untrusted data.
  • Invokes readObject()/readResolve() and other callbacks automatically.
  • Gadget chains can execute code during object creation.
  • Occurs before application-level validation or checks.

Reading Serialized Objects from Network

// VULNERABLE - Socket deserialization
import java.io.*;
import java.net.*;

public class Server {
    public void handleClient(Socket client) throws Exception {
        ObjectInputStream ois = new ObjectInputStream(client.getInputStream());
        Object obj = ois.readObject();  // Can execute malicious code!
        processObject(obj);
    }
}

Why this is vulnerable:

  • Remote attackers fully control the serialized bytes.
  • Deserialization happens before any application validation.
  • Gadget chains can execute code on the server.
  • Common RCE vector for exposed services.

Deserializing from Files Without Validation

// VULNERABLE - File deserialization
public Object loadFromFile(String filename) throws Exception {
    FileInputStream fis = new FileInputStream(filename);
    ObjectInputStream ois = new ObjectInputStream(fis);
    return ois.readObject();  // Attacker can control file content
}

Why this is vulnerable:

  • File contents can be attacker-controlled (upload/traversal/shared).
  • Deserialization trusts file data without validation.
  • Gadget chains can execute during object creation.
  • Executes with the application's privileges.

Using readObject() with Custom Serialization

// VULNERABLE - Custom readObject can be exploited
public class VulnerableClass implements Serializable {
    private void readObject(ObjectInputStream ois) throws Exception {
        ois.defaultReadObject();
        // This code runs during deserialization!
        Runtime.getRuntime().exec(someCommand);  // RCE!
    }
}

Why this is vulnerable:

  • readObject() runs automatically during deserialization.
  • Attackers can control the data that reaches this code.
  • Enables command execution or other side effects.
  • Triggered without explicit method calls.

Secure Patterns

Use JSON Instead of Java Serialization

// SECURE - JSON has no code execution
import com.fasterxml.jackson.databind.ObjectMapper;

public class UserController {
    private final ObjectMapper objectMapper = new ObjectMapper();

    public User loadUser(String jsonData) throws Exception {
        // JSON deserialization doesn't execute code
        return objectMapper.readValue(jsonData, User.class);
    }

    public String saveUser(User user) throws Exception {
        return objectMapper.writeValueAsString(user);
    }
}

Why this works:

  • Requires an explicit target class (User.class).
  • No Java serialization callbacks like readObject().
  • No arbitrary type instantiation by default.
  • Input is treated as data-only primitives/objects.
  • Default typing is off unless explicitly enabled.

Maven Dependency:

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.15.2</version>
</dependency>

Look-Ahead Deserialization with ObjectInputFilter

// SECURE - Allowlist allowed classes (Java 9+)
import java.io.*;

public class SafeDeserializer {
    public Object deserialize(byte[] data) throws Exception {
        ByteArrayInputStream bis = new ByteArrayInputStream(data);
        ObjectInputStream ois = new ObjectInputStream(bis);

        // Set filter to allow only specific classes
        ois.setObjectInputFilter(filterInfo -> {
            Class<?> clazz = filterInfo.serialClass();

            // Allowlist safe classes
            if (clazz != null) {
                if (clazz == User.class || 
                    clazz == String.class ||
                    clazz == java.util.ArrayList.class) {
                    return ObjectInputFilter.Status.ALLOWED;
                }
                return ObjectInputFilter.Status.REJECTED;
            }

            // Reject anything not explicitly allowlisted
            return ObjectInputFilter.Status.REJECTED;
        });

        return ois.readObject();
    }
}

Why this works:

  • Inspects every class before it is instantiated.
  • Allowlists expected classes and rejects everything else.
  • Blocks constructors and readObject() before they run.
  • Applies to the full object graph, including nested types.
  • Stops gadget chains by preventing dangerous types.

ValidatingObjectInputStream

// SECURE - Apache Commons IO validator (before Java 9)
import org.apache.commons.io.serialization.ValidatingObjectInputStream;

public class SecureDeserializer {
    public Object deserialize(byte[] data) throws Exception {
        ByteArrayInputStream bis = new ByteArrayInputStream(data);
        ValidatingObjectInputStream vois = new ValidatingObjectInputStream(bis);

        // Accept only allowlisted classes
        vois.accept(User.class);
        vois.accept(java.lang.String.class);
        vois.accept(java.util.ArrayList.class);

        // Reject dangerous patterns
        vois.reject(java.lang.Runtime.class);
        vois.reject(java.lang.ProcessBuilder.class);
        vois.reject("org.apache.commons.collections.functors.*");

        return vois.readObject();
    }
}

Why this works:

  • Allowlists classes or patterns via accept().
  • Rejects unknown classes before instantiation.
  • Hooks resolveClass() to block dangerous types early.
  • Works as a pre-Java-9 alternative to filters.
  • Supports explicit reject() for known gadget packages.

Maven Dependency:

<dependency>
    <groupId>commons-io</groupId>
    <artifactId>commons-io</artifactId>
    <version>2.13.0</version>
</dependency>

Use Protocol Buffers or MessagePack

// SECURE - Protocol Buffers (no code execution)
import com.google.protobuf.InvalidProtocolBufferException;

public class UserController {
    public UserProto.User loadUser(byte[] data) throws InvalidProtocolBufferException {
        return UserProto.User.parseFrom(data);
    }

    public byte[] saveUser(UserProto.User user) {
        return user.toByteArray();
    }
}

Why this works:

  • Schema defines the only allowed message structure.
  • parseFrom() targets a single message class.
  • No type metadata or polymorphic instantiation.
  • No serialization callbacks like readObject().
  • Rejects malformed input outside the schema.

Maven Dependencies:

<dependency>
    <groupId>com.google.protobuf</groupId>
    <artifactId>protobuf-java</artifactId>
    <version>3.24.0</version>
</dependency>

Java Library Safety Matrix

When reviewing code, use this matrix to identify unsafe deserialization libraries:

Safer Defaults (still validate your config)

jackson-databind (JSON)

  • Safe UNLESS polymorphism enabled (@JsonTypeInfo, enableDefaultTyping())
  • Default configuration is secure
  • Only deserializes to specified types

Kryo v5.0.0+

  • Treat as unsafe unless registration is explicitly required
  • Set setRegistrationRequired(true) and register allowed classes

XStream v1.4.17+

  • Requires explicit allowlisting via the security framework
  • Earlier versions allow arbitrary class instantiation

fastjson2

  • Safe if autotype feature NOT enabled
  • Use latest version with security patches

Requires Configuration (UNSAFE by default)

Kryo < v5.0.0

  • Requires setRegistrationRequired(true):
Kryo kryo = new Kryo();
kryo.setRegistrationRequired(true);  // MUST SET THIS
// Register only allowed classes
kryo.register(User.class);
kryo.register(Address.class);

fastjson v1.2.68+

  • Requires safemode:
ParserConfig.getGlobalInstance().setSafeMode(true);

json-io

  • Must use non-typed mode or custom deserializer
  • Avoid using with untrusted data

SnakeYAML

  • Must use SafeConstructor:
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;

Yaml yaml = new Yaml(new SafeConstructor());
Object obj = yaml.load(input);  // Safe - only basic types

CANNOT Be Used Safely (Replace immediately)

XMLDecoder (JDK)

  • No safe configuration exists
  • Allows arbitrary code execution
  • Replace with JSON or safe XML parser

XStream < v1.4.17

  • Allows arbitrary class instantiation
  • Known RCE vulnerabilities
  • Upgrade to v1.4.17+ or replace with JSON

Castor

  • Abandoned project, no security updates
  • Replace with modern serialization library

ObjectInputStream.readObject() without filters

  • Default Java serialization is unsafe
  • Use ObjectInputFilter (Java 9+) or replace with JSON

fastjson < v1.2.68

  • Known RCE vulnerabilities
  • Upgrade to latest version or replace with Jackson

Migration Recommendations

If you find these patterns in security scan results:

  1. ObjectInputStream.readObject() → Switch to JSON with Jackson
  2. XMLDecoder → Switch to JAXB with known types or JSON
  3. XStream < v1.4.17 → Upgrade or switch to JSON
  4. Kryo without registration → Enable setRegistrationRequired(true)
  5. SnakeYAML with Yaml() → Use new Yaml(new SafeConstructor())

Framework-Specific Guidance

Spring Framework

// SECURE - Use Spring's JSON serialization
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;

@RestController
public class UserController {

    @PostMapping("/users")
    public ResponseEntity<User> createUser(@RequestBody User user) {
        // Spring automatically deserializes JSON to User
        // No dangerous ObjectInputStream involved
        userService.save(user);
        return ResponseEntity.ok(user);
    }

    @GetMapping("/users/{id}")
    public ResponseEntity<User> getUser(@PathVariable Long id) {
        User user = userService.findById(id);
        // Spring serializes to JSON automatically
        return ResponseEntity.ok(user);
    }
}

// Configure Jackson to avoid polymorphic deserialization for untrusted input
@Configuration
public class JacksonConfig {
    @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper mapper = new ObjectMapper();

        // Keep default typing disabled for untrusted data
        mapper.deactivateDefaultTyping();

        return mapper;
    }
}

Java EE / Jakarta EE

// SECURE - Use JSON-B (Jakarta JSON Binding)
import jakarta.json.bind.*;

public class UserService {
    private final Jsonb jsonb = JsonbBuilder.create();

    public User deserialize(String json) {
        return jsonb.fromJson(json, User.class);
    }

    public String serialize(User user) {
        return jsonb.toJson(user);
    }
}

// JAX-RS with JSON
@Path("/users")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class UserResource {

    @POST
    public Response createUser(User user) {
        // JSON deserialization handled by JAX-RS provider
        userService.save(user);
        return Response.ok(user).build();
    }
}

Signature Verification

// SECURE - Verify HMAC before deserializing
import javax.crypto.*;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;

public class SignedDeserializer {
    private final SecretKey key;

    public SignedDeserializer(byte[] secretKey) {
        this.key = new SecretKeySpec(secretKey, "HmacSHA256");
    }

    public Object deserialize(byte[] signedData) throws Exception {
        // Format: [signature][data]
        byte[] signature = new byte[32];  // SHA-256 is 32 bytes
        byte[] data = new byte[signedData.length - 32];

        System.arraycopy(signedData, 0, signature, 0, 32);
        System.arraycopy(signedData, 32, data, 0, data.length);

        // Verify signature
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(key);
        byte[] expectedSignature = mac.doFinal(data);

        if (!MessageDigest.isEqual(signature, expectedSignature)) {
            throw new SecurityException("Invalid signature");
        }

        // Only deserialize if signature is valid
        // (But still prefer JSON over ObjectInputStream!)
        return new ObjectMapper().readValue(data, Object.class);
    }
}

Input Validation

// Validate after deserializing data-only formats (JSON/JSON-B)
import javax.validation.*;
import javax.validation.constraints.*;

public class User {
    @NotNull
    @Size(min = 1, max = 100)
    private String username;

    @Email
    private String email;

    @Min(0)
    @Max(150)
    private Integer age;

    // getters and setters
}

// Controller with validation
@RestController
public class UserController {
    private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();

    @PostMapping("/users")
    public ResponseEntity<?> createUser(@Valid @RequestBody User user) {
        Set<ConstraintViolation<User>> violations = validator.validate(user);

        if (!violations.isEmpty()) {
            return ResponseEntity.badRequest().body(violations);
        }

        userService.save(user);
        return ResponseEntity.ok(user);
    }
}

Detecting Gadget Chains

// Block known gadget chain classes
import java.io.*;

public class GadgetBlocker implements ObjectInputFilter {
    private static final String[] BLOCKED_CLASSES = {
        "org.apache.commons.collections.functors.InvokerTransformer",
        "org.apache.commons.collections.functors.InstantiateTransformer",
        "org.apache.commons.collections4.functors.InvokerTransformer",
        "org.apache.commons.collections4.functors.InstantiateTransformer",
        "org.codehaus.groovy.runtime.ConvertedClosure",
        "org.codehaus.groovy.runtime.MethodClosure",
        "org.springframework.beans.factory.ObjectFactory",
        "com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl",
        "java.rmi.server.UnicastRemoteObject",
        "java.rmi.server.RemoteObjectInvocationHandler"
    };

    @Override
    public Status checkInput(FilterInfo filterInfo) {
        Class<?> clazz = filterInfo.serialClass();

        if (clazz != null) {
            String className = clazz.getName();

            for (String blocked : BLOCKED_CLASSES) {
                if (className.startsWith(blocked)) {
                    return Status.REJECTED;
                }
            }
        }

        return Status.UNDECIDED;
    }
}

Verification

After implementing the recommended secure patterns, verify the fix through multiple approaches:

  • Manual testing: Submit malicious payloads relevant to this vulnerability and confirm they're handled safely without executing unintended operations
  • Code review: Confirm all instances use safe deserialization APIs and reject unsafe formats
  • Static analysis: Use security scanners to verify no unsafe deserialization patterns remain
  • Regression testing: Ensure legitimate user inputs and application workflows continue to function correctly
  • Edge case validation: Test with special characters, boundary conditions, and unusual inputs to verify proper handling
  • Framework verification: If using a framework or library, confirm the recommended APIs are used correctly according to documentation
  • Authentication/session testing: Verify security controls remain effective and cannot be bypassed (if applicable to the vulnerability type)
  • Rescan: Run the security scanner again to confirm the finding is resolved and no new issues were introduced

When reviewing deserialization code or choosing libraries, use this safety reference:

jackson-databind (without polymorphic type handling):

ObjectMapper mapper = new ObjectMapper();
// Safe as long as:
// - Default typing is NOT enabled
// - @JsonTypeInfo is not used on untrusted data
// - enableDefaultTyping() is NEVER called
User user = mapper.readValue(json, User.class);

Kryo v5.0+ (with registration required):

Kryo kryo = new Kryo();
kryo.setRegistrationRequired(true);  // CRITICAL - prevents arbitrary class instantiation
kryo.register(User.class, 0);
kryo.register(Order.class, 1);
// Only registered classes can be deserialized

XStream v1.4.17+ (with security framework):

XStream xstream = new XStream();
// v1.4.17+ has secure defaults
xstream.addPermission(NoTypePermission.NONE);  // Deny all
xstream.addPermission(new ExplicitTypePermission(new Class[]{User.class, Order.class}));  // Allow specific

WARNING: Requires Careful Configuration

Kryo < v5.0:

  • Default allows arbitrary class instantiation
  • Must call setRegistrationRequired(true)
  • Upgrade to v5.0+ recommended

jackson-databind with polymorphism:

// DANGEROUS if enabled for untrusted data
mapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
// Attackers can specify arbitrary classes via @class property
  • Only use polymorphic deserialization with trusted data
  • Use allowlists with PolymorphicTypeValidator

fastjson:

  • Many historical RCE vulnerabilities
  • Use v1.2.83+ with safeMode:
ParserConfig.getGlobalInstance().setSafeMode(true);
  • Consider migrating to jackson-databind

SnakeYAML:

// UNSAFE - allows arbitrary class instantiation
Yaml yaml = new Yaml();

// SAFE - use SafeConstructor
Yaml yaml = new Yaml(new SafeConstructor());

Unsafe (Avoid)

java.io.ObjectInputStream (without filters):

// DANGEROUS - can execute arbitrary code
ObjectInputStream ois = new ObjectInputStream(untrustedStream);
Object obj = ois.readObject();  // RCE risk!
  • Only use with ObjectInputFilter (Java 9+) or ValidatingObjectInputStream
  • Prefer JSON/Protocol Buffers instead

XMLDecoder:

// EXTREMELY DANGEROUS - trivial RCE
XMLDecoder decoder = new XMLDecoder(inputStream);
Object obj = decoder.readObject();
  • No safe configuration exists
  • Never use with untrusted data

Apache Commons Collections InvokerTransformer (in classpath):

  • Presence enables gadget chain attacks
  • Remove dependency or upgrade to 3.2.2+/4.1+

Castor XML:

  • Outdated, multiple vulnerabilities
  • Migrate to jackson-dataformat-xml

fastjson < v1.2.68:

  • Critical RCE vulnerabilities (CVE-2020-8840, CVE-2020-11655)
  • Upgrade immediately or migrate

Migration Examples

From ObjectInputStream → JSON:

// OLD (unsafe)
ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
User user = (User) ois.readObject();

// NEW (safe)
ObjectMapper mapper = new ObjectMapper();
User user = mapper.readValue(socket.getInputStream(), User.class);

From XMLDecoder → JAXB/Jackson XML:

// OLD (extremely dangerous)
XMLDecoder decoder = new XMLDecoder(new FileInputStream("user.xml"));
User user = (User) decoder.readObject();

// NEW (safe)
JAXBContext context = JAXBContext.newInstance(User.class);
Unmarshaller unmarshaller = context.createUnmarshaller();
User user = (User) unmarshaller.unmarshal(new File("user.xml"));

Key Takeaway: Default Java serialization (ObjectInputStream.readObject()) is inherently unsafe. Always prefer data-only formats (JSON, Protocol Buffers, MessagePack) over object serialization.

Additional Resources