Skip to content

Vulnerability research

Not Another Log4Shell: A Serialized-Event Receiver Boundary

The outer class filter worked. Event reconstruction then opened a second object stream beyond that decision. We tested the real TCP receiver and bounded the result.

Coordinated disclosure preview

Pruva verified the production-path claim, but payload construction, gadget details, the reproduction script, and final affected/fixed version guidance remain withheld during coordination.

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. Final vendor guidance was not public when we froze this research, leaving two bad options for everyone watching: 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.

This is what we can responsibly say while operational details remain under embargo.

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.

The filter decision ends at the outer stream; reconstruction crosses into a second stream.
  1. 01Untrusted peerSends a serialized event over TCP
  2. 02Event receiverAccepts the network object stream
  3. 03FOIS boundaryChecks the outer class descriptors
  4. 04Allowed carrierOuter event proxy passes the filter
  5. 05ReconstructionOpens a separate inner object stream
  6. 06Inner graphReceiver classpath processes its contents

We name MarshalledObject because defenders need a concrete class to inventory and reject while coordination continues; the field-level serialized layout and payload bytes remain withheld.

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.”

What the runtime proof adds

At 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.

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 the JDK’s built-in JEP 290 object-input filter to reject java.rmi.MarshalledObject; it rejected the unchanged input and produced no marker.

The full reproduction contains 29 digest-bound proof artifacts: two fresh nested-capability receivers, two fresh command-execution receivers, two fresh narrow-control receivers, and one JEP 290 mitigation receiver. We are withholding the payload, helper code, dependency recipe, and artifact link until disclosure permits publishing them.

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:

  1. it deploys a Java-serialized LogEvent receiver based on this FOIS path;
  2. an untrusted peer can reach that listener;
  3. the receiver uses a vulnerable Log4j build;
  4. its runtime classpath contains a usable deserialization gadget surface; and
  5. no effective object-input filter rejects the nested carrier or inner graph.

The tested receiver had a compatible gadget surface so we could measure the worst case for that classpath. That library is not a default Log4j dependency, and its presence in our lab must not be generalized to every Log4j application.

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. A narrow change to the tested Log4j source caused the outer filter to reject the nested 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.

These are defense-in-depth measures, not a substitute for the vendor’s final remediation. Vendor coordination and final advisory status remained unresolved when we froze this first part. We tested Log4j 2.26.1. We are not publishing a historical affected-version matrix, assigning a severity score, naming a fixed release, or treating our narrow control as the final patch before the maintainers do.

What we are withholding, and what comes next

There is enough information here for a defender to identify the architecture and inspect exposure. There is intentionally not enough to copy a weaponized stream into a socket.

The withheld material includes the field-level serialized layout and payload bytes, the execution gadget and dependency version, payload-generation code, sender implementation, exact proof input, and the reusable reproduction script. Those artifacts remain frozen in Pruva with vulnerable, narrow-control, and runtime-mitigation evidence.

After coordinated disclosure, we plan to update this article with the assigned identifier, vendor-confirmed affected and fixed versions, the agreed remediation, and a link to the public Pruva reproduction. Until then, the most important conclusion is precise and limited: a filter that correctly constrains one object stream does not protect a second stream opened later during object reconstruction.