You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Below is a list of LLM-discovered findings (tool: Claude Code, model: Claude Opus 4.8 (1M context)). These are not security issues. Per our threat model (CWE-502 section), safe deserialization is not something Log4j guarantees, and bypasses of the FilteredObjectInputStream allow-list are explicitly treated as opportunities for further hardening, not as vulnerabilities. Serialization is dropped entirely in Log4j 3, so this is really for legacy 2.x purposes only.
That said, if anyone is interested in improving the serialization/deserialization paths, these are good starting points, and you are welcome to open issues and PRs.
The prompt was roughly: audit every Serializable class in the tree for deserialization gadget surface reachable through the FilteredObjectInputStream / DefaultObjectInputFilter allow-list. Findings are ranked by hardening priority (not by CVSS / exploitability, since there is no supported use case being broken here).
1. What this hardening is (and is not)
Recap of the relevant threat-model points, so the framing below is unambiguous:
Log4j does not deserialize data as part of normal operation. Several classes still implement Serializable purely for backward compatibility; deserializing them is discouraged.
We provide no guarantee that deserializing a stream containing classes from these projects is safe, regardless of the source of the stream.
Filtering such a stream by the org.apache.logging Java package is not sufficient to make deserialization safe.
The hardening utilities we ship are partial and not exhaustive; bypasses are opportunities for further hardening, not vulnerabilities.
The application performing the deserialization is responsible for ensuring that the byte stream originates from a trusted source.
So the purpose of FilteredObjectInputStream / DefaultObjectInputFilter is damage limitation, not a safety boundary. The scenario it assists is: an application that, for legacy reasons, must deserialize a Log4j log-event stream it believes to be trusted, but where an attacker has gained partial influence over that nominally trusted input. The allow-list narrows what such a partially-tampered stream can instantiate; it does not promise that a fully attacker-controlled stream is safe.
A concrete example of this "trusted-but-tampered stream" shape is CVE-2020-9484 in Apache Tomcat (9.0.0.M1 to 9.0.34): when Tomcat is configured with a PersistenceManager backed by a FileStore, session state is written to disk as serialized Java objects and deserialized again on load. Tomcat trusts that store, but an attacker who could plant a file at a known path (and knew its relative name) could get the manager to deserialize an attacker-crafted session and reach RCE via a gadget chain. Notably, Tomcat's own mitigation is exactly the same shape as ours: a class-name allow-list, sessionAttributeValueClassNameFilter, which was null (allow-all) by default.
The follow-up CVE-2021-25329 (fixed in 9.0.42) shows the recurring lesson: such allow-list filtering is a partial, best-effort mitigation whose edge cases get chipped away over time, which is precisely why our threat model declines to treat it as a guarantee. If an application ever persisted or transported serialized Log4j log events the way Tomcat persists sessions, the findings below are what an attacker with that partial write access could still reach through the allow-list.
2. The filter mechanism
There are two enforcement mechanisms, chosen at runtime, both enforcing the same allow-list (SerializationUtil.REQUIRED_JAVA_PACKAGES / REQUIRED_JAVA_CLASSES):
Allow-list: packages java.lang., java.time., java.util., org.apache.logging.log4j.; plus classes java.math.BigDecimal, java.math.BigInteger, java.rmi.MarshalledObject, and all primitives.
Because the filter applies to the whole object graph, a gadget chain must normally be built entirely from those allowed packages. The bulk of the serializable surface honours this correctly.
What is already well defended (no action needed):
The immutable event types reject direct deserialization outright: Log4jLogEvent, MutableLogEvent, RingBufferLogEvent, and ThreadDumpMessage each have a readObject that calls SerializationUtil.assertFiltered(...) and then throws InvalidObjectException("Proxy required").
Nested untrusted objects are read back through SerializationUtil.readWrappedObject, which wraps the bytes in a fresh filtered stream and re-applies the same allow-list to the nested graph (used by ObjectMessage, ParameterizedMessage, SortedArrayStringMap values).
Interface-typed fields (Message, Marker, MessageFactory, ...) exist on many serializable classes, but they are only stored during deserialization, never invoked, and their concrete classes must still pass the filter. Not exploitable.
3. Findings summary
#
Priority
Class / file
Issue
1
High
Log4jLogEvent.LogEventProxy
MarshalledObject.get() deserializes with no filter (allow-list escape)
5
High (gap)
FilteredObjectInputStream
resolveProxyClass not overridden: dynamic proxies bypass the allow-list on Java 8
2
Low
LocalizedMessage
ResourceBundle.getBundle(baseName) on a deserialized string (class-load on attacker data)
3
Low (DoS)
SortedArrayStringMap
attacker-controlled capacity drives array allocation before validation (OOM)
4
Info
ObjectArrayMessage, LocalizedMessage
array fields read via raw readObject instead of readWrappedObject
Findings 1 and 5 are the material ones: the only two places where the allow-list is genuinely escaped rather than merely stretched. Again, per section 1 these are not vulnerabilities and are out of scope for the bug bounty; "priority" ranks how worthwhile the hardening would be.
4. Finding 1 (High) — MarshalledObject.get() is an allow-list escape
Field private MarshalledObject<Message> marshalledMessage; (line ~1136) is non-transient, so it is part of the serialized form and fully attacker-controlled.
Deserializing a LogEventProxy runs readResolve() (line ~1243) which calls message() (line ~1267), which calls marshalledMessage.get() (line ~1270).
java.rmi.MarshalledObject is on the allow-list ("for Message delegate"), but MarshalledObject.get() deserializes its embedded bytes on its own private ObjectInputStream (MarshalledObjectInputStream), which does not inherit Log4j's per-stream filter:
On Java 8 (the runtime baseline) that stream has no filter at all.
On Java 9+ the embedded filter is whatever was serialized into the MarshalledObject; an attacker who crafts the payload controls it (can be null).
Unless the deploying JVM sets a global jdk.serialFilter, the inner bytes are deserialized completely unfiltered: any classic gadget, not just a Message. This is the same gadget surface (CWE-502, historically CVE-2017-5645) the allow-list is meant to narrow as a damage-limiting measure. It does not violate a safety guarantee (there is none), but it means the allow-list provides essentially zero damage limitation for the delegate Message of a tampered LogEventProxy: the one field an attacker is most likely to weaponise is exactly the one that escapes the filter. That makes it the single most valuable place to extend the hardening.
Reachability: the old SocketServer / TcpSocketServer receivers are gone from 2.x main sources, so no shipped component feeds bytes here. The concern is the documented assist use case: an application that deserializes a Log4j log-event stream through FilteredObjectInputStream for legacy reasons, from a source it trusts, that an attacker has partially tampered with (the Tomcat persistent-session class of issue). For that scenario the delegate-message escape means the allow-list buys the application almost nothing.
Fix options (in order of preference):
Serialize/deserialize the delegate Message through SerializationUtil.writeWrappedObject / readWrappedObject (a filtered byte[] wrapper) instead of MarshalledObject.
Drop marshalledMessage entirely and rely on the already-present messageString fallback.
At minimum, remove java.rmi.MarshalledObject from the allow-list.
Options 1 and 2 change the serialized wire format and would need a compatibility note.
5. Finding 5 (High, hardening gap) — proxies bypass the filter on Java 8
FilteredObjectInputStream overrides only resolveClass(ObjectStreamClass) and installs noObjectInputFilter. Dynamic proxy class descriptors (TC_PROXYCLASSDESC) do not flow through resolveClass; the JDK routes them through resolveProxyClass(String[] interfaces) (ObjectInputStream.readProxyDesc). Since FilteredObjectInputStream does not override it, the default implementation runs and the allow-list is never consulted for a proxy or its interfaces.
The two hardening paths are asymmetric:
Java 9+ (DefaultObjectInputFilter via setObjectInputFilter): readProxyDesc calls filterCheck on every interface (for (Class<?> clazz : cl.getInterfaces()) filterCheck(clazz, -1);) and on the proxy class itself. The proxy class name (jdk.proxy1.$Proxy0) and any non-allow-listed interface are REJECTED. Covered.
Java 8 (FilteredObjectInputStream, the actual per-stream mechanism): resolveProxyClass is not overridden and no ObjectInputFilter is set, so filterCheck has nothing to enforce. Proxy interface names bypass the allow-list entirely. Not covered.
How far it goes today: a serialized Proxy still carries its InvocationHandler (field h), which is read as an ordinary object and so must pass resolveClass (allow-listed packages only). The classic proxy-gadget handler sun.reflect.annotation.AnnotationInvocationHandler is blocked, and there is no SerializableInvocationHandler in Log4j or in the allow-listed java.* packages. So a full proxy gadget chain is not constructible with the allow-listed set today. That is why it ranks below Finding 1: the damage-limitation still mostly holds because the handler must be allow-listed, but proxy interfaces are a category the allow-list silently does not cover on Java 8, so it is one allow-listed (or future) gadget handler away from mattering. It also composes with Finding 1: once inside the unfiltered MarshalledObject.get() stream, proxies are unrestricted regardless of runtime.
Fix — override resolveProxyClass to apply the same allow-list per interface (or reject proxies outright, as most hardened filters do). Self-contained, no wire-format change, no public API change:
@OverrideprotectedClass<?> resolveProxyClass(finalString[] interfaces)
throwsIOException, ClassNotFoundException {
for (finalStringintf : interfaces) {
if (!(isAllowedByDefault(intf) || allowedExtraClasses.contains(intf))) {
thrownewInvalidObjectException("Interface is not allowed for deserialization: " + intf);
}
}
returnsuper.resolveProxyClass(interfaces);
}
6. Lower-severity findings
Finding 2 (Low) — LocalizedMessage resource-bundle load on attacker string.log4j-api/.../message/LocalizedMessage.java. After deserialization, getFormattedMessage() reaches getResourceBundle(...) which calls ResourceBundle.getBundle(baseName) on a deserialized string. ResourceBundle.getBundle can load a ResourceBundle subclass by name from the context class loader. Requires such a class already on the classpath and only fires on later formatting, so weak, but it is attacker-string-driven class loading. It also reads stringArgs = (String[]) in.readObject() via raw (filter-constrained) readObject.
Finding 3 (Low, DoS) — SortedArrayStringMap unbounded pre-allocation.log4j-api/.../util/SortedArrayStringMap.java (readExternal path, ~lines 497-527). A deserialized capacity is passed to inflateTable(capacity), allocating new String[capacity] / new Object[capacity] before the entries are read; only capacity < 0 is rejected. A crafted large capacity causes OOM. Not code execution. Fix: bound capacity against the declared entry count / a sane maximum.
Finding 4 (Info) — raw readObject array reads.ObjectArrayMessage ((Object[]) in.readObject()) and LocalizedMessage (String[]) read arrays via raw readObject instead of readWrappedObject. Still bounded by the outer filter and no method is invoked on the elements at deserialization time, so no gadget fires. This is a deliberate design choice (see LOG4J2-3680, "Allow deserialization of arrays"); noted only for consistency.
7. Related prior / in-progress work
PR Harden readObject(ObjectInputStream) method argument checks #4098 (merged) "Harden readObject(ObjectInputStream) method argument checks": added SerializationUtil.assertFiltered() to ObjectArrayMessage.readObject(), matching ObjectMessage / ParameterizedMessage. The security team triaged that family as "Informative — not a vulnerability, code-quality improvement welcomed," and it explicitly left LocalizedMessage and FormattedMessage as known follow-ups (overlaps Finding 2).
LOG4J2-3680 (commit 71ca0865b8) "Allow deserialization of arrays": the raw array reads in Finding 4 are intentional.
No open issue or PR currently addresses MarshalledObject.get() (Finding 1) or resolveProxyClass (Finding 5); both appear to be new.
Note on assertFiltered: it only checks that the stream is a FilteredObjectInputStream or that the JDK exposes setObjectInputFilter. On Java 9+ it does not verify that an actual filter is installed on a plain ObjectInputStream, so it is a coarse gate rather than a guarantee. Secondary to the above.
8. Suggested starting points, if anyone wants to pick this up
Finding 5 first — override resolveProxyClass in FilteredObjectInputStream. Self-contained, no wire-format or API change, closes the Java 8 asymmetry. Cheap win.
Finding 1 — route the LogEventProxy message delegate through readWrappedObject (or drop marshalledMessage), and remove java.rmi.MarshalledObject from the allow-list. Requires a wire-compatibility note.
Finding 3 — bound SortedArrayStringMapcapacity on read.
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
-
Hi all,
Below is a list of LLM-discovered findings (tool: Claude Code, model: Claude Opus 4.8 (1M context)). These are not security issues. Per our threat model (CWE-502 section), safe deserialization is not something Log4j guarantees, and bypasses of the
FilteredObjectInputStreamallow-list are explicitly treated as opportunities for further hardening, not as vulnerabilities. Serialization is dropped entirely in Log4j 3, so this is really for legacy 2.x purposes only.That said, if anyone is interested in improving the serialization/deserialization paths, these are good starting points, and you are welcome to open issues and PRs.
The prompt was roughly: audit every
Serializableclass in the tree for deserialization gadget surface reachable through theFilteredObjectInputStream/DefaultObjectInputFilterallow-list. Findings are ranked by hardening priority (not by CVSS / exploitability, since there is no supported use case being broken here).1. What this hardening is (and is not)
Recap of the relevant threat-model points, so the framing below is unambiguous:
Serializablepurely for backward compatibility; deserializing them is discouraged.org.apache.loggingJava package is not sufficient to make deserialization safe.So the purpose of
FilteredObjectInputStream/DefaultObjectInputFilteris damage limitation, not a safety boundary. The scenario it assists is: an application that, for legacy reasons, must deserialize a Log4j log-event stream it believes to be trusted, but where an attacker has gained partial influence over that nominally trusted input. The allow-list narrows what such a partially-tampered stream can instantiate; it does not promise that a fully attacker-controlled stream is safe.A concrete example of this "trusted-but-tampered stream" shape is CVE-2020-9484 in Apache Tomcat (9.0.0.M1 to 9.0.34): when Tomcat is configured with a
PersistenceManagerbacked by aFileStore, session state is written to disk as serialized Java objects and deserialized again on load. Tomcat trusts that store, but an attacker who could plant a file at a known path (and knew its relative name) could get the manager to deserialize an attacker-crafted session and reach RCE via a gadget chain. Notably, Tomcat's own mitigation is exactly the same shape as ours: a class-name allow-list,sessionAttributeValueClassNameFilter, which wasnull(allow-all) by default.The follow-up CVE-2021-25329 (fixed in 9.0.42) shows the recurring lesson: such allow-list filtering is a partial, best-effort mitigation whose edge cases get chipped away over time, which is precisely why our threat model declines to treat it as a guarantee. If an application ever persisted or transported serialized Log4j log events the way Tomcat persists sessions, the findings below are what an attacker with that partial write access could still reach through the allow-list.
2. The filter mechanism
There are two enforcement mechanisms, chosen at runtime, both enforcing the same allow-list (
SerializationUtil.REQUIRED_JAVA_PACKAGES/REQUIRED_JAVA_CLASSES):FilteredObjectInputStream(overridesresolveClass)log4j-api/.../util/FilteredObjectInputStream.javaDefaultObjectInputFilter(a JEP 290ObjectInputFilter, installed viasetObjectInputFilter)log4j-api-java9/.../util/internal/DefaultObjectInputFilter.javaAllow-list: packages
java.lang.,java.time.,java.util.,org.apache.logging.log4j.; plus classesjava.math.BigDecimal,java.math.BigInteger,java.rmi.MarshalledObject, and all primitives.Because the filter applies to the whole object graph, a gadget chain must normally be built entirely from those allowed packages. The bulk of the serializable surface honours this correctly.
What is already well defended (no action needed):
Log4jLogEvent,MutableLogEvent,RingBufferLogEvent, andThreadDumpMessageeach have areadObjectthat callsSerializationUtil.assertFiltered(...)and then throwsInvalidObjectException("Proxy required").SerializationUtil.readWrappedObject, which wraps the bytes in a fresh filtered stream and re-applies the same allow-list to the nested graph (used byObjectMessage,ParameterizedMessage,SortedArrayStringMapvalues).Message,Marker,MessageFactory, ...) exist on many serializable classes, but they are only stored during deserialization, never invoked, and their concrete classes must still pass the filter. Not exploitable.3. Findings summary
Log4jLogEvent.LogEventProxyMarshalledObject.get()deserializes with no filter (allow-list escape)FilteredObjectInputStreamresolveProxyClassnot overridden: dynamic proxies bypass the allow-list on Java 8LocalizedMessageResourceBundle.getBundle(baseName)on a deserialized string (class-load on attacker data)SortedArrayStringMapcapacitydrives array allocation before validation (OOM)ObjectArrayMessage,LocalizedMessagereadObjectinstead ofreadWrappedObjectFindings 1 and 5 are the material ones: the only two places where the allow-list is genuinely escaped rather than merely stretched. Again, per section 1 these are not vulnerabilities and are out of scope for the bug bounty; "priority" ranks how worthwhile the hardening would be.
4. Finding 1 (High) —
MarshalledObject.get()is an allow-list escapeFile:
log4j-core/src/main/java/org/apache/logging/log4j/core/impl/Log4jLogEvent.java(classLogEventProxy).private MarshalledObject<Message> marshalledMessage;(line ~1136) is non-transient, so it is part of the serialized form and fully attacker-controlled.LogEventProxyrunsreadResolve()(line ~1243) which callsmessage()(line ~1267), which callsmarshalledMessage.get()(line ~1270).java.rmi.MarshalledObjectis on the allow-list ("for Message delegate"), butMarshalledObject.get()deserializes its embedded bytes on its own privateObjectInputStream(MarshalledObjectInputStream), which does not inherit Log4j's per-stream filter:MarshalledObject; an attacker who crafts the payload controls it (can benull).Unless the deploying JVM sets a global
jdk.serialFilter, the inner bytes are deserialized completely unfiltered: any classic gadget, not just aMessage. This is the same gadget surface (CWE-502, historically CVE-2017-5645) the allow-list is meant to narrow as a damage-limiting measure. It does not violate a safety guarantee (there is none), but it means the allow-list provides essentially zero damage limitation for the delegateMessageof a tamperedLogEventProxy: the one field an attacker is most likely to weaponise is exactly the one that escapes the filter. That makes it the single most valuable place to extend the hardening.Reachability: the old
SocketServer/TcpSocketServerreceivers are gone from 2.x main sources, so no shipped component feeds bytes here. The concern is the documented assist use case: an application that deserializes a Log4j log-event stream throughFilteredObjectInputStreamfor legacy reasons, from a source it trusts, that an attacker has partially tampered with (the Tomcat persistent-session class of issue). For that scenario the delegate-message escape means the allow-list buys the application almost nothing.Fix options (in order of preference):
MessagethroughSerializationUtil.writeWrappedObject/readWrappedObject(a filteredbyte[]wrapper) instead ofMarshalledObject.marshalledMessageentirely and rely on the already-presentmessageStringfallback.java.rmi.MarshalledObjectfrom the allow-list.Options 1 and 2 change the serialized wire format and would need a compatibility note.
5. Finding 5 (High, hardening gap) — proxies bypass the filter on Java 8
File:
log4j-api/src/main/java/org/apache/logging/log4j/util/FilteredObjectInputStream.java.FilteredObjectInputStreamoverrides onlyresolveClass(ObjectStreamClass)and installs noObjectInputFilter. Dynamic proxy class descriptors (TC_PROXYCLASSDESC) do not flow throughresolveClass; the JDK routes them throughresolveProxyClass(String[] interfaces)(ObjectInputStream.readProxyDesc). SinceFilteredObjectInputStreamdoes not override it, the default implementation runs and the allow-list is never consulted for a proxy or its interfaces.The two hardening paths are asymmetric:
DefaultObjectInputFilterviasetObjectInputFilter):readProxyDesccallsfilterCheckon every interface (for (Class<?> clazz : cl.getInterfaces()) filterCheck(clazz, -1);) and on the proxy class itself. The proxy class name (jdk.proxy1.$Proxy0) and any non-allow-listed interface areREJECTED. Covered.FilteredObjectInputStream, the actual per-stream mechanism):resolveProxyClassis not overridden and noObjectInputFilteris set, sofilterCheckhas nothing to enforce. Proxy interface names bypass the allow-list entirely. Not covered.How far it goes today: a serialized
Proxystill carries itsInvocationHandler(fieldh), which is read as an ordinary object and so must passresolveClass(allow-listed packages only). The classic proxy-gadget handlersun.reflect.annotation.AnnotationInvocationHandleris blocked, and there is noSerializableInvocationHandlerin Log4j or in the allow-listedjava.*packages. So a full proxy gadget chain is not constructible with the allow-listed set today. That is why it ranks below Finding 1: the damage-limitation still mostly holds because the handler must be allow-listed, but proxy interfaces are a category the allow-list silently does not cover on Java 8, so it is one allow-listed (or future) gadget handler away from mattering. It also composes with Finding 1: once inside the unfilteredMarshalledObject.get()stream, proxies are unrestricted regardless of runtime.Fix — override
resolveProxyClassto apply the same allow-list per interface (or reject proxies outright, as most hardened filters do). Self-contained, no wire-format change, no public API change:6. Lower-severity findings
Finding 2 (Low) —
LocalizedMessageresource-bundle load on attacker string.log4j-api/.../message/LocalizedMessage.java. After deserialization,getFormattedMessage()reachesgetResourceBundle(...)which callsResourceBundle.getBundle(baseName)on a deserialized string.ResourceBundle.getBundlecan load aResourceBundlesubclass by name from the context class loader. Requires such a class already on the classpath and only fires on later formatting, so weak, but it is attacker-string-driven class loading. It also readsstringArgs = (String[]) in.readObject()via raw (filter-constrained)readObject.Finding 3 (Low, DoS) —
SortedArrayStringMapunbounded pre-allocation.log4j-api/.../util/SortedArrayStringMap.java(readExternal path, ~lines 497-527). A deserializedcapacityis passed toinflateTable(capacity), allocatingnew String[capacity]/new Object[capacity]before the entries are read; onlycapacity < 0is rejected. A crafted largecapacitycauses OOM. Not code execution. Fix: boundcapacityagainst the declared entry count / a sane maximum.Finding 4 (Info) — raw
readObjectarray reads.ObjectArrayMessage((Object[]) in.readObject()) andLocalizedMessage(String[]) read arrays via rawreadObjectinstead ofreadWrappedObject. Still bounded by the outer filter and no method is invoked on the elements at deserialization time, so no gadget fires. This is a deliberate design choice (see LOG4J2-3680, "Allow deserialization of arrays"); noted only for consistency.7. Related prior / in-progress work
readObject(ObjectInputStream)method argument checks #4098 (merged) "HardenreadObject(ObjectInputStream)method argument checks": addedSerializationUtil.assertFiltered()toObjectArrayMessage.readObject(), matchingObjectMessage/ParameterizedMessage. The security team triaged that family as "Informative — not a vulnerability, code-quality improvement welcomed," and it explicitly leftLocalizedMessageandFormattedMessageas known follow-ups (overlaps Finding 2).71ca0865b8) "Allow deserialization of arrays": the raw array reads in Finding 4 are intentional.MarshalledObject.get()(Finding 1) orresolveProxyClass(Finding 5); both appear to be new.Note on
assertFiltered: it only checks that the stream is aFilteredObjectInputStreamor that the JDK exposessetObjectInputFilter. On Java 9+ it does not verify that an actual filter is installed on a plainObjectInputStream, so it is a coarse gate rather than a guarantee. Secondary to the above.8. Suggested starting points, if anyone wants to pick this up
resolveProxyClassinFilteredObjectInputStream. Self-contained, no wire-format or API change, closes the Java 8 asymmetry. Cheap win.LogEventProxymessage delegate throughreadWrappedObject(or dropmarshalledMessage), and removejava.rmi.MarshalledObjectfrom the allow-list. Requires a wire-compatibility note.SortedArrayStringMapcapacityon read.LocalizedMessage/FormattedMessageinto the sameassertFiltered+readWrappedObjecttreatment the security team already anticipated in the HardenreadObject(ObjectInputStream)method argument checks #4098 discussion.Beta Was this translation helpful? Give feedback.
All reactions