Skip to content

Commit cba8962

Browse files
committed
Add JetBrains TeamCity HTTP Login Scanner
1 parent f40e986 commit cba8962

File tree

4 files changed

+366
-0
lines changed

4 files changed

+366
-0
lines changed
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
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
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# -*- coding: binary -*-
2+
3+
module Msf
4+
class Exploit
5+
class Remote
6+
module HTTP
7+
# This module provides a way of interacting with JetBrains TeamCity instances.
8+
module Teamcity
9+
include Msf::Exploit::Remote::HttpClient
10+
11+
def initialize(info = {})
12+
super
13+
14+
register_options(
15+
[
16+
Msf::OptString.new('TARGETURI', [true, 'The base path to the TeamCity application', '/']),
17+
Opt::RPORT(8111)
18+
], self.class
19+
)
20+
end
21+
end
22+
end
23+
end
24+
end
25+
end

lib/rex/proto/teamcity/rsa.rb

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
module Rex::Proto::Teamcity::Rsa
2+
# https://github.com/openssl/openssl/blob/a08a145d4a7e663dd1e973f06a56e983a5e916f7/crypto/rsa/rsa_pk1.c#L125
3+
# https://datatracker.ietf.org/doc/html/rfc3447#section-7.2.1
4+
def self.pkcs1pad2(text, n)
5+
if n < text.length + 11
6+
raise ArgumentError, 'Message too long'
7+
end
8+
9+
r = Array.new(n, 0)
10+
n -= 1
11+
r[n] = text.length
12+
13+
i = text.length - 1
14+
15+
while i >= 0 && n > 0
16+
c = text[i].ord
17+
i -= 1
18+
n -= 1
19+
r[n] = c % 0x100
20+
end
21+
n -= 1
22+
r[n] = 0
23+
24+
while n > 2
25+
n -= 1
26+
# TODO: Random
27+
r[n] = 0xff
28+
end
29+
30+
n -= 1
31+
r[n] = 2
32+
n -= 1
33+
r[n] = 0
34+
35+
r.pack("C*").unpack1("H*").to_i(16)
36+
end
37+
38+
# @param [String] modulus
39+
# @param [String] exponent
40+
# @param [String] text
41+
# @return [String]
42+
def self.rsa_encrypt(modulus, exponent, text)
43+
n = modulus.to_i(16)
44+
e = exponent.to_i(16)
45+
46+
padded_as_big_int = pkcs1pad2(text, (n.bit_length + 7) >> 3)
47+
encrypted = padded_as_big_int.to_bn.mod_exp(e, n)
48+
h = encrypted.to_s(16)
49+
50+
h.length.odd? ? h.prepend('0') : h
51+
end
52+
53+
# @param [String] text The text to encrypt.
54+
# @param [String] public_key The hex representation of the public key to use.
55+
# @return [String] A string blob.
56+
def self.encrypt_data(text, public_key)
57+
exponent = '10001'
58+
e = []
59+
g = 116 # TODO: wire up d.maxDataSize(f)
60+
61+
c = 0
62+
while c < text.length
63+
b = [text.length, c + g].min
64+
65+
a = text[c..b]
66+
67+
encrypt = rsa_encrypt(public_key, exponent, a)
68+
e.push(encrypt)
69+
c += g
70+
end
71+
72+
e.join('')
73+
end
74+
end
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
##
2+
# This module requires Metasploit: https://metasploit.com/download
3+
# Current source: https://github.com/rapid7/metasploit-framework
4+
##
5+
6+
require 'metasploit/framework/credential_collection'
7+
require 'metasploit/framework/login_scanner/teamcity'
8+
require 'msf/core/exploit/remote/http/teamcity'
9+
10+
class MetasploitModule < Msf::Auxiliary
11+
include Msf::Auxiliary::Scanner
12+
include Msf::Auxiliary::AuthBrute
13+
include Msf::Exploit::Remote::HTTP::Teamcity
14+
15+
def initialize(info = {})
16+
super(update_info(info,
17+
'Name' => 'JetBrains TeamCity Login Scanner',
18+
'Description' => 'This module performs login attempts against a JetBrains TeamCity webpage to bruteforce possible credentials.',
19+
'Author' => [ 'sjanusz-r7' ],
20+
'License' => MSF_LICENSE,
21+
)
22+
)
23+
24+
options_to_deregister = ['DOMAIN']
25+
deregister_options(*options_to_deregister)
26+
end
27+
28+
def process_credential(credential_data)
29+
credential_combo = "#{credential_data[:username]}:#{credential_data[:private_data]}"
30+
case credential_data[:status]
31+
when Metasploit::Model::Login::Status::SUCCESSFUL
32+
print_good "#{credential_data[:address]}:#{credential_data[:port]} - Login Successful: #{credential_combo}"
33+
credential_core = create_credential(credential_data)
34+
credential_data[:core] = credential_core
35+
create_credential_login(credential_data)
36+
return { status: :success, credential: credential_data }
37+
else
38+
error_msg = "#{credential_data[:address]}:#{credential_data[:port]} - LOGIN FAILED: #{credential_combo} (#{credential_data[:status]})"
39+
vprint_error error_msg
40+
invalidate_login(credential_data)
41+
return { status: :fail, credential: credential_data }
42+
end
43+
end
44+
45+
def run_scanner(scanner)
46+
successful_logins = []
47+
scanner.scan! do |result|
48+
credential_data = result.to_h
49+
credential_data.merge!(
50+
module_fullname: self.fullname,
51+
workspace_id: myworkspace_id
52+
)
53+
54+
processed_credential = process_credential(credential_data)
55+
successful_logins << processed_credential[:credential] if processed_credential[:status] == :success
56+
end
57+
{ successful_logins: successful_logins }
58+
end
59+
60+
def run_host(ip)
61+
cred_collection = build_credential_collection(
62+
realm: datastore['DATABASE'],
63+
username: datastore['USERNAME'],
64+
password: datastore['PASSWORD']
65+
)
66+
67+
scanner_opts = configure_http_login_scanner(
68+
host: ip,
69+
uri: target_uri,
70+
port: datastore['RPORT'],
71+
proxies: datastore['Proxies'],
72+
cred_details: cred_collection,
73+
stop_on_success: datastore['STOP_ON_SUCCESS'],
74+
bruteforce_speed: datastore['BRUTEFORCE_SPEED'],
75+
connection_timeout: datastore['HttpClientTimeout'] || 5,
76+
framework: framework,
77+
framework_module: self,
78+
http_success_codes: [200, 302],
79+
method: 'POST',
80+
ssl: datastore['SSL']
81+
)
82+
83+
scanner = Metasploit::Framework::LoginScanner::Teamcity.new(scanner_opts)
84+
run_scanner(scanner)
85+
end
86+
end

0 commit comments

Comments
 (0)