# REPRO-2026-00322: 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 ## Summary Status: published Severity: critical CVSS: Unknown CWE: CWE-502 (Deserialization of Untrusted Data) Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00322 CVE: CVE-2026-70426 ## Package Name: Unknown Ecosystem: Unknown Affected: Unknown Fixed: Unknown ## Root Cause # RCA: Jenkins Remoting SECURITY-3911 / CVE-2026-70426 — JEP-200 Deserialization Filter Bypass ## Summary Jenkins Remoting (agent–controller communication library) contains two unfiltered `ClassNotFoundException` fallback paths in its deserialization class-resolution code. In `hudson.remoting.MultiClassLoaderSerializer.Input.resolveClass()` (and identically in `hudson.remoting.ObjectInputStreamEx.resolveClass()`), the primary path resolves the incoming class name against the channel-annotated classloader and then applies the JEP-200 class filter (`channel.classFilter.check(c)`). When that lookup throws `ClassNotFoundException`, the fallback `super.resolveClass(desc)` resolved the class via the receiving JVM's own classloader **without applying the class filter**. An attacker able to speak the agent protocol (Agent/Connect permission, a compromised agent, or code running on an agent) can therefore deserialize a JEP-200-blocked class on the Jenkins controller by serializing it with a spoofed `TAG_SYSTEMCLASSLOADER` annotation, forcing the `ClassNotFoundException` fallback. The blocked class's `readObject` then executes in the controller JVM, yielding remote code execution on the controller. ## Impact - Package/component: `org.jenkins-ci.main:remoting` (Jenkins Remoting), embedded in Jenkins core (`jenkins.war`). - Affected: Remoting <= 3384.v60d89463d9e0 (except backport 3355.3357.v931d3c992987); Jenkins weekly <= 2.575; LTS <= 2.568.1. - Fixed: Remoting 3385.vf1123fb_515da_ (Jenkins 2.576 / LTS 2.568.2). - 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 RCE. AC:H because the gadget class must live on the controller's core classpath and evade the pre-JEP-200 static denylist (which this bypass does not defeat). ## Impact Parity - Disclosed/claimed maximum impact: arbitrary code execution on the Jenkins controller from the agent side of a Remoting channel (RCE). - Reproduced impact from this run: **OS command execution on the Jenkins controller JVM** across the real JNLP4-connect (TCP) channel, proven twice by two independent direct OS-level observations per attempt: (1) `/bin/sh -c` executed on the controller writing a marker file with `id`/`hostname` output inside the controller container (`uid=0(root) ... jvm_pid=...`), and (2) an outbound TCP callback from the controller JVM to an attacker-controlled listener carrying the per-attempt token and the controller hostname. - Parity: `full`. - Not demonstrated: nothing material; the ClassCastException after `readObject` detonation is an artifact of the minimal PoC gadget (Serializable-only), not a limitation of the bypass. A real-world exploit would use a gadget class already on the core classpath whose `readObject` performs the malicious action (exactly the pattern this PoC models). ## Root Cause `src/main/java/hudson/remoting/MultiClassLoaderSerializer.java` (vulnerable line 137): ```java } catch (ClassNotFoundException ex) { return super.resolveClass(desc); // <-- no channel.classFilter.check(...) } ``` and identically `src/main/java/hudson/remoting/ObjectInputStreamEx.java` line 64: ```java } catch (ClassNotFoundException ex) { return super.resolveClass(desc); // <-- no filter.check(...) } ``` The name-based check `channel.classFilter.check(name)` still runs first, so classes on the pre-JEP-200 static denylist (e.g. commons-collections functors) remain blocked; but the *class-level* JEP-200 check (`jenkins.security.ClassFilterImpl.isBlacklisted(Class)`), which rejects classes whose code location is not Jenkins core/Remoting/a plugin and which are not in `whitelisted-classes.txt`, is skipped on the fallback path. An attacker forces the fallback by writing `TAG_SYSTEMCLASSLOADER` (-3) as the class annotation: the receiver then tries `Class.forName(name, false, null)` (bootstrap loader), which throws `ClassNotFoundException` for any non-JDK class, and the fallback resolves the class through the deserializing frame's classloader (on a real controller, the Jetty webapp classloader, which can see the whole core classpath and delegates to the application classloader) — completely bypassing the JEP-200 decision. Fix commit: `f1123fb515da74560db60645539019cfa77bce49` (jenkinsci/remoting, released as 3385.vf1123fb_515da_): both fallbacks became `return channel.classFilter.check(super.resolveClass(desc));` / `return filter.check(super.resolveClass(desc));`. Diff captured in `bundle/repro/security3911-fix.diff`; vulnerable/fixed source lines captured in `bundle/logs/vuln_unfiltered_fallback.txt` and `bundle/logs/fixed_filtered_fallback.txt`. ## Reproduction Steps 1. `bundle/repro/reproduction_steps.sh` (self-contained; uses only Docker images `jenkins/jenkins:2.575-jdk21`, `jenkins/jenkins:2.576-jdk21`, `maven:3.9-eclipse-temurin-21`, `alpine:3.22` plus the harness sources in `bundle/repro/harness/`). 2. What it does: - Records the image digests and the war manifests (`Remoting-Embedded-Version`: 3384.v60d89463d9e0 for 2.575, 3385.vf1123fb_515da_ for 2.576) and the real fix-commit diff from a jenkinsci/remoting checkout (verifying the exact vulnerable and fixed lines). - Extracts the byte-identical `remoting-3384.v60d89463d9e0.jar` from the vulnerable war and compiles the PoC gadget (`hudson.security3911.Payload`, Serializable, `readObject` executes `/bin/sh` + outbound callback) into `payload.jar`, and the attack agent. - For each of two vulnerable and two fixed attempts: boots a **real Jenkins controller** (fresh `JENKINS_HOME`, setup wizard disabled, inbound-agent TCP listener on port 50000, production `JnlpSlaveAgentProtocol4` accept path) with `payload.jar` on the controller's own launch classpath (a harness jar, as sanctioned by the ticket's reproduction requirements — the filter, channel, and `UserRequest.deserialize` path are 100% product code). `init.groovy.d` creates the `agent1` inbound node, prints its JNLP secret, and proves the production JEP-200 filter identity: `SECURITY3911_CHANNEL_DEFAULT_FILTER=jenkins.security.ClassFilterImpl` and `SECURITY3911_FILTER_PROBE=REJECTED: Rejected: hudson.security3911.Payload` (i.e. the production filter blocks the payload class at class level on both builds). - The attack agent performs the **real JNLP4-connect handshake** using the production negotiation classes (`JnlpAgentEndpoint`, `JnlpProtocolHandlerFactory`, `JnlpProtocol4Handler`, `IOHub`, `PublicKeyMatchingX509ExtendedTrustManager`), builds a genuine `hudson.remoting.UserRequest`, replaces its serialized request bytes with bytes produced by a `SpoofedTagSystemClassLoaderOutput` (exactly the regression-test helper from the fix commit), and sends it over the established channel. The controller's `DefaultJnlpSlaveReceiver.afterChannel`/`SlaveComputer.setChannel` production flow runs on the same connection. 3. Expected evidence: - Vulnerable (2.575): `Payload.readObject` executes on the controller during `UserRequest.deserialize` → marker file `/tmp/CONTROLLER_PWNED_.txt` inside the controller container (contents include `uid=0(root)` and the controller hostname) AND an outbound callback `SECURITY3911_CALLBACK token=... phase=readObject controller_host=` received by the attacker's listener. - Fixed (2.576): `SecurityException: Rejected: hudson.security3911.Payload; see https://jenkins.io/redirect/class-filter/` returned over the channel; no marker, no callback. ## Evidence - Full run log: `bundle/logs/reproduction_steps.log` (two consecutive full runs, both `OVERALL=0`, exit code 0). - Per-attempt controller and agent logs: `bundle/logs/attempt-{vuln,fixed}-{1,2}/{controller,agent}.log`. - Direct OS-execution markers (collected from inside the controller containers via `docker exec`): `bundle/logs/attempt-vuln-1/CONTROLLER_PWNED_security3911-vuln-1-*.txt` and `bundle/logs/attempt-vuln-2/CONTROLLER_PWNED_security3911-vuln-2-*.txt`, e.g.: ``` SECURITY3911_MARKER token=security3911-vuln-1-1786004490 phase=readObject uid=0(root) gid=0(root) groups=0(root) dc3492613861 <- controller container hostname jvm_pid=100 ``` - Outbound callback (agent log): `CALLBACK_RECEIVED from=/172.19.0.2:38064 msg=SECURITY3911_CALLBACK token=security3911-vuln-1-1786004490 phase=readObject controller_host=dc3492613861` — the source IP and hostname belong to the controller container, proving the controller JVM executed attacker code and dialed out. - Vulnerable-channel exception (expected, post-detonation): `ClassCastException: class hudson.security3911.Payload cannot be cast to class hudson.remoting.Callable (hudson.security3911.Payload is in unnamed module of loader 'app'; ...)` — confirms the fallback resolved the class via the controller-side loader. - Fixed-channel rejection: `EXCEPTION=Error: Failed to deserialize the Callable object. <- SecurityException: Rejected: hudson.security3911.Payload; see https://jenkins.io/redirect/class-filter/` with `CALLBACK_COUNT=0` and no marker. - Production filter provenance (controller log, both builds): `SECURITY3911_CHANNEL_DEFAULT_FILTER=jenkins.security.ClassFilterImpl`, `SECURITY3911_FILTER_PROBE=REJECTED: Rejected: hudson.security3911.Payload`, and the JUL line `jenkins.security.ClassFilterImpl#notifyRejected: hudson.security3911.Payload in file:/opt/harness/payload.jar might be dangerous, so rejecting`. - Negative control: `bundle/repro/negative_control_observation.json` (both fixed attempts: rejection observed, `command_executed=false`, zero callbacks, zero marker files). - Environment: Docker 29.1.3; `jenkins/jenkins:2.575-jdk21` (`@sha256:16778c994cfc...`, Remoting 3384.v60d89463d9e0) and `jenkins/jenkins:2.576-jdk21` (`@sha256:8c1c7e28b463...`, Remoting 3385.vf1123fb_515da_); OpenJDK 21 controllers/agents; harness SHA-256s in `bundle/logs/harness_sha256.txt`. ## Recommendations / Next Steps - Upgrade to Jenkins 2.576 / LTS 2.568.2 (Remoting 3385.vf1123fb_515da_) or the 3355.3357.v931d3c992987 backport line. - The fix is correct and minimal: route both `ClassNotFoundException` fallbacks through the channel's class filter, mirroring the primary path. - Defense in depth: restrict Agent/Connect, isolate agents, and monitor for `Rejected: ... class-filter` log lines plus unexpected outbound connections from the controller. - Testing: the fix commit's regression tests (`ClassFilterTest` `multiClassLoaderSerializer_spoofedSystemClassLoader_isRejected` and `objectInputStreamEx_emptyClassLoader_fallbackIsFiltered`) cover both fallbacks; this run reproduces the same spoof over the real JNLP4/TCP production path. ## Additional Notes - Idempotency: the script runs two clean attempts per role with fresh `JENKINS_HOME` per attempt, unique per-attempt tokens, a fresh Docker network per run, and full container cleanup; two consecutive end-to-end runs both passed (`OVERALL=0`). - The second vulnerable fallback (`ObjectInputStreamEx.resolveClass`) is exercised when the remote peer does not advertise multi-classloader RPC capability; it is fixed by the same commit and follows the identical pattern (documented in the diff); the primary proof uses the `MultiClassLoaderSerializer` path, which is the default for JNLP4 agent channels. - The harness payload class implements only `Serializable` (not `hudson.remoting.Callable`) because the controller's webapp/application classloader split would otherwise fail linking; this mirrors a real gadget whose effect lives in `readObject`, and it makes the proof stronger: execution happens during deserialization, before any `Callable` cast or arbitrary-callable permission check. - Fresh Jenkins installs do not auto-install bundled detached plugins; the script installs the war-bundled `instance-identity.hpi` + `bouncycastle-api.hpi` into `JENKINS_HOME/plugins` (what the setup wizard would do) so the production JNLP4 TLS listener is functional. ## Reproduction Details Reproduced: 2026-08-23T15:38:07.273Z Duration: 9763 seconds Tool calls: 355 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00322 pruva-verify CVE-2026-70426 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00322&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00322/artifacts/bundle/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-70426 - Source: https://www.jenkins.io/security/advisory/2026-08-05/ ## Artifacts - bundle/repro/rca_report.md (analysis, 12146 bytes) - bundle/repro/reproduction_steps.sh (reproduction_script, 16534 bytes) - bundle/logs/attempt-fixed-1/controller.log (log, 4979 bytes) - bundle/logs/attempt-fixed-2/agent.log (log, 875 bytes) - bundle/logs/attempt-fixed-2/controller.log (log, 4979 bytes) - bundle/logs/attempt-vuln-1/CONTROLLER_PWNED_security3911-vuln-1-1786004490.txt (other, 138 bytes) - bundle/logs/attempt-vuln-2/CONTROLLER_PWNED_security3911-vuln-2-1786004490.txt (other, 138 bytes) - bundle/logs/attempt-vuln-2/agent.log (log, 1150 bytes) - bundle/logs/attempt-vuln-2/controller.log (log, 9910 bytes) - bundle/logs/attempt_results.txt (other, 176 bytes) - bundle/logs/fixed_filtered_fallback.txt (other, 80 bytes) - bundle/logs/fixed_war_manifest.txt (other, 104 bytes) - bundle/logs/harness_sha256.txt (other, 342 bytes) - bundle/logs/vuln_unfiltered_fallback.txt (other, 53 bytes) - bundle/logs/vuln_war_manifest.txt (other, 102 bytes) - bundle/repro/harness/agent/AttackAgent.java (other, 14476 bytes) - bundle/repro/harness/agent/DummyCallable.java (other, 668 bytes) - bundle/repro/harness/init.groovy.d/01-agent.groovy (other, 2100 bytes) - bundle/repro/harness/payload/hudson/security3911/Payload.java (other, 3685 bytes) - bundle/repro/runtime_manifest.json (other, 2660 bytes) - bundle/repro/security3911-fix.diff (other, 7123 bytes) - bundle/repro/validation_verdict.json (other, 1204 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00322 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00322/artifacts/bundle/repro/reproduction_steps.sh - Web: https://www.pruva.dev/reproductions/REPRO-2026-00322 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://www.pruva.dev