{"repro_id":"REPRO-2026-00322","version":6,"title":"Jenkins Remoting JEP-200 deserialization filter bypass (SECURITY-3911): unfiltered ClassNotFoundException fallback in MultiClassLoaderSerializer.resolveClass and ObjectInputStreamEx.resolveClass lets agents deserialize blocked core-classpath classes on the controller","repro_type":"security","status":"published","severity":"critical","description":"Agent-to-controller deserialization filter bypass (JEP-200) in Jenkins Remoting (SECURITY-3911).","root_cause":"# RCA: Jenkins Remoting SECURITY-3911 / CVE-2026-70426 — JEP-200 Deserialization Filter Bypass\n\n## Summary\n\nJenkins Remoting (agent–controller communication library) contains two unfiltered\n`ClassNotFoundException` fallback paths in its deserialization class-resolution code. In\n`hudson.remoting.MultiClassLoaderSerializer.Input.resolveClass()` (and identically in\n`hudson.remoting.ObjectInputStreamEx.resolveClass()`), the primary path resolves the\nincoming class name against the channel-annotated classloader and then applies the JEP-200\nclass filter (`channel.classFilter.check(c)`). When that lookup throws\n`ClassNotFoundException`, the fallback `super.resolveClass(desc)` resolved the class via the\nreceiving JVM's own classloader **without applying the class filter**. An attacker able to\nspeak the agent protocol (Agent/Connect permission, a compromised agent, or code running on\nan agent) can therefore deserialize a JEP-200-blocked class on the Jenkins controller by\nserializing it with a spoofed `TAG_SYSTEMCLASSLOADER` annotation, forcing the\n`ClassNotFoundException` fallback. The blocked class's `readObject` then executes in the\ncontroller JVM, yielding remote code execution on the controller.\n\n## Impact\n\n- Package/component: `org.jenkins-ci.main:remoting` (Jenkins Remoting), embedded in Jenkins\n  core (`jenkins.war`).\n- Affected: Remoting <= 3384.v60d89463d9e0 (except backport 3355.3357.v931d3c992987);\n  Jenkins weekly <= 2.575; LTS <= 2.568.1.\n- Fixed: Remoting 3385.vf1123fb_515da_ (Jenkins 2.576 / LTS 2.568.2).\n- Risk: Critical (CVSS 3.1 9.6, AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H) — agent-to-controller\n  RCE. AC:H because the gadget class must live on the controller's core classpath and evade\n  the pre-JEP-200 static denylist (which this bypass does not defeat).\n\n## Impact Parity\n\n- Disclosed/claimed maximum impact: arbitrary code execution on the Jenkins controller from\n  the agent side of a Remoting channel (RCE).\n- Reproduced impact from this run: **OS command execution on the Jenkins controller JVM**\n  across the real JNLP4-connect (TCP) channel, proven twice by two independent direct\n  OS-level observations per attempt: (1) `/bin/sh -c` executed on the controller writing a\n  marker file with `id`/`hostname` output inside the controller container\n  (`uid=0(root) ... jvm_pid=...`), and (2) an outbound TCP callback from the controller JVM\n  to an attacker-controlled listener carrying the per-attempt token and the controller\n  hostname.\n- Parity: `full`.\n- Not demonstrated: nothing material; the ClassCastException after `readObject` detonation is\n  an artifact of the minimal PoC gadget (Serializable-only), not a limitation of the bypass.\n  A real-world exploit would use a gadget class already on the core classpath whose\n  `readObject` performs the malicious action (exactly the pattern this PoC models).\n\n## Root Cause\n\n`src/main/java/hudson/remoting/MultiClassLoaderSerializer.java` (vulnerable line 137):\n\n```java\n} catch (ClassNotFoundException ex) {\n    return super.resolveClass(desc);        // <-- no channel.classFilter.check(...)\n}\n```\n\nand identically `src/main/java/hudson/remoting/ObjectInputStreamEx.java` line 64:\n\n```java\n} catch (ClassNotFoundException ex) {\n    return super.resolveClass(desc);        // <-- no filter.check(...)\n}\n```\n\nThe name-based check `channel.classFilter.check(name)` still runs first, so classes on the\npre-JEP-200 static denylist (e.g. commons-collections functors) remain blocked; but the\n*class-level* JEP-200 check (`jenkins.security.ClassFilterImpl.isBlacklisted(Class)`), which\nrejects classes whose code location is not Jenkins core/Remoting/a plugin and which are not\nin `whitelisted-classes.txt`, is skipped on the fallback path. An attacker forces the\nfallback by writing `TAG_SYSTEMCLASSLOADER` (-3) as the class annotation: the receiver then\ntries `Class.forName(name, false, null)` (bootstrap loader), which throws\n`ClassNotFoundException` for any non-JDK class, and the fallback resolves the class through\nthe deserializing frame's classloader (on a real controller, the Jetty webapp classloader,\nwhich can see the whole core classpath and delegates to the application classloader) —\ncompletely bypassing the JEP-200 decision.\n\nFix commit: `f1123fb515da74560db60645539019cfa77bce49` (jenkinsci/remoting, released as\n3385.vf1123fb_515da_): both fallbacks became\n`return channel.classFilter.check(super.resolveClass(desc));` /\n`return filter.check(super.resolveClass(desc));`. Diff captured in\n`bundle/repro/security3911-fix.diff`; vulnerable/fixed source lines captured in\n`bundle/logs/vuln_unfiltered_fallback.txt` and `bundle/logs/fixed_filtered_fallback.txt`.\n\n## Reproduction Steps\n\n1. `bundle/repro/reproduction_steps.sh` (self-contained; uses only Docker images\n   `jenkins/jenkins:2.575-jdk21`, `jenkins/jenkins:2.576-jdk21`, `maven:3.9-eclipse-temurin-21`,\n   `alpine:3.22` plus the harness sources in `bundle/repro/harness/`).\n2. What it does:\n   - Records the image digests and the war manifests (`Remoting-Embedded-Version`:\n     3384.v60d89463d9e0 for 2.575, 3385.vf1123fb_515da_ for 2.576) and the real fix-commit\n     diff from a jenkinsci/remoting checkout (verifying the exact vulnerable and fixed lines).\n   - Extracts the byte-identical `remoting-3384.v60d89463d9e0.jar` from the vulnerable war and\n     compiles the PoC gadget (`hudson.security3911.Payload`, Serializable, `readObject`\n     executes `/bin/sh` + outbound callback) into `payload.jar`, and the attack agent.\n   - For each of two vulnerable and two fixed attempts: boots a **real Jenkins controller**\n     (fresh `JENKINS_HOME`, setup wizard disabled, inbound-agent TCP listener on port 50000,\n     production `JnlpSlaveAgentProtocol4` accept path) with `payload.jar` on the controller's\n     own launch classpath (a harness jar, as sanctioned by the ticket's reproduction\n     requirements — the filter, channel, and `UserRequest.deserialize` path are 100% product\n     code). `init.groovy.d` creates the `agent1` inbound node, prints its JNLP secret, and\n     proves the production JEP-200 filter identity:\n     `SECURITY3911_CHANNEL_DEFAULT_FILTER=jenkins.security.ClassFilterImpl` and\n     `SECURITY3911_FILTER_PROBE=REJECTED: Rejected: hudson.security3911.Payload`\n     (i.e. the production filter blocks the payload class at class level on both builds).\n   - The attack agent performs the **real JNLP4-connect handshake** using the production\n     negotiation classes (`JnlpAgentEndpoint`, `JnlpProtocolHandlerFactory`,\n     `JnlpProtocol4Handler`, `IOHub`, `PublicKeyMatchingX509ExtendedTrustManager`), builds a\n     genuine `hudson.remoting.UserRequest`, replaces its serialized request bytes with bytes\n     produced by a `SpoofedTagSystemClassLoaderOutput` (exactly the regression-test helper\n     from the fix commit), and sends it over the established channel. The controller's\n     `DefaultJnlpSlaveReceiver.afterChannel`/`SlaveComputer.setChannel` production flow runs\n     on the same connection.\n3. Expected evidence:\n   - Vulnerable (2.575): `Payload.readObject` executes on the controller during\n     `UserRequest.deserialize` → marker file `/tmp/CONTROLLER_PWNED_<token>.txt` inside the\n     controller container (contents include `uid=0(root)` and the controller hostname) AND an\n     outbound callback `SECURITY3911_CALLBACK token=... phase=readObject\n     controller_host=<controller>` received by the attacker's listener.\n   - Fixed (2.576): `SecurityException: Rejected: hudson.security3911.Payload; see\n     https://jenkins.io/redirect/class-filter/` returned over the channel; no marker, no\n     callback.\n\n## Evidence\n\n- Full run log: `bundle/logs/reproduction_steps.log` (two consecutive full runs, both\n  `OVERALL=0`, exit code 0).\n- Per-attempt controller and agent logs: `bundle/logs/attempt-{vuln,fixed}-{1,2}/{controller,agent}.log`.\n- Direct OS-execution markers (collected from inside the controller containers via\n  `docker exec`): `bundle/logs/attempt-vuln-1/CONTROLLER_PWNED_security3911-vuln-1-*.txt` and\n  `bundle/logs/attempt-vuln-2/CONTROLLER_PWNED_security3911-vuln-2-*.txt`, e.g.:\n\n  ```\n  SECURITY3911_MARKER token=security3911-vuln-1-1786004490 phase=readObject\n  uid=0(root) gid=0(root) groups=0(root)\n  dc3492613861            <- controller container hostname\n  jvm_pid=100\n  ```\n- Outbound callback (agent log): `CALLBACK_RECEIVED from=/172.19.0.2:38064\n  msg=SECURITY3911_CALLBACK token=security3911-vuln-1-1786004490 phase=readObject\n  controller_host=dc3492613861` — the source IP and hostname belong to the controller\n  container, proving the controller JVM executed attacker code and dialed out.\n- Vulnerable-channel exception (expected, post-detonation):\n  `ClassCastException: class hudson.security3911.Payload cannot be cast to class\n  hudson.remoting.Callable (hudson.security3911.Payload is in unnamed module of loader\n  'app'; ...)` — confirms the fallback resolved the class via the controller-side loader.\n- Fixed-channel rejection: `EXCEPTION=Error: Failed to deserialize the Callable object. <-\n  SecurityException: Rejected: hudson.security3911.Payload; see\n  https://jenkins.io/redirect/class-filter/` with `CALLBACK_COUNT=0` and no marker.\n- Production filter provenance (controller log, both builds):\n  `SECURITY3911_CHANNEL_DEFAULT_FILTER=jenkins.security.ClassFilterImpl`,\n  `SECURITY3911_FILTER_PROBE=REJECTED: Rejected: hudson.security3911.Payload`, and the\n  JUL line `jenkins.security.ClassFilterImpl#notifyRejected: hudson.security3911.Payload in\n  file:/opt/harness/payload.jar might be dangerous, so rejecting`.\n- Negative control: `bundle/repro/negative_control_observation.json` (both fixed attempts:\n  rejection observed, `command_executed=false`, zero callbacks, zero marker files).\n- Environment: Docker 29.1.3; `jenkins/jenkins:2.575-jdk21`\n  (`@sha256:16778c994cfc...`, Remoting 3384.v60d89463d9e0) and\n  `jenkins/jenkins:2.576-jdk21` (`@sha256:8c1c7e28b463...`, Remoting 3385.vf1123fb_515da_);\n  OpenJDK 21 controllers/agents; harness SHA-256s in `bundle/logs/harness_sha256.txt`.\n\n## Recommendations / Next Steps\n\n- Upgrade to Jenkins 2.576 / LTS 2.568.2 (Remoting 3385.vf1123fb_515da_) or the\n  3355.3357.v931d3c992987 backport line.\n- The fix is correct and minimal: route both `ClassNotFoundException` fallbacks through the\n  channel's class filter, mirroring the primary path.\n- Defense in depth: restrict Agent/Connect, isolate agents, and monitor for\n  `Rejected: ... class-filter` log lines plus unexpected outbound connections from the\n  controller.\n- Testing: the fix commit's regression tests (`ClassFilterTest`\n  `multiClassLoaderSerializer_spoofedSystemClassLoader_isRejected` and\n  `objectInputStreamEx_emptyClassLoader_fallbackIsFiltered`) cover both fallbacks; this run\n  reproduces the same spoof over the real JNLP4/TCP production path.\n\n## Additional Notes\n\n- Idempotency: the script runs two clean attempts per role with fresh `JENKINS_HOME` per\n  attempt, unique per-attempt tokens, a fresh Docker network per run, and full container\n  cleanup; two consecutive end-to-end runs both passed (`OVERALL=0`).\n- The second vulnerable fallback (`ObjectInputStreamEx.resolveClass`) is exercised when the\n  remote peer does not advertise multi-classloader RPC capability; it is fixed by the same\n  commit and follows the identical pattern (documented in the diff); the primary proof uses\n  the `MultiClassLoaderSerializer` path, which is the default for JNLP4 agent channels.\n- The harness payload class implements only `Serializable` (not `hudson.remoting.Callable`)\n  because the controller's webapp/application classloader split would otherwise fail linking;\n  this mirrors a real gadget whose effect lives in `readObject`, and it makes the proof\n  stronger: execution happens during deserialization, before any `Callable` cast or\n  arbitrary-callable permission check.\n- Fresh Jenkins installs do not auto-install bundled detached plugins; the script installs\n  the war-bundled `instance-identity.hpi` + `bouncycastle-api.hpi` into `JENKINS_HOME/plugins`\n  (what the setup wizard would do) so the production JNLP4 TLS listener is functional.\n","cve_id":"CVE-2026-70426","cwe_id":"CWE-502","source_url":"https://www.jenkins.io/security/advisory/2026-08-05/","reproduced_at":"2026-08-23T15:38:07.273270+00:00","duration_secs":9763.0,"tool_calls":355,"handoffs":2,"total_cost_usd":17.334643,"agent_costs":{"claim_matcher":0.026649,"judge":0.838936,"learning_policy":0.020036,"repro":12.40586,"support":0.050056,"vuln_variant":3.993106},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.026649},"judge":{"gpt-5.6-sol":0.838936},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.020036},"repro":{"accounts/fireworks/models/kimi-k3":12.40586},"support":{"accounts/fireworks/models/kimi-k3":0.050056},"vuln_variant":{"accounts/fireworks/models/kimi-k3":3.993106}},"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-08-23T15:38:08.067474+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":12146,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":16534,"category":"reproduction_script"},{"path":"bundle/logs/attempt-fixed-1/controller.log","filename":"controller.log","size":4979,"category":"log"},{"path":"bundle/logs/attempt-fixed-2/agent.log","filename":"agent.log","size":875,"category":"log"},{"path":"bundle/logs/attempt-fixed-2/controller.log","filename":"controller.log","size":4979,"category":"log"},{"path":"bundle/logs/attempt-vuln-1/CONTROLLER_PWNED_security3911-vuln-1-1786004490.txt","filename":"CONTROLLER_PWNED_security3911-vuln-1-1786004490.txt","size":138,"category":"other"},{"path":"bundle/logs/attempt-vuln-2/CONTROLLER_PWNED_security3911-vuln-2-1786004490.txt","filename":"CONTROLLER_PWNED_security3911-vuln-2-1786004490.txt","size":138,"category":"other"},{"path":"bundle/logs/attempt-vuln-2/agent.log","filename":"agent.log","size":1150,"category":"log"},{"path":"bundle/logs/attempt-vuln-2/controller.log","filename":"controller.log","size":9910,"category":"log"},{"path":"bundle/logs/attempt_results.txt","filename":"attempt_results.txt","size":176,"category":"other"},{"path":"bundle/logs/fixed_filtered_fallback.txt","filename":"fixed_filtered_fallback.txt","size":80,"category":"other"},{"path":"bundle/logs/fixed_war_manifest.txt","filename":"fixed_war_manifest.txt","size":104,"category":"other"},{"path":"bundle/logs/harness_sha256.txt","filename":"harness_sha256.txt","size":342,"category":"other"},{"path":"bundle/logs/vuln_unfiltered_fallback.txt","filename":"vuln_unfiltered_fallback.txt","size":53,"category":"other"},{"path":"bundle/logs/vuln_war_manifest.txt","filename":"vuln_war_manifest.txt","size":102,"category":"other"},{"path":"bundle/repro/harness/agent/AttackAgent.java","filename":"AttackAgent.java","size":14476,"category":"other"},{"path":"bundle/repro/harness/agent/DummyCallable.java","filename":"DummyCallable.java","size":668,"category":"other"},{"path":"bundle/repro/harness/init.groovy.d/01-agent.groovy","filename":"01-agent.groovy","size":2100,"category":"other"},{"path":"bundle/repro/harness/payload/hudson/security3911/Payload.java","filename":"Payload.java","size":3685,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":2660,"category":"other"},{"path":"bundle/repro/security3911-fix.diff","filename":"security3911-fix.diff","size":7123,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1204,"category":"other"}]}