# RCA Report: CVE-2026-43825 — Apache OpenNLP SvmDoccatModel Unsafe Deserialization

## Summary

Apache OpenNLP 3.x before 3.0.0-M4 contains an unsafe Java deserialization vulnerability in `SvmDoccatModel.deserialize(InputStream)` within the `opennlp-ml-libsvm` module. The method creates a `java.io.ObjectInputStream` and calls `readObject()` on an attacker-supplied byte stream without installing an `ObjectInputFilter`. Because `ObjectInputStream.readObject()` materialises every class referenced in the stream before the result is cast to `SvmDoccatModel`, any gadget class available on the consumer's classpath can execute arbitrary code during deserialization — before the caller ever inspects the returned object.

## Impact

- **Package/component affected:** `org.apache.opennlp:opennlp-ml-libsvm` — class `opennlp.tools.ml.libsvm.doccat.SvmDoccatModel`
- **Affected versions:** Apache OpenNLP 3.x before 3.0.0-M4 (the libsvm document categorization module was introduced via OPENNLP-1808 and exists only on the 3.x line)
- **Fixed in:** 3.0.0-M4 (commit `3cf42d4a0145cefd9dd06138873a91312052c767`, PR #1029, OPENNLP-1823)
- **Risk level:** High (CVSSv3 7.3 per Red Hat advisory)
- **Consequences:** Remote code execution in any JVM process that loads `SvmDoccatModel` instances from untrusted or semi-trusted sources. The method is `public static`, so any caller can pass an untrusted stream directly. Apache OpenNLP itself does not ship a known gadget chain, so the realistic risk is to downstream applications that embed the libsvm module alongside vulnerable transitive dependencies (e.g., CommonsCollections, Groovy, Spring).

## Impact Parity

- **Disclosed/claimed maximum impact:** Code execution — arbitrary code execution via a crafted serialized stream when a gadget chain is present on the classpath.
- **Reproduced impact from this run:** Code execution — a custom `MaliciousGadget` class (implementing `Serializable` with a `readObject()` side-effect) was placed on the classpath and its `readObject()` method executed during `SvmDoccatModel.deserialize()`, writing a marker file as proof of arbitrary code execution. The cast to `SvmDoccatModel` threw `ClassCastException` only AFTER the gadget code had already run.
- **Parity:** `full` — the proof demonstrates the exact mechanism described in the advisory: `ObjectInputStream.readObject()` materialises foreign objects before the cast, allowing arbitrary code execution during deserialization.
- **Not demonstrated:** A real-world gadget chain (e.g., CommonsCollections) was not used; instead, a purpose-built gadget class simulated the same mechanism. This is appropriate because the CVE explicitly states "Apache OpenNLP itself does not ship a known gadget chain."

## Root Cause

The vulnerable `deserialize` method in `SvmDoccatModel.java` (before 3.0.0-M4) is:

```java
public static SvmDoccatModel deserialize(InputStream in) throws IOException, ClassNotFoundException {
    try (ObjectInputStream ois = new ObjectInputStream(in)) {
      return (SvmDoccatModel) ois.readObject();
    }
}
```

**No `ObjectInputFilter` is installed.** Java's `ObjectInputStream.readObject()` resolves and instantiates every class descriptor in the serialized stream, invoking each class's `readObject()`, `readResolve()`, or other deserialization hooks as part of the process. The cast to `SvmDoccatModel` occurs only AFTER the entire object graph has been deserialised. This means:

1. An attacker crafts a serialized byte stream containing a gadget class (any `Serializable` class with a dangerous `readObject()` method).
2. The stream is passed to `SvmDoccatModel.deserialize()`.
3. `ObjectInputStream.readObject()` encounters the gadget class, loads it from the classpath, and invokes its `readObject()` method — **arbitrary code executes**.
4. Only then does the cast `(SvmDoccatModel)` fail with `ClassCastException` — but the damage is already done.

**Fix (commit `3cf42d4a`):** The fix adds an `ObjectInputFilter` via `ois.setObjectInputFilter(buildFilter(limits))` before `readObject()`. The filter:
- Allow-lists only the specific classes that can legitimately appear in a `SvmDoccatModel` serialization graph (OpenNLP types, zlibsvm domain types, libsvm structures, and a minimal set of JDK types).
- Bounds graph depth (64), references (5,000,000), and array length (10,000,000).
- Rejects any class not on the allow-list with `ObjectInputFilter.Status.REJECTED`, causing `readObject()` to throw `InvalidClassException` BEFORE the foreign class is materialised.

Fix commit: `3cf42d4a0145cefd9dd06138873a91312052c767`
Fix PR: https://github.com/apache/opennlp/pull/1029 (OPENNLP-1823)

## Reproduction Steps

1. **Script:** `bundle/repro/reproduction_steps.sh`
2. **What the script does:**
   - Installs JDK 21 and Maven (required by OpenNLP 3.x).
   - Clones the Apache OpenNLP repository (or reuses the project cache).
   - Checks out and builds the **vulnerable** commit (`3cb7232e`) — the parent of the fix — which has no `ObjectInputFilter`.
   - Compiles a `MaliciousGadget` class (a `Serializable` class whose `readObject()` writes a marker file) and a `Poc` harness that serializes the gadget and feeds the bytes to `SvmDoccatModel.deserialize()`.
   - Runs the PoC against the vulnerable build: the gadget's `readObject()` executes, a marker file is created, and `ClassCastException` is thrown after execution.
   - Checks out and builds the **fixed** commit (`3cf42d4a`) which adds `ObjectInputFilter`.
   - Runs the same PoC against the fixed build: `InvalidClassException` is thrown by the filter, no code executes, no marker file is created.
   - Writes `runtime_manifest.json` with proof artifacts and exits 0 if the vulnerability is confirmed.
3. **Expected evidence:**
   - `bundle/logs/poc_vulnerable.log` — shows `[GADGET EXECUTED]` and `CODE EXECUTION CONFIRMED` with exit code 10.
   - `bundle/logs/gadget_executed_vulnerable.txt` — the marker file written by the gadget's `readObject()`.
   - `bundle/logs/poc_fixed.log` — shows `ObjectInputFilter rejected foreign class` with `InvalidClassException` and exit code 20.

## Evidence

### Vulnerable version PoC output (`bundle/logs/poc_vulnerable.log`):
```
[*] Calling SvmDoccatModel.deserialize() with malicious stream...
[GADGET EXECUTED] Arbitrary code ran during deserialization: id;whoami;uname -a
[GADGET EXECUTED] Marker file written to: /tmp/opennlp_poc_marker_vuln.txt
[!] VULNERABLE version: readObject() completed, cast failed AFTER
[!] Exception: java.lang.ClassCastException: class MaliciousGadget cannot be cast to class opennlp.tools.ml.libsvm.doccat.SvmDoccatModel
[!!!] CODE EXECUTION CONFIRMED: gadget readObject() ran!
```

### Gadget marker file (`bundle/logs/gadget_executed_vulnerable.txt`):
```
DESERIALIZATION_GADGET_EXECUTED: id;whoami;uname -a
Thread: main
Time: 2026-07-09T18:16:03.331869217Z
Class: MaliciousGadget
```

### Fixed version PoC output (`bundle/logs/poc_fixed.log`):
```
[*] Calling SvmDoccatModel.deserialize() with malicious stream...
[+] FIXED version: ObjectInputFilter rejected foreign class
[+] Exception: java.io.InvalidClassException: filter status: REJECTED
[*] No marker file found — gadget code did NOT execute
VERDICT: FIXED — ObjectInputFilter blocked the foreign class
```

### Source verification:
- Vulnerable `SvmDoccatModel.java`: 0 references to `ObjectInputFilter` (confirmed via `grep -c`).
- Fixed `SvmDoccatModel.java`: 11 references to `ObjectInputFilter` (confirmed via `grep -c`).

### Environment:
- JDK: OpenJDK 21.0.11
- Maven: 3.9.12
- OS: Ubuntu 26.04 (Linux)
- Vulnerable commit: `3cb7232ecaccc788e32bf76b7c031fb166840da5`
- Fixed commit: `3cf42d4a0145cefd9dd06138873a91312052c767`

## Recommendations / Next Steps

- **Upgrade:** Users on OpenNLP 3.x before 3.0.0-M4 should upgrade to 3.0.0-M4 immediately.
- **Defense in depth:** Even with the `ObjectInputFilter`, callers should treat serialized `SvmDoccatModel` streams as untrusted input and verify their provenance before deserialization. The filter is defense-in-depth, not a license to deserialize from untrusted sources.
- **Input validation:** Applications that accept model files from end users or third-party sources should add integrity checks (e.g., signatures, checksums) before calling `deserialize()`.
- **Classpath hygiene:** Remove unnecessary gadget-chain-capable libraries from the classpath of any process that deserializes OpenNLP models.
- **Testing:** The fix includes test cases (`SvmDoccatModelTest.java`) that verify foreign classes are rejected. Downstream applications should add integration tests that feed crafted streams to `deserialize()` and verify rejection.

## Additional Notes

- **Idempotency:** The script was run twice consecutively; both runs produced identical results (exit code 0, vulnerable exit 10, fixed exit 20).
- **Gadget class:** The `MaliciousGadget` class is a purpose-built simulation of a real deserialization gadget. In a real attack, a class from a vulnerable transitive dependency (e.g., `org.apache.commons.collections.functors.InvokerTransformer`) would serve the same role. The proof demonstrates the deserialization mechanism, not a specific real-world gadget chain.
- **Scope:** The vulnerability is in the `opennlp-ml-libsvm` module only. Other OpenNLP modules use different serialization mechanisms (e.g., the main `DoccatModel` uses a custom binary format, not Java object serialization).
- **Limitation:** The `MaliciousGadget` class must be on the classpath for the exploit to work, which mirrors the real-world constraint that a gadget-chain-capable library must be present. The CVE description explicitly acknowledges this.
