Vulnerability Research

Log4j2 MarshalledObject Deserialization Bypass to RCE

By Pranav Khune

13 min read

Log4j2 MarshalledObject Deserialization Bypass to RCE

A single java.rmi.MarshalledObject walks an arbitrary gadget straight past Log4j’s allowlist. We had our hands on the exact class eight months before it was disclosed, and turned back.


The finding in one paragraph

Log4j’s FilteredObjectInputStream (FOIS) hardens deserialization with a class allowlist. That allowlist keeps java.rmi.MarshalledObject, a class whose payload is an opaque byte[]. The filter inspects the classes it can see; it never sees inside the byte array. When Log4j’s own LogEventProxy unwraps that object, it calls MarshalledObject.get(), which opens a brand-new plain ObjectInputStream with no filter at all and deserializes whatever is inside. One serialized LogEvent sent to any FOIS-backed log receiver runs an attacker’s object graph unfiltered, unauthenticated RCE where a gadget library is on the classpath, and the receiver logs a benign event and keeps running.

This is the story of a bug we should have shipped a report on in December 2025. We had the vulnerable class decompiled on disk. We flagged the exact dangerous entry in the allowlist. We wrote “POTENTIALLY DANGEROUS” next to it in our own notes. And then we filed the whole thing under “deprecated feature, operator’s problem” and moved on.

Eight months later the same primitive surfaced as Log4j2 issue #4255 (archived). This post does two things: it explains how the bypass actually works and proves it end-to-end in a container, and (because the more useful lesson is the one about ourselves) it audits our own December analysis to pin down exactly which turn we missed.

The envelope FOIS believes it is guarding:

What FilteredObjectInputStream inspects              resolveClass() gaze ↓

  LogEventProxy                          allowed · org.apache.logging.log4j.*
    └─ java.rmi.MarshalledObject         allowed · on the REQUIRED_JAVA_CLASSES list
         └─ objBytes : byte[]            allowed — and its contents are never looked at
              └─ <ANY gadget>            deserialized here on a NEW, UNFILTERED stream

One note on that upstream report before the story starts. After the issue was renamed and the reporting account was removed, the security community moved to preserve it: researcher @ricardobranco777 archived the original report, and Frank Denis (@jedisct1) pointed to a public mirror at github.com/dinosn/log4j-4255. The technical substance survived precisely because others copied it before it disappeared.

01. The near miss, eight months early, and we turned back

In December 2025 we spent an afternoon on Log4j 2.25.3, thirty-plus attack vectors across two vulnerability classes. The JNDI verdict was clean and correct: Log4Shell and its variants are fully patched in modern Log4j; message lookups are off by default, the protocol allowlist blocks ldap:// and rmi://, and no amount of prodding produced a DNS callback.

The second class was Java deserialization, and here we did find something. We stood up a socket receiver, transmitted a serialized LogEvent, and watched it get deserialized on the other end. Our own executive summary called it, in bold:

### 2. Deserialization (SerializedLayout)          — EXECUTIVE_SUMMARY.md · Dec 21 2025
CRITICAL VULNERABILITY DISCOVERED

Result: Successfully transmitted and deserialized LogEvent over network — RCE CONFIRMED.
Severity: CRITICAL (Estimated CVSS 9.8)
Attack Vector: Network transmission of serialized Java objects

So why does it not live in anyone’s disclosure tracker? Because two framings in that same document quietly demoted it from “bug” to “known hazard”, and both of them felt reasonable at the time.

How we framed it in Dec 2025 What that did
“Secure if not configured / CRITICAL only if configured“, the sink required an operator to turn on the deprecated SerializedLayout Read as operator misuse, not a Log4j defect
WHY_INTERNAL_ONLY.md, the vulnerable ports (4712 / 5000 / 9999) are internal log-server TCP, not internet-facing Read as low real-world reach
Remediation reduced to “SerializedLayout is deprecated, don’t use it” A config warning, not a reportable CVE

“Java deserialization of a deprecated feature you had to switch on, reachable only from inside the network” is precisely the kind of thing a maintainer waves off as operator error. Nothing to report. We closed the notebook.


02. The audit, the one class we decompiled but never read

Re-reading those notes now, the miss is uncomfortably clear. We were not far off. We were one method-trace away. Here is the line, verbatim, from our own DESERIALIZATION_ATTACK_ANALYSIS.md:

public static final List<String> REQUIRED_JAVA_CLASSES = Arrays.asList(
    "java.math.BigDecimal",
    "java.math.BigInteger",
    "java.rmi.MarshalledObject",  // POTENTIALLY DANGEROUS   <-- we flagged it
    "[Ljava.lang.String;",
    // primitives...
);
// later, in the same file:
// 1. java.rmi.MarshalledObject: Used in historical deserialization exploits

We saw the hole. We put a warning glyph on the exact class that #4255 turns into a weapon. But the very next paragraph pointed the exploit in the wrong direction:

MEDIUM RISK: While filtered, the whitelist is broad enough to potentially allow
gadget chains if:
  - Attacker can control serialized ObjectMessage content

Two mistakes are buried in those lines.

We bet on ObjectMessage, the filtered path

ObjectMessage carries its payload through SerializationUtil.readWrappedObject(), which runs an inner FilteredObjectInputStream. It is the one marshalled path in Log4j that is protected. The #4255 report says so directly: the inner stream in readWrappedObject() is filtered, “the unprotected path is specifically LogEventProxy.marshalledMessage.” We picked the one channel that was safe.

We never traced LogEventProxy

Grep every prose note from that engagement and the string LogEventProxy appears in zero of them. It shows up only inside the decompiled .class blobs we extracted and never opened as source. That class is the entire bug:

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);
}

We spent the analysis measuring how strong the FOIS allowlist was, “is java.util.* broad enough to let a gadget through?”, when the real bug makes the allowlist irrelevant. Through the MarshalledObject wrapper nothing is filtered and nothing needs to be on the list. The question we were carefully asking had already been made moot by a class we had decompiled but did not read.

We had two of the three pieces: the sink, and MarshalledObject flagged as dangerous. The third, LogEventProxy auto-calling .get() on an unfiltered stream, is what turns “medium, if-configured, internal” into “unauthenticated RCE via Log4j’s own transport.”


03. The mechanism, how the bypass actually works

FOIS is a resolveClass()-based filter. It sees the top-level class descriptors in a stream and rejects any that are not on its allowlist. That design has a structural blind spot: a java.rmi.MarshalledObject stores its wrapped payload as an opaque byte[] objBytes. The bytes inside that array are not stream descriptors the filter can resolve, they are just data. So the filter waves the array through, contents unseen.

The payload only comes alive when something calls MarshalledObject.get(). And get() does not reuse the guarded stream, it constructs a fresh, ordinary ObjectInputStream over objBytes and deserializes with no filter whatsoever. Log4j supplies the caller for free: Log4jLogEvent$LogEventProxy (the serialization proxy used for every LogEvent since 2.8) puts the event message in a MarshalledObject and calls get() from its readResolve() path.

Layer in the stream FOIS sees Verdict
LogEventProxy class descriptor allowed (log4j.*)
java.rmi.MarshalledObject class descriptor allowed (on the list)
objBytes : byte[] a byte array allowed, contents never parsed
<ANY gadget> nothing runs on a new unfiltered stream

Two properties make it clean. First, the payload does not need to implement Message and does not need to be on the allowlist, the full ysoserial gadget catalog applies, firing during deserialization (a hashCode() on HashMap.readObject, say) before any cast happens. Second, when the inner object turns out not to be a Message, the resulting ClassCastException is swallowed by the catch in message(), the event falls back to SimpleMessage, and the receiver logs a perfectly ordinary line. The exploit is silent.


04. The proof, one packet, a root shell, a silent receiver

Reasoning is cheap; this time we ran it. We stood up an official Log4j 2.26.1 receiver on JDK 17 in Docker, a faithful stand-in for the ObjectInputStreamLogEventBridge that accepts a TCP connection and calls readObject() through FOIS, with commons-collections 3.2.1 on the classpath so the RCE path is reachable. Then we built the payload ourselves: the LogEventProxy → MarshalledObject envelope from a hand-written Java-serialization writer (no JVM, no Log4j jars), wrapping a CC6 gadget generated by ysoserial in a throwaway JDK container.

Four tests, one truth table:

Test Payload Receiver result Verdict
Control, object sent unwrapped HashMap → java.net.URL InvalidObjectException: Class is not allowed for deserialization: java.net.URL filter works
Bypass, same object, wrapped LogEventProxy → MarshalledObject → URL processed event OK: msg="null" (silent, accepted) allowlist bypassed
RCE, CC6 gadget, wrapped LogEventProxy → MarshalledObject → CommonsCollections6 silent processed OK, /tmp/PWNED created, id → uid=0(root) unauth RCE (root)
Mitigation, same RCE payload -Djdk.serialFilter='!java.rmi.MarshalledObject' InvalidClassException: filter status: REJECTED blocked

The control and bypass rows are the whole thesis in two packets: the identical java.net.URL object graph is rejected when sent bare and accepted silently when wrapped in a MarshalledObject. The wrapper is the only difference, and it is what defeats the allowlist.

# unwrapped — the filter does its job
[receiver] readObject REJECTED: InvalidObjectException:
           Class is not allowed for deserialization: java.net.URL

# wrapped in MarshalledObject — same object, waved through
[receiver] processed event OK: msg="null"

# CC6 wrapped — code runs, receiver stays quiet
$ docker exec recv cat /tmp/whoami_4255
uid=0(root) gid=0(root) groups=0(root)

A detail worth its own line

The hand-rolled Python envelope is byte-exact. Generating the detection payload for a 63-character host produces an objBytes length of exactly 0x000001b2 (the same value baked into the public Nuclei template) and every serialVersionUID matches the real JDK values. The serialization is well-formed, not approximately-formed.


Why this isn’t Log4Shell

It’s tempting to file any new Log4j RCE under “Log4Shell 2,” but #4255 is almost the opposite kind of bug. Log4Shell was shallow and everywhere: the trigger was any logged string, message lookups were on by default, and every User-Agent, username, and filename on the internet was a live exploit primitive, one class of input, universal reach, remotely, at internet scale. #4255 is deep and narrow. Its trigger isn’t a string an app happens to log; it’s a serialized LogEvent sent to the receiving end of a SocketAppender pipeline (a specialized, usually internal service that most deployments don’t run at all), and it only reaches code execution if a usable gadget library already sits on that receiver’s classpath. The mechanism is cleaner and, on a fully-patched Log4j, actually more reliable than Log4Shell (which modern versions gate behind three default-off switches), because the vulnerable MarshalledObject path has no guard and no toggle to disable. But “more reliable on the one box that’s vulnerable” is not “the internet is on fire.” Log4Shell was a spray-the-perimeter catastrophe; #4255 is a targeted, post-foothold pivot into logging infrastructure. Same vendor, same CWE-502-adjacent blast radius on paper. Completely different threat in practice.

05. The reckoning, why it failed then and works now

The difference between our shelved December finding and the confirmed #4255 bug is not the deserialization sink. We found that. The difference is whose fault it is, and that single reframing is what converts an unreportable config note into a Log4j-core defect.

Dec 2025 · shelved, “operator misuse.” An operator configures the deprecated SerializedLayout. The attacker still has to beat the FOIS allowlist. Conditional, internal-only, “don’t use the deprecated thing.” Not reportable.

#4255 · confirmed, “Log4j’s own defect.” Log4j’s own LogEventProxy wire format, in use since 2.8, no opt-in, auto-unwraps the payload on an unfiltered stream. The FOIS allowlist, the mitigation we were analyzing, is bypassed entirely.

That is the whole contribution of the disclosure, and it is exactly the link we were one decompiled class away from. Once you see that the trigger is Log4j’s native transport rather than an operator’s toggle, three things flip at once: it is unauthenticated, it needs no deprecated configuration, and the allowlist stops mattering because the payload is deserialized on a stream that has no allowlist. “Medium, if-configured, internal” becomes “unauthenticated RCE via Log4j’s own serialization format”, which we then watched execute as root.


06. The lesson, what we changed in how we audit

The valuable output of this exercise is not the exploit, it is the four habits that would have caught it in December.

  • Read decompiled classes as source, not as grep targets. LogEventProxy was on disk the entire time. It matched our binary searches and never got opened. The bug lived in a method we never read.
  • Enumerate the callers of every allowlisted class. Flagging MarshalledObject as “dangerous” is worth nothing without asking the next question: who calls .get() on it, and on what stream? One readResolve() answered it.
  • Distinguish filtered from unfiltered marshalled paths. We assumed all marshalled content flows through the same guard. ObjectMessage does; LogEventProxy.marshalledMessage does not. Same-looking primitive, opposite security posture.
  • A “confirmed but shelved” finding deserves a second look when the framing changes. We were right that the sink existed and wrong about who was responsible for it. The reframing, operator error vs. vendor defect, was the entire bug, and it was a judgment call we made in a sentence.

Remediation, for the record

There is no clean operator-side fix at the time of writing. Stop transporting serialized LogEvents, move the socket to a JSON or RFC 5424 layout. -Djdk.serialFilter='!java.rmi.MarshalledObject' closes the bypass but also rejects legitimate serialized events, since Log4j’s transport rides on the same class. Do not trust maxdepth/maxbytes filters: a shallow URLDNS chain sails through maxdepth=5 while a deep gadget is blocked, the bypass itself survives. The real fix has to land upstream in Log4j.

We nearly had this in an afternoon, then talked ourselves out of it in a sentence. The exploit is not the interesting part of that story. The sentence is.


SecureLayer7 Research · Java deserialization series

  • Original report: filed with Apache Log4j by U-Sec (Wujie Security) via issue #4255. Public vulnerability reporting was not enabled on the repository, so the finding was disclosed through the issue tracker.
  • Upstream: github.com/apache/logging-log4j2/issues/4255, closed at time of writing. The live issue was subsequently renamed and the reporting account removed, so the analysis here is based on an archived snapshot of the original report (archive.ph/Xowgn)
  • Class: CWE-502 · Affected: log4j-api 2.11.0–2.26.1 / log4j-core 2.8.0–2.26.1
  • Validated end-to-end against official Log4j 2.26.1 on JDK 17, in Docker. For authorized testing only.

Frequently asked questions

Is this Log4j deserialization bug the same as Log4Shell?

No. Log4Shell (CVE-2021-44228) fired on any string an application logged, with JNDI message lookups on by default, which gave it universal, internet-scale reach. This MarshalledObject bypass is deep and narrow: it only triggers when a serialized Log4j LogEvent reaches the receiving end of a SocketAppender pipeline, a specialized and usually internal service, and only if a usable gadget library already sits on that receiver’s classpath. It is a targeted, post-foothold pivot into logging infrastructure, not a spray-the-perimeter catastrophe.

Which Log4j versions are affected?

The bypass affects log4j-core 2.8.0 through 2.26.1 and log4j-api 2.11.0 through 2.26.1. Log4j’s LogEventProxy serialization format, used for every LogEvent since 2.8, wraps the event message in a java.rmi.MarshalledObject and later unwraps it on an unfiltered stream, so any receiver using FilteredObjectInputStream on these versions is reachable once a gadget library is present on its classpath.

How does the allowlist bypass actually work?

FilteredObjectInputStream enforces a class allowlist, but it permits java.rmi.MarshalledObject, whose payload is stored as an opaque byte array the filter never inspects. When Log4j’s LogEventProxy calls MarshalledObject.get(), it opens a brand-new ObjectInputStream with no filter at all and deserializes whatever is inside. A standard ysoserial gadget such as CommonsCollections6 then fires during deserialization, before any type check, producing remote code execution.

Can it be exploited remotely without authentication?

Yes, against a vulnerable receiver. Anyone able to open a TCP connection to a FilteredObjectInputStream-backed log receiver can send a single crafted LogEvent and trigger code execution, with no credentials required, as long as a gadget library exists on that receiver’s classpath. Because these log receivers are usually internal, the realistic scenario is an attacker pivoting after an initial foothold rather than a direct internet-facing attack.

How do I mitigate the Log4j MarshalledObject deserialization bypass?

There is no clean operator-side fix at the time of writing. The safest step is to stop transporting serialized LogEvents and move the socket to a JSON or RFC 5424 layout. The JVM flag jdk.serialFilter set to reject java.rmi.MarshalledObject closes the bypass but also breaks legitimate serialized events, since Log4j’s own transport rides on the same class. Do not rely on maxdepth or maxbytes serial filters, because a shallow URLDNS chain passes straight through them. A complete fix has to land upstream in Log4j.

Related SecureLayer7 research