# Patch Analysis: Jenkins SECURITY-3972 / CVE-2026-84645

## Scope and target identity

The submitted vulnerable target is Jenkins 2.579, tag commit `9095ea3a5c5e7dcd392695a5dd880af1c9910ddf`, executed from image digest `sha256:a7342867ea33efaacf825229d50b7fc77c144ecada9719ab4e32419f5d7412be`. The fixed target is Jenkins 2.580, tag commit `497de4961ad80d97e26bfdeb0d2e40442a84ecb0`, executed from image digest `sha256:0e50a5b11ac14f3b84e529d725ed3a1c4b17ba16188dfa8d9a0189428b0839b1`. Runtime identity is recorded in `bundle/logs/vuln_variant/fixed_version.txt`.

The primary fix is Jenkins commit [`0d731367e08656f8cd1e8275f0e820f97af07fc6`](https://github.com/jenkinsci/jenkins/commit/0d731367e08656f8cd1e8275f0e820f97af07fc6), titled `[SECURITY-3972]`. The same patch also appears as `e174eeb116a565c166215acc2e0c1ce94d60a16c` in the repository's security/release history. The tested 2.580 tag contains the fix.

## What the fix changes

### `core/src/main/java/hudson/util/RobustReflectionConverter.java`

The main enforcement is added in `RobustReflectionConverter#doUnmarshal`. After determining the concrete type for an XML field and before calling `unmarshalField`, it checks whether that type implements `hudson.model.PersistenceRoot`. If so, it permits conversion only when `isSafePersistenceRootReference(reader)` classifies the XML node as a safe reference form. Otherwise it logs the field and containing class and throws `CriticalXStreamException` with the invariant that `PersistenceRoot` objects are document roots and must not occur as nested field values.

This is a structural-position check, not another class allowlist. It applies to concrete types selected through `class=` as well as default field types whenever conversion flows through the core reflection converter. A `CriticalXStreamException` is deliberately propagated by robust collection/list handling rather than being downgraded to an ordinary missing-plugin/old-data error.

The helper recognizes three compatibility cases:

1. `reference=`: XStream must resolve an object that already exists in the current graph; the `class=` attribute is not used to instantiate a second object.
2. `resolves-to=` without a `class=` override: a non-`PersistenceRoot` replacement placeholder, such as `Run$Replacer`, `User$Replacer`, or `Jenkins$Replacer`, may deserialize and its `readResolve` performs a registry lookup.
3. A `class=` whose registered converter is a `SingleValueConverter`: queue persistence can represent an Item or Run as a scalar identifier that is looked up in the live Jenkins registry.

A `ConverterLookup` field and setter support the third decision.

### `core/src/main/java/hudson/util/XStream2.java`

`XStream2#setupConverters` now supplies its converter lookup to `RobustReflectionConverter`. This makes the scalar-reference exception dependent on the actual converter registered for the concrete type in that particular XStream instance; it does not blindly trust a class name.

All normal Jenkins `XStream2` instances that retain the core reflection converter inherit the structural rule. This includes `Items.XSTREAM2`, `Jenkins.XSTREAM2`, and custom instances such as queue persistence unless a higher-priority custom converter wholly owns the object conversion.

### `core/src/main/java/jenkins/model/Jenkins.java`

`Jenkins#readResolve` rejects a newly deserialized Jenkins object when another singleton already exists. `Jenkins#writeReplace` emits `Jenkins$Replacer` outside the true top-level Jenkins document; the replacer's `readResolve` returns `Jenkins.get()`. This both preserves legitimate references and prevents a second attacker-populated controller object from surviving if it reaches this defense-in-depth layer.

### `core/src/main/java/hudson/model/Queue.java`

Queue load catches `CriticalXStreamException`, logs a security-policy violation, and starts from an empty queue. Queue persistence has legitimate scalar Item and Run references, so the fix both preserves its registered `AbstractSingleValueConverter` behavior and fails closed if a nested object graph violates policy.

### Regression tests

`test/src/test/java/jenkins/security/Security3972Test.java` covers rejection of a nested `FreeStyleBuild` in a field, a legitimate `Run$Replacer`, a `reference=` plus `class=` case, and Jenkins replacement behavior. `Security3972QueueRestartTest` covers queue Item references. `RunActionTest` changes the compatibility expectation so a build containing an unsafe nested build owner fails to load. `ViewTest` tests view persistence through its real top-level Jenkins context rather than independently round-tripping a View with its owner.

### Related 2.580 hardening outside the primary commit

The 2.579-to-2.580 release diff adds `@XStreamNotDeserializable` to sensitive transient back-reference fields, including:

- `SCMTrigger.BuildAction#run` and its deprecated `build` field;
- `Fingerprinter.FingerprintAction#build`;
- `ParametersAction#run`;
- `AbstractScmTagAction#run` and deprecated `build`;
- `Run#project`, `Run#number`, state/runner fields;
- `Node#parent` and transient launch state.

These changes close individual carrier/accessor chains even before or after the global nested-root check. The variant runtime directly exercised `FingerprintAction#build` and `ParametersAction#run`.

Jenkins 2.580 also makes `hudson.model.User` implement `PersistenceRoot`, matching the fact that user state is stored in an independent top-level file. This closes the same structural class of nested-user injection rather than treating it as an unrelated type restriction.

## Assumptions made by the fix

1. **Converter coverage:** attacker-controlled persistence XML reaches `RobustReflectionConverter` for the field that would materialize a nested root. A wholly custom plugin converter must enforce equivalent semantics if it can construct a `PersistenceRoot` graph itself.
2. **Reference semantics:** an XStream `reference=` resolves only to an already-deserialized object and therefore cannot cause the ignored `class=` to instantiate attacker-controlled state.
3. **Replacement semantics:** a non-root `resolves-to` placeholder's `readResolve` is a trustworthy registry lookup, not an attacker-controlled object constructor. The no-`class=` condition prevents overriding the placeholder type.
4. **Single-value semantics:** a registered `SingleValueConverter` consumes scalar text and returns an existing live object. It must not interpret child XML as reflective state.
5. **Critical exception propagation:** collection/map compatibility logic must rethrow `CriticalXStreamException` instead of retaining a partially materialized forbidden value.
6. **Top-level legitimacy:** Jenkins' independent persistence documents legitimately construct their own root, while ordinary nested fields must use references/replacers.

## Paths and inputs not covered directly

The global check is intentionally implemented at reflective field assignment. It does not directly inspect:

- arbitrary object creation performed inside a plugin-defined custom converter;
- a plugin's custom XML parser that does not use `XStream2`;
- unsafe behavior inside a registered `SingleValueConverter` or a replacement class's `readResolve`;
- classes that semantically behave as independent roots but do not implement `PersistenceRoot`;
- non-persistence request binding paths unrelated to XStream.

Those are review boundaries, not demonstrated bypasses. A candidate outside them would need to cross the same limited-user HTTP boundary and reach the same forged routable-root condition to qualify.

## Variant matrix and before/after behavior

| Candidate | Distinct path | Jenkins 2.579 | Jenkins 2.580 | Result |
|---|---|---|---|---|
| A: `FingerprintAction#build` | `POST /job/carrier/config.xml` with `hudson.tasks.Fingerprinter$FingerprintAction -> build: FreeStyleBuild -> project: FreeStyleProject -> parent: Hudson`; route `/job/carrier/fingerprints/run/project/parent/scriptText` | HTTP 200 from forged route, unique marker created, and `id` returns `uid=1000(jenkins)` | Nested build absent after save, route HTTP 404, marker absent | Confirmed alternate data path on vulnerable target; no fix bypass |
| B: `ParametersAction#run` | Different core action/field; route `/job/carrier/parameters/run/project/parent/scriptText` | Not routable because 2.579 has no public `getRun`; marker absent | 2.580 exposes `getRun`, but the related annotation/global guard removes the injected Run; HTTP 404 and marker absent | Newly exposed fixed-version surface fails closed |
| C: safe-exception confusion | `reference=` with an incompatible `class=` plus a nonexistent `Run$Replacer` registry identifier | Both action routes HTTP 404; markers absent | Both action routes HTTP 404; markers absent | Reference and replacement assumptions held in runtime |

Candidate A is materially distinct from the parent reproduction's `SCMTrigger.BuildAction#run` and `/pollingLog/...` route, even though both begin at the same authenticated job `config.xml` endpoint and converge on nested `Run -> Job -> Hudson` plus `Hudson#doScriptText`. It therefore confirms an alternate field/object path for the same root cause, not a mere renaming of the parent trigger.

The fixed behavior is complete for all tested paths. Jenkins 2.580 may accept the outer job update and retain an empty action, but the prohibited nested root is absent, Stapler cannot traverse the route, and no command marker appears. This is secure neutralization even when the initial configuration response is HTTP 200.

## Threat model and trust boundary

The Jenkins security reporting policy treats controller core and official images as in scope. It generally excludes vulnerabilities requiring `Overall/Administer`, because administrators can already use `/script`, `/scriptText`, and plugin installation. This test instead provisions an authenticated user with only `Overall/Read`, `Item/Read`, and `Item/Configure`; direct `/scriptText` is required to return HTTP 403. The attacker then supplies XML over Jenkins' HTTP API. Candidate A crosses the same policy-relevant permission boundary as the advisory and is therefore a valid alternate trigger on 2.579.

Local file loading by an administrator and documented administrator Script Console use were not considered variants. Candidate B and C are reported as negative controls, not findings.

## Completeness conclusion

No Jenkins 2.580 bypass was found. The combination of a global `PersistenceRoot` position check, strict exception propagation, singleton defense, and targeted transient-field annotations blocks both the alternate routable carrier and the tested exception-confusion paths. The fix is complete for the materially distinct core paths evaluated here.

Residual hardening should focus on plugin-defined converters and plugin root-like types: require custom converters to return only registry-resolved `PersistenceRoot` identities, audit implementations of `SingleValueConverter`/`readResolve`, and add regression tests for plugin actions with routable getters. These are recommendations based on fix boundaries, not evidence of a remaining vulnerability.
