Path: blob/master/lib/metasploit/framework/login_scanner/teamcity.rb
27907 views
require 'metasploit/framework/login_scanner/http'12module Metasploit3module Framework4module LoginScanner56# This is the LoginScanner class for dealing with JetBrains TeamCity instances.7# It is responsible for taking a single target, and a list of credentials8# and attempting them. It then saves the results.9class TeamCity < HTTP1011module Crypto12# https://github.com/openssl/openssl/blob/a08a145d4a7e663dd1e973f06a56e983a5e916f7/crypto/rsa/rsa_pk1.c#L12513# https://datatracker.ietf.org/doc/html/rfc3447#section-7.2.114def pkcs1pad2(text, n)15raise ArgumentError, "Cannot pad the text: '#{text.inspect}'" unless text.is_a?(String)16raise ArgumentError, "Invalid message length: '#{n.inspect}'" unless n.is_a?(Integer)1718bytes_per_char = two_byte_chars?(text) ? 2 : 119if n < ((bytes_per_char * text.length) + 11)20raise ArgumentError, 'Message too long'21end2223ba = Array.new(n, 0)24n -= 125ba[n] = text.length2627i = text.length - 12829while i >= 0 && n > 030char_code = text[i].ord31i -= 13233num_bytes = bytes_per_char3435while num_bytes > 036next_byte = char_code % 0x10037char_code >>= 83839n -= 140ba[n] = next_byte4142num_bytes -= 143end44end45n -= 146ba[n] = 04748while n > 249n -= 150ba[n] = rand(1..255) # Can't be a null byte.51end5253n -= 154ba[n] = 255n -= 156ba[n] = 05758ba.pack("C*").unpack1("H*").to_i(16)59end6061# @param [String] modulus62# @param [String] exponent63# @param [String] text64# @return [String]65def rsa_encrypt(modulus, exponent, text)66n = modulus.to_i(16)67e = exponent.to_i(16)6869padded_as_big_int = pkcs1pad2(text, (n.bit_length + 7) >> 3)70encrypted = padded_as_big_int.to_bn.mod_exp(e, n)71h = encrypted.to_s(16)7273h.length.odd? ? h.prepend('0') : h74end7576def two_byte_chars?(str)77raise ArgumentError, 'Unable to check char size for non-string value' unless str.is_a?(String)7879str.each_codepoint do |codepoint|80return true if codepoint >> 8 > 081end8283false84end8586def max_data_size(str)87raise ArgumentError, 'Unable to get maximum data size for non-string value' unless str.is_a?(String)8889# Taken from TeamCity's login page JavaScript sources.90two_byte_chars?(str) ? 58 : 11691end9293# @param [String] text The text to encrypt.94# @param [String] public_key The hex representation of the public key to use.95# @return [String] A string blob.96def encrypt_data(text, public_key)97raise ArgumentError, "Cannot encrypt the provided data: '#{text.inspect}'" unless text.is_a?(String)98raise ArgumentError, "Cannot encrypt data with the public key: '#{public_key.inspect}'" unless public_key.is_a?(String)99100exponent = '10001'101e = []102utf_text = text.dup.force_encoding(::Encoding::UTF_8)103g = max_data_size(utf_text)104105c = 0106while c < utf_text.length107b = [utf_text.length, c + g].min108109a = utf_text[c..b]110111encrypt = rsa_encrypt(public_key, exponent, a)112e.push(encrypt)113c += g114end115116e.join('')117end118end119120include Crypto121122DEFAULT_PORT = 8111123LIKELY_PORTS = self.superclass::LIKELY_PORTS + [8111]124LIKELY_SERVICE_NAMES = self.superclass::LIKELY_SERVICE_NAMES + [125# Comes from nmap 7.95 on MacOS126'skynetflow',127'teamcity'128]129PRIVATE_TYPES = [:password]130REALM_KEY = nil131132LOGIN_PAGE = 'login.html'133LOGOUT_PAGE = 'ajax.html?logout=1'134SUBMIT_PAGE = 'loginSubmit.html'135136class TeamCityError < StandardError; end137class StackLevelTooDeepError < TeamCityError; end138class NoPublicKeyError < TeamCityError; end139class PublicKeyExpiredError < TeamCityError; end140class DecryptionError < TeamCityError; end141class ServerNeedsSetupError < TeamCityError; end142143# Checks if the target is JetBrains TeamCity. The login module should call this.144#145# @return [Boolean] TrueClass if target is TeamCity, otherwise FalseClass146def check_setup147request_params = {148'method' => 'GET',149'uri' => normalize_uri(@uri.to_s, LOGIN_PAGE)150}151res = send_request(request_params)152153if res && res.code == 200 && res.body&.include?('Log in to TeamCity')154return false155end156157"Unable to locate \"Log in to TeamCity\" in body. (Is this really TeamCity?)"158end159160# Extract the server's public key from the server.161# @return [Hash] A hash with a status and an error or the server's public key.162def get_public_key163request_params = {164'method' => 'GET',165'uri' => normalize_uri(@uri.to_s, LOGIN_PAGE)166}167168begin169res = send_request(request_params)170rescue ::Rex::ConnectionError, ::Rex::ConnectionProxyError, ::Errno::ECONNRESET, ::Errno::EINTR, ::Rex::TimeoutError, ::Timeout::Error, ::EOFError => e171return { status: ::Metasploit::Model::Login::Status::UNABLE_TO_CONNECT, proof: e }172end173174return { status: ::Metasploit::Model::Login::Status::UNABLE_TO_CONNECT, proof: 'Unable to connect to the TeamCity service' } if res.nil?175176raise ServerNeedsSetupError, 'The server has not performed the initial setup' if res.code == 503177178html_doc = res.get_html_document179public_key = html_doc.xpath('//input[@id="publicKey"]/@value').text180raise NoPublicKeyError, 'Could not find the TeamCity public key in the HTML document' if public_key.empty?181182{ status: :success, proof: public_key }183end184185# Create a login request for the provided credentials.186# @param [String] username The username to create the login request for.187# @param [String] password The password to log in with.188# @param [String] public_key The public key to encrypt the password with.189# @return [Hash] The login request parameter hash.190def create_login_request(username, password, public_key)191{192'method' => 'POST',193'uri' => normalize_uri(@uri.to_s, SUBMIT_PAGE),194'ctype' => 'application/x-www-form-urlencoded',195'vars_post' => {196username: username,197remember: true,198_remember: '',199submitLogin: 'Log in',200publicKey: public_key,201encryptedPassword: encrypt_data(password, public_key)202}203}204end205206# Try logging in with the provided username, password and public key.207# @param [String] username The username to send the login request for.208# @param [String] password The user's password.209# @param [String] public_key The public key used to encrypt the password.210# @return [Hash] A hash with the status and an error or the response.211def try_login(username, password, public_key, retry_counter = 0)212raise StackLevelTooDeepError, 'try_login stack level too deep!' if retry_counter >= 2213214login_request = create_login_request(username, password, public_key)215216begin217res = send_request(login_request)218rescue ::Rex::ConnectionError, ::Rex::ConnectionProxyError, ::Errno::ECONNRESET, ::Errno::EINTR, ::Rex::TimeoutError, ::Timeout::Error, ::EOFError => e219return { status: ::Metasploit::Model::Login::Status::UNABLE_TO_CONNECT, proof: e }220end221222return { status: ::Metasploit::Model::Login::Status::UNABLE_TO_CONNECT, proof: 'Unable to connect to the TeamCity service' } if res.nil?223return { status: ::Metasploit::Model::Login::Status::UNABLE_TO_CONNECT, proof: "Received an unexpected status code: #{res.code}" } if res.code != 200224225# Check if the current username is timed out. Sleep if so.226# TODO: This can be improved. The `try_login` method should not block until it can retry credentials.227# This responsibility should fall onto the caller, and the caller should keep track of the tried, locked out and untried sets of credentials,228# and it should be up to the caller and its scheduler algorithm to retry credentials, rather than force this method to block.229# Currently, those building blocks are not available, so this is the approach I have implemented.230timeout = res.body.match(/login only in (?<timeout>\d+)s/)&.named_captures&.dig('timeout')&.to_i231if timeout232framework_module.print_status "#{@host}:#{@port} - User '#{username}:#{password}' locked out for #{timeout} seconds. Sleeping, and retrying..." if framework_module233sleep(timeout + 1)234return try_login(username, password, public_key, retry_counter + 1)235end236237return { status: ::Metasploit::Model::Login::Status::INCORRECT, proof: res } if res.body.match?('Incorrect username or password')238239raise DecryptionError, 'The server failed to decrypt the encrypted password' if res.body.match?('DecryptionFailedException')240raise PublicKeyExpiredError, 'The server public key has expired' if res.body.match?('publicKeyExpired')241242# After filtering out known failures, default to retuning the credential as working.243# This way, people are more likely to notice any incorrect credential reporting going forward and report them,244# the scenarios for which can then be correctly implemented and handled similar to the above.245{ status: :success, proof: res }246end247248# Send a logout request for the provided user's headers.249# This header stores the user's cookie.250def logout_with_headers(headers)251logout_params = {252'method' => 'POST',253'uri' => normalize_uri(@uri.to_s, LOGOUT_PAGE),254'headers' => headers255}256257begin258send_request(logout_params)259rescue Rex::ConnectionError => _e260# ignore261end262end263264def attempt_login(credential)265result_options = {266credential: credential,267host: @host,268port: @port,269protocol: 'tcp',270service_name: 'teamcity'271}272273if @public_key.nil?274public_key_result = get_public_key275return Result.new(result_options.merge(public_key_result)) if public_key_result[:status] != :success276277@public_key = public_key_result[:proof]278end279280login_result = try_login(credential.public, credential.private, @public_key)281return Result.new(result_options.merge(login_result)) if login_result[:status] != :success282283# Ensure we log the user out, so that our logged in session does not appear under the user's profile.284logout_with_headers(login_result[:proof].headers)285286result_options[:status] = ::Metasploit::Model::Login::Status::SUCCESSFUL287Result.new(result_options)288end289290private291292attr_accessor :public_key293294end295end296end297end298299300