{"repro_id":"REPRO-2026-00350","version":6,"title":"Jenkins Stapler: CSRF crumb exposed in dynamically generated JavaScript endpoint (SECURITY-3607)","repro_type":"security","status":"published","severity":"medium","description":"From Jenkins Security Advisory 2026-09-02. In Stapler 1839.ved17667b_a_eb_5 through 2107.v8dfcb_e8ed317 (both inclusive), except 2088.2093.vd7c3e58008a_6, included in Jenkins 2.447 through 2.579 (both inclusive) and LTS 2.452.1 through 2.568.2 (both inclusive), an HTTP endpoint serving dynamically generated JavaScript resources embeds the user's cross-site request forgery (CSRF) token (crumb) as a string literal. This allows attackers with control over a page hosted on the same site as Jenkins to obtain a valid crumb for the targeted user's session and perform actions on their behalf (CSRF crumb theft → full session action forgery). Fixed in Jenkins weekly 2.580 and LTS 2.568.3.","root_cause":"# Root Cause Analysis: CVE-2026-84649 (SECURITY-3878)\n\n## Summary\n\nJenkins 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.\n\n## Impact\n\n- **Affected component:** Jenkins core's Stapler integration and Stapler's dynamic JavaScript proxy endpoint `/$stapler/bound/script/...`.\n- **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.\n- **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.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** CSRF crumb theft leading to full authenticated-session action forgery (`authz_bypass`).\n- **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.\n- **Parity:** `full`.\n- **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.\n\n## Root Cause\n\nJenkins 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:\n\n```javascript\nvarname = makeStaplerProxy('/whatever','SESSION_CRUMB',['methodname']);\n```\n\nScript 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.\n\nFix 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:\n\n```javascript\nvarname = makeStaplerProxy('/whatever',document.head.dataset.crumbValue,['methodname']);\n```\n\nThe 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.\n\n## Reproduction Steps\n\n1. Run `bundle/repro/reproduction_steps.sh` from any directory. The script reads `PRUVA_ROOT` or resolves the bundle path itself.\n2. The script pulls and verifies these exact images:\n   - vulnerable Jenkins 2.579 JDK 21: `sha256:a7342867ea33efaacf825229d50b7fc77c144ecada9719ab4e32419f5d7412be`;\n   - fixed Jenkins 2.580 JDK 21: `sha256:0e50a5b11ac14f3b84e529d725ed3a1c4b17ba16188dfa8d9a0189428b0839b1`;\n   - Playwright v1.55.0 Noble: `sha256:b27e719ecbfef153e13fd24e8341736733bf2658b229677eb21ff57ff5d7fb29`.\n3. 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`.\n4. 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.\n5. 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.\n\n## Evidence\n\n- **Real browser exploit result:** `bundle/repro/proof/browser-same-site-result.json`\n  - `attacker_origin=http://attacker.example.test:8081/attack.html`\n  - `victim_authenticated_as=victim`\n  - `no_crumb_http=403`\n  - `stolen_crumb_http=200`\n  - `created_token_uuid=e81361f4-1998-41ca-98e0-dd36d6a7b747`\n- **Browser-created state-change marker:** `bundle/repro/proof/browser-same-site-marker.txt`.\n- **Browser/Jenkins service transcript:** `bundle/repro/proof/browser-same-site.service.log`.\n- **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.\n- **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.\n- **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.\n- **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).\n- **Image identities:** `bundle/repro/proof/target-identities.txt`.\n- **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.\n- **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`.\n- **Repeated execution:** `bundle/logs/reproduction_steps-redacted1.stdout` and `reproduction_steps-redacted2.stdout` both contain the confirmation line; both corresponding stderr logs are empty.\n\nNo sanitizer was used. This was a production HTTP and browser workflow against the official Jenkins images, not a parser/unit harness.\n\n## Recommendations / Next Steps\n\n- Upgrade Jenkins weekly to 2.580 or later, or LTS to 2.568.3 or later.\n- 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.\n- Do not host untrusted active content on the Jenkins site.\n- 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.\n- Test with a real browser and sibling-host setup so script inclusion and SameSite cookie behavior remain covered.\n\n## Additional Notes\n\n- 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`.\n- Every controller uses a fresh temporary Docker volume and every invocation removes its containers, network, and labeled volumes.\n- The ticket summary calls this `SECURITY-3607`; the Jenkins advisory, source test, and fix commit consistently identify it as `SECURITY-3878`.\n- 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.\n","cve_id":"CVE-2026-84649","cwe_id":"CWE-352","source_url":"https://www.jenkins.io/security/advisory/2026-09-02/","package":{"name":"jenkinsci/jenkins","ecosystem":"github"},"reproduced_at":"2026-09-11T10:13:35.085451+00:00","duration_secs":3116.101338,"tool_calls":472,"handoffs":2,"total_cost_usd":20.916148,"agent_costs":{"claim_matcher":0.03034,"judge":0.407043,"learning_policy":0.013489,"repro":15.985103,"support":1.523028,"vuln_variant":2.957145},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.03034},"judge":{"gpt-5.6-sol":0.407043},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.013489},"repro":{"gpt-5.6-sol":15.985103},"support":{"gpt-5.6-sol":1.523028},"vuln_variant":{"gpt-5.6-sol":2.957145}},"vulnerable_version_variant_outcome":"unknown","fix_bypass_outcome":"not_found","variant_disclosure_state":"not_applicable","quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-09-11T10:13:36.086535+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":9217,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":17021,"category":"reproduction_script"},{"path":"bundle/logs/repro/security-3878-source-diff.txt","filename":"security-3878-source-diff.txt","size":2190,"category":"other"},{"path":"bundle/logs/reproduction_steps-form1.stdout","filename":"reproduction_steps-form1.stdout","size":140,"category":"other"},{"path":"bundle/logs/reproduction_steps-form2.stdout","filename":"reproduction_steps-form2.stdout","size":140,"category":"other"},{"path":"bundle/logs/reproduction_steps-redacted1.stdout","filename":"reproduction_steps-redacted1.stdout","size":140,"category":"other"},{"path":"bundle/logs/reproduction_steps-redacted2.stdout","filename":"reproduction_steps-redacted2.stdout","size":140,"category":"other"},{"path":"bundle/repro/proof/browser-same-site-marker.txt","filename":"browser-same-site-marker.txt","size":37,"category":"other"},{"path":"bundle/repro/proof/browser-same-site-result.json","filename":"browser-same-site-result.json","size":436,"category":"other"},{"path":"bundle/repro/proof/browser-same-site-summary.txt","filename":"browser-same-site-summary.txt","size":189,"category":"other"},{"path":"bundle/repro/proof/browser-same-site.log","filename":"browser-same-site.log","size":0,"category":"log"},{"path":"bundle/repro/proof/browser-same-site.service.log","filename":"browser-same-site.service.log","size":3675,"category":"log"},{"path":"bundle/repro/proof/fixed_1.dynamic-js.request.txt","filename":"fixed_1.dynamic-js.request.txt","size":138,"category":"other"},{"path":"bundle/repro/proof/fixed_1.dynamic-js.response.headers","filename":"fixed_1.dynamic-js.response.headers","size":884,"category":"other"},{"path":"bundle/repro/proof/fixed_1.no_crumb.request.txt","filename":"fixed_1.no_crumb.request.txt","size":235,"category":"other"},{"path":"bundle/repro/proof/fixed_1.no_crumb.response.body","filename":"fixed_1.no_crumb.response.body","size":603,"category":"other"},{"path":"bundle/repro/proof/fixed_1.no_crumb.response.headers","filename":"fixed_1.no_crumb.response.headers","size":1017,"category":"other"},{"path":"bundle/repro/proof/fixed_1.product-component-identity.txt","filename":"fixed_1.product-component-identity.txt","size":305,"category":"other"},{"path":"bundle/repro/proof/fixed_1.service.log","filename":"fixed_1.service.log","size":3385,"category":"log"},{"path":"bundle/repro/proof/fixed_2.dynamic-js.request.txt","filename":"fixed_2.dynamic-js.request.txt","size":138,"category":"other"},{"path":"bundle/repro/proof/fixed_2.dynamic-js.response.body","filename":"fixed_2.dynamic-js.response.body","size":88,"category":"other"},{"path":"bundle/repro/proof/fixed_2.dynamic-js.response.headers","filename":"fixed_2.dynamic-js.response.headers","size":884,"category":"other"},{"path":"bundle/repro/proof/fixed_2.no_crumb.request.txt","filename":"fixed_2.no_crumb.request.txt","size":235,"category":"other"},{"path":"bundle/repro/proof/fixed_2.no_crumb.response.body","filename":"fixed_2.no_crumb.response.body","size":603,"category":"other"},{"path":"bundle/repro/proof/fixed_2.no_crumb.response.headers","filename":"fixed_2.no_crumb.response.headers","size":1017,"category":"other"},{"path":"bundle/repro/proof/fixed_2.product-component-identity.txt","filename":"fixed_2.product-component-identity.txt","size":305,"category":"other"},{"path":"bundle/repro/proof/fixed_2.result.txt","filename":"fixed_2.result.txt","size":267,"category":"other"},{"path":"bundle/repro/proof/fixed_2.service.log","filename":"fixed_2.service.log","size":3385,"category":"log"},{"path":"bundle/repro/proof/target-identities.txt","filename":"target-identities.txt","size":358,"category":"other"},{"path":"bundle/repro/proof/vulnerable_1.dynamic-js.request.txt","filename":"vulnerable_1.dynamic-js.request.txt","size":138,"category":"other"},{"path":"bundle/repro/proof/vulnerable_1.dynamic-js.response.headers","filename":"vulnerable_1.dynamic-js.response.headers","size":885,"category":"other"},{"path":"bundle/repro/proof/vulnerable_1.no_crumb.request.txt","filename":"vulnerable_1.no_crumb.request.txt","size":235,"category":"other"},{"path":"bundle/repro/proof/vulnerable_1.no_crumb.response.body","filename":"vulnerable_1.no_crumb.response.body","size":603,"category":"other"},{"path":"bundle/repro/proof/vulnerable_1.no_crumb.response.headers","filename":"vulnerable_1.no_crumb.response.headers","size":1017,"category":"other"},{"path":"bundle/repro/proof/vulnerable_1.product-component-identity.txt","filename":"vulnerable_1.product-component-identity.txt","size":305,"category":"other"},{"path":"bundle/repro/proof/vulnerable_1.service.log","filename":"vulnerable_1.service.log","size":3383,"category":"log"},{"path":"bundle/repro/proof/vulnerable_1.stolen_crumb.request.txt","filename":"vulnerable_1.stolen_crumb.request.txt","size":315,"category":"other"},{"path":"bundle/repro/proof/vulnerable_1.stolen_crumb.response.body","filename":"vulnerable_1.stolen_crumb.response.body","size":172,"category":"other"},{"path":"bundle/repro/proof/vulnerable_1.stolen_crumb.response.headers","filename":"vulnerable_1.stolen_crumb.response.headers","size":985,"category":"other"},{"path":"bundle/repro/proof/vulnerable_2.dynamic-js.request.txt","filename":"vulnerable_2.dynamic-js.request.txt","size":138,"category":"other"},{"path":"bundle/repro/proof/vulnerable_2.dynamic-js.response.body","filename":"vulnerable_2.dynamic-js.response.body","size":122,"category":"other"},{"path":"bundle/repro/proof/vulnerable_2.dynamic-js.response.headers","filename":"vulnerable_2.dynamic-js.response.headers","size":885,"category":"other"},{"path":"bundle/repro/proof/vulnerable_2.no_crumb.request.txt","filename":"vulnerable_2.no_crumb.request.txt","size":235,"category":"other"},{"path":"bundle/repro/proof/vulnerable_2.no_crumb.response.body","filename":"vulnerable_2.no_crumb.response.body","size":603,"category":"other"},{"path":"bundle/repro/proof/vulnerable_2.no_crumb.response.headers","filename":"vulnerable_2.no_crumb.response.headers","size":1017,"category":"other"},{"path":"bundle/repro/proof/vulnerable_2.product-component-identity.txt","filename":"vulnerable_2.product-component-identity.txt","size":305,"category":"other"},{"path":"bundle/repro/proof/vulnerable_2.result.txt","filename":"vulnerable_2.result.txt","size":336,"category":"other"},{"path":"bundle/repro/proof/vulnerable_2.service.log","filename":"vulnerable_2.service.log","size":3383,"category":"log"},{"path":"bundle/repro/proof/vulnerable_2.stolen_crumb.request.txt","filename":"vulnerable_2.stolen_crumb.request.txt","size":315,"category":"other"},{"path":"bundle/repro/proof/vulnerable_2.stolen_crumb.response.body","filename":"vulnerable_2.stolen_crumb.response.body","size":172,"category":"other"},{"path":"bundle/repro/proof/vulnerable_2.stolen_crumb.response.headers","filename":"vulnerable_2.stolen_crumb.response.headers","size":985,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":9344,"category":"other"},{"path":"bundle/repro/same_site_attack.js","filename":"same_site_attack.js","size":3961,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1424,"category":"other"}]}