Blog

Not another Log4Shell: inside the Log4j 2 deserialization allowlist bypass

We reproduced this java deserialization vulnerability against official Log4j 2.26.1 JARs. Getting to command execution took two more things that Log4j itself does not ship. Here is how the bypass works, which versions carry it, and what to hunt for.

This is not another Log4Shell. Log4Shell (CVE-2021-44228) was a JNDI lookup triggered by a logged string. The bypass reported in August is a Java deserialization vulnerability in Log4j 2's FilteredObjectInputStream, and we reproduced it on official Maven Central log4j-api and log4j-core 2.26.1. Getting to command execution took two more things that Log4j itself does not ship. You need a process that still deserializes Java-serialized LogEvent objects, and you need a gadget library already sitting on that JVM. Apache does not class allowlist bypasses like this one as product vulnerabilities, because Log4j does not deserialize data during normal logging. In this post, we will cover:

  • The mechanism, cited from the official 2.26.1 source, and how it differs from Log4Shell.
  • What we did and did not observe in the lab, including the two conditions for command execution.
  • Which Elastic rules would fire on post-exploitation.
  • Hunting queries for Java-spawned shells and leftover serialized LogEvent collectors.

Why Apache does not class this as a Log4j vulnerability

Apache's published CWE-502 text is explicit: Log4j does not deserialize data as part of normal logging; FilteredObjectInputStream assists applications that still deserialize log-event streams; bypasses of that allowlist are treated as hardening, not as product vulnerabilities; the application that deserializes is responsible for validating the byte stream.

This post is grounded in public sources, Apache's own security page, discussion logging-log4j2#4168, and a lab we ran on official 2.26.1 JARs. We are not claiming complete detection of every custom deserializer.

How this Java deserialization vulnerability differs from Log4Shell

Log4j 2 can emit log events over the network (for example, SocketAppender). A separate process can receive those events. One historical way of receiving events was Java serialization: read an ObjectInputStream and cast to LogEvent.

That receive style is how CVE-2017-5645 worked. Apache's advisory: when the TCP or UDP socket server received serialized log events, a crafted binary payload could execute code. Affected log4j-core versions were [2.0-alpha1, 2.8.2). The fix shipped in 2.8.2, and that fix is what introduced FilteredObjectInputStream, initially in log4j-core (org.apache.logging.log4j.core.util.FilteredObjectInputStream)`. Java 7+ users were told to upgrade or stop using those socket-server classes.

FilteredObjectInputStream is the allowlist wrapper around that deserialize-an-event idea. The current class lives in log4j-api and its 2.26.1 source marks it @since 2.11.0, an earlier FilteredObjectInputStream shipped in log4j-core in 2.8.2 as part of the CVE-2017-5645 fix. It overrides resolveClass() and rejects class names that are not on an allowlist (plus caller extras).

Issue #4255 is about that allowlist, not about JNDI or LDAP. Elastic's 2021 write-up Detecting Exploitation of CVE-2021-44228 (Log4j2) with Elastic Security covers Log4Shell.

A Log4j collaborator already wrote the same allowlist escape as Finding 1 in discussion #4168 on 1 July 2026 (MarshalledObject.get() deserializes with no Log4j filter). That discussion frames the work as 2.x hardening. Issue #4255 later named the same path.

How the Java deserialization allowlist bypass works

None of the pieces here is a bug on its own. The bypass comes from three behaviors in the official 2.26.1 source. Chained together, they let an attacker-controlled object graph deserialize with no filter applied. Here they are in order:

StepComponentBehaviourWhy it matters
1SerializationUtil.REQUIRED_JAVA_CLASSESjava.rmi.MarshalledObject sits on the allowlist as a Message delegateThe carrier class is permitted by design
2FilteredObjectInputStream.resolveClass()Checks only class descriptors present on the stream it readsA payload held as an opaque byte[] is never inspected
3Log4jLogEvent.LogEventProxy.message()Calls marshalledMessage.get() after the outer stream accepts the proxyDeserializes those bytes on a fresh stream with no filter attached

1. The allowlist includes java.rmi.MarshalledObject.

SerializationUtil.REQUIRED_JAVA_CLASSES lists it with the comment for Message delegate, together with BigDecimal, BigInteger, and the primitive type names. Allowed packages are java.lang., java.time., java.util., and org.apache.logging.log4j..

We retrieved the same list from the 2.x branch of apache/logging-log4j2 on 26 August 2026.

2. FilteredObjectInputStream.resolveClass() only sees class descriptors on that stream.

protected Class<?> resolveClass(final ObjectStreamClass desc)
        throws IOException, ClassNotFoundException {
    final String name = SerializationUtil.stripArray(desc.getName());
    if (!(isAllowedByDefault(name) || allowedExtraClasses.contains(name))) {
        throw new InvalidObjectException("Class is not allowed for deserialization: " + name);
    }
    return super.resolveClass(desc);
}

Source: FilteredObjectInputStream.java at rel/2.26.1. A MarshalledObject carries its payload as an opaque byte[]. Those inner class names do not pass through this resolveClass().

3. LogEventProxy then calls MarshalledObject.get().

Log4jLogEvent.LogEventProxy has held private MarshalledObject<Message> marshalledMessage since 2.8. After the outer stream accepts the proxy, readResolve() builds a Log4jLogEvent and message() does this:

private Message message() {
    if (marshalledMessage != null) {
        try {
            return marshalledMessage.get();
        } catch (final Exception ex) {
            // ignore me
        }
    }
    return new SimpleMessage(messageString);
}

Source: Log4jLogEvent.java at rel/2.26.1. Any exception from get() is processed. The receiver can still look healthy and fall back to SimpleMessage(messageString).

MarshalledObject.get() deserializes objBytes on an inner ObjectInputStream that is not a FilteredObjectInputStream. On JDK 9+, MarshalledObject can copy the enclosing stream's JEP 290 ObjectInputFilter. FilteredObjectInputStream in 2.26.1 does not call setObjectInputFilter(), so that copy is null unless something else installed a filter. Discussion #4168 states the same Java 8 / Java 9+ split.

Log4j already has a nested-object path that re-applies the filter: SerializationUtil.writeWrappedObject / readWrappedObject. marshalledMessage.get() does not use it. That is the suggested direction in discussion #4168 (Finding 1), alongside removing MarshalledObject from the allowlist.

TcpSocketServer / ObjectInputStreamLogEventBridge are gone from current 2.x log4j-core sources. For current versions, the remaining exposure is leftover or custom code that still does new FilteredObjectInputStream(socket.getInputStream()) and readObject() into a LogEvent. Historical TcpSocketServer constructed new ServerSocket(port), which listens on all interfaces of that host. The exception is 2.8.2: there, TcpSocketServer and the (log4j-core) FilteredObjectInputStream both ship in core, so a stock deployment using the built-in socket server is exposed without any leftover or custom receiver. #4255 bypasses the CVE-2017-5645 fix inside the same release that introduced it. Whether a given leftover collector is reachable without credentials depends on how it was deployed. We are not stating that every collector is unauthenticated.

What we reproduced on Log4j 2.26.1

We used official Maven Central log4j-api 2.26.1 and log4j-core 2.26.1. The receiver implemented the public FOIS-plus-LogEvent read, bound to loopback for the run.

  • A class that FilteredObjectInputStream rejects on the outer stream executed once it was carried inside MarshalledObject on a serialized Log4jLogEvent.
  • Command execution in that lab required both a Java-serialized LogEvent receiver and a gadget library already on the victim JVM. log4j-api and log4j-core alone were not enough.
  • Installing a JEP 290 ObjectInputFilter on the same FOIS instance rejected the payload we had used. Before that call, the stream filter was null.

Table showing Log4j deserialization command execution needed a serialized LogEvent receiver and a gadget library

Affected Log4j 2 versions

We inspected multiple JAR versions for the presence of both the field and FOIS in log4j-api.

Versions checkedmarshalledMessage fieldFilteredObjectInputStream in log4j-api
2.6.2, 2.7absentabsent
2.8presentabsent
2.8.2presentpresent (log4j-core)
2.9.1, 2.10.0presentabsent
2.11.0 through 2.26.1presentpresent (log4j-api)

The bypass requires both the marshalledMessage field and a FilteredObjectInputStream to be the bypassed component. Both coincide at 2.8.2 (FOIS in log4j-core) and at 2.11.0 through 2.26.1 (FOIS in log4j-api); we confirmed command execution on 2.8.2 and on 2.11.0–2.26.1. Versions 2.9.1 and 2.10.0 carry the field, but no FOIS, so the specific allowlist bypass does not apply there. We did not test every 2.x release, nor did we test Log4j 3 (discussion #4168 says serialization is dropped there).

The defensive takeaway is the classpath: if the JVM that deserializes LogEvents also ships a known gadget library, command execution is in play. If it does not, the inner get() can still run unfiltered code in whatever is on that JVM.

Setting up detection for Java deserialization attacks

  1. Deploy Elastic Defend with process and network events on hosts that run JVMs (Complete EDR if you can).
  2. Enable the pre-built SIEM rules listed under Detection.
  3. Inventory processes that deserialize Java-serialized LogEvents: leftover TcpSocketServer, log4j-server samples, SerializedLayout on a socket, or any custom FilteredObjectInputStream + LogEvent bridge. JSON, syslog, and HTTP log ingest are a different path.

Elastic detection rules for post-exploitation activity

Potential Reverse Shell via Java looks for a java network event (connection_accepted or connection_attempted) followed within 5 seconds by a shell whose parent is java with -jar. It excludes private and loopback destination.ip ranges, including 10.0.0.0/8, 192.168.0.0/16, 172.16.0.0/12, and 127.0.0.0/8. Potential Reverse Shell Activity via Terminal is another sibling rule that triggered during our testing.

So it can fire when a java -jar process talks to a public destination and then starts a shell. It will not fire on:

  • Loopback-only collectors (our lab bind).
  • Internal RFC1918 collectors, which are the leftover-socket-server case you actually expect on a LAN.
  • JVM launches that do not pass -jar (module path, org.apache.logging.log4j main classes, many app-server launches).

Suspicious Child Execution via Web Server includes process.parent.name == "java" only when parent args match known server launchers (Tomcat Bootstrap, Jetty, WildFly, Spring Boot loader, Jenkins .war, and similar). A standalone FOIS collector will usually miss that list.

Rules we recommend running on Internet-facing hosts:

If you see java spawn bash/sh without an LDAP/RMI/DNS precursor, do not pin it as a Log4Shell miss. Treat it as possible gadget execution from a Java deserialization or injection path, and check whether that JVM is a log-event receiver.

Hunting for Java deserialization attacks

Hunting queries could return high signals or false positives. These queries identify potentially suspicious behavior, but an investigation is required to validate the findings.

Hunting for leftover serialized LogEvent collectors

Goal: find JVMs that accept TCP connections, especially on hosts that are not supposed to be log-event hubs.

Persistence Through Reverse/Bind Shells ships Osquery for listening_ports, process_open_sockets, and processes. Use it as an inventory pass, then filter to java.

Keep private addresses. Example ES|QL (Linux Elastic Defend network events, last 7 days):

FROM logs-endpoint.events.network-*
| WHERE @timestamp > NOW() - 7 days
  AND host.os.type == "linux"
  AND event.action : "connection_accepted"
  AND process.name == "java"
| STATS
    accepts = COUNT(*)
  BY host.name, process.executable, destination.port
| SORT accepts DESC
| LIMIT 100

Triage: is this Tomcat/JBoss on 8080/8443, or an unexplained listen? SocketAppender's historical default TCP port is 4560. That port is a hint to look, not proof of Java serialization (JSON/XML layouts also used sockets). Command lines worth pulling out of the same data:

  • TcpSocketServer
  • createSerializedSocketServer
  • SerializedLayout
  • log4j-server
  • ObjectInputStreamLogEventBridge
  • FilteredObjectInputStream (rare on a command line; more likely in source or a fat JAR)

On disk, search application repos and config for SerializedLayout and for new FilteredObjectInputStream. Prefer JSON or Syslog receivers if you still need a network log path.

Kibana ES|QL hunting query for java connection_accepted network events grouped by host and destination.port

Hunting for java-spawned shells without a JNDI precursor

Goal: Detect gadget-style executions.

FROM logs-endpoint.events.process-*
| WHERE @timestamp > NOW() - 30 days
  AND host.os.type == "linux"
  AND event.action : "exec"
  AND process.parent.name == "java"
  AND (
    process.name IN (
      "bash", "dash", "sh", "ash", "zsh", "ksh", "fish", "csh", "tcsh", "mksh", "busybox",
      "curl", "wget", "perl*", "python*", "ruby*", "php*", "lua*", "socat",
      "nc", "ncat", "netcat", "netcat.openbsd", "netcat.traditional", "nc.openbsd",
      "nc.traditional", "nohup", "setsid", "disown", "hostname", "whoami", "id"

    ) OR
    process.name LIKE "python*" OR
    process.name LIKE "perl*" OR
    process.name LIKE "ruby*" OR
    process.name LIKE "lua*" OR
    process.name LIKE "php*"
  )
| STATS
    execs = COUNT(*)
  BY host.name, process.parent.executable, process.parent.command_line, process.command_line
| WHERE execs <= 20
| SORT execs ASC
| LIMIT 100

Kibana ES|QL query detecting a java parent process spawning /bin/sh during Log4j deserialization exploitation

Pivot each hit to network events using the parent.entity_id field. If you see connection_accepted on an unexpected port (389, 1389, 1099, 53, 5353) and no outbound destination.port in the same minute, it is not the JNDI sequence. Investigate the JVM classpath for deserialization gadgets and whether that process reads an ObjectInputStream.

Osquery, from the same reverse/bind-shell hunt, for a live host:

SELECT p.pid, p.cmdline, lp.port, lp.protocol, lp.address
FROM processes p
JOIN listening_ports lp ON p.pid = lp.pid
WHERE p.name = 'java';

Windows hosts can run the same leftover Java code. Field names differ; the questions do not: who listens, who spawns cmd.exe/powershell.exe from java.exe, and whether that process is a serialized LogEvent receiver.

MITRE ATT&CK techniques and tactics

Elastic uses the MITRE ATT&CK framework to document common tactics, techniques, and procedures that threats use against enterprise networks.

Tactics

Tactics represent the why of a technique or sub-technique. It is the adversary’s tactical goal: the reason for performing an action.

Techniques

Techniques represent how an adversary achieves a tactical goal by performing an action.

Conclusion

We dug into issue #4255 as soon as it surfaced. After the last few years, anything pairing “Log4j” with remote code execution earns an immediate look. After confirming the bypass against Log4j 2.26.1 via PoC validation and peeling back the layers on the prerequisites, we concluded this is not another Log4Shell.

Either way, the detections we shared fire on what comes post-exploit for this family of vulnerabilities. Commonly, a Java process spawns a shell after a connection. None are inherently a signature of #4255 exploitation, but triage can trace them back during investigation. Inventory your LogEvent receivers, hunt Java listeners on unexpected ports, and if you still deserialize Java LogEvent streams, look at alternatives, such as adding an ObjectInputFilter if using JDK 9. Assume FOIS alone is not a safe boundary.

Happy hunting!

Related Content

SOC case management and detection rule history in Elastic Security

Kseniia Ignatovych

Your UEBA is lying to you: Why entity record quality decides everything

Erik Huang

Know who to watch before the incident finds you

Erik Huang

Streamlining the Security Analyst Experience

Paul Ewing

Automating detection tuning requests with Kibana cases

Aaron Jewitt