Deserialization of untrusted data

Deserialization of untrusted data

Description

Java native serialization reconstructs class information and object graphs from a byte stream. The Java 25 ObjectInputStream documentation warns that deserializing untrusted data is inherently dangerous and should be avoided.

The new ObjectInputStream(input) constructor reads and checks the stream header and version without restoring an object yet. Object deserialization starts at readObject() or readUnshared(). It traverses the referenced object graph and can invoke class-specific behavior, including:

  • readObject and readResolve in serializable classes.
  • Constructors and readExternal in Externalizable implementations.
  • Dynamic proxy and class-resolution logic.
  • The same deserialization behavior in other objects in the graph.

If an attacker controls the stream and an exploitable gadget chain is available on the runtime classpath, code may execute with the application's privileges. Even without a code-execution gadget, malformed object state, data modification, repeated exceptions, or excessive arrays, references, and depth may exhaust resources.

Potential impact

  • Arbitrary code execution: Gadget-chain methods or process-execution APIs may run during deserialization.
  • Data and state manipulation: Restoring fields without invoking their normal constructors can break application invariants.
  • Information disclosure: Gadgets or subsequent processing may access files, secrets, or internal services.
  • Denial of service: Deep graphs, many references, large arrays, or malicious class behavior may consume CPU and memory.

Remediation

1. Remove Java native serialization from external input

Use a data-only format such as JSON and bind it to an explicit DTO. Limit the request size first, then validate DTO fields against the application's domain constraints. Java and Kotlin examples follow below.

2. Apply context-specific filters if immediate removal is impossible

If compatibility requires temporarily retaining ObjectInputStream, set a per-stream ObjectInputFilter before reading any objects.

  • Allow only the exact classes and modules required by the protocol; reject everything else.
  • Do not treat UNDECIDED as permission to proceed.
  • Limit graph depth, total references, array length, and bytes consumed.
  • Apply a restrictive JVM-wide filter as additional protection.
  • Call readObject() or readUnshared() only after setting the filter.

Java serialization filtering is not enabled by default. A class-name denylist or a resolveClass override that checks only some classes cannot cover every new gadget, array, proxy, or overlooked classpath entry. Filters are supplementary migration controls, not a substitute for a data-only format.

3. Restrict trust boundaries and the runtime environment

  • If only trusted producers may supply serialized data, verify a signature or HMAC over the exact bytes before deserializing.
  • A signature or HMAC does not make a malicious authorized producer or an overly broad class allow-list safe.
  • Remove unnecessary dependencies that could supply gadgets and keep the JDK and libraries on current security patches.
  • Run remaining compatibility paths with least privilege and migrate stored records to a data-only format.

Examples

Java

Before

java
import java.io.ObjectInputStream;

import jakarta.servlet.http.HttpServletRequest;

final class ProfileEndpoint {
    Object readProfile(HttpServletRequest request) throws Exception {
        try (ObjectInputStream input =
                new ObjectInputStream(request.getInputStream())) {
            return input.readObject();
        }
    }
}

Constructing ObjectInputStream reads only the header. input.readObject() is where the object graph is restored and class-specific behavior can run.

After

For new Java projects, use the latest security patch of a supported Jackson line. This example uses the Jackson 3 API. On the source review date of September 2, 2026, the latest jackson-databind 3.1 tag was 3.1.6.

Do not enable Jackson's class-name-based default polymorphic typing. If polymorphism is required, use server-defined logical type names and a narrow subtype allow-list.

java
import java.io.IOException;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import tools.jackson.databind.json.JsonMapper;

final class ProfileEndpoint {
    private static final int MAX_BODY_BYTES = 64 * 1024;
    private static final JsonMapper JSON = JsonMapper.builder().build();

    record Profile(String name, int age) {}

    Profile readProfile(HttpServletRequest request, HttpServletResponse response)
            throws IOException {
        if (request.getContentLengthLong() > MAX_BODY_BYTES) {
            response.sendError(413);
            return null;
        }

        byte[] body = request.getInputStream().readNBytes(MAX_BODY_BYTES + 1);
        if (body.length > MAX_BODY_BYTES) {
            response.sendError(413);
            return null;
        }

        Profile profile = JSON.readValue(body, Profile.class);
        if (profile.name() == null
                || profile.name().isBlank()
                || profile.name().length() > 100
                || profile.age() < 0
                || profile.age() > 130) {
            response.sendError(400);
            return null;
        }
        return profile;
    }
}

Validation after DTO binding restricts the meaning of JSON values. Checking only the returned object after unsafe native deserialization cannot undo gadget behavior that has already executed.

Kotlin

Before

kotlin
import java.io.ObjectInputStream

import jakarta.servlet.http.HttpServletRequest

fun readProfile(request: HttpServletRequest): Any =
    ObjectInputStream(request.inputStream).use { it.readObject() }

Kotlin's use closes the stream, but does not make restoration of untrusted object graphs safe. The dangerous operation here is it.readObject().

After

Kotlin's official kotlinx.serialization provides a stable JSON format and compiler-generated serializers. On the source review date of September 2, 2026, its official getting-started guide used Kotlin 2.4.10 and kotlinx-serialization-json 1.11.0. If polymorphism is needed, restrict it to explicitly registered server-side subtypes.

kotlin
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json

private const val MAX_BODY_BYTES = 64 * 1024

@Serializable
data class Profile(val name: String, val age: Int)

fun readProfile(
    request: HttpServletRequest,
    response: HttpServletResponse,
): Profile? {
    if (request.contentLengthLong > MAX_BODY_BYTES) {
        response.sendError(413)
        return null
    }

    val body = request.inputStream.readNBytes(MAX_BODY_BYTES + 1)
    if (body.size > MAX_BODY_BYTES) {
        response.sendError(413)
        return null
    }

    val profile = Json.decodeFromString<Profile>(body.decodeToString())
    if (
        profile.name.isBlank() ||
        profile.name.length > 100 ||
        profile.age !in 0..130
    ) {
        response.sendError(400)
        return null
    }
    return profile
}

The example limits input size, decodes JSON into the explicit Profile type, and checks domain constraints. Input does not select native JVM class names.

References