{"repro_id":"REPRO-2026-00349","version":6,"title":"Jenkins Stapler form binding instantiates arbitrary config types → RCE (SECURITY-3966)","repro_type":"security","status":"published","severity":"high","description":"A crafted structured form submission can name an unintended configuration class. Vulnerable Stapler instantiates it without enforcing compatibility with the field's declared type, so a low-privilege user can trigger constructor or setter side effects and potentially controller code execution.","root_cause":"# Root Cause Analysis: CVE-2026-84647 (Jenkins SECURITY-3915)\n\n## Summary\n\nJenkins 2.579 bundles Stapler 2107.v8dfcb_e8ed317, whose structured-form collection binder trusts every class name encoded as a key beneath `stapler-class-bag`. It loads and instantiates the requested class without first checking that the class is a subtype of the collection's declared item type. An authenticated user with only Overall/Read can reach the personal My Views creation endpoint and submit a `ListView.columns` collection that names an unrelated configuration class. In this run, the attacker selected `io.jenkins.plugins.file_parameters.StashedFileParameterValue`; its data-bound constructor consumed an uploaded file and an attacker-controlled traversal name, placing attacker Groovy under `JENKINS_HOME/init.groovy.d`. On an ordinary Jenkins restart, the real controller executed the Groovy startup hook and created a unique command-execution marker. Jenkins 2.580 reached the same endpoint but ignored the incompatible class before construction.\n\n## Impact\n\n- **Affected component:** Jenkins core's bundled Stapler structured form-binding implementation, specifically collection conversion in `org.kohsuke.stapler.RequestImpl.TypePair.convertJSON`.\n- **Affected versions:** Stapler 2107.v8dfcb_e8ed317 and earlier, except 2088.2093.vd7c3e58008a_6; included in Jenkins weekly 2.579 and earlier and Jenkins LTS 2.568.2 and earlier. The issue is fixed in Jenkins weekly 2.580 and LTS 2.568.3.\n- **Tested vulnerable target:** Immutable image `jenkins/jenkins@sha256:a7342867ea33efaacf825229d50b7fc77c144ecada9719ab4e32419f5d7412be` (Jenkins 2.579, source commit `9095ea3a5c5e7dcd392695a5dd880af1c9910ddf`).\n- **Tested fixed target:** Immutable image `jenkins/jenkins@sha256:0e50a5b11ac14f3b84e529d725ed3a1c4b17ba16188dfa8d9a0189428b0839b1` (Jenkins 2.580, source commit `497de4961ad80d97e26bfdeb0d2e40442a84ecb0`).\n- **Risk:** High. A network attacker with only Overall/Read can invoke constructors and setters of configuration-related classes outside the intended destination type. Impact depends on available data-bound gadget classes. With File Parameter Plugin 425.v3fa_801681b_5e, the run demonstrated a controller file-write gadget and persistent controller-side code execution after restart. The low-privilege user was denied direct Script Console access.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** Remote code execution on the Jenkins controller.\n- **Reproduced impact from this run:** Full controller code execution. Two independent low-privilege HTTP submissions placed attacker-selected Groovy in `JENKINS_HOME/init.groovy.d`; after restarting each controller, Jenkins' real `GroovyHookScript` executed it and created a unique marker.\n- **Parity:** `full`.\n- **Not demonstrated:** Immediate, restart-free command execution was not required or demonstrated. The proven chain requires a later controller restart and the installed File Parameter Plugin 425 gadget. No sanitizer, direct unit harness, administrative token, Script Console, or Item/Configure permission was used for the attack.\n\n## Root Cause\n\nFor collection-valued form fields, Stapler recognizes a JSON object containing `\"stapler-class-bag\": true`. In vulnerable `RequestImpl.TypePair.convertJSON`, it iterates over all object entries, converts each entry key from JSON-safe `-` escaping back to `.`, and performs:\n\n```java\nClass<?> itemType = cl.loadClass(className);\nl.add(bindJSON(itemType, (JSONObject) v));\n```\n\nAlthough the collection lister carries the expected element class as `l.itemType`, the vulnerable code never checks `l.itemType.isAssignableFrom(itemType)` before recursively binding and constructing the attacker-selected class. Type incompatibility is discovered only later, if at all; constructor and setter side effects have already occurred. The low-privilege personal-view endpoint is exploitable because creating a personal view is permitted with Overall/Read and `ListView` has a data-bound `List<ListViewColumn>` property.\n\nThe security fix is Stapler commit [`b49b34c07103fb238a566e51b531729f1b68e73d`](https://github.com/jenkinsci/stapler/commit/b49b34c07103fb238a566e51b531729f1b68e73d) (`SECURITY-3915`). It changes class loading to:\n\n```java\nClass<?> itemType = cl.loadClass(className).asSubclass(l.itemType);\n```\n\nand catches `ClassCastException` together with `ClassNotFoundException`. Consequently, Jenkins 2.580 ignores `StashedFileParameterValue` when binding `ListView.columns` because it is not a `ListViewColumn`, so its constructor never receives the upload and cannot create the startup hook.\n\n## Reproduction Steps\n\n1. Run `bundle/repro/reproduction_steps.sh` with Bash and a working Docker daemon.\n2. The script reads `bundle/project_cache_context.json` when available, otherwise uses `bundle/artifacts/jenkins-security-3915`; downloads checksum-pinned plugins; and uses immutable Jenkins 2.579 and 2.580 image digests.\n3. For each target, it starts clean controller processes with matrix authorization granting `alice` only Overall/Read. It proves the restriction with `GET /script` returning HTTP 403.\n4. It sends an authenticated multipart `POST /user/alice/my-views/createView`. The submitted `ListView.columns.stapler-class-bag` names `StashedFileParameterValue`, and the uploaded file is attacker Groovy whose constructor argument places it at `../../init.groovy.d/cve84647.groovy` relative to the gadget's temporary directory.\n5. It restarts the real Jenkins controller and checks for a unique target-local marker written by that Groovy.\n6. The script performs two clean Jenkins 2.579 attempts and two clean Jenkins 2.580 controls, emits `bundle/repro/runtime_manifest.json`, verifies every bound artifact hash, and exits 0 only for the expected vulnerable/fixed divergence.\n7. Expected terminal output includes:\n\n```text\nCONFIRMED vulnerable attempt 1: low-privilege request executed attacker Groovy after restart\nCONFIRMED vulnerable attempt 2: low-privilege request executed attacker Groovy after restart\nCONFIRMED fixed attempt 1: target path reached but incompatible class constructor did not run\nCONFIRMED fixed attempt 2: target path reached but incompatible class constructor did not run\nCVE-2026-84647 CONFIRMED: two low-privilege production HTTP attempts achieved controller code execution; two fixed controls failed closed.\n```\n\n## Evidence\n\n- `bundle/repro/runtime_manifest.json`: strict runtime manifest with 42 immutable proof artifacts and SHA-256 bindings.\n- `bundle/repro/proof/vulnerable_1.request.json` and `vulnerable_2.request.json`: attacker request shape crossing the actual Jenkins HTTP endpoint.\n- `bundle/repro/proof/vulnerable_1.response.headers` and `vulnerable_2.response.headers`: endpoint responses.\n- `bundle/repro/proof/vulnerable_1.privilege.txt` and `vulnerable_2.privilege.txt`: `GET /script HTTP 403`, proving the attacker did not have Overall/Administer.\n- `bundle/repro/proof/vulnerable_1.result.txt` and `vulnerable_2.result.txt`: each records `payload_script_present=true` and `marker_present=true`.\n- `bundle/repro/proof/vulnerable_1.marker.txt` and `vulnerable_2.marker.txt`: unique controller-local command markers.\n- `bundle/repro/proof/vulnerable_1.service.log` and `vulnerable_2.service.log`: real Jenkins lifecycle logs. Each includes `Starting version 2.579` and `Executing /var/jenkins_home/init.groovy.d/cve84647.groovy` after restart.\n- `bundle/repro/proof/fixed_1.result.txt` and `fixed_2.result.txt`: same endpoint reached on Jenkins 2.580, but `payload_script_present=false` and `marker_present=false`.\n- `bundle/repro/proof/fixed_1.negative_control.json` and `fixed_2.negative_control.json`: explicit target-reached fixed observations.\n- `bundle/repro/proof/fixed_1.service.log` and `fixed_2.service.log`: Jenkins 2.580 starts and restarts without executing the attacker hook.\n- `bundle/logs/reproduction_steps.log`: concise final-run diagnostic summary.\n\nA final vulnerable log excerpt is:\n\n```text\nStarting version 2.579\nExecuting /var/jenkins_home/init.groovy.d/cve84647.groovy\nJenkins is fully up and running\n```\n\nThe corresponding fixed result is:\n\n```text\nPOST /user/alice/my-views/createView HTTP 302\npayload_script_present=false\nmarker_present=false\n```\n\nThe final script was executed twice consecutively. Both complete runs exited 0 (approximately 63 seconds and 58 seconds) and reproduced two vulnerable executions plus two fixed controls on each run.\n\n## Recommendations / Next Steps\n\n- Upgrade Jenkins weekly to 2.580 or newer, or Jenkins LTS to 2.568.3 or newer.\n- Ensure the bundled Stapler version contains commit `b49b34c07103fb238a566e51b531729f1b68e73d` or an equivalent backport.\n- Independently upgrade plugins fixed in the 2026-09-02 advisory. In particular, update File Parameter Plugin to 433.va_0b_80359d54d or newer; this removes the traversal primitive used in the demonstrated composition.\n- Audit `@DataBoundConstructor` and `@DataBoundSetter` methods for side effects and permission checks. Constructors should not write files, initiate network connections, modify global state, or execute commands merely as a consequence of binding.\n- Add regression tests for every structured-form collection syntax, especially `stapler-class-bag`, proving that unrelated classes are rejected before constructors or setters execute.\n- Monitor `JENKINS_HOME/init.groovy.d`, plugin configuration files, and unexpected controller restarts for evidence of persistence. Rotate secrets if compromise is suspected.\n\n## Additional Notes\n\n- **Idempotency:** Confirmed. The final reproducer passed twice consecutively and uses isolated Docker volumes, unique container names, unique marker values, bounded health checks, and cleanup traps.\n- **Runtime boundary:** Production Jenkins HTTP/Stapler endpoint, not a direct parser call or unit harness.\n- **Sanitizers:** None used.\n- **Exploit preconditions:** Authenticated Overall/Read access; an available configuration-related data-bound gadget (File Parameter Plugin 425 in this proof); write access of the Jenkins process to its own home; and a later controller restart for startup-hook execution.\n- **Endpoint behavior:** Both vulnerable and fixed versions return HTTP 302 because the outer personal ListView remains valid. Security is proven by the constructor side effect and marker divergence, not status code alone.\n- **Scope limitation:** The demonstrated file gadget comes from an affected plugin. The root vulnerability itself is in Stapler and permits incompatible class selection; other installed classes may provide different consequences.\n","cve_id":"CVE-2026-84647","cwe_id":"CWE-502","source_url":"https://www.jenkins.io/security/advisory/2026-09-02/","package":{"name":"jenkinsci/jenkins","ecosystem":"github"},"reproduced_at":"2026-09-11T10:13:28.688292+00:00","duration_secs":3062.631057,"tool_calls":422,"handoffs":2,"total_cost_usd":19.621997,"agent_costs":{"claim_matcher":0.035641,"judge":0.424714,"learning_policy":0.009203,"repro":14.058097,"support":0.631497,"vuln_variant":4.462845},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.035641},"judge":{"gpt-5.6-sol":0.424714},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.009203},"repro":{"gpt-5.6-sol":14.058097},"support":{"gpt-5.6-sol":0.631497},"vuln_variant":{"gpt-5.6-sol":4.462845}},"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:29.688245+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":10592,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":15760,"category":"reproduction_script"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":828,"category":"log"},{"path":"bundle/repro/proof/fixed_1.health.after-restart.txt","filename":"fixed_1.health.after-restart.txt","size":47,"category":"other"},{"path":"bundle/repro/proof/fixed_1.health.txt","filename":"fixed_1.health.txt","size":47,"category":"other"},{"path":"bundle/repro/proof/fixed_1.identity.txt","filename":"fixed_1.identity.txt","size":142,"category":"other"},{"path":"bundle/repro/proof/fixed_1.marker.txt","filename":"fixed_1.marker.txt","size":0,"category":"other"},{"path":"bundle/repro/proof/fixed_1.privilege.txt","filename":"fixed_1.privilege.txt","size":69,"category":"other"},{"path":"bundle/repro/proof/fixed_1.request.json","filename":"fixed_1.request.json","size":581,"category":"other"},{"path":"bundle/repro/proof/fixed_1.response.headers","filename":"fixed_1.response.headers","size":461,"category":"other"},{"path":"bundle/repro/proof/fixed_1.result.txt","filename":"fixed_1.result.txt","size":96,"category":"other"},{"path":"bundle/repro/proof/fixed_1.service.before.log","filename":"fixed_1.service.before.log","size":4494,"category":"log"},{"path":"bundle/repro/proof/fixed_1.service.log","filename":"fixed_1.service.log","size":9476,"category":"log"},{"path":"bundle/repro/proof/fixed_2.health.after-restart.txt","filename":"fixed_2.health.after-restart.txt","size":47,"category":"other"},{"path":"bundle/repro/proof/fixed_2.health.txt","filename":"fixed_2.health.txt","size":47,"category":"other"},{"path":"bundle/repro/proof/fixed_2.identity.txt","filename":"fixed_2.identity.txt","size":142,"category":"other"},{"path":"bundle/repro/proof/fixed_2.marker.txt","filename":"fixed_2.marker.txt","size":0,"category":"other"},{"path":"bundle/repro/proof/fixed_2.negative_control.json","filename":"fixed_2.negative_control.json","size":278,"category":"other"},{"path":"bundle/repro/proof/fixed_2.privilege.txt","filename":"fixed_2.privilege.txt","size":69,"category":"other"},{"path":"bundle/repro/proof/fixed_2.request.json","filename":"fixed_2.request.json","size":581,"category":"other"},{"path":"bundle/repro/proof/fixed_2.response.headers","filename":"fixed_2.response.headers","size":461,"category":"other"},{"path":"bundle/repro/proof/fixed_2.result.txt","filename":"fixed_2.result.txt","size":96,"category":"other"},{"path":"bundle/repro/proof/fixed_2.service.before.log","filename":"fixed_2.service.before.log","size":4494,"category":"log"},{"path":"bundle/repro/proof/fixed_2.service.log","filename":"fixed_2.service.log","size":9575,"category":"log"},{"path":"bundle/repro/proof/vulnerable_1.health.after-restart.txt","filename":"vulnerable_1.health.after-restart.txt","size":47,"category":"other"},{"path":"bundle/repro/proof/vulnerable_1.health.txt","filename":"vulnerable_1.health.txt","size":47,"category":"other"},{"path":"bundle/repro/proof/vulnerable_1.identity.txt","filename":"vulnerable_1.identity.txt","size":142,"category":"other"},{"path":"bundle/repro/proof/vulnerable_1.privilege.txt","filename":"vulnerable_1.privilege.txt","size":69,"category":"other"},{"path":"bundle/repro/proof/vulnerable_1.result.txt","filename":"vulnerable_1.result.txt","size":94,"category":"other"},{"path":"bundle/repro/proof/vulnerable_1.service.before.log","filename":"vulnerable_1.service.before.log","size":4491,"category":"log"},{"path":"bundle/repro/proof/vulnerable_2.health.after-restart.txt","filename":"vulnerable_2.health.after-restart.txt","size":47,"category":"other"},{"path":"bundle/repro/proof/vulnerable_2.health.txt","filename":"vulnerable_2.health.txt","size":47,"category":"other"},{"path":"bundle/repro/proof/vulnerable_2.identity.txt","filename":"vulnerable_2.identity.txt","size":142,"category":"other"},{"path":"bundle/repro/proof/vulnerable_2.privilege.txt","filename":"vulnerable_2.privilege.txt","size":69,"category":"other"},{"path":"bundle/repro/proof/vulnerable_2.response.headers","filename":"vulnerable_2.response.headers","size":466,"category":"other"},{"path":"bundle/repro/proof/vulnerable_2.result.txt","filename":"vulnerable_2.result.txt","size":94,"category":"other"},{"path":"bundle/repro/proof/vulnerable_2.service.before.log","filename":"vulnerable_2.service.before.log","size":4492,"category":"log"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":7673,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1570,"category":"other"}]}