CVE-2026-43825: Verified Repro With Script Download
CVE-2026-43825: Apache OpenNLP SvmDoccatModel unsafe deserialization
CVE-2026-43825 is verified against apache/opennlp · github. Affected versions: 3.0.0-M1 before 3.0.0-M4 (only on 3.x line; introduced in OPENNLP-1808). Fixed in 3.0.0-M4. Vulnerability class: Deserialization. This high reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00279.
What Is CVE-2026-43825?
CVE-2026-43825 is a high-severity unsafe deserialization vulnerability (CWE-502) in Apache OpenNLP's SvmDoccatModel.deserialize(InputStream), part of the opennlp-ml-libsvm module. Pruva reproduced it (reproduction REPRO-2026-00279).
CVE-2026-43825 Severity & CVSS Score
CVE-2026-43825 is rated high severity, with a CVSS base score of 7.3 out of 10.
High — serious impact or readily exploitable. Prioritize remediation.
Affected apache/opennlp Versions
apache/opennlp · github versions 3.0.0-M1 before 3.0.0-M4 (only on 3.x line; introduced in OPENNLP-1808) are affected.
How to Reproduce CVE-2026-43825
pruva-verify REPRO-2026-00279 curl -O https://pruva.dev/api/v1/reproductions/REPRO-2026-00279/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh Proof of Reproduction for CVE-2026-43825
- reached the target end-to-end
- full exploit chain demonstrated
- high confidence
- the upstream fix blocks the same trigger
Crafted Java serialized byte stream containing a MaliciousGadget object (Serializable class with readObject() side-effect) fed to SvmDoccatModel.deserialize(InputStream)
- SvmDoccatModel.deserialize()
- new ObjectInputStream(in)
- readObject()
- MaliciousGadget.readObject() [arbitrary code execution before cast to SvmDoccatModel]
reproduction_steps.sh Memory-exhaustion denial-of-service bypass of the 3.0.0-M4 fix for CVE-2026-43825, reproducing on the FIXED build (opennlp-3.0.0-M4, commit 3cf42d4a). The fix's ObjectInputFilter allow-lists java.util.HashMap and java.lang.String, allows any primitive array up to maxArrayLength (10,000,000), and bounds per-array lengt…
How the agent worked
Root Cause and Exploit Chain for CVE-2026-43825
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.
- Package/component affected:
org.apache.opennlp:opennlp-ml-libsvm— classopennlp.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
SvmDoccatModelinstances from untrusted or semi-trusted sources. The method ispublic 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
MaliciousGadgetclass (implementingSerializablewith areadObject()side-effect) was placed on the classpath and itsreadObject()method executed duringSvmDoccatModel.deserialize(), writing a marker file as proof of arbitrary code execution. The cast toSvmDoccatModelthrewClassCastExceptiononly 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:
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:
- An attacker crafts a serialized byte stream containing a gadget class (any
Serializableclass with a dangerousreadObject()method). - The stream is passed to
SvmDoccatModel.deserialize(). ObjectInputStream.readObject()encounters the gadget class, loads it from the classpath, and invokes itsreadObject()method — arbitrary code executes.- Only then does the cast
(SvmDoccatModel)fail withClassCastException— 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
SvmDoccatModelserialization 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, causingreadObject()to throwInvalidClassExceptionBEFORE the foreign class is materialised.
Fix commit: 3cf42d4a0145cefd9dd06138873a91312052c767
Fix PR: https://github.com/apache/opennlp/pull/1029 (OPENNLP-1823)
Reproduction Steps
- Script:
bundle/repro/reproduction_steps.sh - 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 noObjectInputFilter. - Compiles a
MaliciousGadgetclass (aSerializableclass whosereadObject()writes a marker file) and aPocharness that serializes the gadget and feeds the bytes toSvmDoccatModel.deserialize(). - Runs the PoC against the vulnerable build: the gadget's
readObject()executes, a marker file is created, andClassCastExceptionis thrown after execution. - Checks out and builds the fixed commit (
3cf42d4a) which addsObjectInputFilter. - Runs the same PoC against the fixed build:
InvalidClassExceptionis thrown by the filter, no code executes, no marker file is created. - Writes
runtime_manifest.jsonwith proof artifacts and exits 0 if the vulnerability is confirmed.
- Expected evidence:
bundle/logs/poc_vulnerable.log— shows[GADGET EXECUTED]andCODE EXECUTION CONFIRMEDwith exit code 10.bundle/logs/gadget_executed_vulnerable.txt— the marker file written by the gadget'sreadObject().bundle/logs/poc_fixed.log— showsObjectInputFilter rejected foreign classwithInvalidClassExceptionand 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 toObjectInputFilter(confirmed viagrep -c). - Fixed
SvmDoccatModel.java: 11 references toObjectInputFilter(confirmed viagrep -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 serializedSvmDoccatModelstreams 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 todeserialize()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
MaliciousGadgetclass 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-libsvmmodule only. Other OpenNLP modules use different serialization mechanisms (e.g., the mainDoccatModeluses a custom binary format, not Java object serialization). - Limitation: The
MaliciousGadgetclass 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.
Variant Analysis & Alternative Triggers for CVE-2026-43825
A denial-of-service bypass of the 3.0.0-M4 fix for CVE-2026-43825 was
confirmed on the fixed code path. The fix (commit 3cf42d4a,
opennlp-3.0.0-M4) hardens SvmDoccatModel.deserialize(InputStream) with an
ObjectInputFilter whose class allow-list and numeric limits (graph depth,
total references, per-array length) are intended to "bound pathological
streams." However, the filter bounds per-array length and total reference
count but not total allocated memory: it allow-lists java.util.HashMap
and java.lang.String, allows any primitive array whose length does not
exceed maxArrayLength (10,000,000), and counts each array as roughly one
reference. A HashMap<String, double[8_000_000]> with a handful of entries is
entirely allow-listed and within every numeric limit, yet deserialising it
allocates n × 64 MiB. On a constrained heap this throws
OutOfMemoryError inside ObjectInputStream.readObject() →
HashMap.readObject() → readArray — i.e. during deserialisation on the
fixed SvmDoccatModel.deserialize(), before the cast to SvmDoccatModel
is ever attempted. The filter does not reject this stream (the large-heap
run completes all allocations and then fails only at the cast with
ClassCastException, never InvalidClassException).
This is a bypass of the fix's stated resource-limit protection, reaching the
same sink (SvmDoccatModel.deserialize() → ObjectInputStream.readObject())
and the same trust boundary (an attacker-controlled serialised stream supplied
to deserialize()). The impact class is denial-of-service (memory
exhaustion), not the original CVE's code-execution (RCE). An RCE bypass of
the allow-list was not found and is explicitly ruled out below.
Fix Coverage / Assumptions
The fix (3cf42d4a, PR #1029, OPENNLP-1823) modifies exactly one production
file — opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/main/java/opennlp/tools/ml/libsvm/doccat/SvmDoccatModel.java — plus its test. It changes
deserialize(InputStream) from:
public static SvmDoccatModel deserialize(InputStream in) throws IOException, ClassNotFoundException {
try (ObjectInputStream ois = new ObjectInputStream(in)) {
return (SvmDoccatModel) ois.readObject();
}
}
to a version that installs an ObjectInputFilter before readObject(),
adds a deserialize(InputStream, DeserializationLimits) overload, and adds a
DeserializationLimits record. The filter (buildFilter):
- Rejects when
depth > 64,references > 5,000,000, orarrayLength > 10,000,000. - For each class descriptor: unwraps array component types; if the component
type
isPrimitive()→ ALLOWED (no class-name check, only the array-length bound above). - Otherwise allows only the ~23 fully-qualified names in
ALLOWED_CLASSES(OpenNLP doccat types, zlibsvmde.hhn.mi.*domain/config types, libsvmsvm_model/svm_node/svm_parameter, and a minimal JDK set:String,Number,Integer,Double,Boolean,Enum,HashMap,Map$Entry); everything else → REJECTED.
Assumptions the fix relies on:
- Foreign gadget classes (the original CVE's RCE mechanism) are not on the
allow-list and are rejected before materialisation. (Verified: a
MaliciousGadgetstream is rejected withInvalidClassException: filter status: REJECTEDon the fixed build — see RCE control below.) - The numeric limits "bound pathological streams" (per the javadoc: "defense in depth against pathological inputs").
What the fix does NOT cover:
- Total allocated memory is unbounded.
maxArrayLengthbounds a single array;maxRefsbounds the count of references; neither bounds the sum of bytes allocated.narrays of length up to10,000,000of an 8-byte primitive yield up ton × 80 MiBwhile staying withinmaxRefs(each array ≈ one reference) andmaxArrayLength. Withnin the hundreds, total allocation is tens of GiB — far beyond any consumer heap. - The class allow-list is type-blind for
HashMapvalues: the filter checks class names per descriptor, not that aHashMapvalue conforms to the declared field type. A craftedSvmDoccatModelgraph could carryHashMap<String,double[]>inside anyHashMapfield; the filter allows it and the OOM lands duringreadObject. (The PoC uses a top-levelHashMap, which is sufficient because the OOM occurs duringreadObject, before the cast — the same "work happens before the cast" property as the original CVE.) - No other sink in the module is affected —
SvmDoccatModel.deserialize()is the onlyObjectInputStream.readObject()call inopennlp-ml-libsvmmain source (confirmed by grep). Sibling modules use different mechanisms (BaseModeluses OpenNLP's custom binary/zip format;ObjectDataReaderreads primitives only) and are out of the CVE's scope.
Variant / Alternate Trigger
Entry point (identical to the CVE):
opennlp.tools.ml.libsvm.doccat.SvmDoccatModel.deserialize(InputStream) — the
public static method in org.apache.opennlp:opennlp-ml-libsvm.
Alternate data path (the variant): instead of a foreign gadget class
(rejected by the allow-list), the attacker supplies a stream built only from
allow-listed classes — HashMap<String, double[L]> with L ≤ 10,000,000 —
that defeats the fix's resource-limit goal rather than its class-allow-list
goal.
Code path:
SvmDoccatModel.deserialize(InputStream) (line 236) →
deserialize(InputStream, DeserializationLimits) (line 261) →
ois.setObjectInputFilter(buildFilter(limits)) →
ois.readObject() (line 271) →
ObjectInputStream.readObject() → HashMap.readObject() →
ObjectInputStream.readArray() → java.lang.reflect.Array.newInstance() →
OutOfMemoryError (on a constrained heap), or full allocation + later
ClassCastException (on a large heap).
Trigger path (machine-readable):
attacker stream (HashMap<String,double[<=10M]>) →
SvmDoccatModel.deserialize → new ObjectInputStream →
setObjectInputFilter(buildFilter) [filter ALLOWS HashMap/String/double[],
arrayLength ≤ maxArrayLength, refs ≤ maxRefs] →
readObject() → HashMap.readObject() → readArray allocates n×L×8 bytes →
OutOfMemoryError (memory-exhaustion DoS) before cast.
Ruled-out candidates (bounded search):
- RCE bypass via an allow-listed gadget class — RULED OUT. Every
allow-listed class was inspected (bytecode via
javap/ source):de.hhn.mi.*(SvmConfigurationImpl,SvmModelImpl,SvmMetaInformationImpl,SvmFeatureImpl,SvmClassLabelImpl,NativeSvmModelWrapper) — records/data classes with onlyequals/hashCode, noreadObject/readResolve/writeReplace.libsvm.svm_model/svm_node/svm_parameter— plainSerializabledata structs (public fields), no serialization hooks.- OpenNLP
SvmDoccatConfiguration(plainSerializable),TermWeightingStrategy&FeatureSelectionStrategy(enums),SvmDoccatModel(no instancereadObject/readResolve). - JDK allow-listed types:
String,Number/Integer/Double/Boolean,Enum,HashMap,Map$Entry— none has a side-effectingreadObject/readResolvethat can invoke attacker-controlled code. No gadget chain can be built from allow-listed classes alone → no RCE bypass. The RCE control run confirms the foreign gadget is still rejected on the fixed build.
- Alternate entry point within the module — RULED OUT.
SvmDoccatModel.deserialize()is the onlyObjectInputStream.readObject()sink inopennlp-ml-libsvmmain source. - HashMap hash-collision CPU DoS — considered and deprioritised:
HashMap+ collidingStringkeys are allow-listed, but JDK 8+ treeifies buckets with >8Comparableentries, reducing worst-case rebuild to ~O(N log N) (≈1e8 ops at the 5M ref limit, sub-second) — a weak DoS compared to the deterministic memory-exhaustion bypass above, which is not mitigated by any JDK mechanism.
- Package/component affected:
org.apache.opennlp:opennlp-ml-libsvm—opennlp.tools.ml.libsvm.doccat.SvmDoccatModel.deserialize(InputStream). - Affected versions (as tested):
- Fixed/vulnerable-to-this-variant:
opennlp-3.0.0-M4(commit3cf42d4a0145cefd9dd06138873a91312052c767) — the bypass reproduces here. - Original CVE target (RCE): commit
3cb7232e(parent of fix, pre-M4) — RCE reproduces here; the DoS variant also reproduces here (trivially, no filter).
- Fixed/vulnerable-to-this-variant:
- Risk level / consequences: Denial of service via Java heap exhaustion in
any JVM process that calls
SvmDoccatModel.deserialize()on an attacker- or semi-trusted-source stream. Because the filter does not bound total memory, a stream of a few hundred max-length primitive arrays (~tens of GiB of allocation) collapses the consumer's heap duringreadObject(). The fix's own javadoc concedes the filter is "defense in depth, not a license to deserialize from untrusted sources"; this variant shows that defense-in-depth is incomplete for memory-exhaustion.
Impact Parity
- Disclosed/claimed maximum impact (parent CVE): code execution — arbitrary code execution via a gadget class on the consumer's classpath during deserialisation.
- Reproduced impact from this variant run: denial-of-service —
OutOfMemoryError(Java heap space) duringSvmDoccatModel.deserialize()on the fixed build, insideObjectInputStream.readObject()→HashMap.readObject()→readArray. - Parity:
partial— same sink, same trust boundary, same entry point, and a bypass of the fix's stated resource-limit protection; but a different (lesser) impact class (DoS, not RCE). The original CVE's RCE mechanism is not bypassed: the allow-list still rejects foreign gadget classes (verified by the RCE control run:InvalidClassException: filter status: REJECTED, no gadget execution on the fixed build). - Not demonstrated: code execution on the fixed build. No allow-listed class provides a gadget hook, so an RCE bypass was not achievable and is ruled out (see above).
Root Cause
The original root cause — ObjectInputStream.readObject() on an
attacker-controlled stream — is only partially mitigated by the fix. The fix
adds a class allow-list (which correctly closes the RCE vector) and numeric
limits, but the numeric model is per-array and per-reference, not per-byte.
ObjectInputFilter exposes depth(), references(), and arrayLength() but
no cumulative allocated-byte metric. The fix's filter therefore cannot reject a
stream whose class graph is benign and within limits yet whose total
allocation is pathological. HashMap.readObject() faithfully allocates every
double[] the stream requests; with each double[] up to 10,000,000 × 8 B
and up to 5,000,000 references permitted, the upper bound on deserialisation
memory is ~`5,000,000 × 80 MiB— astronomically beyond any real heap. The DoS is delivered duringreadObject()`, before the cast, exactly as the original
CVE's gadget side-effects were.
Fix commit: 3cf42d4a0145cefd9dd06138873a91312052c767
("OPENNLP-1823: Harden SvmDoccatModel.deserialize() with ObjectInputFilter and
resource limits (#1029)", tag opennlp-3.0.0-M4).
Reproduction Steps
- Script:
bundle/vuln_variant/reproduction_steps.sh - What the script does:
- Resolves the OpenNLP repo (prepared project cache or fresh clone) and
creates two isolated git worktrees —
wt-vulnat3cb7232eandwt-fixedat3cf42d4a— without mutating the cache repo's HEAD. - Builds
opennlp-ml-libsvmfor each commit (reusing jars if present) and resolves the shared dependency classpath. - Compiles a
Variantharness (build/oom/rcemodes) and theMaliciousGadgetclass. - Builds the DoS payload:
HashMap<String, double[8_000_000]>× 4 entries (256 MiB), serialised to/tmp/opennlp_variant_oom.bin. - Runs five scenarios against the appropriate build:
fixed_oom_small— fixed build,-Xmx96m→OutOfMemoryErrorduringdeserialize(bypass).fixed_oom_large— fixed build,-Xmx1g→ClassCastExceptionafter full allocation (filter allowed the payload, no rejection).vuln_oom_large— vulnerable build,-Xmx1g→ClassCastException(baseline; no filter).fixed_rce— fixed build +MaliciousGadget→InvalidClassException: filter status: REJECTED, no marker (fix blocks foreign-gadget RCE).vuln_rce— vulnerable build +MaliciousGadget→ gadget executes, marker created (RCE parity baseline).
- Classifies the logs and exits
0iff the DoS bypass is confirmed on the fixed build and the fix still blocks foreign-gadget RCE; otherwise1.
- Resolves the OpenNLP repo (prepared project cache or fresh clone) and
creates two isolated git worktrees —
- Expected evidence:
bundle/logs/vuln_variant/fixed_oom_small.log—OutOfMemoryError: Java heap spacewith stack throughObjectInputStream.readArray→HashMap.readObject→SvmDoccatModel.deserialize:271(fixed).bundle/logs/vuln_variant/fixed_oom_large.log—ClassCastExceptionatSvmDoccatModel.deserialize:271(filter allowed the payload).bundle/logs/vuln_variant/fixed_rce.log—InvalidClassException: filter status: REJECTED(RCE blocked).bundle/logs/vuln_variant/vuln_rce.log+gadget_executed_vuln_variant.txt—[GADGET EXECUTED]+ marker (RCE on vulnerable).bundle/logs/vuln_variant/fixed_version.txt,vulnerable_version.txt— exact tested commit identity.
Evidence
fixed_oom_small.log (the bypass — OOM during deserialize on the FIXED build):
VARIANT_OOM start in=/tmp/opennlp_variant_oom.bin file_bytes=256000155 calling SvmDoccatModel.deserialize() ...
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.base/java.lang.reflect.Array.newArray(Native Method)
at java.base/java.lang.reflect.Array.newInstance(Array.java:78)
at java.base/java.io.ObjectInputStream.readArray(ObjectInputStream.java:2150)
at java.base/java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1750)
at java.base/java.io.ObjectInputStream.readObject(ObjectInputStream.java:540)
...
at java.base/java.util.HashMap.readObject(HashMap.java:1560)
...
at opennlp.tools.ml.libsvm.doccat.SvmDoccatModel.deserialize(SvmDoccatModel.java:271)
at opennlp.tools.ml.libsvm.doccat.SvmDoccatModel.deserialize(SvmDoccatModel.java:236)
at Variant.runOom(Variant.java:105)
fixed_oom_large.log (filter ALLOWS the payload — no rejection):
VARIANT_OOM start ... calling SvmDoccatModel.deserialize() ...
Exception in thread "main" java.lang.ClassCastException: class java.util.HashMap cannot be cast to class opennlp.tools.ml.libsvm.doccat.SvmDoccatModel ...
at opennlp.tools.ml.libsvm.doccat.SvmDoccatModel.deserialize(SvmDoccatModel.java:271)
fixed_rce.log (fix's PRIMARY goal holds — foreign gadget rejected):
VARIANT_RCE stream_bytes=79 calling SvmDoccatModel.deserialize() ...
Exception in thread "main" java.io.InvalidClassException: filter status: REJECTED
at opennlp.tools.ml.libsvm.doccat.SvmDoccatModel.deserialize(SvmDoccatModel.java:271)
Marker file: not created on the fixed build.
vuln_rce.log + gadget_executed_vuln_variant.txt (RCE parity baseline):
[GADGET EXECUTED] Arbitrary code ran during deserialization: id;whoami
[GADGET EXECUTED] Marker file written to: /tmp/opennlp_variant_marker.txt
Exception in thread "main" java.lang.ClassCastException: class MaliciousGadget ...
at opennlp.tools.ml.libsvm.doccat.SvmDoccatModel.deserialize(SvmDoccatModel.java:196)
Marker content: DESERIALIZATION_GADGET_EXECUTED: id;whoami (Thread: main,
Class: MaliciousGadget).
Environment:
- JDK: OpenJDK 21.0.11; Maven 3.9.12; OS: Ubuntu 26.04 (Linux).
- Variant target (bypass reproduces):
3cf42d4a0145cefd9dd06138873a91312052c767(opennlp-3.0.0-M4). - Original CVE target:
3cb7232ecaccc788e32bf76b7c031fb166840da5(parent of fix). - Cache repo HEAD left unchanged at
3cf42d4a(worktrees used for isolation).
Recommendations / Next Steps
To close the gap, the fix should bound total deserialisation cost, not only per-array length and reference count:
- Add a cumulative allocation budget. Track the sum of
arrayLength × elementSizeacross all array allocations in the filter (or a wrappingInputStream/ObjectInputStreamsubclass) and reject when it exceeds a configurable byte budget (e.g. a few hundred MiB).ObjectInputFilterdoes not expose cumulative bytes, so this likely needs a customObjectInputStreamthat overridesreadArray/allocation accounting, or pre-scanning the stream. - Tighten primitive-array handling. Rather than allowing any primitive
array up to
maxArrayLengthunconditionally, bound the aggregate primitive payload (e.g. reject when total primitive-array bytes exceed a limit), and/or lowermaxArrayLengthfor the specific primitive types actually used by a legitimateSvmDoccatModelgraph (double[],int[]for libsvmrho/label/nSV/sv_indices,svm_node[][]). - Constrain
HashMapvalue/key types. The filter is type-blind for map contents. Consider a stricter allow-list that rejectsHashMapvalues that are arrays when the declared field type is not array-bearing, or cap the number of entries perHashMap. - Keep defense-in-depth messaging. The javadoc already says callers must not deserialize from untrusted sources; retain and emphasise this — the filter is not a complete guarantee (this variant proves it for memory).
Additional Notes
- Idempotency:
reproduction_steps.shwas run three times consecutively; all runs exited0with identical verdicts (BYPASS_CONFIRMED=yes,FIX_RCE_BLOCKED=yes,VULN_RCE_WORKS=yes). It reuses existing worktrees, module jars, the dependency classpath, and the payload file when present. - Isolation / pipeline safety: Testing uses
git worktreeunderbundle/vuln_variant/; the project-cache repo's HEAD is never changed (verified3cf42d4abefore and after). No checkout of the cache repo occurs. - Payload locality: The 256 MiB payload is kept in
/tmp(not underbundle/) to avoid bloating the bundle; it is regenerated deterministically by thebuildmode if missing. The evidence is the log output, not the payload bytes. - Scope / trust boundary: The variant crosses the same trust boundary as the
CVE — an attacker-controlled serialised stream supplied to
SvmDoccatModel.deserialize()by a consumer that treats model bytes as semi-trusted. It is not a local-file-self-attack: the realistic threat is a downstream service that ingests model files from users/network and callsdeserialize(). - Limitation: The OOM is demonstrated with a purpose-built payload of
allow-listed classes (simulating the memory-exhaustion mechanism). A real
attacker would craft the same
HashMap<String,double[]>graph; no exotic gadget library is required, which makes this bypass cheaper to mount than the original RCE (which needed a gadget-capable transitive dependency on the classpath).
CVE-2026-43825 Reproduction Transcript
The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.
Full session Replay every step — scrub the timeline or play it back.
Artifacts and Evidence for CVE-2026-43825
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix CVE-2026-43825
Upgrade apache/opennlp · github to 3.0.0-M4 or later.
FAQ: CVE-2026-43825
How does the CVE-2026-43825 deserialization attack work?
Which OpenNLP versions are affected by CVE-2026-43825, and where is it fixed?
How severe is CVE-2026-43825?
How can I reproduce CVE-2026-43825?
References for CVE-2026-43825
Authoritative sources for CVE-2026-43825 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.