Skip to content

Commit

Permalink
Add JetBrains TeamCity HTTP Login Scanner
Browse files Browse the repository at this point in the history
  • Loading branch information
sjanusz-r7 committed Oct 30, 2024
1 parent 7b745b2 commit ca28087
Show file tree
Hide file tree
Showing 4 changed files with 374 additions and 0 deletions.
189 changes: 189 additions & 0 deletions lib/metasploit/framework/login_scanner/teamcity.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
require 'metasploit/framework/login_scanner/http'
require 'rex/proto/teamcity/rsa'

module Metasploit
module Framework
module LoginScanner

# This is the LoginScanner class for dealing with MySQL Database servers.
# It is responsible for taking a single target, and a list of credentials
# and attempting them. It then saves the results.
class Teamcity < HTTP
DEFAULT_PORT = 8111
LIKELY_PORTS = [8111]
LIKELY_SERVICE_NAMES = ['skynetflow'] # Comes from nmap 7.95 on MacOS
PRIVATE_TYPES = [:password]
REALM_KEY = nil

LOGIN_PAGE = 'login.html'
LOGOUT_PAGE = 'ajax.html?logout=1'
SUBMIT_PAGE = 'loginSubmit.html'

SUCCESS = ::Metasploit::Model::Login::Status::SUCCESSFUL
UNABLE_TO_CONNECT = ::Metasploit::Model::Login::Status::UNABLE_TO_CONNECT
INVALID_PUBLIC_PART = ::Metasploit::Model::Login::Status::INVALID_PUBLIC_PART
LOCKED_OUT = ::Metasploit::Model::Login::Status::LOCKED_OUT
INCORRECT = ::Metasploit::Model::Login::Status::INCORRECT

# Send a GET request to the server and return a response.
# @param [Hash] opts A hash with options that will take precedence over default values used to make the HTTP request.
# @return [Hash] A hash with a status and an error or the response from the login page.
def get_page_data(opts: { timeout: 5 })
request_params = {
'method' => 'GET',
'uri' => normalize_uri(@uri.to_s, LOGIN_PAGE)
}

opts.each { |param, value| request_params[param] = value }
begin
res = send_request(request_params)
rescue ::Rex::ConnectionError, ::Rex::ConnectionProxyError, ::Errno::ECONNRESET, ::Errno::EINTR, ::Rex::TimeoutError, ::Timeout::Error, ::EOFError => e
return { status: UNABLE_TO_CONNECT, proof: e }
end

return { status: UNABLE_TO_CONNECT, proof: 'Unable to connect to the TeamCity service' } if res.nil?
# Does the service need to be setup & configured with the initial DB migration & admin account?
return { status: UNABLE_TO_CONNECT, proof: "Received an unexpected status code: #{res.code}. Does the service need to be configured?" } if res.code != 200

{ status: :success, proof: res }
end

# Extract the server's public key from the response.
# @param [Rex::Proto::Http::Response] response The response to extract the public RSA key from.
# @return [Hash] A hash with a status and an error or the server's public key.
def get_public_key(response)
html_doc = response.get_html_document
public_key_choices = html_doc.xpath('//input[@id="publicKey"]/@value')
return { status: UNABLE_TO_CONNECT, proof: 'Could not find the TeamCity public key in the HTML document' } if public_key_choices.empty?

{ status: :success, proof: public_key_choices.first.value }
end

# Create a login request body for the provided credentials.
# @param [String] username The username to create the request body for.
# @param [String] password The user's password.
# @param [Object] public_key The public key to use when encrypting the password.
def create_login_request_body(username, password, public_key)
vars = {}
vars['username'] = URI.encode_www_form_component(username)
vars['remember'] = 'true'
vars['_remember'] = ''
vars['submitLogin'] = URI.encode_www_form_component('Log in')
vars['publicKey'] = public_key
vars['encryptedPassword'] = Rex::Proto::Teamcity::Rsa.encrypt_data(password, public_key)

vars.each.map { |key, value| "#{key}=#{value}" }.join('&')
end

# Create a login request for the provided credentials.
# @param [String] username The username to create the login request for.
# @param [String] password The password to log in with.
# @param [String] public_key The public key to encrypt the password with.
# @return [Hash] The login request parameter hash.
def create_login_request(username, password, public_key)
{
'method' => 'POST',
'uri' => normalize_uri(@uri.to_s, SUBMIT_PAGE),
'ctype' => 'application/x-www-form-urlencoded',
'data' => create_login_request_body(username, password, public_key)
}
end

# Try logging in with the provided username, password and public key.
# @param [String] username The username to send the login request for.
# @param [String] password The user's password.
# @param [String] public_key The public key used to encrypt the password.
# @return [Hash] A hash with the status and an error or the response.
def try_login(username, password, public_key)
login_request = create_login_request(username, password, public_key)

begin
res = send_request(login_request)
rescue ::Rex::ConnectionError, ::Rex::ConnectionProxyError, ::Errno::ECONNRESET, ::Errno::EINTR, ::Rex::TimeoutError, ::Timeout::Error, ::EOFError => e
return { status: UNABLE_TO_CONNECT, proof: e }
end

return { status: UNABLE_TO_CONNECT, proof: 'Unable to connect to the TeamCity service' } if res.nil?
return { status: UNABLE_TO_CONNECT, proof: "Received an unexpected status code: #{res.code}" } if res.code != 200

# Check if the current username is timed out. Sleep if so.
# TODO: This can be improved. The `try_login` method should not block until it can retry credentials.
# This responsibility should fall onto the caller, and the caller should keep track of the tried, locked out and untried sets of credentials,
# and it should be up to the caller and its scheduler algorithm to retry credentials, rather than force this method to block.
# Currently, those building blocks are not available, so this is the approach I have implemented.
timeout = res.body.match(/login only in (?<timeout>\d+)s/)&.named_captures&.dig('timeout')&.to_i
if timeout
framework_module.print_status "User '#{username}' locked out for #{timeout} seconds. Sleeping, and retrying..."
sleep(timeout + 1) # + 1 as TeamCity is off-by-one when reporting the lockout timer.
result = try_login(username, password, public_key)
return result
end

return { status: INCORRECT, proof: res } if res.body.match?('Incorrect username or password')
return { status: UNABLE_TO_CONNECT, proof: res } if res.body.match?('ajax') # TODO: Get the exact error message here.
return { status: INVALID_PUBLIC_PART, proof: res } if res.body.match?('publicKeyExpired') # TODO: Invalid public part? Or Incorrect/Unable_to_connect?

{ status: :success, proof: res }
end

# Send a logout request for the provided user's headers.
# This header stores the user's cookie.
# @return [Hash] A hash with the status and an error or the response.
def logout(headers)
logout_params = {
'method' => 'POST',
'uri' => normalize_uri(@uri.to_s, LOGOUT_PAGE),
'headers' => headers
}

begin
logout_res = send_request(logout_params)
rescue ::Rex::ConnectionError, ::Rex::ConnectionProxyError, ::Errno::ECONNRESET, ::Errno::EINTR, ::Rex::TimeoutError, ::Timeout::Error, ::EOFError => e
return { status: UNABLE_TO_CONNECT, proof: e }
end

return { status: UNABLE_TO_CONNECT, proof: 'Unable to connect to the TeamCity service' } if logout_res.nil?
# A successful logout request wants to redirect us back to the login page
return { status: UNABLE_TO_CONNECT, proof: "Received an unexpected status code: #{logout_res.code}" } if logout_res.code != 302

{ status: :success, proof: logout_res }
end

def attempt_login(credential)
result_options = {
credential: credential,
host: @host,
port: @port,
protocol: 'tcp',
service_name: 'teamcity'
}

# Needed to retrieve the public key that will be used to encrypt the user's password.
page_data = get_page_data
if page_data[:status] != :success
return Result.new(result_options.merge(page_data))
end

public_key_result = get_public_key(page_data[:proof])
if public_key_result[:status] != :success
return Result.new(result_options.merge(public_key_result))
end

login_result = try_login(credential.public, credential.private, public_key_result[:proof])
if login_result[:status] != :success
return Result.new(result_options.merge(login_result))
end

# Ensure we log the user out, so that our logged in session does not appear under the user's profile.
logout_result = logout(login_result[:proof].headers)
if logout_result[:status] != :success
return Result.new(result_options.merge(logout_result))
end

result_options[:status] = SUCCESS
Result.new(result_options)
end
end
end
end
end
25 changes: 25 additions & 0 deletions lib/msf/core/exploit/remote/http/teamcity.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# -*- coding: binary -*-

module Msf
class Exploit
class Remote
module HTTP
# This module provides a way of interacting with JetBrains TeamCity instances.
module Teamcity
include Msf::Exploit::Remote::HttpClient

def initialize(info = {})
super

register_options(
[
Msf::OptString.new('TARGETURI', [true, 'The base path to the TeamCity application', '/']),
Opt::RPORT(8111)
], self.class
)
end
end
end
end
end
end
74 changes: 74 additions & 0 deletions lib/rex/proto/teamcity/rsa.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
module Rex::Proto::Teamcity::Rsa
# https://github.com/openssl/openssl/blob/a08a145d4a7e663dd1e973f06a56e983a5e916f7/crypto/rsa/rsa_pk1.c#L125
# https://datatracker.ietf.org/doc/html/rfc3447#section-7.2.1
def self.pkcs1pad2(text, n)
if n < text.length + 11
raise ArgumentError, 'Message too long'
end

r = Array.new(n, 0)
n -= 1
r[n] = text.length

i = text.length - 1

while i >= 0 && n > 0
c = text[i].ord
i -= 1
n -= 1
r[n] = c % 0x100
end
n -= 1
r[n] = 0

while n > 2
n -= 1
# TODO: Random
r[n] = 0xff
end

n -= 1
r[n] = 2
n -= 1
r[n] = 0

r.pack("C*").unpack1("H*").to_i(16)
end

# @param [String] modulus
# @param [String] exponent
# @param [String] text
# @return [String]
def self.rsa_encrypt(modulus, exponent, text)
n = modulus.to_i(16)
e = exponent.to_i(16)

padded_as_big_int = pkcs1pad2(text, (n.bit_length + 7) >> 3)
encrypted = padded_as_big_int.to_bn.mod_exp(e, n)
h = encrypted.to_s(16)

h.length.odd? ? h.prepend('0') : h
end

# @param [String] text The text to encrypt.
# @param [String] public_key The hex representation of the public key to use.
# @return [String] A string blob.
def self.encrypt_data(text, public_key)
exponent = '10001'
e = []
g = 116 # TODO: wire up d.maxDataSize(f)

c = 0
while c < text.length
b = [text.length, c + g].min

a = text[c..b]

encrypt = rsa_encrypt(public_key, exponent, a)
e.push(encrypt)
c += g
end

e.join('')
end
end
86 changes: 86 additions & 0 deletions modules/auxiliary/scanner/teamcity/teamcity_login.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

require 'metasploit/framework/credential_collection'
require 'metasploit/framework/login_scanner/teamcity'
require 'msf/core/exploit/remote/http/teamcity'
require 'rex/proto/http/packet/header'

class MetasploitModule < Msf::Auxiliary
include Msf::Auxiliary::Scanner
include Msf::Auxiliary::AuthBrute
include Msf::Exploit::Remote::HTTP::Teamcity

def initialize(info = {})
super(update_info(info,
'Name' => 'JetBrains TeamCity Login Scanner',
'Description' => 'This module performs login attempts against a JetBrains TeamCity webpage to bruteforce possible credentials.',
'Author' => [ 'sjanusz-r7' ],
'License' => MSF_LICENSE,
)
)

options_to_deregister = []
deregister_options(*options_to_deregister)
end

def process_credential(credential_data)
credential_combo = "#{credential_data[:username]}:#{credential_data[:private_data]}"
case credential_data[:status]
when Metasploit::Model::Login::Status::SUCCESSFUL
print_good "#{credential_data[:address]}:#{credential_data[:port]} - Login Successful: #{credential_combo}"
credential_core = create_credential(credential_data)
credential_data[:core] = credential_core
create_credential_login(credential_data)
return { status: :success, credential: credential_data }
else
error_msg = "#{credential_data[:address]}:#{credential_data[:port]} - LOGIN FAILED: #{credential_combo} (#{credential_data[:status]})"
vprint_error error_msg
invalidate_login(credential_data)
return { status: :fail, credential: credential_data }
end
end

def run_scanner(scanner)
successful_logins = []
scanner.scan! do |result|
credential_data = result.to_h
credential_data.merge!(
module_fullname: self.fullname,
workspace_id: myworkspace_id
)

processed_credential = process_credential(credential_data)
successful_logins << processed_credential[:credential] if processed_credential[:status] == :success
end
{ successful_logins: successful_logins }
end

def run_host(ip)
cred_collection = build_credential_collection(
realm: datastore['DATABASE'],
username: datastore['USERNAME'],
password: datastore['PASSWORD']
)

scanner_opts = configure_http_login_scanner(
host: ip,
uri: target_uri.to_s,
port: datastore['RPORT'],
proxies: datastore['Proxies'],
cred_details: cred_collection,
stop_on_success: datastore['STOP_ON_SUCCESS'],
bruteforce_speed: datastore['BRUTEFORCE_SPEED'],
connection_timeout: datastore['HttpClientTimeout'] || 5,
framework: framework,
framework_module: self,
http_success_codes: [200, 302],
method: 'POST'
)

scanner = Metasploit::Framework::LoginScanner::Teamcity.new(scanner_opts)
run_scanner(scanner)
end
end

0 comments on commit ca28087

Please sign in to comment.