# REPRO-2026-00369: Jenkins transient fields deserializable from config.xml → config object overwrite / RCE (SECURITY-3972 companion) ## Summary Status: published Severity: high CVSS: Unknown CWE: CWE-502 (Deserialization of Untrusted Data) Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00369 CVE: CVE-2026-84650 ## 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-84650 (Jenkins SECURITY-4032) is an authenticated unsafe-deserialization flaw in Jenkins configuration updates. Jenkins historically excluded Java `transient` fields from serialization but still populated them while unmarshalling attacker-submitted `config.xml`. In Jenkins 2.579, an attacker with `Item/Configure` can therefore create a nested object graph whose transient ownership and identity links are attacker-selected. This run used the real Jenkins HTTP endpoint to set `SCMTrigger.BuildAction.run`, `Run.project`, `AbstractItem.parent`, and `AbstractItem.name`; Stapler then traversed that forged graph to a nested `Jenkins` object with an attacker-supplied unsecured authorization strategy, allowing arbitrary Groovy and operating-system command execution on the controller. ## Impact - **Affected component:** Jenkins core, principally `hudson.util.RobustReflectionConverter` and security-sensitive transient fields in core model classes. - **Affected versions:** Jenkins weekly 2.579 and earlier; Jenkins LTS 2.568.2 and earlier. - **Fixed versions:** Jenkins weekly 2.580; Jenkins LTS 2.568.3. - **Risk:** High. An authenticated user able to update a job configuration can overwrite object identity/ownership state, forge Stapler traversal to privileged controller functions, and execute attacker-selected commands as the Jenkins controller process. The same primitive also corrupts protected cross-item state: this proof made a separate `target` job disappear from Jenkins' item map during an ordinary rename. ## Impact Parity - **Disclosed/claimed maximum impact:** Controller code execution through the remote Jenkins configuration API. - **Reproduced impact:** Two fresh Jenkins 2.579 controller processes accepted crafted `config.xml`, exposed the forged `/job/carrier/pollingLog/run/project/parent/scriptText` route, evaluated attacker-selected Groovy, and ran `/bin/sh` to create unique controller-local marker files. Two Jenkins 2.580 controls accepted the same endpoint request but returned HTTP 404 on the forged route and created no marker. - **Parity:** `full` - **Not demonstrated:** No privilege beyond the Jenkins controller process account or host/container escape was attempted; neither is required by the claim. ## Root Cause `AbstractItem.updateByXml(Source)` checks `Item.CONFIGURE`, then unmarshals the submitted XML directly into the existing item using `Items.XSTREAM2.unmarshal(..., this, ..., true)`. Before the fix, `RobustReflectionConverter.fieldDefinedInClass` treated every reflected field as deserializable, deliberately including Java `transient` fields for old-configuration compatibility. There was no field-level mechanism to reject attacker values for runtime-only identity, parent, owner, or execution references. The exploit composes those writes as follows: 1. The persistent `Actionable.actions` list receives `hudson.triggers.SCMTrigger$BuildAction`. 2. Its transient `run` field receives a freshly constructed `hudson.model.FreeStyleBuild`. 3. `Run.project` receives a nested `FreeStyleProject`. 4. The nested project's transient `AbstractItem.parent` receives a nested `jenkins.model.Jenkins`. 5. That nested Jenkins object's ordinary `authorizationStrategy` receives `AuthorizationStrategy$Unsecured`. 6. Stapler follows `carrier -> pollingLog -> run -> project -> parent -> scriptText`. `Jenkins.doScriptText` checks the ACL of the nested Jenkins object, which now grants all permissions, rather than the real controller ACL. Attacker Groovy is evaluated on the real controller JVM and launches the selected command. The same request sets transient `AbstractItem.name` to `target`. `updateByXml` calls `onLoad` afterward and restores the visible carrier name, but an ordinary rename records the injected value as `oldName` before restoration in the relevant flow and removes/replaces the protected item-map entry only on the vulnerable build. This supplies independent evidence of transient configuration-object overwrite. The fix is commit [`6244ebadf5b30257fde506c2d12d8eac9b6bc0e6`](https://github.com/jenkinsci/jenkins/commit/6244ebadf5b30257fde506c2d12d8eac9b6bc0e6) (`[SECURITY-4032]`). It adds `@XStreamNotDeserializable` / `@XStreamDeserializable`, teaches `RobustReflectionConverter` to skip forbidden transient fields, and annotates security-sensitive core fields including `AbstractItem.name`, `AbstractItem.parent`, `Run.project`, and `SCMTrigger.BuildAction.run`. ## Reproduction Steps 1. Run `bundle/repro/reproduction_steps.sh` from any directory. The script resolves its bundle through `PRUVA_ROOT`, reads `bundle/project_cache_context.json`, and uses the prepared repository location when available. 2. The script pulls and verifies immutable Jenkins 2.579 and 2.580 JDK 21 image digests, downloads checksum-pinned Matrix Authorization Strategy dependencies, and creates four clean Jenkins controllers: two vulnerable and two fixed controls. 3. Each controller is configured with an `attacker` account possessing `Overall/Read`, `Item/Read`, and `Item/Configure`, but not `Overall/Administer`; direct `/script` access is captured as denied. 4. Through the real authenticated `/job/carrier/config.xml` endpoint, the script submits the crafted transient-field object graph. It then posts attacker-selected Groovy to the forged Stapler route and checks a unique controller-local command marker. 5. It performs the secondary poisoned-name rename check, finalizes per-attempt request/response/service evidence, and writes `bundle/repro/runtime_manifest.json` with direct SHA-256 bindings. 6. Expected result: both Jenkins 2.579 attempts show `config=200 route=200 marker=true`, while both 2.580 attempts show `config=200 route=404 marker=false`. The script exits 0 only if all vulnerable and fixed assertions hold. ## Evidence - `bundle/logs/reproduction_steps.log`: top-level diagnostic transcript. - `bundle/repro/runtime_manifest.json`: immutable target identities and SHA-256 mapping for 48 finalized proof artifacts. - `bundle/repro/proof/vulnerable_1.result.txt` and `vulnerable_2.result.txt`: HTTP 200 on the config and forged script route, `marker_present=true`, target removed. - `bundle/repro/proof/vulnerable_1.marker.txt` and `vulnerable_2.marker.txt`: unique bytes written by attacker-selected `/bin/sh` commands. - `bundle/repro/proof/vulnerable_1.config.request.xml`: exact transient-field object graph sent through Item/Configure. - `bundle/repro/proof/vulnerable_1.route.request.txt` and `.route.response.body`: forged route and successful attacker Groovy output. - `bundle/repro/proof/fixed_1.result.txt` and `fixed_2.result.txt`: HTTP 404 on the same route, `marker_present=false`, and preserved `target-secret` state. - `bundle/repro/proof/fixed_1.marker.txt` and `fixed_2.marker.txt`: explicit absence records for unique fixed-control markers. - `bundle/repro/proof/*_*.script.headers`: direct Script Console controls showing `X-Required-Permission: hudson.model.Hudson.Administer` for the low-privilege user (supporting diagnostic evidence; the finalized manifest binds the per-attempt exploit records). - Vulnerable image: `jenkins/jenkins@sha256:a7342867ea33efaacf825229d50b7fc77c144ecada9719ab4e32419f5d7412be`, source release commit `9095ea3a5c5e7dcd392695a5dd880af1c9910ddf`. - Fixed image: `jenkins/jenkins@sha256:0e50a5b11ac14f3b84e529d725ed3a1c4b17ba16188dfa8d9a0189428b0839b1`, source release commit `497de4961ad80d97e26bfdeb0d2e40442a84ecb0`. Key latest-run transcript: ```text [vulnerable/1] config=200 route=200 marker=true rename=302 target=404: owned=200:carrier-updated-through-config-api [vulnerable/2] config=200 route=200 marker=true rename=302 target=404: owned=200:carrier-updated-through-config-api [fixed/1] config=200 route=404 marker=false rename=302 target=200:target-secret owned=200:carrier-updated-through-config-api [fixed/2] config=200 route=404 marker=false rename=302 target=200:target-secret owned=200:carrier-updated-through-config-api ``` ## Recommendations / Next Steps - Upgrade to Jenkins weekly 2.580 or newer, or LTS 2.568.3 or newer. - Do not enable the `hudson.util.RobustReflectionConverter.DISABLE_XSTREAM_NOT_DESERIALIZABLE_CHECK` escape hatch; it disables SECURITY-4032 protection. - Annotate plugin transient fields that encode identity, ownership, parentage, ACLs, runtime execution state, or object-root references with an annotation whose simple name is `XStreamNotDeserializable`, as supported for plugins targeting older cores. - Use `@XStreamDeserializable` only for reviewed migration-only transient fields and add vulnerable/fixed tests through real `config.xml` endpoints, not only direct XStream unit tests. - Audit plugin model types reachable from user-submitted configuration for unannotated transient `Item`, `Run`, `Job`, `Node`, `User`, ACL, or controller references. ## Additional Notes - The final script passed two complete consecutive executions. Each execution itself launches two fresh vulnerable and two fresh fixed controller processes. - The primary proof is the normal Jenkins HTTP product path; no sanitizer, direct parser harness, or mocked response is used. - Rootless Docker emits a benign cgroup warning. The script uses a checksum-pinned Alpine cleanup image to remove subordinate-ID files left by interrupted runs and preserves caller-owned bundle paths. - The Matrix Authorization Strategy plugin is used only to establish the stated low-privilege precondition. Its downloaded bytes and dependency are checksum pinned. The vulnerability and exploit object graph are in Jenkins core. ## Reproduction Details Reproduced: 2026-09-24T17:06:41.881Z Duration: 3648 seconds Tool calls: 461 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00369 pruva-verify CVE-2026-84650 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00369&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00369/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-84650 - Source: https://www.jenkins.io/security/advisory/2026-09-02/ ## Artifacts - bundle/repro/rca_report.md (analysis, 9597 bytes) - bundle/repro/reproduction_steps.sh (reproduction_script, 16214 bytes) - bundle/repro/proof/fixed_1.config.request.xml (other, 1133 bytes) - bundle/repro/proof/fixed_1.config.response.headers (other, 398 bytes) - bundle/repro/proof/fixed_1.marker.txt (other, 47 bytes) - bundle/repro/proof/fixed_1.owned.response.json (other, 757 bytes) - bundle/repro/proof/fixed_1.rename.response.headers (other, 423 bytes) - bundle/repro/proof/fixed_1.result.txt (other, 334 bytes) - bundle/repro/proof/fixed_1.route.request.txt (other, 280 bytes) - bundle/repro/proof/fixed_1.route.response.body (other, 19405 bytes) - bundle/repro/proof/fixed_1.route.response.headers (other, 1098 bytes) - bundle/repro/proof/fixed_1.service.log (log, 4651 bytes) - bundle/repro/proof/fixed_1.target.response.json (other, 738 bytes) - bundle/repro/proof/fixed_2.config.request.xml (other, 1133 bytes) - bundle/repro/proof/fixed_2.config.response.headers (other, 398 bytes) - bundle/repro/proof/fixed_2.marker.txt (other, 47 bytes) - bundle/repro/proof/fixed_2.observation.json (other, 218 bytes) - bundle/repro/proof/fixed_2.owned.response.json (other, 757 bytes) - bundle/repro/proof/fixed_2.rename.response.headers (other, 423 bytes) - bundle/repro/proof/fixed_2.result.txt (other, 334 bytes) - bundle/repro/proof/fixed_2.route.request.txt (other, 280 bytes) - bundle/repro/proof/fixed_2.route.response.body (other, 19405 bytes) - bundle/repro/proof/fixed_2.route.response.headers (other, 1098 bytes) - bundle/repro/proof/fixed_2.service.log (log, 4359 bytes) - bundle/repro/proof/fixed_2.target.response.json (other, 738 bytes) - bundle/repro/proof/vulnerable_1.config.response.headers (other, 398 bytes) - bundle/repro/proof/vulnerable_1.owned.response.json (other, 757 bytes) - bundle/repro/proof/vulnerable_1.rename.response.headers (other, 423 bytes) - bundle/repro/proof/vulnerable_1.result.txt (other, 340 bytes) - bundle/repro/proof/vulnerable_1.route.response.headers (other, 447 bytes) - bundle/repro/proof/vulnerable_1.target.response.json (other, 84516 bytes) - bundle/repro/proof/vulnerable_2.config.response.headers (other, 398 bytes) - bundle/repro/proof/vulnerable_2.observation.json (other, 232 bytes) - bundle/repro/proof/vulnerable_2.owned.response.json (other, 757 bytes) - bundle/repro/proof/vulnerable_2.rename.response.headers (other, 423 bytes) - bundle/repro/proof/vulnerable_2.result.txt (other, 340 bytes) - bundle/repro/proof/vulnerable_2.route.response.headers (other, 447 bytes) - bundle/repro/proof/vulnerable_2.target.response.json (other, 84516 bytes) - bundle/repro/runtime_manifest.json (other, 9495 bytes) - bundle/repro/validation_verdict.json (other, 1350 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00369 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00369/artifacts/bundle/repro/reproduction_steps.sh - Web: https://www.pruva.dev/reproductions/REPRO-2026-00369 ## 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