Skip to content

[Security] FilteredObjectInputStream allowlist bypass via java.rmi.MarshalledObject with auto-trigger during LogEvent deserialization (2.11.0-2.26.1) #4255

Description

@U-Sec

Description

Reported by U-Sec (Wujie Security). Public disclosure of security vulnerabilities is not enabled on this repository, so we are reporting this through the issue tracker. If you prefer, this issue can be converted into a private discussion / advisory; we will keep full details confidential and are happy to move this report to security@logging.apache.org on request — please just confirm receipt and a tracking case number so we can coordinate.

FilteredObjectInputStream explicitly allows java.rmi.MarshalledObject in its default allowlist. A MarshalledObject carries its payload as an opaque byte[] (objBytes), so a resolveClass-based filter never inspects it. When the payload is later unwrapped, MarshalledObject.get() constructs a new plain ObjectInputStream on those bytes and deserializes it completely unfiltered.

Log4j provides the trigger itself: Log4jLogEvent.LogEventProxy serializes the event Message inside a MarshalledObject (field marshalledMessage, since 2.8) and calls marshalledMessage.get() inside readResolve()message(). A single serialized LogEventProxy received by any FOIS-based log receiver therefore executes attacker-controlled deserialization with zero filtering, while the receiver continues processing normally — the ClassCastException from a non-Message payload is swallowed by message(), which falls back to SimpleMessage(messageString).

This means the complete Java serialization gadget catalog applies: the payload does not need to implement Message and does not need to pass the allowlist.

Configuration

Version: log4j-api 2.11.0 – 2.26.1 / log4j-core 2.8.0 – 2.26.1 (verified against official 2.26.1 artifacts from Maven Central)

Operating system: platform-independent

JDK: all versions (Java 8: no filter API at all; JDK 9+: MarshalledObject copies the stream's ObjectInputFilter, but FOIS never calls setObjectInputFilter() — the stream filter is null, so the capture is a no-op)

Root cause (rel/2.26.1)

# Location Defect
1 log4j-api …/util/internal/SerializationUtil.java:81 REQUIRED_JAVA_CLASSES contains java.rmi.MarshalledObject
2 log4j-api …/util/FilteredObjectInputStream.java:66-72 resolveClass() only sees top-level descriptors; objBytes payload is invisible
3 log4j-core …/impl/Log4jLogEvent.java:1134 private MarshalledObject<Message> marshalledMessage;
4 log4j-core …/impl/Log4jLogEvent.java:1265-1274 message() calls marshalledMessage.get() (filterless) and swallows all exceptions
private Message message() {
    if (marshalledMessage != null) {
        try {
            return marshalledMessage.get();  // NEW plain ObjectInputStream, NO filter
        } catch (final Exception ex) { /* ignore me */ }
    }
    return new SimpleMessage(messageString);
}

Logs

Verified end-to-end against official 2.26.1 jars, receiver semantics byte-for-byte from apache/logging-log4j-samples log4j-server / ObjectInputStreamLogEventBridge:

try (ObjectInputStream in = new FilteredObjectInputStream(socket.getInputStream())) {
    final LogEvent event = (LogEvent) in.readObject();
}

Control test (malicious object sent directly, no wrapper) — correctly rejected:

InvalidObjectException: Class is not allowed for deserialization: poc.EvilMessage

Attack test (same object wrapped via LogEventProxyMarshalledObject):

[receiver] bound to 0.0.0.0 (all interfaces), no auth, port 4563
[receiver] connection from /<attacker-address>
[attacker] single fire-and-forget write: 1356 bytes - done
[!!] GadgetOnly.readObject() EXECUTED - no Message iface, no allowlist entry
[receiver] processed event: benign message

A non-Message, non-allowlisted object executes inside the receiver; the receiver shows no error and keeps processing. A second PoC demonstrates the collection-trigger shape (CC6/CC7/CB-style hashCode() during HashSet.readObject) with the receiver dialing an outbound TCP connection back to the attacker — effective when only inbound traffic is firewalled.

Reproduction

Full PoC sources (JUnit-style standalone runners, no external dependencies beyond the official jars) are available to maintainers on request and will be published after a fix is released. Minimal attacker-side sketch:

final Log4jLogEvent event = Log4jLogEvent.newBuilder()
        .setLoggerName("attacker")
        .setLevel(Level.INFO)
        .setLoggerFqcn("x")
        .setMessage(new MessageShell(gadget))  // Message wrapper, arbitrary graph inside
        .build();
final LogEventProxy proxy = new LogEventProxy(event, false);
// serialize proxy, write once to any FOIS receiver socket — done

Impact

  • Remote code execution on any service that receives serialized LogEvents through FOIS (official samples ObjectInputStreamLogEventBridge, vertigo-analytics-server createSerializedSocketServer — FOIS bridge by default, no TLS/auth, binds all interfaces — and ≤ 2.14.x deployments where the receiver lived inside log4j-core as net.server.TcpSocketServer.createSerializedSocketServer).
  • Without a gadget library on the classpath: resource-exhaustion DoS (object-graph bombs) and injection of attacker-chosen log content into downstream appenders.
  • We note the inner stream in SerializationUtil.readWrappedObject() (used by ObjectMessage) is filtered — the unprotected path is specifically LogEventProxy.marshalledMessage.

Suggested fix

Option A (minimal, two files): remove java.rmi.MarshalledObject from REQUIRED_JAVA_CLASSES; transport the marshalled message via SerializationUtil.writeWrappedObject() / readWrappedObject() (plain byte[] + inner FOIS), the approach already used by ObjectMessage. Removes the unfiltered get() entirely; covers Java 8.

Option B (structural): migrate FOIS to a JEP 290 ObjectInputFilter, which sees through MarshalledObject on JDK 9+.


We have read the published threat model, FAQ and disclosure records and did not find this issue reported. We acknowledge FOIS is positioned as defense-in-depth; we respectfully submit that this case differs from operator misuse: the trigger is log4j-core's own serialization format (LogEventProxy) automatically unwrapping the payload with an unfiltered stream — fixable inside log4j, and the pattern affects every resolveClass-based allowlist that permits java.*. We request a tracking case number and will keep details confidential until a fix is released (or 90 days after acknowledgment).

Credits: U-Sec (Wujie Security)

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Status
    To triage

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions