# Root Cause Analysis

## Summary
Apache Log4j2 2.26.1 and earlier permits an allowlist bypass in `FilteredObjectInputStream` (FOIS) because `java.rmi.MarshalledObject` is itself allowlisted. A serialized `Log4jLogEvent.LogEventProxy` can carry attacker-controlled inner serialized bytes that are automatically unpacked by `MarshalledObject.get()` through another `ObjectInputStream`. In this run, one exact payload was replayed through the real Apache `TcpSocketServer` / `ObjectInputStreamLogEventBridge` TCP product path and executed an attacker-selected OS command in two fresh receiver JVMs. The same bytes were rejected by two receivers using the minimal patched API.

## Impact
- **Affected components:** `log4j-api` 2.11.0 through 2.26.1 and `log4j-core` 2.8.0 through 2.26.1 when used by a FOIS-based serialized-event receiver.
- **Validated product path:** `org.apache.logging.log4j.server.TcpSocketServer` with `ObjectInputStreamLogEventBridge` from Apache `logging-log4j-samples` commit `672a1555c7f5670e7affcc7b9984a90b492eb322`, running official Log4j API/Core 2.26.1 artifacts.
- **Risk:** Critical where this unauthenticated serialized-event listener is reachable and a usable gadget library is on the receiver classpath. An attacker can bypass the intended class allowlist and execute commands with the receiver process account.

## Impact Parity
- **Disclosed/claimed maximum impact:** Remote unauthenticated arbitrary deserialization leading to code execution.
- **Reproduced impact from this run:** Remote receiver-side command execution through the required TCP entrypoint. A single generated CC6 payload file was replayed unchanged to two vulnerable JVM instances and produced process-unique `PWNED-TIER2` markers containing receiver-side shell and parent process IDs plus account identity.
- **Parity:** `full`.
- **Not demonstrated:** Privilege escalation beyond the account running the receiver was not claimed or tested.

## Root Cause
Vulnerable `log4j-api` includes `java.rmi.MarshalledObject` in `SerializationUtil.REQUIRED_JAVA_CLASSES`. FOIS checks outer class descriptors in `resolveClass()`, but the object graph stored by `MarshalledObject` is opaque bytes at that stage. During deserialization of `Log4jLogEvent.LogEventProxy`, `readResolve()` calls `message()`, which calls `marshalledMessage.get()`. That operation deserializes the inner graph in another `ObjectInputStream`, outside FOIS's class-name check. A non-allowlisted object can therefore execute its `readObject()` callback, and a receiver-loadable gadget graph can reach `Runtime.exec`.

The tested minimal correction removes `java.rmi.MarshalledObject` from `REQUIRED_JAVA_CLASSES`, causing FOIS to reject the carrier before its inner bytes are unpacked. `bundle/logs/fix.patch` records the exact change. The upstream report is https://github.com/apache/logging-log4j2/issues/4255; it was still open at reproduction time and did not identify a merged fix commit, so the negative control applies the report's minimal suggested fix to official 2.26.1 source.

## Reproduction Steps
1. Run `bash bundle/repro/reproduction_steps.sh` from any directory. The script uses `PRUVA_ROOT` or resolves the bundle path itself.
2. It reads `bundle/project_cache_context.json`, uses `<project_cache_dir>/repo` when prepared, verifies the Apache samples origin and exact commit, and extracts the real `log4j-server` sources.
3. It verifies official Log4j 2.26.1 and Commons Collections 3.2.1 jar hashes, compiles the real `TcpSocketServer` and `ObjectInputStreamLogEventBridge` classes, and records their runtime `CodeSource` paths.
4. It generates each serialized payload once and sends the exact file bytes over localhost TCP. It performs direct unwrapped-object rejection, wrapped inner deserialization twice, wrapped CC6 command execution twice, the same tier-2 bytes against two patched receivers, and the same bytes against `-Djdk.serialFilter=!java.rmi.MarshalledObject`.
5. It validates all assertions and writes `bundle/repro/runtime_manifest.json`. Exit 0 means the claim is confirmed.

## Evidence
- **Exact identity:** `bundle/logs/target_identity.txt` binds samples commit `672a1555c7f5670e7affcc7b9984a90b492eb322`, Log4j commit `dd0f9d255e24e6bcc13bd2641407a409c0524803`, official API/Core jar hashes, target digest `7310f18dd2601851bcc37b1963906cbd86f417a18b196251c8fe64a6b3b18673`, and runtime digest `98b16715ef49ca21a5b32b7184a718cb9110ddc5269b06be0c3e3167467df639`.
- **Exact attacker input:** `bundle/logs/payload_identity.txt` records the SHA-256 of each pre-generated input. The final tier-2 input has SHA-256 `4e3a504e6d390c37a57253a323d3d91382f8ef92caa95a9e2c2193f38a707217`; sender logs prove identical byte count/file reuse across vulnerable, patched, and mitigated attempts.
- **Real TCP/product boundary:** `bundle/logs/receiver_tier2_a1.log` and `receiver_tier2_a2.log` identify loaded `TcpSocketServer`, `ObjectInputStreamLogEventBridge`, vulnerable API/Core jars, and Commons Collections; they then show binding, connection acceptance, socket details, and the deserialized event.
- **Command execution:** `bundle/logs/tier2_rce_marker_a1.txt` contains token `RCE-1787737383615632389-15376`, `shell_pid=39677`, and `parent_pid=39626`; attempt a2 contains the same payload token but distinct `shell_pid=39759` and `parent_pid=39708`. Both include `uid=1000(vscode)`, proving execution in two fresh receiver contexts.
- **Direct control:** `bundle/logs/receiver_control.log` shows `ObjectInputStreamLogEventBridge.logEvents()` rejecting unwrapped `poc.GadgetOnly` through `FilteredObjectInputStream.resolveClass()`.
- **Fixed controls:** `bundle/logs/receiver_fixed_a1.log` and `receiver_fixed_a2.log` show TCP acceptance followed by `InvalidObjectException: Class is not allowed for deserialization: java.rmi.MarshalledObject`; the shared command target is absent.
- **Mitigation:** `bundle/logs/receiver_mitigation.log` shows the same TCP/readObject path failing with `InvalidClassException: filter status: REJECTED` and no marker.
- **Digest closure:** `bundle/repro/runtime_manifest.json` binds 29 finalized proof artifacts to SHA-256 digests and records `entrypoint_kind=tcp_peer`, service/health/path success, and full target/runtime identity.

## Recommendations / Next Steps
Remove `java.rmi.MarshalledObject` from the default Log4j deserialization allowlist and avoid unfiltered `MarshalledObject.get()` for event messages; use a filtered wrapped-object format instead. Upgrade to the first vendor release containing the final upstream correction once published. Do not expose Java serialized-event listeners to untrusted networks. As defense in depth, configure a JEP 290 class filter rejecting `java.rmi.MarshalledObject`, remove unnecessary gadget libraries, and add regression tests that replay a malicious event through the real TCP receiver and require fail-closed rejection.

## Additional Notes
The final script was executed successfully twice consecutively after all amendments. Every execution uses isolated receiver JVMs and randomized ports, removes prior proof artifacts, generates a fresh payload token, and verifies exact payload-byte reuse across vulnerable and controls. Service startup, TCP health, and target-path reachability are tracked independently, and a failed run preserves digest-bound evidence from completed phases. The launcher does not implement a socket, parser, or `readObject()` path; it only selects serialized mode through `TcpSocketServer.createSerializedSocketServer()`.
