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

## Summary

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:

```java
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`):

1. Rejects when `depth > 64`, `references > 5,000,000`, or
   `arrayLength > 10,000,000`.
2. For each class descriptor: unwraps array component types; if the component
   type `isPrimitive()` → **ALLOWED** (no class-name check, only the
   array-length bound above).
3. Otherwise allows only the ~23 fully-qualified names in `ALLOWED_CLASSES`
   (OpenNLP doccat types, zlibsvm `de.hhn.mi.*` domain/config types, libsvm
   `svm_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
  `MaliciousGadget` stream is rejected with `InvalidClassException:
  filter status: REJECTED` on 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.** `maxArrayLength` bounds a *single*
  array; `maxRefs` bounds the *count* of references; neither bounds the *sum of
  bytes* allocated. `n` arrays of length up to `10,000,000` of an 8-byte
  primitive yield up to `n × 80 MiB` while staying within `maxRefs` (each array
  ≈ one reference) and `maxArrayLength`. With `n` in the hundreds, total
  allocation is tens of GiB — far beyond any consumer heap.
- The class allow-list is **type-blind for `HashMap` values**: the filter checks
  class *names* per descriptor, not that a `HashMap` value conforms to the
  declared field type. A crafted `SvmDoccatModel` graph could carry
  `HashMap<String,double[]>` inside any `HashMap` field; the filter allows it
  and the OOM lands during `readObject`. (The PoC uses a top-level `HashMap`,
  which is sufficient because the OOM occurs during `readObject`, 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 only `ObjectInputStream.readObject()` call in `opennlp-ml-libsvm`
  main source (confirmed by grep). Sibling modules use different mechanisms
  (`BaseModel` uses OpenNLP's custom binary/zip format; `ObjectDataReader`
  reads 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):**

1. **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 only `equals`/`hashCode`,
     **no** `readObject`/`readResolve`/`writeReplace`.
   - `libsvm.svm_model` / `svm_node` / `svm_parameter` — plain `Serializable`
     data structs (public fields), **no** serialization hooks.
   - OpenNLP `SvmDoccatConfiguration` (plain `Serializable`), `TermWeightingStrategy`
     & `FeatureSelectionStrategy` (enums), `SvmDoccatModel` (no instance
     `readObject`/`readResolve`).
   - JDK allow-listed types: `String`, `Number`/`Integer`/`Double`/`Boolean`,
     `Enum`, `HashMap`, `Map$Entry` — none has a side-effecting
     `readObject`/`readResolve` that 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.
2. **Alternate entry point within the module** — RULED OUT.
   `SvmDoccatModel.deserialize()` is the only `ObjectInputStream.readObject()`
   sink in `opennlp-ml-libsvm` main source.
3. **HashMap hash-collision CPU DoS** — considered and **deprioritised**:
   `HashMap` + colliding `String` keys are allow-listed, but JDK 8+ treeifies
   buckets with >8 `Comparable` entries, 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.

## Impact

- **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` (commit
    `3cf42d4a0145cefd9dd06138873a91312052c767`) — 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).
- **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 during `readObject()`. 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) during `SvmDoccatModel.deserialize()` on
  the **fixed** build, inside `ObjectInputStream.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 during `readObject()`, 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

1. **Script:** `bundle/vuln_variant/reproduction_steps.sh`
2. **What the script does:**
   - Resolves the OpenNLP repo (prepared project cache or fresh clone) and
     creates two **isolated git worktrees** — `wt-vuln` at `3cb7232e` and
     `wt-fixed` at `3cf42d4a` — without mutating the cache repo's HEAD.
   - Builds `opennlp-ml-libsvm` for each commit (reusing jars if present) and
     resolves the shared dependency classpath.
   - Compiles a `Variant` harness (`build` / `oom` / `rce` modes) and the
     `MaliciousGadget` class.
   - 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:
     1. `fixed_oom_small` — fixed build, `-Xmx96m` → `OutOfMemoryError` during
        `deserialize` (**bypass**).
     2. `fixed_oom_large` — fixed build, `-Xmx1g` → `ClassCastException` after
        full allocation (filter **allowed** the payload, no rejection).
     3. `vuln_oom_large` — vulnerable build, `-Xmx1g` → `ClassCastException`
        (baseline; no filter).
     4. `fixed_rce` — fixed build + `MaliciousGadget` →
        `InvalidClassException: filter status: REJECTED`, no marker (fix blocks
        foreign-gadget RCE).
     5. `vuln_rce` — vulnerable build + `MaliciousGadget` → gadget executes,
        marker created (RCE parity baseline).
   - Classifies the logs and exits `0` iff the DoS bypass is confirmed on the
     fixed build **and** the fix still blocks foreign-gadget RCE; otherwise `1`.
3. **Expected evidence:**
   - `bundle/logs/vuln_variant/fixed_oom_small.log` — `OutOfMemoryError: Java
     heap space` with stack through `ObjectInputStream.readArray` →
     `HashMap.readObject` → `SvmDoccatModel.deserialize:271` (fixed).
   - `bundle/logs/vuln_variant/fixed_oom_large.log` — `ClassCastException` at
     `SvmDoccatModel.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:

1. **Add a cumulative allocation budget.** Track the sum of `arrayLength ×
   elementSize` across all array allocations in the filter (or a wrapping
   `InputStream`/`ObjectInputStream` subclass) and reject when it exceeds a
   configurable byte budget (e.g. a few hundred MiB). `ObjectInputFilter` does
   not expose cumulative bytes, so this likely needs a custom
   `ObjectInputStream` that overrides `readArray`/allocation accounting, or
   pre-scanning the stream.
2. **Tighten primitive-array handling.** Rather than allowing *any* primitive
   array up to `maxArrayLength` unconditionally, bound the *aggregate* primitive
   payload (e.g. reject when total primitive-array bytes exceed a limit), and/or
   lower `maxArrayLength` for the specific primitive types actually used by a
   legitimate `SvmDoccatModel` graph (`double[]`, `int[]` for libsvm
   `rho`/`label`/`nSV`/`sv_indices`, `svm_node[][]`).
3. **Constrain `HashMap` value/key types.** The filter is type-blind for map
   contents. Consider a stricter allow-list that rejects `HashMap` values that
   are arrays when the declared field type is not array-bearing, or cap the
   number of entries per `HashMap`.
4. **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.sh` was run three times consecutively;
  all runs exited `0` with 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 worktree` under
  `bundle/vuln_variant/`; the project-cache repo's HEAD is never changed
  (verified `3cf42d4a` before and after). No checkout of the cache repo occurs.
- **Payload locality:** The 256 MiB payload is kept in `/tmp` (not under
  `bundle/`) to avoid bloating the bundle; it is regenerated deterministically
  by the `build` mode 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 calls
  `deserialize()`.
- **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).
