# REPRO-2026-00343: Jenkins XStream deserialization of nested PersistenceRoot objects leads to RCE via Stapler (SECURITY-3972) ## Summary Status: published Severity: high CVSS: Unknown CWE: CWE-94 (Improper Control of Generation of Code ('Code Injection')) Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00343 CVE: CVE-2026-84645 ## Package Name: jenkinsci/jenkins Ecosystem: github Affected: Jenkins weekly 2.579 and earlier; Jenkins LTS 2.568.2 and earlier. Fixed: Unknown ## Root Cause ## Summary CVE-2026-84645 (Jenkins SECURITY-3972) is an authenticated remote code execution vulnerability caused by Jenkins XStream deserialization accepting implementations of `hudson.model.PersistenceRoot` in nested positions of attacker-submitted configuration object graphs. The accepted nested objects remain reflectively traversable by Stapler. In this run, a user limited to Overall/Read, Item/Read, and Item/Configure submitted a job `config.xml` containing `SCMTrigger.BuildAction -> FreeStyleBuild -> FreeStyleProject -> hudson.model.Hudson`, where the forged `Hudson` carried the core `AuthorizationStrategy$Unsecured`; the attacker then reached that object's `doScriptText` method through the forged Stapler route and executed Groovy plus the controller-local `id` command. ## Impact - **Affected package/component:** Jenkins core XStream handling in `hudson.util.RobustReflectionConverter`, combined with Stapler routing over Jenkins model objects. - **Affected versions:** Jenkins weekly 2.579 and earlier, and Jenkins LTS 2.568.2 and earlier, per the official advisory. - **Fixed versions:** Jenkins weekly 2.580 and LTS 2.568.3. - **Risk level:** High (official CVSS 3.1 vector `AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H`, score 8.8). - **Consequences:** An authenticated user with job configuration rights can execute arbitrary Groovy and operating-system commands in the Jenkins controller JVM/container security context, enabling full compromise of controller data and behavior. The runtime used the real Jenkins HTTP/API boundary. Matrix Authorization Strategy Plugin 3.3 was installed only to express the claim's low-privilege account precisely. Before exploitation, the same `attacker` account received HTTP 403 from the legitimate root `/scriptText` endpoint in every vulnerable and fixed attempt. ## Impact Parity - **Disclosed/claimed maximum impact:** Authenticated remote code execution on the Jenkins controller. - **Reproduced impact from this run:** Authenticated remote code execution on two independent Jenkins 2.579 controller processes. Each attacker-supplied Groovy script created a unique controller-local marker and executed `id`, returning `uid=1000(jenkins) gid=1000(jenkins) groups=1000(jenkins)`. - **Parity:** `full`. - **Not demonstrated:** No claimed impact remains unproven. The proof intentionally stops after a harmless unique file write and `id`; it does not perform persistence, secret extraction, or destructive actions. ## Root Cause `PersistenceRoot` identifies Jenkins model objects whose state belongs in an independent top-level persistence document, such as a Jenkins/Hudson singleton, item/job, node, or build. Before the fix, `RobustReflectionConverter` applied the JEP-200 class allowlist but did not enforce the structural invariant that newly deserialized `PersistenceRoot` instances must not occur as ordinary nested field values. Consequently, a class could be allowed by identity yet unsafe in its graph position. The exploit uses only Jenkins core types: 1. `hudson.triggers.SCMTrigger$BuildAction` is inserted into the carrier job's persistent `actions` list. It exposes the Stapler URL name `pollingLog` and a public `getRun()` accessor. 2. Its private `run` field is deserialized as a nested `hudson.model.FreeStyleBuild` (a `PersistenceRoot`). 3. The build's `project` field is deserialized as a nested `hudson.model.FreeStyleProject` (also a `PersistenceRoot`). 4. The nested project's `parent` field is deserialized as a second `hudson.model.Hudson`/`jenkins.model.Jenkins` singleton object (also a `PersistenceRoot`). 5. That forged root object carries `hudson.security.AuthorizationStrategy$Unsecured`, so `Hudson#doScriptText` calls `Jenkins._doScript(..., getACL())` with an ACL that allows `ADMINISTER`. 6. Stapler reflectively traverses `/job/carrier/pollingLog/run/project/parent/scriptText` and invokes the forged object's Script Console endpoint, despite the authenticated principal lacking real Jenkins `ADMINISTER` permission. The primary fixing commit is [`0d731367e08656f8cd1e8275f0e820f97af07fc6`](https://github.com/jenkinsci/jenkins/commit/0d731367e08656f8cd1e8275f0e820f97af07fc6) (`[SECURITY-3972]`), which is present in tag `jenkins-2.580` and absent from `jenkins-2.579`. It adds a `PersistenceRoot` check in `RobustReflectionConverter` and throws `CriticalXStreamException` for unsafe nested instances, with narrow exceptions for references, replacement placeholders, and registered single-value converters. It also adds a second-Jenkins-instance guard and safe replacer logic in `Jenkins`. Related hardening annotations in the 2.580 release prevent unsafe transient-field reconstruction. In the fixed runtime, the same `config.xml` POST completes but serializing the job back shows only ``; the forbidden nested Run/Job/Hudson graph is absent. The forged route returns HTTP 404 and no marker is created. ## Reproduction Steps 1. Run `bundle/repro/reproduction_steps.sh` from any directory. `PRUVA_ROOT` may optionally identify the bundle root. 2. The script reads `bundle/project_cache_context.json`, reuses the prepared cache when available, and otherwise uses bundle-owned fallback paths. 3. It downloads three pinned plugin dependencies only when absent and verifies their SHA-256 values: Matrix Authorization Strategy 3.3, Ionicons API `94.vcc3065403257`, and commons-lang3 API `3.18.0-98.v3a_674c06072d`. 4. It pulls Jenkins by immutable image digest: vulnerable 2.579 (`sha256:a7342867…d7412be`) and fixed 2.580 (`sha256:0e50a5b1…0839b1`). 5. For each of two vulnerable and two fixed clean controller processes, it provisions `admin` and a low-privilege `attacker`, creates the `carrier` freestyle job, verifies that direct Script Console access returns 403, submits the crafted XML over authenticated HTTP, and posts unique Groovy to the forged route. 6. Vulnerable success requires HTTP 200, the unique marker in both the HTTP response and controller-local marker file, and `uid=1000(jenkins)` output. Fixed success requires a non-success forged-route status, marker absence, and absence of the nested graph from the resulting job XML. 7. The script finalizes controller logs, writes `bundle/repro/runtime_manifest.json` with SHA-256 bindings for all immutable proof artifacts, prints `CONFIRMED`, and exits 0 only if every assertion passes. Expected terminal output: ```text CONFIRMED: SECURITY-3972 achieved authenticated remote command execution on two Jenkins 2.579 controllers; two Jenkins 2.580 controls failed closed. ``` ## Evidence Primary current-run evidence is under `bundle/repro/proof/`, and every file is bound in `bundle/repro/runtime_manifest.json`: - `vulnerable_1.route.response.body` and `vulnerable_2.route.response.body` contain unique markers and controller command output. Latest run excerpts: ```text Result: {marker=CVE_2026_84645_vulnerable_1_12521_12184, id=uid=1000(jenkins) gid=1000(jenkins) groups=1000(jenkins)} Result: {marker=CVE_2026_84645_vulnerable_2_12521_18651, id=uid=1000(jenkins) gid=1000(jenkins) groups=1000(jenkins)} ``` - `vulnerable_1.marker.txt` and `vulnerable_2.marker.txt` are controller-local command markers whose bytes match the per-process values in `vulnerable_1.capability_observation.json` and `vulnerable_2.capability_observation.json`. - `vulnerable_{1,2}.direct_console.headers` begin with `HTTP/1.1 403 Forbidden`, proving the attacker did not already have Script Console access. - `vulnerable_{1,2}.config.request.txt` record the redacted authenticated API request and exact nested graph. - `vulnerable_{1,2}.route.request.txt` record the forged Stapler route and bounded Groovy effect. - `fixed_{1,2}.route.response.headers` begin with `HTTP/1.1 404 Not Found`. - `fixed_{1,2}.marker_absent.txt` and `fixed_{1,2}.negative_control.json` record that the corresponding unique marker was not created after the same procedure reached the fixed target. - `fixed_{1,2}.config.after.xml` contain the empty `SCMTrigger_-BuildAction` and no nested `FreeStyleBuild`. - `target_identity.txt` records the immutable image digests/image IDs, source tag commits, platform, and architecture. - `vulnerable_{1,2}.service.log` and `fixed_{1,2}.service.log` show real Jenkins 2.579/2.580 startup and production service initialization. - `bundle/logs/reproduction_steps.log` and `bundle/logs/reproduction_steps_second.log` show two consecutive successful executions of the final script. Environment identity: - Vulnerable source tag commit: `9095ea3a5c5e7dcd392695a5dd880af1c9910ddf` (`jenkins-2.579`). - Fixed source tag commit: `497de4961ad80d97e26bfdeb0d2e40442a84ecb0` (`jenkins-2.580`). - Vulnerable image digest: `sha256:a7342867ea33efaacf825229d50b7fc77c144ecada9719ab4e32419f5d7412be`. - Fixed image digest: `sha256:0e50a5b11ac14f3b84e529d725ed3a1c4b17ba16188dfa8d9a0189428b0839b1`. - Runtime platform: Linux x86-64/amd64, Docker, Jenkins bundled JVM 21. - Sanitizers: None. ## Recommendations / Next Steps - Upgrade Jenkins weekly to 2.580 or later, or Jenkins LTS to 2.568.3 or later. - Preserve the fixed `PersistenceRoot` structural check and its safe-reference exceptions; do not rely on class allowlisting alone for objects with graph-position invariants. - Retain the second-singleton guard in `Jenkins#readResolve` as defense in depth. - Review plugin-defined `PersistenceRoot` implementations and plugin actions that expose routable accessors to root objects. - Add regression tests for all configuration-accepting endpoints, including jobs, nodes, builds, users, views, and plugin-defined XML documents. Tests should verify both deserialization rejection/neutralization and that Stapler cannot traverse any partially retained graph. - Independently test safe back-references, `writeReplace`/`readResolve` placeholders, and single-value converters to avoid compatibility regressions without weakening the structural policy. ## Additional Notes - **Idempotency:** Confirmed. The final `bundle/repro/reproduction_steps.sh` passed twice consecutively. Each execution created two new vulnerable and two new fixed Jenkins controller processes with unique container IDs and markers. - **Privileges:** The `attacker` account has only Overall/Read, Item/Read, and Item/Configure. A direct `/scriptText` request is a mandatory 403 negative precondition in all four attempts. - **Safety:** The only command effects are a uniquely named file under the ephemeral controller's `/tmp` and `id`; all test containers are removed on completion or interruption. - **Fixed behavior nuance:** The 2.580 endpoint returned HTTP 200 for the configuration update while omitting the prohibited nested values. Security parity is established by the non-routable graph, HTTP 404 forged route, and marker absence rather than by requiring the initial XML POST itself to return an error. - **Network dependency:** A cold run needs access to the pinned Jenkins images and plugin URLs. A warm prepared cache reuses exact plugin bytes, and Docker reuses digest-addressed images. ## Reproduction Details Reproduced: 2026-09-03T17:02:18.865Z Duration: 2645 seconds Tool calls: 502 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00343 pruva-verify CVE-2026-84645 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00343&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00343/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-84645 - Source: https://www.jenkins.io/security/advisory/2026-09-02/ ## Artifacts - bundle/repro/rca_report.md (analysis, 11081 bytes) - bundle/repro/reproduction_steps.sh (reproduction_script, 15151 bytes) - bundle/vuln_variant/rca_report.md (analysis, 12752 bytes) - bundle/vuln_variant/reproduction_steps.sh (reproduction_script, 18797 bytes) - bundle/logs/reproduction_steps.log (log, 446 bytes) - bundle/logs/reproduction_steps_second.log (log, 446 bytes) - bundle/logs/vuln_variant/fixed_version.txt (other, 910 bytes) - bundle/logs/vuln_variant/proof/fixed.container_id.txt (other, 65 bytes) - bundle/logs/vuln_variant/proof/fixed.direct_console.body (other, 19972 bytes) - bundle/logs/vuln_variant/proof/fixed.direct_console.headers (other, 1504 bytes) - bundle/logs/vuln_variant/proof/fixed.exceptions.config.after.xml (other, 757 bytes) - bundle/logs/vuln_variant/proof/fixed.exceptions.config.response.body (other, 0 bytes) - bundle/logs/vuln_variant/proof/fixed.exceptions.config.response.headers (other, 398 bytes) - bundle/logs/vuln_variant/proof/fixed.exceptions.fingerprints.body (other, 19328 bytes) - bundle/logs/vuln_variant/proof/fixed.exceptions.fingerprints.headers (other, 1200 bytes) - bundle/logs/vuln_variant/proof/fixed.exceptions.fingerprints.marker_absent.txt (other, 57 bytes) - bundle/logs/vuln_variant/proof/fixed.exceptions.parameters.body (other, 19328 bytes) - bundle/logs/vuln_variant/proof/fixed.exceptions.parameters.headers (other, 1202 bytes) - bundle/logs/vuln_variant/proof/fixed.exceptions.parameters.marker_absent.txt (other, 55 bytes) - bundle/logs/vuln_variant/proof/fixed.fingerprint.config.after.xml (other, 624 bytes) - bundle/logs/vuln_variant/proof/fixed.fingerprint.config.request.txt (other, 1385 bytes) - bundle/logs/vuln_variant/proof/fixed.fingerprint.config.response.body (other, 0 bytes) - bundle/logs/vuln_variant/proof/fixed.fingerprint.config.response.headers (other, 398 bytes) - bundle/logs/vuln_variant/proof/fixed.fingerprint.marker_absent.txt (other, 54 bytes) - bundle/logs/vuln_variant/proof/fixed.fingerprint.observation.json (other, 191 bytes) - bundle/logs/vuln_variant/proof/fixed.fingerprint.route.request.txt (other, 257 bytes) - bundle/logs/vuln_variant/proof/fixed.fingerprint.route.response.body (other, 19328 bytes) - bundle/logs/vuln_variant/proof/fixed.fingerprint.route.response.headers (other, 1200 bytes) - bundle/logs/vuln_variant/proof/fixed.health.json (other, 732 bytes) - bundle/logs/vuln_variant/proof/fixed.parameters.config.after.xml (other, 705 bytes) - bundle/logs/vuln_variant/proof/fixed.parameters.config.request.txt (other, 1367 bytes) - bundle/logs/vuln_variant/proof/fixed.parameters.config.response.body (other, 0 bytes) - bundle/logs/vuln_variant/proof/fixed.parameters.config.response.headers (other, 398 bytes) - bundle/logs/vuln_variant/proof/fixed.parameters.marker_absent.txt (other, 53 bytes) - bundle/logs/vuln_variant/proof/fixed.parameters.observation.json (other, 189 bytes) - bundle/logs/vuln_variant/proof/fixed.parameters.route.request.txt (other, 254 bytes) - bundle/logs/vuln_variant/proof/fixed.parameters.route.response.body (other, 19328 bytes) - bundle/logs/vuln_variant/proof/fixed.parameters.route.response.headers (other, 1202 bytes) - bundle/logs/vuln_variant/proof/fixed.service.log (log, 4847 bytes) - bundle/logs/vuln_variant/proof/vulnerable.container_id.txt (other, 65 bytes) - bundle/logs/vuln_variant/proof/vulnerable.direct_console.body (other, 19972 bytes) - bundle/logs/vuln_variant/proof/vulnerable.direct_console.headers (other, 1504 bytes) - bundle/logs/vuln_variant/proof/vulnerable.exceptions.config.after.xml (other, 757 bytes) - bundle/logs/vuln_variant/proof/vulnerable.exceptions.config.response.body (other, 0 bytes) - bundle/logs/vuln_variant/proof/vulnerable.exceptions.config.response.headers (other, 398 bytes) - bundle/logs/vuln_variant/proof/vulnerable.exceptions.fingerprints.body (other, 19328 bytes) - bundle/logs/vuln_variant/proof/vulnerable.exceptions.fingerprints.headers (other, 1200 bytes) - bundle/logs/vuln_variant/proof/vulnerable.exceptions.fingerprints.marker_absent.txt (other, 62 bytes) - bundle/logs/vuln_variant/proof/vulnerable.exceptions.parameters.body (other, 19328 bytes) - bundle/logs/vuln_variant/proof/vulnerable.exceptions.parameters.headers (other, 1201 bytes) - bundle/logs/vuln_variant/proof/vulnerable.exceptions.parameters.marker_absent.txt (other, 60 bytes) - bundle/logs/vuln_variant/proof/vulnerable.fingerprint.config.after.xml (other, 624 bytes) - bundle/logs/vuln_variant/proof/vulnerable.fingerprint.config.request.txt (other, 1385 bytes) - bundle/logs/vuln_variant/proof/vulnerable.fingerprint.config.response.body (other, 0 bytes) - bundle/logs/vuln_variant/proof/vulnerable.fingerprint.config.response.headers (other, 398 bytes) - bundle/logs/vuln_variant/proof/vulnerable.fingerprint.marker.txt (other, 64 bytes) - bundle/logs/vuln_variant/proof/vulnerable.fingerprint.observation.json (other, 200 bytes) - bundle/logs/vuln_variant/proof/vulnerable.fingerprint.route.request.txt (other, 262 bytes) - bundle/logs/vuln_variant/proof/vulnerable.fingerprint.route.response.body (other, 143 bytes) - bundle/logs/vuln_variant/proof/vulnerable.fingerprint.route.response.headers (other, 447 bytes) - bundle/logs/vuln_variant/proof/vulnerable.health.json (other, 732 bytes) - bundle/logs/vuln_variant/proof/vulnerable.parameters.config.after.xml (other, 705 bytes) - bundle/logs/vuln_variant/proof/vulnerable.parameters.config.request.txt (other, 1367 bytes) - bundle/logs/vuln_variant/proof/vulnerable.parameters.config.response.body (other, 0 bytes) - bundle/logs/vuln_variant/proof/vulnerable.parameters.config.response.headers (other, 398 bytes) - bundle/logs/vuln_variant/proof/vulnerable.parameters.marker_absent.txt (other, 58 bytes) - bundle/logs/vuln_variant/proof/vulnerable.parameters.observation.json (other, 199 bytes) - bundle/logs/vuln_variant/proof/vulnerable.parameters.route.request.txt (other, 259 bytes) - bundle/logs/vuln_variant/proof/vulnerable.parameters.route.response.body (other, 19328 bytes) - bundle/logs/vuln_variant/proof/vulnerable.parameters.route.response.headers (other, 1201 bytes) - bundle/logs/vuln_variant/proof/vulnerable.service.log (log, 6402 bytes) - bundle/logs/vuln_variant/reproduction_first.log (log, 244 bytes) - bundle/logs/vuln_variant/reproduction_second.log (log, 244 bytes) - bundle/repro/proof/fixed_1.config.after.xml (other, 613 bytes) - bundle/repro/proof/fixed_1.config.response.body (other, 0 bytes) - bundle/repro/proof/fixed_1.config.response.headers (other, 398 bytes) - bundle/repro/proof/fixed_1.container_id.txt (other, 65 bytes) - bundle/repro/proof/fixed_1.direct_console.body (other, 19972 bytes) - bundle/repro/proof/fixed_1.direct_console.headers (other, 1503 bytes) - bundle/repro/proof/fixed_1.health.json (other, 732 bytes) - bundle/repro/proof/fixed_1.marker_absent.txt (other, 59 bytes) - bundle/repro/proof/fixed_1.route.request.txt (other, 263 bytes) - bundle/repro/proof/fixed_1.route.response.body (other, 19328 bytes) - bundle/repro/proof/fixed_1.service.log (log, 4366 bytes) - bundle/repro/proof/fixed_2.config.after.xml (other, 613 bytes) - bundle/repro/proof/fixed_2.config.request.txt (other, 1352 bytes) - bundle/repro/proof/fixed_2.config.response.body (other, 0 bytes) - bundle/repro/proof/fixed_2.config.response.headers (other, 398 bytes) - bundle/repro/proof/fixed_2.container_id.txt (other, 65 bytes) - bundle/repro/proof/fixed_2.direct_console.body (other, 19972 bytes) - bundle/repro/proof/fixed_2.direct_console.headers (other, 1504 bytes) - bundle/repro/proof/fixed_2.health.json (other, 732 bytes) - bundle/repro/proof/fixed_2.marker_absent.txt (other, 59 bytes) - bundle/repro/proof/fixed_2.negative_control.json (other, 212 bytes) - bundle/repro/proof/fixed_2.route.request.txt (other, 263 bytes) - bundle/repro/proof/fixed_2.route.response.body (other, 19328 bytes) - bundle/repro/proof/fixed_2.route.response.headers (other, 1200 bytes) - bundle/repro/proof/fixed_2.service.log (log, 4366 bytes) - bundle/repro/proof/target_identity.txt (other, 598 bytes) - bundle/repro/proof/vulnerable_1.config.after.xml (other, 613 bytes) - bundle/repro/proof/vulnerable_1.config.response.body (other, 0 bytes) - bundle/repro/proof/vulnerable_1.config.response.headers (other, 398 bytes) - bundle/repro/proof/vulnerable_1.container_id.txt (other, 65 bytes) - bundle/repro/proof/vulnerable_1.direct_console.body (other, 19972 bytes) - bundle/repro/proof/vulnerable_1.direct_console.headers (other, 1503 bytes) - bundle/repro/proof/vulnerable_1.health.json (other, 732 bytes) - bundle/repro/proof/vulnerable_1.route.response.headers (other, 447 bytes) - bundle/repro/proof/vulnerable_2.capability_observation.json (other, 216 bytes) - bundle/repro/proof/vulnerable_2.config.after.xml (other, 613 bytes) - bundle/repro/proof/vulnerable_2.config.request.txt (other, 1352 bytes) - bundle/repro/proof/vulnerable_2.config.response.body (other, 0 bytes) - bundle/repro/proof/vulnerable_2.config.response.headers (other, 398 bytes) - bundle/repro/proof/vulnerable_2.container_id.txt (other, 65 bytes) - bundle/repro/proof/vulnerable_2.direct_console.body (other, 19972 bytes) - bundle/repro/proof/vulnerable_2.direct_console.headers (other, 1503 bytes) - bundle/repro/proof/vulnerable_2.health.json (other, 732 bytes) - bundle/repro/proof/vulnerable_2.route.request.txt (other, 268 bytes) - bundle/repro/proof/vulnerable_2.route.response.body (other, 118 bytes) - bundle/repro/proof/vulnerable_2.route.response.headers (other, 447 bytes) - bundle/repro/proof/vulnerable_2.service.log (log, 4364 bytes) - bundle/repro/runtime_manifest.json (other, 10433 bytes) - bundle/repro/validation_verdict.json (other, 1401 bytes) - bundle/vuln_variant/patch_analysis.md (documentation, 11188 bytes) - bundle/vuln_variant/root_cause_equivalence.json (other, 2013 bytes) - bundle/vuln_variant/runtime_manifest.json (other, 14260 bytes) - bundle/vuln_variant/source_identity.json (other, 1150 bytes) - bundle/vuln_variant/validation_verdict.json (other, 3749 bytes) - bundle/vuln_variant/variant_manifest.json (other, 4347 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00343 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00343/artifacts/bundle/repro/reproduction_steps.sh - Web: https://www.pruva.dev/reproductions/REPRO-2026-00343 ## 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://www.pruva.dev