Skip to content

Commit a3a2441

Browse files
committed
MS-9517 Jenkins Login Scanner
Jenkins does not implement Authentication challenges. By default, Jenkins responds with a HTTP 403 FORBIDDEN response, and does not include the `WWW-Authenticate` header. This causes problems with the underlying http client, as this one expects the challenge to come forward and resend the request with the auth header. By changing the code to look for the HTTP 403 response, and setting the default URL to the correct login validation endpoint Pro will have an easier time to investigate whether Jenkins can be bruteforced or not. The original code checks for a 401 response only. Overwriting the behavior for Jenkins allows us to handle this use-case properly and report the correct behavior.
1 parent 52fb857 commit a3a2441

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)