{"repro_id":"REPRO-2026-00279","version":6,"title":"Apache OpenNLP SvmDoccatModel unsafe deserialization","repro_type":"security","status":"published","severity":"high","description":"Apache OpenNLP 3.x before 3.0.0-M4 contains an unsafe deserialization path in SvmDoccatModel.deserialize(InputStream). The method uses ObjectInputStream.readObject() on attacker-controlled serialized data before any filtering or validation, allowing gadget-chain execution on the consumer's classpath. The issue is in org.apache.opennlp:opennlp-ml-libsvm and is fixed in 3.0.0-M4 with ObjectInputFilter and resource limits.","root_cause":"# RCA Report: CVE-2026-43825 — Apache OpenNLP SvmDoccatModel Unsafe Deserialization\n\n## Summary\n\nApache 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.\n\n## Impact\n\n- **Package/component affected:** `org.apache.opennlp:opennlp-ml-libsvm` — class `opennlp.tools.ml.libsvm.doccat.SvmDoccatModel`\n- **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)\n- **Fixed in:** 3.0.0-M4 (commit `3cf42d4a0145cefd9dd06138873a91312052c767`, PR #1029, OPENNLP-1823)\n- **Risk level:** High (CVSSv3 7.3 per Red Hat advisory)\n- **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).\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** Code execution — arbitrary code execution via a crafted serialized stream when a gadget chain is present on the classpath.\n- **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.\n- **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.\n- **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.\"\n\n## Root Cause\n\nThe vulnerable `deserialize` method in `SvmDoccatModel.java` (before 3.0.0-M4) is:\n\n```java\npublic static SvmDoccatModel deserialize(InputStream in) throws IOException, ClassNotFoundException {\n    try (ObjectInputStream ois = new ObjectInputStream(in)) {\n      return (SvmDoccatModel) ois.readObject();\n    }\n}\n```\n\n**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:\n\n1. An attacker crafts a serialized byte stream containing a gadget class (any `Serializable` class with a dangerous `readObject()` method).\n2. The stream is passed to `SvmDoccatModel.deserialize()`.\n3. `ObjectInputStream.readObject()` encounters the gadget class, loads it from the classpath, and invokes its `readObject()` method — **arbitrary code executes**.\n4. Only then does the cast `(SvmDoccatModel)` fail with `ClassCastException` — but the damage is already done.\n\n**Fix (commit `3cf42d4a`):** The fix adds an `ObjectInputFilter` via `ois.setObjectInputFilter(buildFilter(limits))` before `readObject()`. The filter:\n- 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).\n- Bounds graph depth (64), references (5,000,000), and array length (10,000,000).\n- Rejects any class not on the allow-list with `ObjectInputFilter.Status.REJECTED`, causing `readObject()` to throw `InvalidClassException` BEFORE the foreign class is materialised.\n\nFix commit: `3cf42d4a0145cefd9dd06138873a91312052c767`\nFix PR: https://github.com/apache/opennlp/pull/1029 (OPENNLP-1823)\n\n## Reproduction Steps\n\n1. **Script:** `bundle/repro/reproduction_steps.sh`\n2. **What the script does:**\n   - Installs JDK 21 and Maven (required by OpenNLP 3.x).\n   - Clones the Apache OpenNLP repository (or reuses the project cache).\n   - Checks out and builds the **vulnerable** commit (`3cb7232e`) — the parent of the fix — which has no `ObjectInputFilter`.\n   - 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()`.\n   - Runs the PoC against the vulnerable build: the gadget's `readObject()` executes, a marker file is created, and `ClassCastException` is thrown after execution.\n   - Checks out and builds the **fixed** commit (`3cf42d4a`) which adds `ObjectInputFilter`.\n   - Runs the same PoC against the fixed build: `InvalidClassException` is thrown by the filter, no code executes, no marker file is created.\n   - Writes `runtime_manifest.json` with proof artifacts and exits 0 if the vulnerability is confirmed.\n3. **Expected evidence:**\n   - `bundle/logs/poc_vulnerable.log` — shows `[GADGET EXECUTED]` and `CODE EXECUTION CONFIRMED` with exit code 10.\n   - `bundle/logs/gadget_executed_vulnerable.txt` — the marker file written by the gadget's `readObject()`.\n   - `bundle/logs/poc_fixed.log` — shows `ObjectInputFilter rejected foreign class` with `InvalidClassException` and exit code 20.\n\n## Evidence\n\n### Vulnerable version PoC output (`bundle/logs/poc_vulnerable.log`):\n```\n[*] Calling SvmDoccatModel.deserialize() with malicious stream...\n[GADGET EXECUTED] Arbitrary code ran during deserialization: id;whoami;uname -a\n[GADGET EXECUTED] Marker file written to: /tmp/opennlp_poc_marker_vuln.txt\n[!] VULNERABLE version: readObject() completed, cast failed AFTER\n[!] Exception: java.lang.ClassCastException: class MaliciousGadget cannot be cast to class opennlp.tools.ml.libsvm.doccat.SvmDoccatModel\n[!!!] CODE EXECUTION CONFIRMED: gadget readObject() ran!\n```\n\n### Gadget marker file (`bundle/logs/gadget_executed_vulnerable.txt`):\n```\nDESERIALIZATION_GADGET_EXECUTED: id;whoami;uname -a\nThread: main\nTime: 2026-07-09T18:16:03.331869217Z\nClass: MaliciousGadget\n```\n\n### Fixed version PoC output (`bundle/logs/poc_fixed.log`):\n```\n[*] Calling SvmDoccatModel.deserialize() with malicious stream...\n[+] FIXED version: ObjectInputFilter rejected foreign class\n[+] Exception: java.io.InvalidClassException: filter status: REJECTED\n[*] No marker file found — gadget code did NOT execute\nVERDICT: FIXED — ObjectInputFilter blocked the foreign class\n```\n\n### Source verification:\n- Vulnerable `SvmDoccatModel.java`: 0 references to `ObjectInputFilter` (confirmed via `grep -c`).\n- Fixed `SvmDoccatModel.java`: 11 references to `ObjectInputFilter` (confirmed via `grep -c`).\n\n### Environment:\n- JDK: OpenJDK 21.0.11\n- Maven: 3.9.12\n- OS: Ubuntu 26.04 (Linux)\n- Vulnerable commit: `3cb7232ecaccc788e32bf76b7c031fb166840da5`\n- Fixed commit: `3cf42d4a0145cefd9dd06138873a91312052c767`\n\n## Recommendations / Next Steps\n\n- **Upgrade:** Users on OpenNLP 3.x before 3.0.0-M4 should upgrade to 3.0.0-M4 immediately.\n- **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.\n- **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()`.\n- **Classpath hygiene:** Remove unnecessary gadget-chain-capable libraries from the classpath of any process that deserializes OpenNLP models.\n- **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.\n\n## Additional Notes\n\n- **Idempotency:** The script was run twice consecutively; both runs produced identical results (exit code 0, vulnerable exit 10, fixed exit 20).\n- **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.\n- **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).\n- **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.\n","cve_id":"CVE-2026-43825","cwe_id":"CWE-502 Deserialization of Untrusted Data","source_url":"https://github.com/apache/opennlp","package":{"name":"apache/opennlp","ecosystem":"github","affected_versions":"3.0.0-M1 before 3.0.0-M4 (only on 3.x line; introduced in OPENNLP-1808)","fixed_version":"3.0.0-M4"},"reproduced_at":"2026-07-09T19:33:21.961029+00:00","duration_secs":899.0,"tool_calls":168,"handoffs":2,"total_cost_usd":2.2733113900000004,"agent_costs":{"judge":0.02660335,"repro":0.6486558900000001,"support":0.07245771,"vuln_variant":1.52559444},"cost_breakdown":{"judge":{"gpt-5.4-mini":0.02660335},"repro":{"accounts/fireworks/routers/glm-5p2-fast":0.6486558900000001},"support":{"accounts/fireworks/routers/glm-5p2-fast":0.07245771},"vuln_variant":{"accounts/fireworks/routers/glm-5p2-fast":1.52559444}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-09T19:33:50.421452+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":12993,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":9698,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":12891,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":19750,"category":"analysis"},{"path":"bundle/artifact_promotion_manifest.json","filename":"artifact_promotion_manifest.json","size":11730,"category":"other"},{"path":"bundle/artifact_promotion_report.json","filename":"artifact_promotion_report.json","size":11748,"category":"other"},{"path":"bundle/vuln_variant/source_identity.json","filename":"source_identity.json","size":2381,"category":"other"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":3438,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":913,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":650,"category":"other"},{"path":"bundle/logs/poc_vulnerable.log","filename":"poc_vulnerable.log","size":1145,"category":"log"},{"path":"bundle/logs/poc_fixed.log","filename":"poc_fixed.log","size":615,"category":"log"},{"path":"bundle/logs/gadget_executed_vulnerable.txt","filename":"gadget_executed_vulnerable.txt","size":125,"category":"other"},{"path":"bundle/repro/harness/MaliciousGadget.java","filename":"MaliciousGadget.java","size":3064,"category":"other"},{"path":"bundle/repro/harness/Poc.java","filename":"Poc.java","size":5371,"category":"other"},{"path":"bundle/logs/build_vulnerable.log","filename":"build_vulnerable.log","size":0,"category":"log"},{"path":"bundle/logs/build_fixed.log","filename":"build_fixed.log","size":0,"category":"log"},{"path":"bundle/logs/vuln_variant/fixed_oom_small.log","filename":"fixed_oom_small.log","size":1971,"category":"log"},{"path":"bundle/logs/vuln_variant/fixed_oom_large.log","filename":"fixed_oom_large.log","size":657,"category":"log"},{"path":"bundle/logs/vuln_variant/fixed_rce.log","filename":"fixed_rce.log","size":1030,"category":"log"},{"path":"bundle/logs/vuln_variant/vuln_rce.log","filename":"vuln_rce.log","size":684,"category":"log"},{"path":"bundle/logs/vuln_variant/gadget_executed_vuln_variant.txt","filename":"gadget_executed_vuln_variant.txt","size":116,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_version.txt","filename":"fixed_version.txt","size":402,"category":"other"},{"path":"bundle/vuln_variant/harness/Variant.java","filename":"Variant.java","size":6472,"category":"other"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":5047,"category":"other"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":4548,"category":"other"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":2754,"category":"other"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":8372,"category":"documentation"},{"path":"bundle/logs/vuln_variant/vuln_oom_large.log","filename":"vuln_oom_large.log","size":570,"category":"log"},{"path":"bundle/logs/vuln_variant/vulnerable_version.txt","filename":"vulnerable_version.txt","size":357,"category":"other"}]}