Skip to content

CWE-502: Deserialization of Untrusted Data - Java

Overview

Insecure deserialization occurs when untrusted data is used to create objects, letting an attacker execute code through a gadget chain or exhaust memory with an oversized object graph. Java's native serialization is particularly dangerous because it invokes methods such as readObject() during deserialization, before any application-level validation runs.

Primary Defence: Use JSON (Jackson, Gson) instead of Java serialization, or if Java serialization is required, implement ObjectInputFilter to allowlist expected classes and enforce object graph limits.

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: default typing is off unless explicitly enabled.
  • Input is treated as data-only primitives/objects.

Maven Dependency:

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

Look-Ahead Deserialization with ObjectInputFilter

// SECURE - Allowlist allowed classes and limit object graph size
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 -> {
            if (filterInfo.depth() > 10 ||
                filterInfo.references() > 1000 ||
                filterInfo.arrayLength() > 10000 ||
                filterInfo.streamBytes() > 1_000_000) {
                return ObjectInputFilter.Status.REJECTED;
            }

            Class<?> clazz = filterInfo.serialClass();

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

            return ObjectInputFilter.Status.UNDECIDED;
        });

        return ois.readObject();
    }
}

Why this works:

  • Inspects every class before it is instantiated.
  • Allowlists expected classes and rejects everything else, so the types a gadget chain needs never resolve.
  • Blocks dangerous class resolution and deserialization callbacks such as readObject()/readResolve() before they process attacker-controlled state.
  • Applies to the full object graph, including nested types.
  • Enforces graph limits to reduce memory and recursion denial-of-service risk.

Version note: ObjectInputFilter was introduced by JEP 290 and is available in Java 9+ and later Java 8 update releases. Use it where available; use a validating ObjectInputStream wrapper only for older runtimes that cannot be upgraded.

Prefer the built-in filter factories on Java 17+

Writing the lambda by hand works, but the status a filter returns for a class it does not recognise is the part that is easy to get wrong - UNDECIDED means "no opinion", and with no other filter in the chain that resolves to accepted. Java 17 added factories that make the decision explicit:

// SECURE - allowlist by predicate, then reject anything left undecided
import java.io.ObjectInputFilter;
import java.util.Set;

private static final Set<Class<?>> ALLOWED = Set.of(
    User.class, String.class, java.util.ArrayList.class,
    // ArrayList's own backing store reaches the filter as a separate class
    Object[].class);

private static ObjectInputFilter safeFilter() {
    ObjectInputFilter allow =
        ObjectInputFilter.allowFilter(ALLOWED::contains, ObjectInputFilter.Status.UNDECIDED);
    // Anything the allowlist did not decide on is rejected rather than admitted
    return ObjectInputFilter.rejectUndecidedClass(allow);
}

Why this works: rejectUndecidedClass converts the default from "accept what I did not think about" to "reject it", which is the property the hand-written filter has to remember to encode in every branch.

That stricter default has a cost the allowlist has to pay for explicitly, and it is the reason Object[].class is in the set above. A filter sees the implementation classes of what it deserializes, not only the ones you named: measured on JDK 26, reading a two-element ArrayList presents java.util.ArrayList and then [Ljava.lang.Object;, its internal elementData array. Without Object[].class in the allowlist, rejectUndecidedClass refuses every non-empty ArrayList - including the one the allowlist was written to permit - with InvalidClassException: filter status: REJECTED. An empty one still passes, because no backing array is written for it, which is exactly the shape of test that reports the allowlist as working. The hand-written filter above does not have this problem because its clazz.isArray() branch returns UNDECIDED, which with no other filter in the chain resolves to accepted.

That difference is narrower than it looks. Date[] is rejected by both, though not by the same route: the hand-written filter returns UNDECIDED for the array and rejects java.util.Date when the element class reaches it at the next depth, while allowFilter tests the array class itself and then, failing that, its component type - so [Ljava.util.Date; is rejected at depth 1 without the element ever being seen. That fallback to the component type is also why String[] is admitted with only String.class in the set, and testing the array class first is why naming Object[].class works at all. What the two forms actually differ on is whether the container is inspected, and the practical consequence is the one above: adding rejectUndecidedClass to an existing allowlist can turn a working deserializer into one that rejects everything, so test it against a legitimate payload of each allowlisted type before shipping it.

Combine it with the resource limits using ObjectInputFilter.merge, or keep them as the separate depth/references/arrayLength/streamBytes checks shown above - the limits are unrelated to the class allowlist and both forms are fine.

ValidatingObjectInputStream

// SECURE - Apache Commons IO validator, for runtimes without ObjectInputFilter
import org.apache.commons.io.serialization.ValidatingObjectInputStream;

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

        // The ValidatingObjectInputStream(InputStream) constructor is
        // deprecated - build it instead
        try (ValidatingObjectInputStream vois = ValidatingObjectInputStream.builder()
                .setInputStream(bis)
                .get()) {

            // 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.22.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>4.36.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.LoaderOptions;
import org.yaml.snakeyaml.constructor.SafeConstructor;

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

For older SnakeYAML 1.x versions, new SafeConstructor() may be available without LoaderOptions, but the security requirement is the same: use the safe constructor or Yaml configuration that restricts YAML to basic data 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 where available or replace with JSON

fastjson < v1.2.68

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

Migration Considerations

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(new LoaderOptions())) on SnakeYAML 2.x, or the equivalent SafeConstructor configuration for your version

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 com.fasterxml.jackson.databind.ObjectMapper;
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]
        if (signedData.length < 32) {
            throw new SecurityException("Invalid signed 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 jakarta.validation.*;
import jakarta.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;
    }
}

Known-gadget blocklists are useful only as temporary hardening around legacy Java serialization. They are incomplete by nature and should not replace ObjectInputFilter allowlists, graph limits, dependency reduction, and migration to data-only formats.

Testing

  • Test normal JSON or data-only payloads and confirm validation accepts expected objects.
  • Test native Java serialization payloads and confirm untrusted endpoints reject them before object construction.
  • Test filter limits for object depth, reference count, array length, and total bytes.
  • Test unexpected classes, proxy classes, and known gadget payloads in a controlled environment.
  • Test tampered signed payloads and truncated payloads so integrity checks fail closed.
  • Re-run static analysis and dependency scans for ObjectInputStream, permissive YAML/XML mappers, and gadget-prone libraries.

Common Pitfalls

  • Relying on a gadget blocklist as the primary defense.
  • Adding validation after readObject() has already constructed attacker-controlled objects.
  • Allowing broad package prefixes such as com.company.* when only a few DTO classes are expected.
  • Forgetting graph limits; class filters alone do not prevent memory or CPU exhaustion.
  • Signing serialized objects and then treating them as safe for long-term external input.
  • Replacing Java serialization in controllers while leaving message queues, caches, or RMI endpoints unchanged.

Dependencies and Installation

  • ObjectInputFilter is available in Java 9+ and later Java 8 update releases through JEP 290.
  • Jackson, Gson, JSON-B, and Protocol Buffers are safer alternatives when used as data-only formats without polymorphic type loading from untrusted input.
  • SnakeYAML 2.x safe loading requires SafeConstructor(new LoaderOptions()); verify constructor APIs when maintaining older 1.x code.
  • Keep gadget-prone libraries current and remove unused libraries from the runtime classpath.

Additional Resources