|
| 1 | +require 'metasploit/framework/login_scanner/http' |
| 2 | +require 'rex/proto/teamcity/rsa' |
| 3 | + |
| 4 | +module Metasploit |
| 5 | + module Framework |
| 6 | + module LoginScanner |
| 7 | + |
| 8 | + # This is the LoginScanner class for dealing with JetBrains TeamCity instances. |
| 9 | + # It is responsible for taking a single target, and a list of credentials |
| 10 | + # and attempting them. It then saves the results. |
| 11 | + class Teamcity < HTTP |
| 12 | + DEFAULT_PORT = 8111 |
| 13 | + LIKELY_PORTS = [8111] |
| 14 | + LIKELY_SERVICE_NAMES = ['skynetflow'] # Comes from nmap 7.95 on MacOS |
| 15 | + PRIVATE_TYPES = [:password] |
| 16 | + REALM_KEY = nil |
| 17 | + |
| 18 | + LOGIN_PAGE = 'login.html' |
| 19 | + LOGOUT_PAGE = 'ajax.html?logout=1' |
| 20 | + SUBMIT_PAGE = 'loginSubmit.html' |
| 21 | + |
| 22 | + SUCCESSFUL = ::Metasploit::Model::Login::Status::SUCCESSFUL |
| 23 | + UNABLE_TO_CONNECT = ::Metasploit::Model::Login::Status::UNABLE_TO_CONNECT |
| 24 | + INVALID_PUBLIC_PART = ::Metasploit::Model::Login::Status::INVALID_PUBLIC_PART |
| 25 | + LOCKED_OUT = ::Metasploit::Model::Login::Status::LOCKED_OUT |
| 26 | + INCORRECT = ::Metasploit::Model::Login::Status::INCORRECT |
| 27 | + |
| 28 | + # Send a GET request to the server and return a response. |
| 29 | + # @param [Hash] opts A hash with options that will take precedence over default values used to make the HTTP request. |
| 30 | + # @return [Hash] A hash with a status and an error or the response from the login page. |
| 31 | + def get_page_data(opts: { timeout: 5 }) |
| 32 | + request_params = { |
| 33 | + 'method' => 'GET', |
| 34 | + 'uri' => normalize_uri(@uri.to_s, LOGIN_PAGE) |
| 35 | + } |
| 36 | + |
| 37 | + opts.each { |param, value| request_params[param] = value } |
| 38 | + begin |
| 39 | + res = send_request(request_params) |
| 40 | + rescue ::Rex::ConnectionError, ::Rex::ConnectionProxyError, ::Errno::ECONNRESET, ::Errno::EINTR, ::Rex::TimeoutError, ::Timeout::Error, ::EOFError => e |
| 41 | + return { status: UNABLE_TO_CONNECT, proof: e } |
| 42 | + end |
| 43 | + |
| 44 | + return { status: UNABLE_TO_CONNECT, proof: 'Unable to connect to the TeamCity service' } if res.nil? |
| 45 | + # Does the service need to be setup & configured with the initial DB migration & admin account? |
| 46 | + return { status: UNABLE_TO_CONNECT, proof: "Received an unexpected status code: #{res.code}. Does the service need to be configured?" } if res.code != 200 |
| 47 | + |
| 48 | + { status: :success, proof: res } |
| 49 | + end |
| 50 | + |
| 51 | + # Extract the server's public key from the response. |
| 52 | + # @param [Rex::Proto::Http::Response] response The response to extract the public RSA key from. |
| 53 | + # @return [Hash] A hash with a status and an error or the server's public key. |
| 54 | + def get_public_key(response) |
| 55 | + html_doc = response.get_html_document |
| 56 | + public_key_choices = html_doc.xpath('//input[@id="publicKey"]/@value') |
| 57 | + return { status: UNABLE_TO_CONNECT, proof: 'Could not find the TeamCity public key in the HTML document' } if public_key_choices.empty? |
| 58 | + |
| 59 | + { status: :success, proof: public_key_choices.first.value } |
| 60 | + end |
| 61 | + |
| 62 | + # Create a login request body for the provided credentials. |
| 63 | + # @param [String] username The username to create the request body for. |
| 64 | + # @param [String] password The user's password. |
| 65 | + # @param [Object] public_key The public key to use when encrypting the password. |
| 66 | + def create_login_request_body(username, password, public_key) |
| 67 | + vars = {} |
| 68 | + vars['username'] = URI.encode_www_form_component(username) |
| 69 | + vars['remember'] = 'true' |
| 70 | + vars['_remember'] = '' |
| 71 | + vars['submitLogin'] = URI.encode_www_form_component('Log in') |
| 72 | + vars['publicKey'] = public_key |
| 73 | + vars['encryptedPassword'] = Rex::Proto::Teamcity::Rsa.encrypt_data(password, public_key) |
| 74 | + |
| 75 | + vars.each.map { |key, value| "#{key}=#{value}" }.join('&') |
| 76 | + end |
| 77 | + |
| 78 | + # Create a login request for the provided credentials. |
| 79 | + # @param [String] username The username to create the login request for. |
| 80 | + # @param [String] password The password to log in with. |
| 81 | + # @param [String] public_key The public key to encrypt the password with. |
| 82 | + # @return [Hash] The login request parameter hash. |
| 83 | + def create_login_request(username, password, public_key) |
| 84 | + { |
| 85 | + 'method' => 'POST', |
| 86 | + 'uri' => normalize_uri(@uri.to_s, SUBMIT_PAGE), |
| 87 | + 'ctype' => 'application/x-www-form-urlencoded', |
| 88 | + 'data' => create_login_request_body(username, password, public_key) |
| 89 | + } |
| 90 | + end |
| 91 | + |
| 92 | + # Try logging in with the provided username, password and public key. |
| 93 | + # @param [String] username The username to send the login request for. |
| 94 | + # @param [String] password The user's password. |
| 95 | + # @param [String] public_key The public key used to encrypt the password. |
| 96 | + # @return [Hash] A hash with the status and an error or the response. |
| 97 | + def try_login(username, password, public_key) |
| 98 | + login_request = create_login_request(username, password, public_key) |
| 99 | + |
| 100 | + begin |
| 101 | + res = send_request(login_request) |
| 102 | + rescue ::Rex::ConnectionError, ::Rex::ConnectionProxyError, ::Errno::ECONNRESET, ::Errno::EINTR, ::Rex::TimeoutError, ::Timeout::Error, ::EOFError => e |
| 103 | + return { status: UNABLE_TO_CONNECT, proof: e } |
| 104 | + end |
| 105 | + |
| 106 | + return { status: UNABLE_TO_CONNECT, proof: 'Unable to connect to the TeamCity service' } if res.nil? |
| 107 | + return { status: UNABLE_TO_CONNECT, proof: "Received an unexpected status code: #{res.code}" } if res.code != 200 |
| 108 | + |
| 109 | + # Check if the current username is timed out. Sleep if so. |
| 110 | + # TODO: This can be improved. The `try_login` method should not block until it can retry credentials. |
| 111 | + # This responsibility should fall onto the caller, and the caller should keep track of the tried, locked out and untried sets of credentials, |
| 112 | + # and it should be up to the caller and its scheduler algorithm to retry credentials, rather than force this method to block. |
| 113 | + # Currently, those building blocks are not available, so this is the approach I have implemented. |
| 114 | + timeout = res.body.match(/login only in (?<timeout>\d+)s/)&.named_captures&.dig('timeout')&.to_i |
| 115 | + if timeout |
| 116 | + framework_module.print_status "User '#{username}' locked out for #{timeout} seconds. Sleeping, and retrying..." |
| 117 | + sleep(timeout + 1) # + 1 as TeamCity is off-by-one when reporting the lockout timer. |
| 118 | + result = try_login(username, password, public_key) |
| 119 | + return result |
| 120 | + end |
| 121 | + |
| 122 | + return { status: INCORRECT, proof: res } if res.body.match?('Incorrect username or password') |
| 123 | + return { status: UNABLE_TO_CONNECT, proof: res } if res.body.match?('ajax') # TODO: Get the exact error message here. |
| 124 | + return { status: INVALID_PUBLIC_PART, proof: res } if res.body.match?('publicKeyExpired') # TODO: Invalid public part? Or Incorrect/Unable_to_connect? |
| 125 | + |
| 126 | + { status: :success, proof: res } |
| 127 | + end |
| 128 | + |
| 129 | + # Send a logout request for the provided user's headers. |
| 130 | + # This header stores the user's cookie. |
| 131 | + # @return [Hash] A hash with the status and an error or the response. |
| 132 | + def logout(headers) |
| 133 | + logout_params = { |
| 134 | + 'method' => 'POST', |
| 135 | + 'uri' => normalize_uri(@uri.to_s, LOGOUT_PAGE), |
| 136 | + 'headers' => headers |
| 137 | + } |
| 138 | + |
| 139 | + begin |
| 140 | + logout_res = send_request(logout_params) |
| 141 | + rescue ::Rex::ConnectionError, ::Rex::ConnectionProxyError, ::Errno::ECONNRESET, ::Errno::EINTR, ::Rex::TimeoutError, ::Timeout::Error, ::EOFError => e |
| 142 | + return { status: UNABLE_TO_CONNECT, proof: e } |
| 143 | + end |
| 144 | + |
| 145 | + return { status: UNABLE_TO_CONNECT, proof: 'Unable to connect to the TeamCity service' } if logout_res.nil? |
| 146 | + # A successful logout request wants to redirect us back to the login page |
| 147 | + return { status: UNABLE_TO_CONNECT, proof: "Received an unexpected status code: #{logout_res.code}" } if logout_res.code != 302 |
| 148 | + |
| 149 | + { status: :success, proof: logout_res } |
| 150 | + end |
| 151 | + |
| 152 | + def attempt_login(credential) |
| 153 | + result_options = { |
| 154 | + credential: credential, |
| 155 | + host: @host, |
| 156 | + port: @port, |
| 157 | + protocol: 'tcp', |
| 158 | + service_name: 'teamcity' |
| 159 | + } |
| 160 | + |
| 161 | + # Needed to retrieve the public key that will be used to encrypt the user's password. |
| 162 | + page_data = get_page_data |
| 163 | + return Result.new(result_options.merge(page_data)) if page_data[:status] != :success |
| 164 | + |
| 165 | + public_key_result = get_public_key(page_data[:proof]) |
| 166 | + return Result.new(result_options.merge(public_key_result)) if public_key_result[:status] != :success |
| 167 | + |
| 168 | + login_result = try_login(credential.public, credential.private, public_key_result[:proof]) |
| 169 | + return Result.new(result_options.merge(login_result)) if login_result[:status] != :success |
| 170 | + |
| 171 | + # Ensure we log the user out, so that our logged in session does not appear under the user's profile. |
| 172 | + logout_result = logout(login_result[:proof].headers) |
| 173 | + return Result.new(result_options.merge(logout_result)) if logout_result[:status] != :success |
| 174 | + |
| 175 | + result_options[:status] = SUCCESSFUL |
| 176 | + Result.new(result_options) |
| 177 | + end |
| 178 | + end |
| 179 | + end |
| 180 | + end |
| 181 | +end |
0 commit comments