Skip to content

Commit d039bea

Browse files
authored
Merge pull request rapid7#19601 from sjanusz-r7/add-teamcity-login-scanner
Add JetBrains TeamCity HTTP Login Scanner
2 parents de39b69 + 68ec0c8 commit d039bea

File tree

3 files changed

+469
-0
lines changed

3 files changed

+469
-0
lines changed
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
require 'metasploit/framework/login_scanner/http'
2+
3+
module Metasploit
4+
module Framework
5+
module LoginScanner
6+
7+
# This is the LoginScanner class for dealing with JetBrains TeamCity instances.
8+
# It is responsible for taking a single target, and a list of credentials
9+
# and attempting them. It then saves the results.
10+
class Teamcity < HTTP
11+
12+
module Crypto
13+
# https://github.com/openssl/openssl/blob/a08a145d4a7e663dd1e973f06a56e983a5e916f7/crypto/rsa/rsa_pk1.c#L125
14+
# https://datatracker.ietf.org/doc/html/rfc3447#section-7.2.1
15+
def pkcs1pad2(text, n)
16+
raise ArgumentError, "Cannot pad the text: '#{text.inspect}'" unless text.is_a?(String)
17+
raise ArgumentError, "Invalid message length: '#{n.inspect}'" unless n.is_a?(Integer)
18+
19+
bytes_per_char = two_byte_chars?(text) ? 2 : 1
20+
if n < ((bytes_per_char * text.length) + 11)
21+
raise ArgumentError, 'Message too long'
22+
end
23+
24+
ba = Array.new(n, 0)
25+
n -= 1
26+
ba[n] = text.length
27+
28+
i = text.length - 1
29+
30+
while i >= 0 && n > 0
31+
char_code = text[i].ord
32+
i -= 1
33+
34+
num_bytes = bytes_per_char
35+
36+
while num_bytes > 0
37+
next_byte = char_code % 0x100
38+
char_code >>= 8
39+
40+
n -= 1
41+
ba[n] = next_byte
42+
43+
num_bytes -= 1
44+
end
45+
end
46+
n -= 1
47+
ba[n] = 0
48+
49+
while n > 2
50+
n -= 1
51+
ba[n] = rand(1..255) # Can't be a null byte.
52+
end
53+
54+
n -= 1
55+
ba[n] = 2
56+
n -= 1
57+
ba[n] = 0
58+
59+
ba.pack("C*").unpack1("H*").to_i(16)
60+
end
61+
62+
# @param [String] modulus
63+
# @param [String] exponent
64+
# @param [String] text
65+
# @return [String]
66+
def rsa_encrypt(modulus, exponent, text)
67+
n = modulus.to_i(16)
68+
e = exponent.to_i(16)
69+
70+
padded_as_big_int = pkcs1pad2(text, (n.bit_length + 7) >> 3)
71+
encrypted = padded_as_big_int.to_bn.mod_exp(e, n)
72+
h = encrypted.to_s(16)
73+
74+
h.length.odd? ? h.prepend('0') : h
75+
end
76+
77+
def two_byte_chars?(str)
78+
raise ArgumentError, 'Unable to check char size for non-string value' unless str.is_a?(String)
79+
80+
str.each_codepoint do |codepoint|
81+
return true if codepoint >> 8 > 0
82+
end
83+
84+
false
85+
end
86+
87+
def max_data_size(str)
88+
raise ArgumentError, 'Unable to get maximum data size for non-string value' unless str.is_a?(String)
89+
90+
# Taken from TeamCity's login page JavaScript sources.
91+
two_byte_chars?(str) ? 58 : 116
92+
end
93+
94+
# @param [String] text The text to encrypt.
95+
# @param [String] public_key The hex representation of the public key to use.
96+
# @return [String] A string blob.
97+
def encrypt_data(text, public_key)
98+
raise ArgumentError, "Cannot encrypt the provided data: '#{text.inspect}'" unless text.is_a?(String)
99+
raise ArgumentError, "Cannot encrypt data with the public key: '#{public_key.inspect}'" unless public_key.is_a?(String)
100+
101+
exponent = '10001'
102+
e = []
103+
utf_text = text.dup.force_encoding(::Encoding::UTF_8)
104+
g = max_data_size(utf_text)
105+
106+
c = 0
107+
while c < utf_text.length
108+
b = [utf_text.length, c + g].min
109+
110+
a = utf_text[c..b]
111+
112+
encrypt = rsa_encrypt(public_key, exponent, a)
113+
e.push(encrypt)
114+
c += g
115+
end
116+
117+
e.join('')
118+
end
119+
end
120+
121+
include Crypto
122+
123+
DEFAULT_PORT = 8111
124+
LIKELY_PORTS = [8111]
125+
LIKELY_SERVICE_NAMES = ['skynetflow'] # Comes from nmap 7.95 on MacOS
126+
PRIVATE_TYPES = [:password]
127+
REALM_KEY = nil
128+
129+
LOGIN_PAGE = 'login.html'
130+
LOGOUT_PAGE = 'ajax.html?logout=1'
131+
SUBMIT_PAGE = 'loginSubmit.html'
132+
133+
class TeamCityError < StandardError; end
134+
class StackLevelTooDeepError < TeamCityError; end
135+
class NoPublicKeyError < TeamCityError; end
136+
class PublicKeyExpiredError < TeamCityError; end
137+
class DecryptionException < TeamCityError; end
138+
class ServerNeedsSetupError < TeamCityError; end
139+
140+
# Extract the server's public key from the server.
141+
# @return [Hash] A hash with a status and an error or the server's public key.
142+
def get_public_key
143+
request_params = {
144+
'method' => 'GET',
145+
'uri' => normalize_uri(@uri.to_s, LOGIN_PAGE)
146+
}
147+
148+
begin
149+
res = send_request(request_params)
150+
rescue ::Rex::ConnectionError, ::Rex::ConnectionProxyError, ::Errno::ECONNRESET, ::Errno::EINTR, ::Rex::TimeoutError, ::Timeout::Error, ::EOFError => e
151+
return { status: ::Metasploit::Model::Login::Status::UNABLE_TO_CONNECT, proof: e }
152+
end
153+
154+
return { status: ::Metasploit::Model::Login::Status::UNABLE_TO_CONNECT, proof: 'Unable to connect to the TeamCity service' } if res.nil?
155+
156+
raise ServerNeedsSetupError, 'The server has not performed the initial setup' if res.code == 503
157+
158+
html_doc = res.get_html_document
159+
public_key = html_doc.xpath('//input[@id="publicKey"]/@value').text
160+
raise NoPublicKeyError, 'Could not find the TeamCity public key in the HTML document' if public_key.empty?
161+
162+
{ status: :success, proof: public_key }
163+
end
164+
165+
# Create a login request for the provided credentials.
166+
# @param [String] username The username to create the login request for.
167+
# @param [String] password The password to log in with.
168+
# @param [String] public_key The public key to encrypt the password with.
169+
# @return [Hash] The login request parameter hash.
170+
def create_login_request(username, password, public_key)
171+
{
172+
'method' => 'POST',
173+
'uri' => normalize_uri(@uri.to_s, SUBMIT_PAGE),
174+
'ctype' => 'application/x-www-form-urlencoded',
175+
'vars_post' => {
176+
username: username,
177+
remember: true,
178+
_remember: '',
179+
submitLogin: 'Log in',
180+
publicKey: public_key,
181+
encryptedPassword: encrypt_data(password, public_key)
182+
}
183+
}
184+
end
185+
186+
# Try logging in with the provided username, password and public key.
187+
# @param [String] username The username to send the login request for.
188+
# @param [String] password The user's password.
189+
# @param [String] public_key The public key used to encrypt the password.
190+
# @return [Hash] A hash with the status and an error or the response.
191+
def try_login(username, password, public_key, retry_counter = 0)
192+
raise StackLevelTooDeepError, 'try_login stack level too deep!' if retry_counter >= 2
193+
194+
login_request = create_login_request(username, password, public_key)
195+
196+
begin
197+
res = send_request(login_request)
198+
rescue ::Rex::ConnectionError, ::Rex::ConnectionProxyError, ::Errno::ECONNRESET, ::Errno::EINTR, ::Rex::TimeoutError, ::Timeout::Error, ::EOFError => e
199+
return { status: ::Metasploit::Model::Login::Status::UNABLE_TO_CONNECT, proof: e }
200+
end
201+
202+
return { status: ::Metasploit::Model::Login::Status::UNABLE_TO_CONNECT, proof: 'Unable to connect to the TeamCity service' } if res.nil?
203+
return { status: ::Metasploit::Model::Login::Status::UNABLE_TO_CONNECT, proof: "Received an unexpected status code: #{res.code}" } if res.code != 200
204+
205+
# Check if the current username is timed out. Sleep if so.
206+
# TODO: This can be improved. The `try_login` method should not block until it can retry credentials.
207+
# This responsibility should fall onto the caller, and the caller should keep track of the tried, locked out and untried sets of credentials,
208+
# and it should be up to the caller and its scheduler algorithm to retry credentials, rather than force this method to block.
209+
# Currently, those building blocks are not available, so this is the approach I have implemented.
210+
timeout = res.body.match(/login only in (?<timeout>\d+)s/)&.named_captures&.dig('timeout')&.to_i
211+
if timeout
212+
framework_module.print_status "User '#{username}' locked out for #{timeout} seconds. Sleeping, and retrying..."
213+
sleep(timeout + 1) # + 1 as TeamCity is off-by-one when reporting the lockout timer.
214+
result = try_login(username, password, public_key, retry_counter + 1)
215+
return result
216+
end
217+
218+
return { status: ::Metasploit::Model::Login::Status::INCORRECT, proof: res } if res.body.match?('Incorrect username or password')
219+
220+
raise DecryptionException, 'The server failed to decrypt the encrypted password' if res.body.match?('DecryptionFailedException')
221+
raise PublicKeyExpiredError, 'The server public key has expired' if res.body.match?('publicKeyExpired')
222+
223+
{ status: :success, proof: res }
224+
end
225+
226+
# Send a logout request for the provided user's headers.
227+
# This header stores the user's cookie.
228+
def logout_with_headers(headers)
229+
logout_params = {
230+
'method' => 'POST',
231+
'uri' => normalize_uri(@uri.to_s, LOGOUT_PAGE),
232+
'headers' => headers
233+
}
234+
235+
begin
236+
send_request(logout_params)
237+
rescue Rex::ConnectionError => _e
238+
# ignore
239+
end
240+
end
241+
242+
def attempt_login(credential)
243+
result_options = {
244+
credential: credential,
245+
host: @host,
246+
port: @port,
247+
protocol: 'tcp',
248+
service_name: 'teamcity'
249+
}
250+
251+
if @public_key.nil?
252+
public_key_result = get_public_key
253+
return Result.new(result_options.merge(public_key_result)) if public_key_result[:status] != :success
254+
255+
@public_key = public_key_result[:proof]
256+
end
257+
258+
login_result = try_login(credential.public, credential.private, @public_key)
259+
return Result.new(result_options.merge(login_result)) if login_result[:status] != :success
260+
261+
# Ensure we log the user out, so that our logged in session does not appear under the user's profile.
262+
logout_with_headers(login_result[:proof].headers)
263+
264+
result_options[:status] = ::Metasploit::Model::Login::Status::SUCCESSFUL
265+
Result.new(result_options)
266+
end
267+
268+
private
269+
270+
attr_accessor :public_key
271+
272+
end
273+
end
274+
end
275+
end
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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+
9+
class MetasploitModule < Msf::Auxiliary
10+
include Msf::Auxiliary::Scanner
11+
include Msf::Auxiliary::AuthBrute
12+
include Msf::Exploit::Remote::HttpClient
13+
14+
def initialize(info = {})
15+
super(
16+
update_info(
17+
info,
18+
'Name' => 'JetBrains TeamCity Login Scanner',
19+
'Description' => 'This module performs login attempts against a JetBrains TeamCity webpage to bruteforce possible credentials.',
20+
'Author' => [ 'adfoster-r7', 'sjanusz-r7' ],
21+
'License' => MSF_LICENSE,
22+
'Notes' => {
23+
'Stability' => [ CRASH_SAFE ],
24+
'Reliability' => [],
25+
'SideEffects' => [ IOC_IN_LOGS, ACCOUNT_LOCKOUTS ]
26+
}
27+
)
28+
)
29+
30+
register_options(
31+
[
32+
Msf::OptString.new('TARGETURI', [true, 'The base path to the TeamCity application', '/']),
33+
Opt::RPORT(8111),
34+
OptBool.new('PASSWORD_SPRAY', [true, 'Reverse the credential pairing order. For each password, attempt every possible user.', true]),
35+
], self.class
36+
)
37+
38+
options_to_deregister = ['DOMAIN']
39+
deregister_options(*options_to_deregister)
40+
end
41+
42+
def process_credential(credential_data)
43+
credential_combo = "#{credential_data[:username]}:#{credential_data[:private_data]}"
44+
case credential_data[:status]
45+
when Metasploit::Model::Login::Status::SUCCESSFUL
46+
print_good "#{credential_data[:address]}:#{credential_data[:port]} - Login Successful: #{credential_combo}"
47+
credential_core = create_credential(credential_data)
48+
credential_data[:core] = credential_core
49+
create_credential_login(credential_data)
50+
return { status: :success, credential: credential_data }
51+
else
52+
error_msg = "#{credential_data[:address]}:#{credential_data[:port]} - LOGIN FAILED: #{credential_combo} (#{credential_data[:status]})"
53+
vprint_error error_msg
54+
invalidate_login(credential_data)
55+
return { status: :fail, credential: credential_data }
56+
end
57+
end
58+
59+
def run_scanner(scanner)
60+
successful_logins = []
61+
scanner.scan! do |result|
62+
credential_data = result.to_h
63+
credential_data.merge!(
64+
module_fullname: fullname,
65+
workspace_id: myworkspace_id
66+
)
67+
68+
processed_credential = process_credential(credential_data)
69+
successful_logins << processed_credential[:credential] if processed_credential[:status] == :success
70+
end
71+
{ successful_logins: successful_logins }
72+
end
73+
74+
def run_host(ip)
75+
cred_collection = build_credential_collection(
76+
username: datastore['USERNAME'],
77+
password: datastore['PASSWORD']
78+
)
79+
80+
scanner_opts = configure_http_login_scanner(
81+
host: ip,
82+
uri: target_uri,
83+
port: datastore['RPORT'],
84+
proxies: datastore['Proxies'],
85+
cred_details: cred_collection,
86+
stop_on_success: datastore['STOP_ON_SUCCESS'],
87+
bruteforce_speed: datastore['BRUTEFORCE_SPEED'],
88+
connection_timeout: datastore['HttpClientTimeout'] || 5,
89+
framework: framework,
90+
framework_module: self,
91+
http_success_codes: [200, 302],
92+
method: 'POST',
93+
ssl: datastore['SSL']
94+
)
95+
96+
scanner = Metasploit::Framework::LoginScanner::Teamcity.new(scanner_opts)
97+
run_scanner(scanner)
98+
end
99+
end

0 commit comments

Comments
 (0)