forked from rapid7/metasploit-framework
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add JetBrains TeamCity HTTP Login Scanner
- Loading branch information
1 parent
7b745b2
commit ca28087
Showing
4 changed files
with
374 additions
and
0 deletions.
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,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 |
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,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 |
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,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 |
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,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 |