# Root Cause Analysis: CVE-2026-84649 (SECURITY-3878)

## Summary

Jenkins 2.579 bundles Stapler 2107.v8dfcb_e8ed317, whose dynamically generated JavaScript proxy endpoint serializes the authenticated user's CSRF crumb as a JavaScript string literal. A page controlled by an attacker on a sibling host in the same site can load that endpoint as a script, define `makeStaplerProxy`, capture the literal, and submit a cross-origin HTML form carrying the stolen crumb. In the current production-path proof, real Chromium logged in a victim, visited `attacker.example.test`, loaded the script from `jenkins.example.test`, recovered the crumb, and created a Jenkins API token through a POST that returned HTTP 403 without the crumb and HTTP 200 with the stolen crumb. Jenkins 2.580 replaces the literal with a DOM expression and is not vulnerable through this endpoint.

## Impact

- **Affected component:** Jenkins core's Stapler integration and Stapler's dynamic JavaScript proxy endpoint `/$stapler/bound/script/...`.
- **Affected versions:** Stapler 1839.ved17667b_a_eb_5 through 2107.v8dfcb_e8ed317 inclusive, except 2088.2093.vd7c3e58008a_6; Jenkins weekly 2.447 through 2.579 inclusive; Jenkins LTS 2.452.1 through 2.568.2 inclusive.
- **Risk and consequences:** High according to Jenkins SECURITY-3878 (the ticket metadata said medium). The prerequisite is attacker-controlled active content on the same site as Jenkins, such as a sibling subdomain; the Resource Root URL being configured on that same site makes this particularly impactful. A victim must visit the attacker content while logged in. The attacker page can then authorize state-changing actions within the victim's Jenkins permissions. This run demonstrated creation of a new API token in the victim account.

## Impact Parity

- **Disclosed/claimed maximum impact:** CSRF crumb theft leading to full authenticated-session action forgery (`authz_bypass`).
- **Reproduced impact from this run:** A real sibling-host attacker page exfiltrated the victim's crumb in Chromium and used an ordinary cross-origin HTML form to create a victim API token. An identical state-changing POST without the stolen crumb returned HTTP 403; the forged POST returned HTTP 200 and produced token UUID `e81361f4-1998-41ca-98e0-dd36d6a7b747` in the final run.
- **Parity:** `full`.
- **Not demonstrated:** No stronger impact than actions already authorized to the victim was claimed or tested. The issued secret token value is intentionally not copied into this report.

## Root Cause

Jenkins initializes Stapler's `org.kohsuke.stapler.CrumbIssuer` adapter in `core/src/main/java/hudson/security/csrf/CrumbIssuer.java`. In the vulnerable release, the adapter implements `issueCrumb` by returning `Jenkins.get().getCrumbIssuer().getCrumb(request)`. Stapler's `Bound.getProxyScript` consumes that value when rendering its dynamic proxy resource and single-quotes it in output such as:

```javascript
varname = makeStaplerProxy('/whatever','SESSION_CRUMB',['methodname']);
```

Script inclusion is not protected by the browser same-origin read restriction in the way `fetch` is: a sibling-host page can load this JavaScript and provide its own `makeStaplerProxy` function to receive the literal argument. Because the sibling hosts are same-site, a victim's Jenkins session cookie is also eligible for the subsequent form POST. The endpoint therefore reveals the second anti-CSRF factor to the party that can already induce the browser to send the first factor.

Fix commit [`95e24c6cfa75efd4a7c079ef091df6fd02748d1a`](https://github.com/jenkinsci/jenkins/commit/95e24c6cfa75efd4a7c079ef091df6fd02748d1a) (`[SECURITY-3878]`) changes the adapter to override `getCrumbExpression()` and return `document.head.dataset.crumbValue` when CSRF protection is enabled. Stapler 2117.vb_66952503166 then emits:

```javascript
varname = makeStaplerProxy('/whatever',document.head.dataset.crumbValue,['methodname']);
```

The secret is no longer present in the cross-host script response. Jenkins 2.580 contains the fix; 2.579 does not. The tested peeled release commits were `9095ea3a5c5e7dcd392695a5dd880af1c9910ddf` and `497de4961ad80d97e26bfdeb0d2e40442a84ecb0` respectively.

## Reproduction Steps

1. Run `bundle/repro/reproduction_steps.sh` from any directory. The script reads `PRUVA_ROOT` or resolves the bundle path itself.
2. The script pulls and verifies these exact images:
   - vulnerable Jenkins 2.579 JDK 21: `sha256:a7342867ea33efaacf825229d50b7fc77c144ecada9719ab4e32419f5d7412be`;
   - fixed Jenkins 2.580 JDK 21: `sha256:0e50a5b11ac14f3b84e529d725ed3a1c4b17ba16188dfa8d9a0189428b0839b1`;
   - Playwright v1.55.0 Noble: `sha256:b27e719ecbfef153e13fd24e8341736733bf2658b229677eb21ff57ff5d7fb29`.
3. It starts real Jenkins controllers, creates and form-authenticates a victim, executes `bundle/repro/same_site_attack.js` in real Chromium, and serves an in-browser attacker page at `attacker.example.test` which loads the Jenkins script endpoint at sibling `jenkins.example.test`.
4. It submits the state-changing token-generation action with an actual cross-origin HTML form using the captured crumb. It also runs two isolated direct vulnerable attempts and two Jenkins 2.580 fixed negative-control attempts.
5. Expected success output is `CONFIRMED: Jenkins 2.579 exposed a session crumb ...; Jenkins 2.580 did not.` The script exits nonzero unless the no-crumb request is 403, the vulnerable stolen-crumb request is 200, and both fixed attempts omit the literal and emit the safe DOM expression.

## Evidence

- **Real browser exploit result:** `bundle/repro/proof/browser-same-site-result.json`
  - `attacker_origin=http://attacker.example.test:8081/attack.html`
  - `victim_authenticated_as=victim`
  - `no_crumb_http=403`
  - `stolen_crumb_http=200`
  - `created_token_uuid=e81361f4-1998-41ca-98e0-dd36d6a7b747`
- **Browser-created state-change marker:** `bundle/repro/proof/browser-same-site-marker.txt`.
- **Browser/Jenkins service transcript:** `bundle/repro/proof/browser-same-site.service.log`.
- **Two direct vulnerable attempt summaries:** `bundle/repro/proof/vulnerable_1.result.txt` and `vulnerable_2.result.txt`. Each records authentication, no-crumb HTTP 403, equality between the dynamic-script literal and the authenticated session crumb, and stolen-crumb HTTP 200.
- **State-changing responses:** `bundle/repro/proof/vulnerable_1.stolen_crumb.response.body` and `vulnerable_2.stolen_crumb.response.body` contain Jenkins `status: ok` and fresh token UUIDs.
- **Fixed negative controls:** `bundle/repro/proof/fixed_1.dynamic-js.response.body` and `fixed_2.dynamic-js.response.body` contain `document.head.dataset.crumbValue`, while both fixed result files record an empty dynamic literal and no attempted forged action.
- **Loaded product/component identity:** `bundle/repro/proof/*product-component-identity.txt` binds the exact WAR SHA-256 and bundled Stapler jars (2107 vulnerable, 2117 fixed).
- **Image identities:** `bundle/repro/proof/target-identities.txt`.
- **Hash binding:** `bundle/repro/runtime_manifest.json` lists 48 finalized proof artifacts and the SHA-256 of each. It identifies the executed vulnerable image and peeled Jenkins commit.
- **Root-cause source evidence:** `bundle/logs/repro/security-3878-source-diff.txt`, `vulnerable-source-snippet.txt`, `fixed-source-snippet.txt`, and `fix-commit-identity.txt`.
- **Repeated execution:** `bundle/logs/reproduction_steps-redacted1.stdout` and `reproduction_steps-redacted2.stdout` both contain the confirmation line; both corresponding stderr logs are empty.

No sanitizer was used. This was a production HTTP and browser workflow against the official Jenkins images, not a parser/unit harness.

## Recommendations / Next Steps

- Upgrade Jenkins weekly to 2.580 or later, or LTS to 2.568.3 or later.
- If immediate upgrade is impossible, disable the Resource Root URL feature or place it on a different registrable domain rather than a sibling host on the Jenkins site.
- Do not host untrusted active content on the Jenkins site.
- Preserve regression tests that request `/$stapler/bound/script/whatever?var=...&methods=...` with CSRF enabled and assert that the response contains no crumb-like literal and does contain the DOM expression.
- Test with a real browser and sibling-host setup so script inclusion and SameSite cookie behavior remain covered.

## Additional Notes

- The exact final script and helper passed twice consecutively after digest pinning and the cross-origin form enhancement. Their SHA-256 values are `487358e21524f0215abf5a00acf9898d07f00b04c995a7fb377285f0bc6f3914` and `88bf6d58888e4ec722c4739c968d8347c730f97ce8d40d21ba0169c1de2f0605`.
- Every controller uses a fresh temporary Docker volume and every invocation removes its containers, network, and labeled volumes.
- The ticket summary calls this `SECURITY-3607`; the Jenkins advisory, source test, and fix commit consistently identify it as `SECURITY-3878`.
- The browser helper installs the exact `playwright@1.55.0` Node package into a temporary volume while reusing the matching immutable Playwright image's Chromium. Network access is therefore required for first-time image and package acquisition.
