# 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_<token>.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=<controller>` 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.
