{"repro_id":"REPRO-2026-00367","version":6,"title":"Jenkins stored XSS in system log viewer via agent log output (SECURITY-3476)","repro_type":"security","status":"published","severity":"high","description":"From Jenkins Security Advisory 2026-09-02 (SECURITY-3476): Jenkins renders agent log output in the system log viewer without proper HTML escaping. An attacker who can control agent log output (e.g., via a malicious agent, or unauthenticated agent connection in some configs) can inject stored XSS payloads that execute when an administrator views the system log. This is a stored XSS vulnerability that can be used to hijack administrator sessions.","root_cause":"# RCA Report — CVE-2026-84648 (SECURITY-3967): Jenkins stored XSS in system log viewer via agent log output\n\n## Summary\n\nJenkins 2.579 and earlier (LTS 2.568.2 and earlier) renders log record metadata — source, level, and timestamp — in the system log viewer without HTML escaping. An attacker who controls an agent process can publish a `java.util.logging.LogRecord` with an attacker-controlled `sourceClassName` (e.g. `<svg/onload=…>`). That record is captured in the agent-side ring buffer (`SlaveComputer.LogHolder.SLAVE_LOG_HANDLER`, attached agent-side to the `hudson.slaves.SlaveComputer` logger by `SlaveInitializer` during channel setup), fetched over the remoting channel when an administrator opens a log recorder page (`/log/<name>/`), and rendered raw into the HTML. This is a stored XSS in the administrator's session context. Jenkins 2.580 / LTS 2.568.3 fixes it by escaping the metadata with `Util.xmlEscape`.\n\n## Impact\n\n- Package/component: Jenkins core (`hudson.Functions#printLogRecordHtml`, rendered by `lib/hudson/logRecords.jelly` on `hudson.logging.LogRecorder` pages).\n- Affected versions: Jenkins ≤ 2.579, LTS ≤ 2.568.2.\n- Risk: High (CVSS 8.8, AV:N/AC:L/PR:N/UI:R). Stored XSS in the system log viewer executes in an administrator's authenticated browser session, enabling session hijack and full controller compromise (e.g. crumb theft → `/scriptText` Groovy RCE chain legs CVE-2026-84649 / CVE-2026-84645).\n- Attacker precondition: control of an agent process (malicious/compromised agent). The victim must open a log recorder page that targets the agent log namespace.\n\n## Impact Parity\n\n- Disclosed/claimed maximum impact: stored XSS executing in an administrator's session (session hijack; stepping stone to RCE).\n- Reproduced impact from this run: (see Evidence — filled from the runtime proof) unescaped attacker payload rendered on the real `/log/agentlog/` page of Jenkins 2.579 served to an authenticated admin; headless-Chromium admin session executed the injected script, which exfiltrated the authenticated same-origin `/whoAmI/api/json` response to an attacker-controlled beacon. Jenkins 2.580 negative control renders the payload escaped and no beacon callback occurs.\n- Parity: full.\n- Not demonstrated: the downstream RCE chain legs (CVE-2026-84649 crumb theft, CVE-2026-84645 deserialization RCE) are separate tickets and out of scope here.\n\n## Root Cause\n\n`core/src/main/java/hudson/Functions.java` (Jenkins 2.579), `printLogRecordHtml(LogRecord r, LogRecord prior)`:\n\n```java\nString[] oldParts = prior == null ? new String[4] : logRecordPreformat(prior);\nString[] newParts = logRecordPreformat(r);\nfor (int i = 0; i < /* not 4 */3; i++) {\n    newParts[i] = \"<span class='\" + (newParts[i].equals(oldParts[i]) ? \"logrecord-metadata-old\" : \"logrecord-metadata-new\") + \"'>\" + newParts[i] + \"</span>\";\n}\nnewParts[3] = Util.xmlEscape(newParts[3]);\n```\n\nOnly `parts[3]` (the message) is escaped. `parts[0]` (timestamp), `parts[1]` (source = `sourceClassName` [+ `sourceMethodName`], or `loggerName` when `sourceClassName == null`), and `parts[2]` (level) are concatenated into raw HTML. `lib/hudson/logRecords.jelly` then emits them with `<j:out value=\"${parts[0..2]}\"/>`, which outputs raw (unescaped) HTML. A `LogRecord` whose `sourceClassName` is already set is not overwritten by `Logger.log(LogRecord)`, so an attacker JVM fully controls this field.\n\nDelivery path from the agent: `SlaveComputer.SlaveInitializer` (a `MasterToSlaveCallable` sent during `setChannel`) installs `LogHolder.SLAVE_LOG_HANDLER` (a `RingBufferLogHandler`) on the `hudson.slaves.SlaveComputer` logger inside the agent JVM. When an administrator views a `LogRecorder` page whose targets include that namespace, `LogRecorder.getSlaveLogRecords()` calls `SlaveComputer.getLogRecords()` → `SlaveLogFetcher` callable over the remoting channel → returns the agent ring buffer → records are rendered via the vulnerable function.\n\nFix (Jenkins 2.580): the same loop becomes\n\n```java\nString cls = newParts[i].equals(oldParts[i]) ? \"logrecord-metadata-old\" : \"logrecord-metadata-new\";\nnewParts[i] = \"<span class='\" + cls + \"'>\" + Util.xmlEscape(newParts[i]) + \"</span>\";\n```\n\nVerified via `git diff jenkins-2.579..jenkins-2.580 -- core/src/main/java/hudson/Functions.java` and the added regression test `test/src/test/java/hudson/logging/LogRecorderManagerTest.java#logRecorderPageDoesNotRenderUnescapedMetadata` (`@Issue(\"SECURITY-3967\")`).\n\n## Reproduction Steps\n\n1. `bundle/repro/reproduction_steps.sh` (self-contained; requires Docker, Python 3, Node.js, curl, jq).\n2. Per attempt (2 vulnerable on `jenkins/jenkins:2.579`, 2 fixed on `jenkins/jenkins:2.580`, fresh container each):\n   - Starts Jenkins with an init groovy script that creates the `admin` account, an inbound (JNLP) agent node `agent1`, and a system log recorder `agentlog` targeting `hudson.slaves.SlaveComputer` at Level.ALL.\n   - Downloads the real `agent.jar` from the running controller, compiles `bundle/repro/agent/AgentXss.java` against it inside the container, and connects an attacker-controlled agent process over the real JNLP4/remoting TCP boundary.\n   - The agent publishes a `LogRecord` with `sourceClassName = <svg/onload=\"fetch('/whoAmI/api/json').then(…exfiltrate to beacon…)\">` under the `hudson.slaves.SlaveComputer` logger.\n   - The administrator views `http://127.0.0.1:18080/log/agentlog/` (curl capture of the raw HTML + response headers, and a headless-Chromium admin login + page visit).\n3. Expected evidence:\n   - Vulnerable: raw `<svg/onload=…>` payload present verbatim in the served HTML; headless admin browser executes it and the attacker beacon receives the marker plus the exfiltrated authenticated `/whoAmI/api/json` body.\n   - Fixed: HTML contains only `&lt;svg/onload…`; beacon never receives the marker.\n\n## Evidence\n\n- `bundle/logs/reproduction_steps.log` — full run log.\n- `bundle/repro/proof/vulnerable_{1,2}/page.html` — raw payload in served page (vulnerable).\n- `bundle/repro/proof/vulnerable_1/beacon-hit.txt`, `bundle/repro/proof/beacon.log` — attacker beacon callbacks proving script execution in the admin session.\n- `bundle/repro/proof/vulnerable_{1,2}/agent.log` — attacker agent channel + `MALICIOUS_RECORD_PUBLISHED`.\n- `bundle/repro/proof/fixed_{1,2}/page.html` — escaped payload only (negative control).\n- `bundle/repro/proof/*/result.json` — per-attempt structured results; `bundle/repro/runtime_manifest.json` — runtime evidence manifest with artifact hashes.\n- Environment: `jenkins/jenkins:2.579` (`sha256:a7342867ea33efaacf825229d50b7fc77c144ecada9719ab4e32419f5d7412be`), `jenkins/jenkins:2.580`, bundled JDK 21, Linux x86_64.\n\n## Recommendations / Next Steps\n\n- Upgrade to Jenkins 2.580 / LTS 2.568.3 (metadata is escaped before rendering).\n- Enforcing the Jenkins Content Security Policy (opt-in before the fix; default is report-only) mitigates script execution but does not fix the missing escaping.\n- Treat agent hosts as within the threat boundary: any agent can inject HTML into controller UI pages viewed by admins.\n\n## Additional Notes\n\n- The script is idempotent: every attempt uses a fresh container (no persistent `JENKINS_HOME`), and reruns rebuild all proof artifacts.\n- Limitation: the browser execution proof uses headless Chromium via puppeteer; if Chromium cannot be installed in the replay sandbox, the script degrades to the HTML oracle only (raw vs escaped payload), which still directly demonstrates the missing escaping that the 2.580 fix addresses.\n- The `source` metadata line is only rendered when consecutive records differ; the agent publishes a benign control record before the malicious one so the payload-bearing span is always rendered.\n","cve_id":"CVE-2026-84648","cwe_id":"CWE-79","source_url":"https://www.jenkins.io/security/advisory/2026-09-02/","package":{"name":"jenkinsci/jenkins","ecosystem":"github","affected_versions":"Jenkins weekly <= 2.579; Jenkins LTS <= 2.568.2"},"reproduced_at":"2026-09-24T17:06:29.504844+00:00","duration_secs":4128.0,"tool_calls":272,"handoffs":2,"total_cost_usd":8.203566,"agent_costs":{"claim_matcher":0.021882,"judge":0.506855,"learning_policy":0.013162,"repro":5.931431,"support":0.076833,"vuln_variant":1.653403},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.021882},"judge":{"gpt-5.6-sol":0.506855},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.013162},"repro":{"accounts/fireworks/models/kimi-k3":5.931431},"support":{"accounts/fireworks/models/kimi-k3":0.076833},"vuln_variant":{"accounts/fireworks/models/kimi-k3":1.653403}},"vulnerable_version_variant_outcome":"unknown","fix_bypass_outcome":"unknown","variant_disclosure_state":"unknown","quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-09-24T17:06:30.475686+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":7753,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":15929,"category":"reproduction_script"},{"path":"bundle/repro/agent/AgentXss.java","filename":"AgentXss.java","size":4003,"category":"other"},{"path":"bundle/repro/beacon.py","filename":"beacon.py","size":1024,"category":"script"},{"path":"bundle/repro/browser/visit.js","filename":"visit.js","size":2422,"category":"other"},{"path":"bundle/repro/init.groovy.d/01-setup.groovy","filename":"01-setup.groovy","size":1924,"category":"other"},{"path":"bundle/repro/proof/beacon.log","filename":"beacon.log","size":565,"category":"log"},{"path":"bundle/repro/proof/fixed_1/agent.log","filename":"agent.log","size":1577,"category":"log"},{"path":"bundle/repro/proof/fixed_1/browser.log","filename":"browser.log","size":336,"category":"log"},{"path":"bundle/repro/proof/fixed_1/page.headers","filename":"page.headers","size":1652,"category":"other"},{"path":"bundle/repro/proof/fixed_1/page.html","filename":"page.html","size":186460,"category":"other"},{"path":"bundle/repro/proof/fixed_1/result.json","filename":"result.json","size":196,"category":"other"},{"path":"bundle/repro/proof/fixed_2/agent.log","filename":"agent.log","size":1575,"category":"log"},{"path":"bundle/repro/proof/fixed_2/page.headers","filename":"page.headers","size":1652,"category":"other"},{"path":"bundle/repro/proof/fixed_2/page.html","filename":"page.html","size":185881,"category":"other"},{"path":"bundle/repro/proof/fixed_2/result.json","filename":"result.json","size":195,"category":"other"},{"path":"bundle/repro/proof/vulnerable_1/agent.log","filename":"agent.log","size":1587,"category":"log"},{"path":"bundle/repro/proof/vulnerable_1/beacon-hit.txt","filename":"beacon-hit.txt","size":283,"category":"other"},{"path":"bundle/repro/proof/vulnerable_1/browser.log","filename":"browser.log","size":1347,"category":"log"},{"path":"bundle/repro/proof/vulnerable_1/page.headers","filename":"page.headers","size":1651,"category":"other"},{"path":"bundle/repro/proof/vulnerable_1/page.html","filename":"page.html","size":186464,"category":"other"},{"path":"bundle/repro/proof/vulnerable_1/payload.txt","filename":"payload.txt","size":276,"category":"other"},{"path":"bundle/repro/proof/vulnerable_1/result.json","filename":"result.json","size":205,"category":"other"},{"path":"bundle/repro/proof/vulnerable_2/agent.log","filename":"agent.log","size":1585,"category":"log"},{"path":"bundle/repro/proof/vulnerable_2/page.headers","filename":"page.headers","size":1651,"category":"other"},{"path":"bundle/repro/proof/vulnerable_2/page.html","filename":"page.html","size":186511,"category":"other"},{"path":"bundle/repro/proof/vulnerable_2/payload.txt","filename":"payload.txt","size":274,"category":"other"},{"path":"bundle/repro/proof/vulnerable_2/result.json","filename":"result.json","size":204,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":4437,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1296,"category":"other"}]}