Dissecting FOIS: Log4j2's Half-Built Deserialization Filter
Vulnerability research
Apache Log4j2
TL;DR
Log4j2's
FilteredObjectInputStream(FOIS) checks the class names in a serialized stream and nothing else. It never limits object size or depth.That one gap gives three problems on a single network log receiver: an unauthenticated RCE, a crash that pins a CPU core, and a 44-byte out-of-memory crash. Two of them need no exploit gadget.
The RCE (Log4j2 #4255) is prior work. CodeAnt's contribution is dissecting the two crashes, the contrast with logback, and the lab numbers.
Exposure is narrow. You need a serialized log receiver on the network, which is not a Log4j default. High severity, low prevalence.
Fix: send logs as text, or add the size and depth caps logback already ships.
At a Glance
Component | Log4j2 FilteredObjectInputStream (FOIS) |
Issue | Apache Log4j2 #4255 |
Class | Java deserialization (CWE-502, CWE-400, CWE-789) |
Impacts | RCE (prior work) · CPU-exhaustion DoS · OOM crash |
DoS details | ~5.7 KB pins a core forever · 44 bytes throws OutOfMemoryError |
Vector | Network, unauthenticated, no gadget, no credentials |
Severity | RCE critical · DoS high · no CVE for the DoS bugs |
Affected | Log4j 2.x–2.26.1 with a network log-event receiver |
Precondition | Serialized log receiver exposed (not a Log4j default) |
Fixed | Text transport, or maxarray + maxdepth caps · gone in Log4j 3.x |
Prior work | #4255 RCE (U-Sec / Wujie) · gadgets (ysoserial) · SerialDOS (Coekaerts) |
Discovered by | CodeAnt AI Security Research Team (two DoS bugs) |
Reproduce it: a self-contained, one-command Docker lab for all three impacts (RCE, CPU-DoS, OOM) plus the mitigation: Log4j2-4255-lab
Log4j2's FilteredObjectInputStream screens the class names in a serialized stream, and nothing else. That half-built filter is one unauthenticated RCE, one CPU bomb, and one 44-byte crash, all on the same receiver. Here is every piece, with the code and the measurements.
Demo: One Receiver, Three Impacts (live)

RCE — a java.rmi.MarshalledObject-wrapped gadget hits the FilteredObjectInputStream receiver; the receiver logs msg=null while whoami returns root, unauthenticated code execution as root.
RCE, one wrapped gadget, and whoami returns root. The receiver logs a benign msg="null"; no error.

DoS — the same receiver, no gadget: a 5.7 KB nested HashSet pins readObject at 100% CPU permanently, and a 44-byte Object array triggers OutOfMemoryError: Requested array size exceeds VM limit.
DoS, no gadget needed. A 5.7 KB nested HashSet pins a core permanently; a 44-byte Object[] throws OutOfMemoryError.
Lab: Log4j 2.26.1 on JDK 17, run it yourself, one command in Docker.
The Filter: What FOIS is, And What it Checks
Log4j2 can ship log events across the network as Java-serialized objects. A receiver reads those bytes and rebuilds the event with ObjectInputStream.readObject(), the primitive behind a decade of Java RCE.
To make that safe, Log4j wraps the read in FilteredObjectInputStream (FOIS), the class Apache added in 2.8.2 to fix CVE-2017-5645, allowlisting java.rmi.MarshalledObject from the start, and later moved from log4j-core to log4j-api in 2.11.0 (its current @since 2.11.0 is that package move).
FOIS does exactly one thing: it overrides resolveClass to reject any class whose name is not on an allowlist. It inspects the class name of every object in the stream, and nothing else. Critically, it installs no ObjectInputFilter, so the JDK's own limits on array size, graph depth, and reference count never run.
That allowlist is a short list of exact class names, crucially including java.rmi.MarshalledObject, plus four package prefixes: java.lang., java.time., java.util., and org.apache.logging.log4j., matched by startsWith.
Two design choices carry the whole rest of this post: MarshalledObject is on the list, and the prefix match admits every class under java.util. and java.lang.
Impact One: RCE: The Class that Carries a Hole
Issue #4255 (the original report, since deleted, is credited by the community reproduction repos to U-Sec / Wujie Security) turns that allowlist against itself. java.rmi.MarshalledObject stores its payload as an opaque byte[]; its get() method deserializes those bytes on a fresh stream.
On a FOIS receiver that fresh stream has no allowlist attached (FOIS filters through a resolveClass override, which the new stream does not inherit), so the inner bytes are deserialized with nothing checking them.
Log4j triggers this itself. The serialized wire form of a LogEvent carries its message inside a MarshalledObject (since 2.8), and reconstruction calls marshalledMessage.get() automatically, inside a try/catch that catches only Exception and swallows it (the source literally comments // ignore me), falling back to the event's plain messageString field.
So the outer, allowlisted read accepts the MarshalledObject; one method call later, get() opens the inner bytes against no list at all; and because the failure is swallowed, no error is logged; the receiver records only that message field, which the attacker sets to anything (the lab payload leaves it empty, giving a benign msg="null") while the payload has already run.
With a deserialization gadget on the receiver's classpath, that is unauthenticated remote code execution. Reproduced end to end against official Log4j 2.26.1 on JDK 17: a wrapped CommonsCollections6 gadget wrote a root-owned marker over the wire, the receiver stayed up and silent, the same gadget sent unwrapped was rejected by FOIS, and -Djdk.serialFilter='!java.rmi.MarshalledObject' blocked it.
The Gadget Precondition: What the Unfiltered Stream Really Means
Because the inner stream has no allowlist, the full published gadget catalog applies: joanbono's log4j2-4255-exploit wraps any ysoserial payload (wrap --gadget).
On a default JDK 17 the working set narrows: the TemplatesImpl chains (Spring, Hibernate1, BeanUtils, CC2/3/4) are blocked by JDK 16+ module encapsulation (java.xml doesn't export the internal xsltc package).
What still fires is the Runtime.exec chains (CommonsCollections6/7, needing commons-collections 3.1-3.2.1) and c3p0, which loads bytecode through its own URLClassLoader, so it needs no commons-collections and dodges the JNDI trustURLCodebase default. The precondition is "a usable gadget on the classpath," not one specific library.

One design decision (filter class names, enforce no resource limits) branches into three impacts. logback's HardenedObjectInputStream, solving the identical problem, adds the resource limits FOIS omits and is immune to all three.
Impacts Two and Three: The Limits That Were Never Set
The RCE is what the allowlist admits. The next two are what the allowlist never measures.
Because FOIS installs no ObjectInputFilter, the JDK's built-in guards (maximum array length, maximum graph depth, maximum reference count) are simply off. Two tiny packets exploit that, and neither needs a gadget.
CPU-DoS: a nested HashSet, no depth limit
java.util.HashSet is admitted by the java.util. prefix, and rebuilding one calls hashCode() on its members.
The payload is a shared DAG (at each level a pair of sets that the level above holds twice), so a graph only tens of levels deep is reachable by exponentially many paths, and hashCode() is recomputed along every one: the classic "SerialDOS" construction (Wouter Coekaerts, 2015). With no maxdepth on the stream, the receiver runs it to completion. Measured, deserializing the payload alone:
Depth | Payload |
|
|---|---|---|
28 | 1,640 B | 9.5 s |
30 | 1,754 B | 37.9 s |
32 | 1,868 B | 151.7 s |
~34 | ~2.0 KB | ~10 min |
100 | 5.7 KB | effectively permanent |
The curve is a clean 4× per two levels of depth (three measured points), so the ~10-minute and permanent rows are extrapolations.
Against the single-threaded reference receiver it is a full stall: a legitimate client that connected during a depth-28 bomb waited 9.7 seconds to be served. A pool doesn't escape it: one packet pins one worker at 100% CPU, and one permanent-depth payload per worker freezes every thread.
It is also silent: a spinning read emits no log line and never returns, so nothing is ever logged or rejected. The tell is a thread pinned at 100% CPU, not the application log.
OOM: a huge array, no size limit
A serialized array header declares its length before any element is read, and readObject allocates that array up front. With no maxarray, the declared length is unbounded: a 44-byte packet can name a multi-gigabyte array.
A serialized Object[] of length Integer.MAX_VALUE is large enough that the JVM refuses it at its own array-size limit, throwing OutOfMemoryError: Requested array size exceeds VM limit before it even reaches the heap.
Fired through the real FilteredObjectInputStream class, the same error appears at the default heap and at 2 GB (the array-size cap is hit before allocation, so it is heap-independent and deterministic), while the one line FOIS omits, an ObjectInputFilter with maxarray (which logback sets), rejects the identical payload outright.
One honest caveat on severity: OutOfMemoryError is a VirtualMachineError, not an Exception. A pool that catches Throwable loses only the one read; but Log4j's reconstruction catches only Exception (the §02 swallow pattern), so there the error escapes uncaught.
A length just under the VM limit shifts it to real heap exhaustion (Java heap space) instead. Either way, a 44-byte packet triggers an OutOfMemoryError on demand, no gadget.
The Contrast: How Logback Solved the Same Problem
The three impacts are not unrelated bugs. They are three consequences of a single decision: FOIS checks what class, never how big or how deep. Same class, same receiver, same readObject.
Impact | The gap | Trigger | Severity |
|---|---|---|---|
RCE | allowlist admits | wrapped gadget | 9.8 |
CPU-DoS | no | 1.6 KB nested | DoS |
OOM | no | 44 B | DoS |
logback faced the same problem and shows the complete answer. Where FOIS stops at class names, its HardenedObjectInputStream adds the rest: an exact-match allowlist (no MarshalledObject, no prefixes), a JEP 290 ObjectInputFilter with maxarray=10000/maxdepth=16, and a resolveProxyClass that rejects proxies, making it immune to all three. But it got there the hard way: the caps arrived only in 1.4.12 (2023, CVE-2023-6378), the exact-match allowlist in 1.5.33, the closed proxy in 1.5.34; the 2017 fix was just a prefix whitelist, FOIS's own design. Kafka's SafeObjectInputStream does the name half only (two classes, no caps). FOIS is, in effect, half a filter.
The filter meant to protect the receiver screens class names but not resource use, so the same endpoint is an RCE, a CPU bomb, and a 44-byte crash.
Reach: How Often the Receiver is Actually Exposed
Every impact here needs the same precondition: a serialized-LogEvent receiver exposed on the network. That is not a default Log4j service: Apache removed the in-core socket server after 2.8.2, so an ordinary Log4j app has no such listener.
In his own testing, Jeff McJunkin ran 53 builds across 52 products in default configuration and found zero exploitable, all failing this one condition. Treat it as high severity, low prevalence: trivial to exploit once a receiver is found, genuinely hard to find one.
It is not purely theoretical, though. The receiver pattern shipped in mainstream software:
logback, Spring Boot's default logging backend (the library §04 credits for the fix). Its
SocketNode/ServerSocketReceiverreads a serializedILoggingEventoff the socket, and before 1.2.0 did so unvalidated (CVE-2017-5929, 9.8). Even the 1.2.0 fix was only a prefix whitelist, FOIS's design, leaving the same DoS class open for six years; the caps didn't land until 1.4.12 (CVE-2023-6378), exact-match until 1.5.33, the closed proxy until 1.5.34.Elastic Logstash, the
logstash-input-log4jplugin binds0.0.0.0:4560in server mode and deserializesorg.apache.log4j.spi.LoggingEventoff the socket. Older versions had no allowlist (unauthenticated deserialization RCE, the CVE-2019-17571 class); current versions add an exact-match allowlist (ALLOWED_CLASSES = ["org.apache.log4j.spi.LoggingEvent"]) and the plugin is deprecated. Historical, in a very widely deployed product.Log4j 1.x SocketNode, a plain
ObjectInputStreamwith no filter at all (CVE-2019-17571). Where a listener is deployed this is strictly easier than #4255, there is no allowlist to bypass. EOL; the fix is reload4j or removal.
Response: Detection and Fix
The application log is useless for detection: the RCE swallows its exception and logs only the event's own message, which the attacker controls.
Detect by behaviour instead: a log-receiver JVM spawning any child process (the Runtime.exec gadget forks directly, no shell, so don't just watch sh/bash), unexpected outbound connections from a log-only box, and, for the DoS pair, a receiver thread pinned at 100% CPU or an OutOfMemoryError in the deserialization path.
A process-wide jdk.serialFilter is honoured on the inner MarshalledObject stream (verified), so it can both close the RCE and cap the DoS pair, but only one filter applies per JVM, and a later -D replaces an earlier one, so every rule must live in a single ;-separated string.
The non-breaking option, which keeps legitimate log events working, denies the gadget package and adds the caps FOIS omits: -Djdk.serialFilter='!org.apache.commons.collections.**;maxarray=10000;maxdepth=16'. A stricter option rejects the wrapper itself (and, with it, legitimate serialized LogEvents, which carry their message the same way): -Djdk.serialFilter='!java.rmi.MarshalledObject;maxarray=10000;maxdepth=16'. Those caps are exactly what current logback sets in code.
The durable fix is uniform: stop moving Java-serialized objects across a trust boundary, carry events as JSON or RFC 5424 text, retire any legacy serialized receiver, and if serialized transport must stay, give the receiver a real ObjectInputFilter with array and depth caps plus an exact-match class list (what current logback ships).
Credit: Prior Work
The #4255 vulnerability is not ours, and neither is the "any gadget works" property or the c3p0 gadget; those belong to the original report and to ysoserial.
This post by the CodeAnt security research team adds the dissection of the filter around the RCE: the CPU-DoS and OOM impacts of FOIS's missing resource limits, and the logback/Kafka contrast. Everything upstream belongs to the people below.
Original research: U-Sec / Wujie Security, first credited with the
MarshalledObjectallowlist bypass behind #4255. The original report was later deleted.Apache Log4j: #4255, LOG4J2-2163, CVE-2017-5645.
Gadgets: Chris Frohoff's ysoserial.
Independent PoCs: joanbono's log4j2-4255-exploit and dinosn's log4j-4255.
Reproduction lab: log4j2-4255-lab.
CPU-crash technique: Wouter Coekaerts, "SerialDOS," 2015.
Related CVEs: CVE-2019-17571, CVE-2017-5929, CVE-2023-6378.
Prior writeup: Jeff McJunkin's post, whose 53-product test and detection guidance inform the exposure and fix sections.
Closing Notes: One filter, Three Impacts, One Fix
One design choice drives the whole finding: FilteredObjectInputStream screens the class name and nothing about the object's shape, so a single log-receiver endpoint is at once an RCE, a CPU bomb, and a 44-byte crash: a name allowlist that is only half a filter.
The precondition stays narrow (a serialized-LogEvent receiver on the network, not a default Log4j service), and the fix is uniform: carry events as JSON or RFC 5424 text, or add an ObjectInputFilter with array and depth caps. Log4j 3.x already dropped serialized-event transport; the design (reported as #4255) is present through 2.26.1, the version still in production.
This breakdown is part of an ongoing effort by CodeAnt AI Security Research to analyse the trust boundaries in widely deployed infrastructure. The lab ran in isolated local Docker containers with synthetic payloads. The underlying issue and the gadgets are prior work, credited above.
Have a receiver like this, or want a deserialization boundary reviewed? Reach us at securityresearch@codeant.ai
[FAQ]
Frequently Asked
Questions
What is the Log4j2 FilteredObjectInputStream (FOIS) vulnerability?
Is the Log4j2 #4255 deserialization RCE a new vulnerability?
How does the Log4j2 MarshalledObject deserialization RCE work?
Can a Java deserialization DoS work without a gadget chain?
Which Log4j2 versions are affected, and how exposed is this in the real world?
How do I fix or mitigate the Log4j2 deserialization vulnerability?
How is FOIS different from logback's HardenedObjectInputStream?
More from CodeAnt
[GET STARTED]
START PENTEST






