# -*- coding: binary -*-

#
# This mixin is a wrapper around Net::LDAP
#

require 'rex/proto/ldap'

module Msf
  module Exploit::Remote::LDAP
    include Msf::Exploit::Remote::Kerberos::Ticket::Storage
    include Msf::Exploit::Remote::Kerberos::ServiceAuthenticator::Options

    # Initialize the LDAP client and set up the LDAP specific datastore
    # options to allow the client to perform authentication and timeout
    # operations. Acts as a wrapper around the caller's
    # implementation of the `initialize` method, which will usually be
    # the module's class's implementation, such as lib/msf/core/auxiliary.rb.
    #
    # @param info [Hash] A hash containing information about the module
    #   using this library which includes its name, description, author, references,
    #   disclosure date, license, actions, default action, default options,
    #   and notes.
    def initialize(info = {})
      super

      register_options([
        Opt::RHOST,
        Opt::RPORT(389),
        OptBool.new('SSL', [false, 'Enable SSL on the LDAP connection', false]),
        Msf::OptString.new('DOMAIN', [false, 'The domain to authenticate to']),
        Msf::OptString.new('USERNAME', [false, 'The username to authenticate with'], aliases: ['BIND_DN']),
        Msf::OptString.new('PASSWORD', [false, 'The password to authenticate with'], aliases: ['BIND_PW'])
      ])

      register_advanced_options(
        [
          Opt::Proxies,
          *kerberos_storage_options(protocol: 'LDAP'),
          *kerberos_auth_options(protocol: 'LDAP', auth_methods: Msf::Exploit::Remote::AuthOption::LDAP_OPTIONS),
          Msf::OptPath.new('LDAP::CertFile', [false, 'The path to the PKCS12 (.pfx) certificate file to authenticate with'], conditions: ['LDAP::Auth', '==', Msf::Exploit::Remote::AuthOption::SCHANNEL]),
          OptFloat.new('LDAP::ConnectTimeout', [true, 'Timeout for LDAP connect', 10.0])
        ]
      )
    end

    # Alias to return the RHOST datastore option.
    #
    # @return [String] The current value of RHOST in the datastore.
    def rhost
      datastore['RHOST']
    end

    # Alias to return the RPORT datastore option.
    #
    # @return [String] The current value of RPORT in the datastore.
    def rport
      datastore['RPORT']
    end

    # Return the peer as a host:port formatted string.
    #
    # @return [String] A string containing the peer details in RHOST:RPORT format.
    def peer
      "#{rhost}:#{rport}"
    end

    # Set the various connection options to use when connecting to the
    # target LDAP server based on the current datastore options. Returns
    # the resulting connection configuration as a hash.
    #
    # @return [Hash] The options to use when connecting to the target
    #    LDAP server.
    def get_connect_opts
      connect_opts = {
        host: rhost,
        port: rport,
        proxies: datastore['Proxies'],
        connect_timeout: datastore['LDAP::ConnectTimeout']
      }

      if datastore['SSL']
        connect_opts[:encryption] = {
          method: :simple_tls,
          tls_options: {
            verify_mode: OpenSSL::SSL::VERIFY_NONE
          }
        }
      end

      case datastore['LDAP::Auth']
      when Msf::Exploit::Remote::AuthOption::SCHANNEL
        pfx_path = datastore['LDAP::CertFile']
        fail_with(Msf::Exploit::Remote::Failure::BadConfig, 'The LDAP::CertFile option is required when using SCHANNEL authentication.') if pfx_path.blank?
        fail_with(Msf::Exploit::Remote::Failure::BadConfig, 'The SSL option must be enabled when using SCHANNEL authentication.') if datastore['SSL'] != true

        unless ::File.file?(pfx_path) and ::File.readable?(pfx_path)
          fail_with(Msf::Exploit::Remote::Failure::BadConfig, 'Failed to load the PFX certificate file. The path was not a readable file.')
        end

        begin
          pkcs = OpenSSL::PKCS12.new(File.binread(pfx_path), '')
        rescue => e
          fail_with(Msf::Exploit::Remote::Failure::BadConfig, "Failed to load the PFX file (#{e})")
        end

        connect_opts[:auth] = {
          method: :sasl,
          mechanism: 'EXTERNAL',
          initial_credential: '',
          challenge_response: true
        }
        connect_opts[:encryption] = {
          method: :start_tls,
          tls_options: {
            verify_mode: OpenSSL::SSL::VERIFY_NONE,
            cert: pkcs.certificate,
            key: pkcs.key
          }
        }
      when Msf::Exploit::Remote::AuthOption::KERBEROS
        fail_with(Msf::Exploit::Failure::BadConfig, 'The Ldap::Rhostname option is required when using Kerberos authentication.') if datastore['Ldap::Rhostname'].blank?
        fail_with(Msf::Exploit::Failure::BadConfig, 'The DOMAIN option is required when using Kerberos authentication.') if datastore['DOMAIN'].blank?
        fail_with(Msf::Exploit::Failure::BadConfig, 'The DomainControllerRhost is required when using Kerberos authentication.') if datastore['DomainControllerRhost'].blank?
        offered_etypes = Msf::Exploit::Remote::AuthOption.as_default_offered_etypes(datastore['Ldap::KrbOfferedEncryptionTypes'])
        fail_with(Msf::Exploit::Failure::BadConfig, 'At least one encryption type is required when using Kerberos authentication.') if offered_etypes.empty?

        kerberos_authenticator = Msf::Exploit::Remote::Kerberos::ServiceAuthenticator::LDAP.new(
          host: datastore['DomainControllerRhost'],
          hostname: datastore['Ldap::Rhostname'],
          proxies: datastore['Proxies'],
          realm: datastore['DOMAIN'],
          username: datastore['USERNAME'],
          password: datastore['PASSWORD'],
          framework: framework,
          framework_module: self,
          cache_file: datastore['Ldap::Krb5Ccname'].blank? ? nil : datastore['Ldap::Krb5Ccname'],
          ticket_storage: kerberos_ticket_storage,
          offered_etypes: offered_etypes
        )

        kerberos_result = kerberos_authenticator.authenticate

        connect_opts[:auth] = {
          method: :sasl,
          mechanism: 'GSS-SPNEGO',
          initial_credential: kerberos_result[:security_blob],
          challenge_response: true
        }
      when Msf::Exploit::Remote::AuthOption::NTLM
        ntlm_client = RubySMB::NTLM::Client.new(
          datastore['USERNAME'],
          datastore['PASSWORD'],
          workstation: 'WORKSTATION',
          domain: datastore['DOMAIN'].blank? ? '.' : datastore['DOMAIN'],
          flags:
            RubySMB::NTLM::NEGOTIATE_FLAGS[:UNICODE] |
            RubySMB::NTLM::NEGOTIATE_FLAGS[:REQUEST_TARGET] |
            RubySMB::NTLM::NEGOTIATE_FLAGS[:NTLM] |
            RubySMB::NTLM::NEGOTIATE_FLAGS[:ALWAYS_SIGN] |
            RubySMB::NTLM::NEGOTIATE_FLAGS[:EXTENDED_SECURITY] |
            RubySMB::NTLM::NEGOTIATE_FLAGS[:KEY_EXCHANGE] |
            RubySMB::NTLM::NEGOTIATE_FLAGS[:TARGET_INFO] |
            RubySMB::NTLM::NEGOTIATE_FLAGS[:VERSION_INFO]
        )

        negotiate = proc do |challenge|
          ntlmssp_offset = challenge.index('NTLMSSP')
          type2_blob = challenge.slice(ntlmssp_offset..-1)
          challenge = [type2_blob].pack('m')
          type3_message = ntlm_client.init_context(challenge)
          type3_message.serialize
        end

        connect_opts[:auth] = {
          method: :sasl,
          mechanism: 'GSS-SPNEGO',
          initial_credential: ntlm_client.init_context.serialize,
          challenge_response: negotiate
        }
      when Msf::Exploit::Remote::AuthOption::PLAINTEXT
        username = datastore['USERNAME'].dup
        username << "@#{datastore['DOMAIN']}" unless datastore['DOMAIN'].blank?
        connect_opts[:auth] = {
          method: :simple,
          username: username,
          password: datastore['PASSWORD']
        }
      when Msf::Exploit::Remote::AuthOption::AUTO
        unless datastore['USERNAME'].blank? # plaintext if specified
          username = datastore['USERNAME'].dup
          username << "@#{datastore['DOMAIN']}" unless datastore['DOMAIN'].blank?
          connect_opts[:auth] = {
            method: :simple,
            username: username,
            password: datastore['PASSWORD']
          }
        end
      end

      connect_opts
    end

    # Connect to the target LDAP server using the options provided,
    # and pass the resulting connection object to the proc provided.
    # Terminate the connection once the proc finishes executing.
    #
    # @param opts [Hash] Options for the LDAP connection.
    # @param block [Proc] A proc containing the functionality to execute
    #   after the LDAP connection has succeeded. The connection is closed
    #   once this proc finishes executing.
    # @see Net::LDAP.open
    # @return [Object] The result of whatever the block that was
    #   passed in via the "block" parameter yielded.
    def ldap_connect(opts = {}, &block)
      Net::LDAP.open(get_connect_opts.merge(opts), &block)
    end

    # Create a new LDAP connection using Net::LDAP.new and yield the
    # resulting connection object to the caller of this method.
    #
    # @param opts [Hash] A hash containing the connection options for the
    #   LDAP connection to the target server.
    # @yieldparam ldap [Net::LDAP] The LDAP connection handle to use for connecting to
    #   the target LDAP server.
    def ldap_new(opts = {})
      ldap = Net::LDAP.new(get_connect_opts.merge(opts))

      # NASTY, but required
      # monkey patch ldap object in order to ignore bind errors
      # Some servers (e.g. OpenLDAP) return result even after a bind
      # has failed, e.g. with LDAP_INAPPROPRIATE_AUTH - anonymous bind disallowed.
      # See: https://www.openldap.org/doc/admin23/security.html#Authentication%20Methods
      # "Note that disabling the anonymous bind mechanism does not prevent anonymous
      # access to the directory."
      # Bug created for Net:LDAP at https://github.com/ruby-ldap/ruby-net-ldap/issues/375
      #
      # @yieldparam conn [Net::LDAP] The LDAP connection handle to use for connecting to
      #   the target LDAP server.
      # @param args [Hash] A hash containing options for the ldap connection
      def ldap.use_connection(args)
        if @open_connection
          yield @open_connection
        else
          begin
            conn = new_connection
            conn.bind(args[:auth] || @auth)
            # Commented out vs. original
            # result = conn.bind(args[:auth] || @auth)
            # return result unless result.result_code == Net::LDAP::ResultCodeSuccess
            yield conn
          ensure
            conn.close if conn
          end
        end
      end
      yield ldap
    end

    # Get the naming contexts for the target LDAP server.
    #
    # @param ldap [Net::LDAP] The Net::LDAP connection handle for the
    #   current LDAP connection.
    # @return [Net::BER::BerIdentifiedArray] Array of naming contexts for the target LDAP server.
    def get_naming_contexts(ldap)
      vprint_status("#{peer} Getting root DSE")

      unless (root_dse = ldap.search_root_dse)
        print_error("#{peer} Could not retrieve root DSE")
        return
      end

      naming_contexts = root_dse[:namingcontexts]

      # NOTE: Net::LDAP converts attribute names to lowercase
      if naming_contexts.empty?
        print_error("#{peer} Empty namingContexts attribute")
        return
      end

      naming_contexts
    end

    # Discover the base DN of the target LDAP server via the LDAP
    # server's naming contexts.
    #
    # @param ldap [Net::LDAP] The Net::LDAP connection handle for the
    #   current LDAP connection.
    # @return [String] A string containing the base DN of the target LDAP server.
    def discover_base_dn(ldap)
      # @type [Net::BER::BerIdentifiedArray]
      naming_contexts = get_naming_contexts(ldap)

      unless naming_contexts
        print_error("#{peer} Base DN cannot be determined")
        return
      end

      # NOTE: Find the first entry that starts with `DC=` as this will likely be the base DN.
      naming_contexts.select! { |context| context =~ /^(DC=[A-Za-z0-9-]+,?)+$/ }
      naming_contexts.reject! { |context| context =~ /(Configuration)|(Schema)|(ForestDnsZones)/ }
      if naming_contexts.blank?
        print_error("#{peer} A base DN matching the expected format could not be found!")
        return
      end
      base_dn = naming_contexts[0]

      print_good("#{peer} Discovered base DN: #{base_dn}")
      base_dn
    end

    # Check whether it was possible to successfully bind to the target LDAP
    # server. Raise a RuntimeException with an appropriate error message
    # if not.
    #
    # @param ldap [Net::LDAP] The Net::LDAP connection handle for the
    #   current LDAP connection.
    #
    # @raise [RuntimeError] A RuntimeError will be raised if the LDAP
    #   bind request failed.
    # @return [Nil] This function does not return any data.
    def validate_bind_success!(ldap)
      bind_result = ldap.get_operation_result.table

      # Codes taken from https://ldap.com/ldap-result-code-reference-core-ldapv3-result-codes
      case bind_result[:code]
      when 0
        vprint_good('Successfully bound to the LDAP server!')
      when 1
        fail_with(Msf::Module::Failure::NoAccess, "An operational error occurred, perhaps due to lack of authorization. The error was: #{bind_result[:error_message].strip}")
      when 7
        fail_with(Msf::Module::Failure::NoTarget, 'Target does not support the simple authentication mechanism!')
      when 8
        fail_with(Msf::Module::Failure::NoTarget, "Server requires a stronger form of authentication than we can provide! The error was: #{bind_result[:error_message].strip}")
      when 14
        fail_with(Msf::Module::Failure::NoTarget, "Server requires additional information to complete the bind. Error was: #{bind_result[:error_message].strip}")
      when 48
        fail_with(Msf::Module::Failure::NoAccess, "Target doesn't support the requested authentication type we sent. Try binding to the same user without a password, or providing credentials if you were doing anonymous authentication.")
      when 49
        fail_with(Msf::Module::Failure::NoAccess, 'Invalid credentials provided!')
      else
        fail_with(Msf::Module::Failure::Unknown, "Unknown error occurred whilst binding: #{bind_result[:error_message].strip}")
      end
    end

    # Validate the query result and check whether the query succeeded.
    # Fail with an appropriate error code if the query failed.
    #
    # @param query_result [Hash] A hash containing the results of the query
    #   as a 'extended_response' representing the extended response,
    #   a 'code' with an integer representing the result code,
    #   a 'error_message' containing an optional error message as a Net::BER::BerIdentifiedString,
    #   a 'matched_dn' containing the matched DN,
    #   and a 'message' containing the query result message.
    # @param filter [Net::LDAP::Filter]  A Net::LDAP::Filter to use to
    #   filter the results of the query.
    #
    # @raise [RuntimeError, ArgumentError] A RuntimeError will be raised if the LDAP
    #   request failed. Alternatively, if the query_result parameter isn't a hash, then an
    #   ArgumentError will be raised.
    # @return [Nil] This function does not return any data.
    def validate_query_result!(query_result, filter=nil)
      if query_result.class != Hash
        raise ArgumentError, 'Parameter to "validate_query_result!" function was not a Hash!'
      end

      # Codes taken from https://ldap.com/ldap-result-code-reference-core-ldapv3-result-codes
      case query_result[:code]
      when 0
        vprint_status("Successfully queried #{filter}.") if filter.present?
      when 1
        # This is unknown as whilst we could fail on lack of authorization, this is not guaranteed with this error code.
        # The user will need to inspect the error message to determine the root cause of the issue.
        fail_with(Msf::Module::Failure::Unknown, "An LDAP operational error occurred. It is likely the client requires authorization! The error was: #{query_result[:error_message].strip}")
      when 2
        fail_with(Msf::Module::Failure::BadConfig, "The LDAP protocol being used by Metasploit isn't supported. The error was #{query_result[:error_message].strip}")
      when 3
        fail_with(Msf::Module::Failure::TimeoutExpired, 'The LDAP server returned a timeout response to the query.')
      when 4
        fail_with(Msf::Module::Failure::UnexpectedReply, 'The LDAP query was determined to result in too many entries for the LDAP server to return.')
      when 11
        fail_with(Msf::Module::Failure::UnexpectedReply, 'The LDAP server indicated some administrative limit within the server whilst the request was being processed.')
      when 16
        fail_with(Msf::Module::Failure::NotFound, 'The LDAP operation failed because the referenced attribute does not exist.')
      when 18
        fail_with(Msf::Module::Failure::BadConfig, 'The LDAP search failed because some matching is not supported for the target attribute type!')
      when 32
        fail_with(Msf::Module::Failure::UnexpectedReply, 'The LDAP search failed because the operation targeted an entity within the base DN that does not exist.')
      when 33
        fail_with(Msf::Module::Failure::BadConfig, "An attempt was made to dereference an alias that didn't resolve properly.")
      when 34
        fail_with(Msf::Module::Failure::BadConfig, 'The request included an invalid base DN entry.')
      when 50
        fail_with(Msf::Module::Failure::NoAccess, 'The LDAP operation failed due to insufficient access rights.')
      when 51
        fail_with(Msf::Module::Failure::UnexpectedReply, 'The LDAP operation failed because the server is too busy to perform the request.')
      when 52
        fail_with(Msf::Module::Failure::UnexpectedReply, 'The LDAP operation failed because the server is not currently available to process the request.')
      when 53
        fail_with(Msf::Module::Failure::UnexpectedReply, 'The LDAP operation failed because the server is unwilling to perform the request.')
      when 64
        fail_with(Msf::Module::Failure::Unknown, 'The LDAP operation failed due to a naming violation.')
      when 65
        fail_with(Msf::Module::Failure::Unknown, 'The LDAP operation failed due to an object class violation.')
      else
        if query_result[:error_message].blank?
          fail_with(Msf::Module::Failure::Unknown, 'The LDAP operation failed but no error message was returned!')
        else
          fail_with(Msf::Module::Failure::Unknown, "The LDAP operation failed with error: #{query_result[:error_message].strip}")
        end
      end
    end
  end
end
