Skip to content

Commit 8d838d4

Browse files
authored
Land rapid7#19366, Jenkins Login Scanner improvments
2 parents d6a03b2 + a3a2441 commit 8d838d4

File tree

4 files changed

+160
-56
lines changed

4 files changed

+160
-56
lines changed

lib/metasploit/framework/login_scanner/http.rb

Lines changed: 51 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
require 'metasploit/framework/login_scanner/base'
32
require 'metasploit/framework/login_scanner/rex_socket'
43

@@ -12,14 +11,16 @@ class HTTP
1211
include Metasploit::Framework::LoginScanner::Base
1312
include Metasploit::Framework::LoginScanner::RexSocket
1413

15-
DEFAULT_REALM = nil
16-
DEFAULT_PORT = 80
17-
DEFAULT_SSL_PORT = 443
18-
DEFAULT_HTTP_SUCCESS_CODES = [ 200, 201 ].append(*(300..309))
19-
LIKELY_PORTS = [ 80, 443, 8000, 8080 ]
20-
LIKELY_SERVICE_NAMES = [ 'http', 'https' ]
21-
PRIVATE_TYPES = [ :password ]
22-
REALM_KEY = Metasploit::Model::Realm::Key::ACTIVE_DIRECTORY_DOMAIN
14+
AUTHORIZATION_HEADER = 'WWW-Authenticate'.freeze
15+
DEFAULT_REALM = nil
16+
DEFAULT_PORT = 80
17+
DEFAULT_SSL_PORT = 443
18+
DEFAULT_HTTP_SUCCESS_CODES = [200, 201].append(*(300..309))
19+
DEFAULT_HTTP_NOT_AUTHED_CODES = [401]
20+
LIKELY_PORTS = [80, 443, 8000, 8080]
21+
LIKELY_SERVICE_NAMES = %w[http https]
22+
PRIVATE_TYPES = [:password]
23+
REALM_KEY = Metasploit::Model::Realm::Key::ACTIVE_DIRECTORY_DOMAIN
2324

2425
# @!attribute uri
2526
# @return [String] The path and query string on the server to
@@ -213,16 +214,14 @@ def check_setup
213214
# authentication
214215
response = http_client._send_recv(request)
215216
rescue ::EOFError, Errno::ETIMEDOUT, OpenSSL::SSL::SSLError, Rex::ConnectionError, ::Timeout::Error
216-
return "Unable to connect to target"
217+
return 'Unable to connect to target'
217218
end
218219

219-
if !(response && response.code == 401 && response.headers['WWW-Authenticate'])
220-
error_message = "No authentication required"
221-
else
222-
error_message = false
220+
if authentication_required?(response)
221+
return false
223222
end
224223

225-
error_message
224+
'No authentication required'
226225
end
227226

228227
# Sends a HTTP request with Rex
@@ -252,7 +251,7 @@ def send_request(opts)
252251
else
253252
cli._send_recv(req)
254253
end
255-
rescue ::EOFError, Errno::ETIMEDOUT ,Errno::ECONNRESET, Rex::ConnectionError, OpenSSL::SSL::SSLError, ::Timeout::Error => e
254+
rescue ::EOFError, Errno::ETIMEDOUT, Errno::ECONNRESET, Rex::ConnectionError, OpenSSL::SSL::SSLError, ::Timeout::Error => e
256255
raise Rex::ConnectionError, e.message
257256
ensure
258257
# If we didn't create the client, don't close it
@@ -315,18 +314,31 @@ def attempt_login(credential)
315314
Result.new(result_opts)
316315
end
317316

317+
protected
318+
319+
# Returns a boolean value indicating whether the request requires authentication or not.
320+
#
321+
# @param [Rex::Proto::Http::Response] response The response received from the HTTP endpoint
322+
# @return [Boolean] True if the request required authentication; otherwise false.
323+
def authentication_required?(response)
324+
return false unless response
325+
326+
self.class::DEFAULT_HTTP_NOT_AUTHED_CODES.include?(response.code) &&
327+
response.headers[self.class::AUTHORIZATION_HEADER]
328+
end
329+
318330
private
319331

320332
def create_client(opts)
321-
rhost = opts['host'] || host
322-
rport = opts['rport'] || port
323-
cli_ssl = opts['ssl'] || ssl
333+
rhost = opts['host'] || host
334+
rport = opts['rport'] || port
335+
cli_ssl = opts['ssl'] || ssl
324336
cli_ssl_version = opts['ssl_version'] || ssl_version
325-
cli_proxies = opts['proxies'] || proxies
326-
username = opts['credential'] ? opts['credential'].public : http_username
327-
password = opts['credential'] ? opts['credential'].private : http_password
328-
realm = opts['credential'] ? opts['credential'].realm : nil
329-
context = opts['context'] || { 'Msf' => framework, 'MsfExploit' => framework_module}
337+
cli_proxies = opts['proxies'] || proxies
338+
username = opts['credential'] ? opts['credential'].public : http_username
339+
password = opts['credential'] ? opts['credential'].private : http_password
340+
realm = opts['credential'] ? opts['credential'].realm : nil
341+
context = opts['context'] || { 'Msf' => framework, 'MsfExploit' => framework_module}
330342

331343
kerberos_authenticator = nil
332344
if kerberos_authenticator_factory
@@ -441,10 +453,22 @@ def set_sane_defaults
441453

442454
# Combine the base URI with the target URI in a sane fashion
443455
#
444-
# @param [String] target_uri the target URL
456+
# @param [Array<String>] target_uri the target URL
445457
# @return [String] the final URL mapped against the base
446-
def normalize_uri(target_uri)
447-
(self.uri.to_s + "/" + target_uri.to_s).gsub(/\/+/, '/')
458+
def normalize_uri(*target_uri)
459+
if target_uri.count == 1
460+
(uri.to_s + '/' + target_uri.first.to_s).gsub(%r{/+}, '/')
461+
else
462+
new_str = target_uri * '/'
463+
new_str = new_str.gsub!('//', '/') while new_str.index('//')
464+
465+
# Makes sure there's a starting slash
466+
unless new_str[0,1] == '/'
467+
new_str = '/' + new_str
468+
end
469+
470+
new_str
471+
end
448472
end
449473

450474
private

lib/metasploit/framework/login_scanner/jenkins.rb

Lines changed: 96 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,51 +5,127 @@ module Framework
55
module LoginScanner
66
# Jenkins login scanner
77
class Jenkins < HTTP
8-
9-
include Msf::Exploit::Remote::HTTP::Jenkins
10-
118
# Inherit LIKELY_PORTS,LIKELY_SERVICE_NAMES, and REALM_KEY from HTTP
129
CAN_GET_SESSION = true
13-
DEFAULT_PORT = 8080
14-
PRIVATE_TYPES = [ :password ]
10+
DEFAULT_HTTP_NOT_AUTHED_CODES = [403]
11+
DEFAULT_PORT = 8080
12+
PRIVATE_TYPES = [:password].freeze
13+
LOGIN_PATH_REGEX = /action="(j_([a-z0-9_]+))"/
14+
15+
# Checks the setup for the Jenkins Login scanner.
16+
#
17+
# @return [String, false] Always returns false.
18+
def check_setup
19+
login_uri = jenkins_login_url
20+
21+
return 'Unable to locate the Jenkins login path' if login_uri.nil?
22+
23+
self.uri = normalize_uri(login_uri)
24+
25+
false
26+
end
1527

1628
# (see Base#set_sane_defaults)
1729
def set_sane_defaults
18-
self.uri = "/j_acegi_security_check" if self.uri.nil?
19-
self.method = "POST" if self.method.nil?
30+
self.uri ||= '/'
2031

21-
if self.uri[0] != '/'
22-
self.uri = "/#{self.uri}"
32+
unless uri.to_s.start_with?('/')
33+
self.uri = "/#{uri}"
2334
end
2435

2536
super
2637
end
2738

2839
def attempt_login(credential)
2940
result_opts = {
30-
credential: credential,
31-
host: host,
32-
port: port,
33-
protocol: 'tcp'
41+
credential: credential,
42+
host: host,
43+
port: port,
44+
protocol: 'tcp'
3445
}
46+
3547
if ssl
3648
result_opts[:service_name] = 'https'
3749
else
3850
result_opts[:service_name] = 'http'
3951
end
4052

41-
status, proof = jenkins_login(credential.public, credential.private) do |request|
42-
send_request({
43-
'method' => method,
44-
'uri' => uri,
45-
'vars_post' => request['vars_post']
46-
})
47-
end
53+
status, proof = jenkins_login(credential.public, credential.private)
4854

4955
result_opts.merge!(status: status, proof: proof)
5056

5157
Result.new(result_opts)
5258
end
59+
60+
protected
61+
62+
# Returns a boolean value indicating whether the request requires authentication or not.
63+
#
64+
# @param [Rex::Proto::Http::Response] response The response received from the HTTP endpoint
65+
# @return [Boolean] True if the request required authentication; otherwise false.
66+
def authentication_required?(response)
67+
return false unless response
68+
69+
self.class::DEFAULT_HTTP_NOT_AUTHED_CODES.include?(response.code)
70+
end
71+
72+
private
73+
74+
# This method takes a username and password and a target URI
75+
# then attempts to login to Jenkins and will either fail with appropriate errors
76+
#
77+
# @param [String] username The username for login credentials
78+
# @param [String] password The password for login credentials
79+
# @return [Array] [status, proof] The result of the login attempt
80+
def jenkins_login(username, password)
81+
begin
82+
res = send_request(
83+
'method' => 'POST',
84+
'uri' => self.uri,
85+
'vars_post' => {
86+
'j_username' => username,
87+
'j_password' => password,
88+
'Submit' => 'log in'
89+
}
90+
)
91+
92+
if res && res.headers['Location'] && !res.headers['Location'].include?('loginError')
93+
status = Metasploit::Model::Login::Status::SUCCESSFUL
94+
proof = res.headers
95+
else
96+
status = Metasploit::Model::Login::Status::INCORRECT
97+
proof = res
98+
end
99+
rescue ::EOFError, Errno::ETIMEDOUT, Errno::ECONNRESET, Rex::ConnectionError, OpenSSL::SSL::SSLError, ::Timeout::Error => e
100+
status = Metasploit::Model::Login::Status::UNABLE_TO_CONNECT
101+
proof = e
102+
end
103+
104+
[status, proof]
105+
end
106+
107+
# This method uses the provided URI to determine whether login is possible for Jenkins.
108+
# Based on the contents of the provided URI, the method looks for the login form and
109+
# extracts the endpoint used to authenticate against.
110+
#
111+
# @return [String, nil] URI for successful login
112+
def jenkins_login_url
113+
response = send_request({ 'uri' => normalize_uri('login') })
114+
115+
if response&.code == 200 && response&.body =~ LOGIN_PATH_REGEX
116+
return Regexp.last_match(1)
117+
end
118+
119+
nil
120+
end
121+
122+
# Determines whether the provided response is considered valid or not.
123+
#
124+
# @param [Rex::Proto::Http::Response, nil] response The response received from the HTTP request.
125+
# @return [Boolean] True if the response if valid; otherwise false.
126+
def valid_response?(response)
127+
http_success_codes.include?(response&.code)
128+
end
53129
end
54130
end
55131
end

lib/msf/core/exploit/remote/http/jenkins.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ def jenkins_version
1515
res = send_request_cgi({ 'uri' => uri })
1616

1717
unless res
18-
return nil
18+
return nil
1919
end
2020

2121
# shortcut for new versions such as 2.426.2 and 2.440

modules/auxiliary/scanner/http/jenkins_login.rb

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ class MetasploitModule < Msf::Auxiliary
1111
include Msf::Exploit::Remote::HttpClient
1212
include Msf::Auxiliary::Report
1313
include Msf::Auxiliary::AuthBrute
14-
include Msf::Exploit::Remote::HTTP::Jenkins
1514

1615
def initialize
1716
super(
@@ -32,16 +31,16 @@ def initialize
3231
end
3332

3433
def run_host(ip)
35-
print_warning("#{self.fullname} is still calling the deprecated LOGIN_URL option! This is no longer supported.") unless datastore['LOGIN_URL'].nil?
34+
print_warning("#{fullname} is still calling the deprecated LOGIN_URL option! This is no longer supported.") unless datastore['LOGIN_URL'].nil?
3635
cred_collection = build_credential_collection(
3736
username: datastore['USERNAME'],
3837
password: datastore['PASSWORD']
3938
)
4039

41-
login_uri = jenkins_uri_check(target_uri)
4240
scanner = Metasploit::Framework::LoginScanner::Jenkins.new(
4341
configure_http_login_scanner(
44-
uri: normalize_uri(login_uri),
42+
uri: datastore['TARGETURI'],
43+
ssl: datastore['SSL'],
4544
method: datastore['HTTP_METHOD'],
4645
cred_details: cred_collection,
4746
stop_on_success: datastore['STOP_ON_SUCCESS'],
@@ -52,12 +51,17 @@ def run_host(ip)
5251
)
5352
)
5453

54+
message = scanner.check_setup
55+
56+
if message
57+
print_brute level: :error, ip: ip, msg: message
58+
return
59+
end
60+
5561
scanner.scan! do |result|
5662
credential_data = result.to_h
57-
credential_data.merge!(
58-
module_fullname: fullname,
59-
workspace_id: myworkspace_id
60-
)
63+
credential_data.merge!(module_fullname: fullname, workspace_id: myworkspace_id)
64+
6165
if result.success?
6266
credential_core = create_credential(credential_data)
6367
credential_data[:core] = credential_core

0 commit comments

Comments
 (0)