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 |
|---|---|
|
the core case, including local subclasses |
|
always unsafe: the document is a call sequence |
|
Commons Lang, wraps a plain |
An unrestricted polymorphic-typing configuration, which makes a later read take its target class from the payload:
| API | Note |
|---|---|
|
Jackson, deprecated and unrestricted by design |
|
the validator accepts every subtype |
|
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(..)orSystem.setProperty("jdk.serialFilter", ..); -
a locally declared
ObjectInputStreamsubclass overridingresolveClass(..)to allowlist the accepted classes; -
a
ValidatingObjectInputStream(Commons IO) carrying at least oneaccept(..)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:
-
XMLDecoderhas 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.