An upstream report raised an alarming possibility: a class filter around Log4j2’s Java-serialized event receiver could approve an outer event and still lose control of what was deserialized inside it. The report then disappeared before final vendor guidance was available, leaving two bad options: dismiss an unverified claim, or repeat the phrase “Log4j RCE” without knowing what it actually applied to.
We chose a third option. Pruva reconstructed the boundary, exercised the real Apache sample TCP receiver, and ran the same network input against vulnerable and controlled targets. The result was receiver-side command execution in two fresh JVMs. It was also much narrower than the phrase “the next Log4Shell” suggests.
No CVE has been assigned, and no vendor-fixed release is public at this update.
The verified reproduction is now public. A separate containerized PoC was published after our August 26, 2026 evidence freeze and exposes the same boundary with a standard Commons Collections chain. Pruva’s runtime proof was already complete and frozen before that repository existed. We cite the later PoC only as post-freeze corroboration and as a public reference for mechanics that were previously withheld.
The filter worked, then the trust boundary moved
The receiver path we tested does not parse ordinary log text. It accepts Java-serialized Log4j
events over TCP. Apache’s sample server wraps the socket in
ObjectInputStreamLogEventBridge,
which uses Log4j’s
FilteredObjectInputStream
(FOIS) before calling readObject().
FOIS is a real boundary. Our direct control sent a non-allowlisted object through the same bridge, and the receiver rejected it. Any explanation that starts with “the filter simply does nothing” is wrong.
The problem appears one transition later. A legitimate serialized Log4jLogEvent uses
LogEventProxy during serialization. In the tested Log4j build, that proxy can carry message
content in a java.rmi.MarshalledObject. The outer stream sees an allowlisted event proxy and an
allowed Java carrier. The carrier’s nested object graph is still opaque bytes at that point.
When the event proxy reconstructs the message, the carrier unpacks those bytes through another object input stream. That second stream is not the FOIS instance that approved the outer class descriptors. The original class decision is therefore not transitive across the nested deserialization boundary.
- 01Untrusted peerSends a serialized event over TCP
- 02Event receiverAccepts the network object stream
- 03FOIS boundaryChecks the outer class descriptors
- 04Allowed carrierOuter event proxy passes the filter
- 05ReconstructionOpens a separate inner object stream
- 06Inner graphReceiver classpath processes its contents
That distinction is the useful new information. The vulnerable condition is not “filter absent.” It is “filter applied to one stream, then security-sensitive deserialization resumed in another.”
How an allowed event carries a graph the filter never sees
The serialized wire object is not a normal Log4jLogEvent. Java serialization calls the event’s
replacement logic and emits Log4jLogEvent.LogEventProxy. One of that proxy’s fields is a
MarshalledObject<Message>. Log4j places java.rmi.MarshalledObject in its required Java class
set because it is the message delegate used by the event’s serialized form.
That choice creates two different views of the same input:
- the outer stream sees an allowed Log4j event proxy, an allowed
MarshalledObject, and an opaque byte array; - the inner stream later interprets that byte array as a new serialized object graph.
The public PoC makes the separation concrete. It first serializes a benign event proxy, locates the
serialized bytes of the benign inner SimpleMessage, and replaces that byte array with a
serialized CommonsCollections6 graph while correcting the array length. The receiver therefore
does not need an attacker-defined class. It needs only the Log4j classes required for the event and
a compatible gadget library already on its classpath.
On reconstruction, LogEventProxy.readResolve() calls its message recovery path. That path invokes
MarshalledObject.get(), which opens a MarshalledObjectInputStream over the embedded bytes. The
outer FOIS decision cannot inspect the graph before this point, and it is not the stream resolving
the inner graph. With Commons Collections 3.2.1 present, the published graph links
TiedMapEntry, LazyMap, and a ChainedTransformer to process execution. Commons Collections
3.2.2 is an important negative control because it disables unsafe functor deserialization by
default.
The byte-splicing technique is one way to construct the carrier, not the root cause. Pruva’s proof used a receiver-loadable message wrapper to carry a CC6 graph and then tested the same network bytes against every target and control. Both constructions reach the same security transition: the inner graph is resolved only after the outer filter has admitted the carrier.
What the runtime proof adds
At the August 26, 2026 evidence freeze, the public record did not establish four facts that change how this issue should be evaluated:
- the outer Log4j filter was active and rejected a direct non-allowlisted object;
- the real Apache sample receiver reached the second object stream during event reconstruction;
- the network path reached command execution repeatedly in fresh receiver JVMs; and
- two source-control receivers plus a JEP 290 policy rejected the unchanged final input without a process-side marker.
Together, those observations turn a source-level concern into a bounded production-path result. They also explain why broad Log4j inventory counts are not an exposure measurement: the receiver, nested transition, classpath, reachability, and runtime filtering all matter.
The direct-rejection and nested-capability tiers used purpose-built control inputs. The final command-execution input was then replayed unchanged against the vulnerable, source-control, and JEP 290 receivers:
| Case | Fresh receivers | Input | Result |
|---|---|---|---|
| Direct disallowed object | 1 | Tier-specific control | Rejected by the outer FOIS control |
| Nested callback capability | 2 | Tier-specific control | Reached after outer admission in both JVMs |
| Receiver-side command execution | 2 | Final input | Process-unique side effect observed in both JVMs |
| Narrow source control | 2 | Same final input | Rejected; no side effect |
| JEP 290 carrier rejection | 1 | Same final input | Rejected; no side effect |
We tested the product path, not a local deserialization toy
A local program that calls ObjectInputStream.readObject() on attacker-provided bytes would prove
very little about a network-facing Log4j deployment. Our acceptance contract required the actual
serialized receiver path:
TCP peer
-> TcpSocketServer
-> ObjectInputStreamLogEventBridge
-> FilteredObjectInputStream
-> Log4j event proxy reconstruction
-> nested object stream
The target used the Apache logging-log4j-samples server at a fixed source commit and official
Log4j API/Core 2.26.1 artifacts. Each attempt started a fresh receiver JVM on a randomized port.
The harness separately checked service readiness, connection acceptance, the direct filter
control, nested deserialization, and the final process-side effect.
One exact final input was generated once and replayed unchanged. It produced run-unique command
markers in two fresh vulnerable receivers with different process identities. The same bytes were
then rejected by two fresh receivers using a narrow Log4j source-level control, with no marker. A
separate run configured JEP 290, the JDK Object Input Filter mechanism, to reject
java.rmi.MarshalledObject; it rejected the unchanged input and produced no marker.
The full reproduction contains 29 digest-bound proof artifacts covering the direct FOIS control, two fresh nested-capability receivers, two fresh command-execution receivers, two fresh narrow-control receivers, and one JEP 290 mitigation receiver. The public record includes the runnable script, source, target identity, receiver logs, exact-input identity, runtime manifest, and typed verdict.
The independently published lab reaches the same primitive with an approximately 2.8 KB serialized
event and a faithful receiver stand-in. In addition to that later stand-in result, Pruva’s frozen
product-path proof shows the unchanged input traversing Apache’s sample TcpSocketServer and
ObjectInputStreamLogEventBridge with repeated vulnerable and defensive controls.
Why this is not another Log4Shell
Log4Shell became broadly exploitable because attacker-controlled text could reach lookup behavior through ordinary logging paths in a huge number of applications. The path we verified has a very different exposure model.
An application is in the demonstrated risk envelope only when all of these conditions hold:
- it deploys a Java-serialized LogEvent receiver based on this FOIS path;
- an untrusted peer can reach that listener;
- the receiver uses a vulnerable Log4j build;
- its runtime classpath contains a usable deserialization gadget surface; and
- no effective object-input filter rejects the nested carrier or inner graph.
The tested receiver had Commons Collections 3.2.1 and a receiver-loadable message wrapper, custom code that is not a Log4j default, so we could measure the worst case for that classpath. Neither is a default Log4j dependency, and their presence in our lab must not be generalized to every Log4j application. The public byte-splicing PoC removes the custom-message precondition, but it does not remove the need for a usable gadget surface on the receiver.
Ordinary calls such as logger.info(attackerText) do not enter the receiver path we tested. Merely
finding log4j-core in a software inventory does not prove reachability. Internet scanning for a
single familiar Log4j port would also be misleading because operators can bind serialized
receivers to arbitrary ports or embed similar bridge code elsewhere.
Where all five preconditions hold, the impact is serious: our proof reached command execution as the receiver process account from an unauthenticated TCP peer. We did not test or claim privilege escalation.
The controls tell us where to defend
The unchanged-input controls matter more than a successful marker. The narrow source control
removed java.rmi.MarshalledObject from Log4j’s required Java class set, causing the outer filter
to reject the carrier before event reconstruction. A JEP 290 policy separately rejected the
unchanged input. Neither control produced the command-side effect.
This gives defenders useful actions before a final vendor advisory is available:
- inventory Java-serialized LogEvent listeners, including copied or adapted sample-server code;
- remove those listeners when they are not required;
- otherwise bind them to authenticated, trusted network segments rather than exposing them to untrusted peers;
- configure an application-specific JEP 290 allowlist and reject nested serialization carriers that the application does not need;
- remove unnecessary gadget-bearing libraries from receiver classpaths; and
- alert on unexpected child processes from JVMs that host serialized event receivers.
The process tree is a stronger detection signal than the serialized byte pattern. In the confirmed case, the network-facing receiver JVM becomes the parent of an unexpected operating-system process. Defenders should correlate that child process with a connection to the serialized-event listener, the receiver’s Java command line, and object-input-filter rejection telemetry. A receiver can log a deserialization error or continue handling later input after the nested graph has already caused a side effect, so an exception alone is not evidence that execution was prevented.
These are defense-in-depth measures, not a substitute for the vendor’s final remediation. Apache’s
Log4j 2.x deserialization-hardening discussion
describes MarshalledObject.get() as an allow-list escape but also states that deserializing
untrusted Log4j data is outside the project’s security guarantee. The old in-core serialized socket
server was removed after 2.8.2; modern exposure comes from applications, copied sample code, or
legacy integrations that still feed serialized events into this path.
Pruva executed Log4j API/Core 2.26.1. Source history places the relevant API allow-list and Core event-proxy machinery across a wider range, but that is not the same as proving every release or deployment remotely exploitable. No CVE, vendor severity, final affected matrix, or vendor-fixed release is public at this update, so we do not invent one. Our narrow source change identifies the first divergent boundary; it is not presented as Apache’s final patch.
What is public, and what remains unresolved
The exploit mechanics, a reusable public PoC, and Pruva’s production-path reproduction are now available. Publishing the evidence does not turn the conditional receiver path into a default Log4j exposure. Operators still need to establish listener reachability, serialized-event usage, the exact Log4j path, the receiver classpath, and effective JVM filters.
The unresolved questions are governance and remediation questions: whether Apache will assign or request an identifier, what source change it will ship, how it will describe affected legacy integrations, and which release will carry that change. The Apache discussion currently treats the condition as hardening because applications are not supposed to deserialize untrusted event data. That threat-model position and the runtime result can both be true: the default product may not expose an untrusted receiver, while an application that does expose one can cross the filter and execute a receiver-loadable graph.
The most reusable conclusion is precise and limited: a filter that correctly constrains one object stream does not protect a second stream opened later during object reconstruction. Security review has to follow the object graph across that transition, not stop when the outer stream returns an allowed class name.