Deserialization without a class restriction

ID

java.insecure_deserialization

Severity

low

Remediation Complexity

medium

Remediation Risk

medium

Remediation Effort

medium

Resource

Misconfiguration

Language

Java

Tags

ASVS50:v1.5.2, CWE:502, NIST.SP.800-53, OWASP:2025:A08, PCI-DSS:6.5.1

Description

A serialized stream is deserialized without restricting the classes the stream may name, or a deserializer is configured to take the class to instantiate from the payload. Whoever writes the payload then chooses which classes are loaded and constructed, and any class on the classpath whose deserialization has a side effect — a gadget — becomes reachable.

Rationale

java.code_injection_deserialization reports deserialization when a data path can be followed from an external input to the read. That is the right answer for application code, but it needs the input to be visible: a readObject() in a codec, a utility class or a shared library, where the caller lives in another project, has no source to follow and is reported by nothing.

The read is unsafe on its own terms, independent of who calls it. This detector reports the read, so a library handing arbitrary-class deserialization to its callers is visible without having to guess at reachability. Its severity is low because the finding says nothing about whether an attacker-controlled payload actually reaches the call — when that can be shown, java.code_injection_deserialization reports it as critical.

What is reported

Two shapes are reported.

A deserialization call with no class restriction in place:

API Note

ObjectInputStream.readObject() / readUnshared()

the core case, including local subclasses

java.beans.XMLDecoder.readObject()

always unsafe: the document is a call sequence

SerializationUtils.deserialize(..)

Commons Lang, wraps a plain ObjectInputStream

An unrestricted polymorphic-typing configuration, which makes a later read take its target class from the payload:

API Note

ObjectMapper.enableDefaultTyping(..)

Jackson, deprecated and unrestricted by design

ObjectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator..)

the validator accepts every subtype

Kryo.setRegistrationRequired(false)

the stream may name unregistered classes

Those last two are reported where they are configured rather than where a document is read: that single call is both the defect and its fix, and the mapper or Kryo instance it produces is normally read from elsewhere. Everything else is reported at the deserialization call, not at the construction of the stream, so it lines up with java.code_injection_deserialization when both fire.

When a source is visible, java.code_injection_deserialization reports the site as critical with a proven data path, and this detector adds nothing. It must therefore not report there: the low finding is removed for any site the tainting rule already covers. A site with no visible source — the reason this detector exists — keeps its finding.

What counts as a restriction

A deserialization call is not reported when any of the following is visible in the same compilation unit:

  • a per-stream class filter, setObjectInputFilter(..);

  • the process-wide filter, ObjectInputFilter.Config.setSerialFilter(..), setSerialFilterFactory(..) or System.setProperty("jdk.serialFilter", ..);

  • a locally declared ObjectInputStream subclass overriding resolveClass(..) to allowlist the accepted classes;

  • a ValidatingObjectInputStream (Commons IO) carrying at least one accept(..) rule;

Nor is it reported when the deserializer is passed to other code, which may restrict it out of sight, and nor when the read is the one a Java serialization callback was handed: readObject(ObjectInputStream) and readExternal(ObjectInput) run while the enclosing object is itself being deserialized, under whatever filter its caller installed. A stream a callback opens for itself is still reported.

Jackson’s activateDefaultTyping(..) overloads taking a real PolymorphicTypeValidator are the sanctioned API and are not reported; only LaissezFaireSubTypeValidator is.

Examples

Reported — the payload chooses the class:

public static Object decode(String encoded) throws Exception {
    byte[] raw = Base64.getDecoder().decode(encoded);
    try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(raw))) {
        return ois.readObject();                                    // reported
    }
}

Not reported — the stream carries a class filter:

public static Object decode(String encoded) throws Exception {
    byte[] raw = Base64.getDecoder().decode(encoded);
    ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(raw));
    ois.setObjectInputFilter(ObjectInputFilter.Config.createFilter("com.acme.dto.*;java.base/*;!*"));
    return ois.readObject();
}

Remediation

The durable fix is not to accept serialized objects from anywhere untrusted: carry the data in a format that does not name classes — JSON or protobuf mapped onto a fixed type — and drop Java serialization from the boundary. Where the format cannot change, restrict the classes accepted.

Install a per-stream filter (Java 9+, back-ported to 8u121):

ObjectInputStream ois = new ObjectInputStream(in);
ois.setObjectInputFilter(ObjectInputFilter.Config.createFilter("com.acme.dto.*;java.base/*;!*"));
return ois.readObject();

Or set the filter for the whole process, from code or with -Djdk.serialFilter=..:

ObjectInputFilter.Config.setSerialFilter(
    ObjectInputFilter.Config.createFilter("com.acme.dto.*;java.base/*;!*"));

Before Java 9, override resolveClass and allowlist there:

final class AllowlistingStream extends ObjectInputStream {

    private static final Set<String> ALLOWED = Set.of("com.acme.dto.Order");

    AllowlistingStream(InputStream in) throws IOException { super(in); }

    @Override
    protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException {
        if (!ALLOWED.contains(desc.getName())) throw new InvalidClassException("rejected", desc.getName());
        return super.resolveClass(desc);
    }
}

For the other libraries:

  • XMLDecoder has no restriction to configure — read only documents your own code produced and stored where an attacker cannot reach, or replace it.

  • Jackson: drop default typing, or replace it with activateDefaultTyping(BasicPolymorphicTypeValidator.builder().allowIfBaseType("com.acme.dto.").build(), ..).

  • Kryo: leave setRegistrationRequired(true) and register the classes you expect.

Not covered: SnakeYAML and XStream

Both are deliberately absent. new Yaml().load(..) and new XStream().fromXML(..) look like the same defect, but each library fixed its default years ago, and this detector cannot see which version a project resolves — so flagging them is a false positive on any current dependency.

new Yaml().load("probe: !!java.io.File [/tmp/x]")            # snakeyaml 2.0+, CVE-2022-1471 fix
  -> ComposerException: Global tag is not allowed: tag:yaml.org,2002:java.io.File

new XStream().fromXML("<MyPojo>..")                          # xstream 1.4.18+, deny-by-default
  -> ForbiddenClassException: MyPojo

Every branch this detector does carry flags an explicit unsafe choice — a stream left without a filter, XMLDecoder, Commons Lang’s wrapper, Jackson default typing, Kryo registration turned off — which no library release can un-choose.

The patterns that are unsafe on a current SnakeYAML or XStream are the ones that deliberately re-open the default: LoaderOptions.setTagInspector(new TrustedTagInspector()) (or setTagInspector(tag → true)) and xstream.addPermission(AnyTypePermission.ANY). Those are narrower rules of their own. Restoring these two families behind a version gate is tracked as a separate epic.

Limitations

The restriction has to be visible in the same file. A filter installed by a helper method in another class, a filter supplied only through the -Djdk.serialFilter JVM flag, or a resolveClass override in an anonymous subclass are not seen. A deserializer passed as an argument to any method is treated as possibly restricted there and is not reported, which trades a few missed findings for the false positives that guessing would produce.

Types are matched by their simple name, so a project’s own class named Yaml or XStream with a method of the same name would be reported.

© 2026 Xygeni. All rights reserved