# REPRO-2026-00279: Apache OpenNLP SvmDoccatModel unsafe deserialization ## Summary Status: published Severity: high Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00279 CVE: CVE-2026-43825 ## Package Name: apache/opennlp Ecosystem: github Affected: 3.0.0-M1 before 3.0.0-M4 (only on 3.x line; introduced in OPENNLP-1808) Fixed: 3.0.0-M4 ## Root Cause # 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. ## Reproduction Details Reproduced: 2026-07-09T19:33:21.961Z Duration: 899 seconds Tool calls: 168 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00279 pruva-verify CVE-2026-43825 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00279&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00279/artifacts/bundle/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-43825 - Source: https://github.com/apache/opennlp ## Artifacts - bundle/repro/reproduction_steps.sh (reproduction_script, 12993 bytes) - bundle/repro/rca_report.md (analysis, 9698 bytes) - bundle/vuln_variant/reproduction_steps.sh (reproduction_script, 12891 bytes) - bundle/vuln_variant/rca_report.md (analysis, 19750 bytes) - bundle/artifact_promotion_manifest.json (other, 11730 bytes) - bundle/artifact_promotion_report.json (other, 11748 bytes) - bundle/vuln_variant/source_identity.json (other, 2381 bytes) - bundle/vuln_variant/root_cause_equivalence.json (other, 3438 bytes) - bundle/repro/validation_verdict.json (other, 913 bytes) - bundle/repro/runtime_manifest.json (other, 650 bytes) - bundle/logs/poc_vulnerable.log (log, 1145 bytes) - bundle/logs/poc_fixed.log (log, 615 bytes) - bundle/logs/gadget_executed_vulnerable.txt (other, 125 bytes) - bundle/repro/harness/MaliciousGadget.java (other, 3064 bytes) - bundle/repro/harness/Poc.java (other, 5371 bytes) - bundle/logs/build_vulnerable.log (log, 0 bytes) - bundle/logs/build_fixed.log (log, 0 bytes) - bundle/logs/vuln_variant/fixed_oom_small.log (log, 1971 bytes) - bundle/logs/vuln_variant/fixed_oom_large.log (log, 657 bytes) - bundle/logs/vuln_variant/fixed_rce.log (log, 1030 bytes) - bundle/logs/vuln_variant/vuln_rce.log (log, 684 bytes) - bundle/logs/vuln_variant/gadget_executed_vuln_variant.txt (other, 116 bytes) - bundle/logs/vuln_variant/fixed_version.txt (other, 402 bytes) - bundle/vuln_variant/harness/Variant.java (other, 6472 bytes) - bundle/vuln_variant/variant_manifest.json (other, 5047 bytes) - bundle/vuln_variant/validation_verdict.json (other, 4548 bytes) - bundle/vuln_variant/runtime_manifest.json (other, 2754 bytes) - bundle/vuln_variant/patch_analysis.md (documentation, 8372 bytes) - bundle/logs/vuln_variant/vuln_oom_large.log (log, 570 bytes) - bundle/logs/vuln_variant/vulnerable_version.txt (other, 357 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00279 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00279/artifacts/bundle/repro/reproduction_steps.sh - Web: https://pruva.dev/r/REPRO-2026-00279 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://pruva.dev