Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
rapid7
GitHub Repository: rapid7/metasploit-framework
Path: blob/master/lib/metasploit/framework/login_scanner/teamcity.rb
27907 views
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 = self.superclass::LIKELY_PORTS + [8111]
125
LIKELY_SERVICE_NAMES = self.superclass::LIKELY_SERVICE_NAMES + [
126
# Comes from nmap 7.95 on MacOS
127
'skynetflow',
128
'teamcity'
129
]
130
PRIVATE_TYPES = [:password]
131
REALM_KEY = nil
132
133
LOGIN_PAGE = 'login.html'
134
LOGOUT_PAGE = 'ajax.html?logout=1'
135
SUBMIT_PAGE = 'loginSubmit.html'
136
137
class TeamCityError < StandardError; end
138
class StackLevelTooDeepError < TeamCityError; end
139
class NoPublicKeyError < TeamCityError; end
140
class PublicKeyExpiredError < TeamCityError; end
141
class DecryptionError < TeamCityError; end
142
class ServerNeedsSetupError < TeamCityError; end
143
144
# Checks if the target is JetBrains TeamCity. The login module should call this.
145
#
146
# @return [Boolean] TrueClass if target is TeamCity, otherwise FalseClass
147
def check_setup
148
request_params = {
149
'method' => 'GET',
150
'uri' => normalize_uri(@uri.to_s, LOGIN_PAGE)
151
}
152
res = send_request(request_params)
153
154
if res && res.code == 200 && res.body&.include?('Log in to TeamCity')
155
return false
156
end
157
158
"Unable to locate \"Log in to TeamCity\" in body. (Is this really TeamCity?)"
159
end
160
161
# Extract the server's public key from the server.
162
# @return [Hash] A hash with a status and an error or the server's public key.
163
def get_public_key
164
request_params = {
165
'method' => 'GET',
166
'uri' => normalize_uri(@uri.to_s, LOGIN_PAGE)
167
}
168
169
begin
170
res = send_request(request_params)
171
rescue ::Rex::ConnectionError, ::Rex::ConnectionProxyError, ::Errno::ECONNRESET, ::Errno::EINTR, ::Rex::TimeoutError, ::Timeout::Error, ::EOFError => e
172
return { status: ::Metasploit::Model::Login::Status::UNABLE_TO_CONNECT, proof: e }
173
end
174
175
return { status: ::Metasploit::Model::Login::Status::UNABLE_TO_CONNECT, proof: 'Unable to connect to the TeamCity service' } if res.nil?
176
177
raise ServerNeedsSetupError, 'The server has not performed the initial setup' if res.code == 503
178
179
html_doc = res.get_html_document
180
public_key = html_doc.xpath('//input[@id="publicKey"]/@value').text
181
raise NoPublicKeyError, 'Could not find the TeamCity public key in the HTML document' if public_key.empty?
182
183
{ status: :success, proof: public_key }
184
end
185
186
# Create a login request for the provided credentials.
187
# @param [String] username The username to create the login request for.
188
# @param [String] password The password to log in with.
189
# @param [String] public_key The public key to encrypt the password with.
190
# @return [Hash] The login request parameter hash.
191
def create_login_request(username, password, public_key)
192
{
193
'method' => 'POST',
194
'uri' => normalize_uri(@uri.to_s, SUBMIT_PAGE),
195
'ctype' => 'application/x-www-form-urlencoded',
196
'vars_post' => {
197
username: username,
198
remember: true,
199
_remember: '',
200
submitLogin: 'Log in',
201
publicKey: public_key,
202
encryptedPassword: encrypt_data(password, public_key)
203
}
204
}
205
end
206
207
# Try logging in with the provided username, password and public key.
208
# @param [String] username The username to send the login request for.
209
# @param [String] password The user's password.
210
# @param [String] public_key The public key used to encrypt the password.
211
# @return [Hash] A hash with the status and an error or the response.
212
def try_login(username, password, public_key, retry_counter = 0)
213
raise StackLevelTooDeepError, 'try_login stack level too deep!' if retry_counter >= 2
214
215
login_request = create_login_request(username, password, public_key)
216
217
begin
218
res = send_request(login_request)
219
rescue ::Rex::ConnectionError, ::Rex::ConnectionProxyError, ::Errno::ECONNRESET, ::Errno::EINTR, ::Rex::TimeoutError, ::Timeout::Error, ::EOFError => e
220
return { status: ::Metasploit::Model::Login::Status::UNABLE_TO_CONNECT, proof: e }
221
end
222
223
return { status: ::Metasploit::Model::Login::Status::UNABLE_TO_CONNECT, proof: 'Unable to connect to the TeamCity service' } if res.nil?
224
return { status: ::Metasploit::Model::Login::Status::UNABLE_TO_CONNECT, proof: "Received an unexpected status code: #{res.code}" } if res.code != 200
225
226
# Check if the current username is timed out. Sleep if so.
227
# TODO: This can be improved. The `try_login` method should not block until it can retry credentials.
228
# This responsibility should fall onto the caller, and the caller should keep track of the tried, locked out and untried sets of credentials,
229
# and it should be up to the caller and its scheduler algorithm to retry credentials, rather than force this method to block.
230
# Currently, those building blocks are not available, so this is the approach I have implemented.
231
timeout = res.body.match(/login only in (?<timeout>\d+)s/)&.named_captures&.dig('timeout')&.to_i
232
if timeout
233
framework_module.print_status "#{@host}:#{@port} - User '#{username}:#{password}' locked out for #{timeout} seconds. Sleeping, and retrying..." if framework_module
234
sleep(timeout + 1)
235
return try_login(username, password, public_key, retry_counter + 1)
236
end
237
238
return { status: ::Metasploit::Model::Login::Status::INCORRECT, proof: res } if res.body.match?('Incorrect username or password')
239
240
raise DecryptionError, 'The server failed to decrypt the encrypted password' if res.body.match?('DecryptionFailedException')
241
raise PublicKeyExpiredError, 'The server public key has expired' if res.body.match?('publicKeyExpired')
242
243
# After filtering out known failures, default to retuning the credential as working.
244
# This way, people are more likely to notice any incorrect credential reporting going forward and report them,
245
# the scenarios for which can then be correctly implemented and handled similar to the above.
246
{ status: :success, proof: res }
247
end
248
249
# Send a logout request for the provided user's headers.
250
# This header stores the user's cookie.
251
def logout_with_headers(headers)
252
logout_params = {
253
'method' => 'POST',
254
'uri' => normalize_uri(@uri.to_s, LOGOUT_PAGE),
255
'headers' => headers
256
}
257
258
begin
259
send_request(logout_params)
260
rescue Rex::ConnectionError => _e
261
# ignore
262
end
263
end
264
265
def attempt_login(credential)
266
result_options = {
267
credential: credential,
268
host: @host,
269
port: @port,
270
protocol: 'tcp',
271
service_name: 'teamcity'
272
}
273
274
if @public_key.nil?
275
public_key_result = get_public_key
276
return Result.new(result_options.merge(public_key_result)) if public_key_result[:status] != :success
277
278
@public_key = public_key_result[:proof]
279
end
280
281
login_result = try_login(credential.public, credential.private, @public_key)
282
return Result.new(result_options.merge(login_result)) if login_result[:status] != :success
283
284
# Ensure we log the user out, so that our logged in session does not appear under the user's profile.
285
logout_with_headers(login_result[:proof].headers)
286
287
result_options[:status] = ::Metasploit::Model::Login::Status::SUCCESSFUL
288
Result.new(result_options)
289
end
290
291
private
292
293
attr_accessor :public_key
294
295
end
296
end
297
end
298
end
299
300