diff --git a/lib/metasploit/framework/login_scanner/ldap.rb b/lib/metasploit/framework/login_scanner/ldap.rb index 37626f596ff9..2d63ddb94c12 100644 --- a/lib/metasploit/framework/login_scanner/ldap.rb +++ b/lib/metasploit/framework/login_scanner/ldap.rb @@ -11,8 +11,10 @@ class LDAP include Metasploit::Framework::LDAP::Client include Msf::Exploit::Remote::LDAP - attr_accessor :opts - attr_accessor :realm_key + attr_accessor :opts, :realm_key + # @!attribute use_client_as_proof + # @return [Boolean] If a login is successful and this attribute is true - an LDAP::Client instance is used as proof + attr_accessor :use_client_as_proof def attempt_login(credential) result_opts = { @@ -36,17 +38,24 @@ def do_login(credential) }.merge(@opts) connect_opts = ldap_connect_opts(host, port, connection_timeout, ssl: opts[:ssl], opts: opts) - ldap_open(connect_opts) do |ldap| - return status_code(ldap.get_operation_result.table) + begin + ldap_client = ldap_open(connect_opts, keep_open: true) + return status_code(ldap_client) rescue StandardError => e { status: Metasploit::Model::Login::Status::UNABLE_TO_CONNECT, proof: e } end end - def status_code(operation_result) - case operation_result[:code] + def status_code(ldap_client) + operation_result = ldap_client.get_operation_result.table[:code] + case operation_result when 0 - { status: Metasploit::Model::Login::Status::SUCCESSFUL } + result = { status: Metasploit::Model::Login::Status::SUCCESSFUL } + if use_client_as_proof + result[:proof] = ldap_client + result[:connection] = ldap_client.socket + end + result else { status: Metasploit::Model::Login::Status::INCORRECT, proof: "Bind Result: #{operation_result}" } end @@ -84,7 +93,6 @@ def each_credential credential.public = "#{credential.public}@#{opts[:domain]}" yield credential end - end end end diff --git a/lib/msf/base/config.rb b/lib/msf/base/config.rb index 01ff7d68f1f0..7ad6e97e0cf4 100644 --- a/lib/msf/base/config.rb +++ b/lib/msf/base/config.rb @@ -221,6 +221,13 @@ def self.smb_session_history self.new.smb_session_history end + # Returns the full path to the ldap session history file. + # + # @return [String] path to the history file. + def self.ldap_session_history + self.new.ldap_session_history + end + # Returns the full path to the PostgreSQL session history file. # # @return [String] path to the history file. @@ -351,6 +358,10 @@ def smb_session_history config_directory + FileSep + "smb_session_history" end + def ldap_session_history + config_directory + FileSep + "ldap_session_history" + end + def postgresql_session_history config_directory + FileSep + "postgresql_session_history" end diff --git a/lib/msf/base/sessions/ldap.rb b/lib/msf/base/sessions/ldap.rb new file mode 100644 index 000000000000..71c9013f781a --- /dev/null +++ b/lib/msf/base/sessions/ldap.rb @@ -0,0 +1,142 @@ +# -*- coding: binary -*- + +require 'rex/post/ldap' + +class Msf::Sessions::LDAP + # + # This interface supports basic interaction. + # + include Msf::Session::Basic + include Msf::Sessions::Scriptable + + # @return [Rex::Post::LDAP::Ui::Console] The interactive console + attr_accessor :console + # @return [Rex::Proto::LDAP::Client] The LDAP client + attr_accessor :client + + attr_accessor :platform, :arch + attr_reader :framework + + # @param[Rex::IO::Stream] rstream + # @param [Hash] opts + # @option opts [Rex::Proto::LDAP::Client] :client + def initialize(rstream, opts = {}) + @client = opts.fetch(:client) + self.console = Rex::Post::LDAP::Ui::Console.new(self) + super(rstream, opts) + end + + def bootstrap(datastore = {}, handler = nil) + session = self + session.init_ui(user_input, user_output) + + @info = "LDAP #{datastore['USERNAME']} @ #{@peer_info}" + end + + def execute_file(full_path, args) + if File.extname(full_path) == '.rb' + Rex::Script::Shell.new(self, full_path).run(args) + else + console.load_resource(full_path) + end + end + + def process_autoruns(datastore) + ['InitialAutoRunScript', 'AutoRunScript'].each do |key| + next if datastore[key].nil? || datastore[key].empty? + + args = Shellwords.shellwords(datastore[key]) + print_status("Session ID #{sid} (#{tunnel_to_s}) processing #{key} '#{datastore[key]}'") + execute_script(args.shift, *args) + end + end + + def type + self.class.type + end + + # Returns the type of session. + # + def self.type + 'ldap' + end + + def self.can_cleanup_files + false + end + + # + # Returns the session description. + # + def desc + 'LDAP' + end + + def address + @address ||= client.peerhost + end + + def port + @port ||= client.peerport + end + + ## + # :category: Msf::Session::Interactive implementors + # + # Initializes the console's I/O handles. + # + def init_ui(input, output) + self.user_input = input + self.user_output = output + console.init_ui(input, output) + console.set_log_source(log_source) + + super + end + + ## + # :category: Msf::Session::Interactive implementors + # + # Resets the console's I/O handles. + # + def reset_ui + console.unset_log_source + console.reset_ui + end + + def exit + console.stop + end + + ## + # :category: Msf::Session::Interactive implementors + # + # Override the basic session interaction to use shell_read and + # shell_write instead of operating on rstream directly. + def _interact + framework.events.on_session_interact(self) + framework.history_manager.with_context(name: type.to_sym) do + _interact_stream + end + end + + ## + # :category: Msf::Session::Interactive implementors + # + def _interact_stream + framework.events.on_session_interact(self) + + console.framework = framework + # Call the console interaction of the ldap client and + # pass it a block that returns whether or not we should still be + # interacting. This will allow the shell to abort if interaction is + # canceled. + console.interact { interacting != true } + console.framework = nil + + # If the stop flag has been set, then that means the user exited. Raise + # the EOFError so we can drop this handle like a bad habit. + raise EOFError if (console.stopped? == true) + end + +end diff --git a/lib/msf/core/exploit/remote/kerberos/client.rb b/lib/msf/core/exploit/remote/kerberos/client.rb index 1a2d36bd0a8d..358cf60dec13 100644 --- a/lib/msf/core/exploit/remote/kerberos/client.rb +++ b/lib/msf/core/exploit/remote/kerberos/client.rb @@ -27,7 +27,7 @@ module Client # @!attribute client # @return [Rex::Proto::Kerberos::Client] The kerberos client - attr_accessor :client + attr_accessor :kerberos_client def initialize(info = {}) super @@ -96,8 +96,8 @@ def connect(opts={}) protocol: 'tcp' ) - disconnect if client - self.client = kerb_client + disconnect if kerberos_client + self.kerberos_client = kerb_client kerb_client end @@ -105,11 +105,11 @@ def connect(opts={}) # Disconnects the Kerberos client # # @param kerb_client [Rex::Proto::Kerberos::Client] the client to disconnect - def disconnect(kerb_client = client) + def disconnect(kerb_client = kerberos_client) kerb_client.close if kerb_client - if kerb_client == client - self.client = nil + if kerb_client == kerberos_client + self.kerberos_client = nil end end @@ -129,7 +129,7 @@ def cleanup def send_request_as(opts = {}) connect(opts) req = opts.fetch(:req) { build_as_request(opts) } - res = client.send_recv(req) + res = kerberos_client.send_recv(req) disconnect res end @@ -143,7 +143,7 @@ def send_request_as(opts = {}) def send_request_tgs(opts = {}) connect(opts) req = opts.fetch(:req) { build_tgs_request(opts) } - res = client.send_recv(req) + res = kerberos_client.send_recv(req) disconnect res end diff --git a/lib/msf/core/exploit/remote/ldap.rb b/lib/msf/core/exploit/remote/ldap.rb index 2bf5b19ee94a..7320e7c48eb7 100644 --- a/lib/msf/core/exploit/remote/ldap.rb +++ b/lib/msf/core/exploit/remote/ldap.rb @@ -1,7 +1,7 @@ # -*- coding: binary -*- # -# This mixin is a wrapper around Net::LDAP +# This mixin is a wrapper around Rex::Proto::LDAP::Client # require 'rex/proto/ldap' @@ -79,6 +79,7 @@ def get_connect_opts username: datastore['USERNAME'], password: datastore['PASSWORD'], domain: datastore['DOMAIN'], + base: datastore['BASE_DN'], domain_controller_rhost: datastore['DomainControllerRhost'], ldap_auth: datastore['LDAP::Auth'], ldap_cert_file: datastore['LDAP::CertFile'], @@ -126,18 +127,24 @@ def ldap_connect(opts = {}, &block) # 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. + # Terminate the connection once the proc finishes executing unless + # `keep_open` is set to true # # @param connect_opts [Hash] Options for the LDAP connection. + # @param keep_open [Boolean] Keep the connection open or close once the block is finished # @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 + # @see Rex::Proto::LDAP::Client.open # @return [Object] The result of whatever the block that was # passed in via the "block" parameter yielded. - def ldap_open(connect_opts, &block) + def ldap_open(connect_opts, keep_open: false, &block) opts = resolve_connect_opts(connect_opts) - Net::LDAP.open(opts, &block) + if keep_open + Rex::Proto::LDAP::Client._open(opts, &block) + else + Rex::Proto::LDAP::Client.open(opts, &block) + end end @@ -152,16 +159,15 @@ def resolve_connect_opts(connect_opts) opts end - # Create a new LDAP connection using Net::LDAP.new and yield the + # Create a new LDAP connection using Rex::Proto::LDAP::Client.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 + # @yieldparam ldap [Rex::Proto::LDAP::Client] The LDAP connection handle to use for connecting to # the target LDAP server. def ldap_new(opts = {}) - - ldap = Net::LDAP.new(resolve_connect_opts(get_connect_opts.merge(opts))) + ldap = Rex::Proto::LDAP::Client.new(resolve_connect_opts(get_connect_opts.merge(opts))) # NASTY, but required # monkey patch ldap object in order to ignore bind errors @@ -172,7 +178,7 @@ def ldap_new(opts = {}) # 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 + # @yieldparam conn [Rex::Proto::LDAP::Client] 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) @@ -184,7 +190,7 @@ def ldap.use_connection(args) conn.bind(args[:auth] || @auth) # Commented out vs. original # result = conn.bind(args[:auth] || @auth) - # return result unless result.result_code == Net::LDAP::ResultCodeSuccess + # return result unless result.result_code == Rex::Proto::LDAP::Client::ResultCodeSuccess yield conn ensure conn.close if conn @@ -194,69 +200,22 @@ def ldap.use_connection(args) 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 =~ /^([Dd][Cc]=[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 + # @param ldap [Rex::Proto::LDAP::Client] The Rex::Proto::LDAP::Client 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) + if defined?(:session) && session + vprint_good('Successfully bound to the LDAP server via existing SESSION!') + return + end + bind_result = ldap.get_operation_result.table # Codes taken from https://ldap.com/ldap-result-code-reference-core-ldapv3-result-codes @@ -292,7 +251,7 @@ def validate_bind_success!(ldap) # 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 + # @param filter [Rex::Proto::LDAP::Client::Filter] A Rex::Proto::LDAP::Client::Filter to use to # filter the results of the query. # # @raise [RuntimeError, ArgumentError] A RuntimeError will be raised if the LDAP diff --git a/lib/msf/core/exploit/remote/ldap/error.rb b/lib/msf/core/exploit/remote/ldap/error.rb new file mode 100644 index 000000000000..c1369d03c984 --- /dev/null +++ b/lib/msf/core/exploit/remote/ldap/error.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Msf::Exploit::Remote::LDAP + + class Error < ::StandardError + + attr_reader :error_code + attr_reader :operation_result + def initialize(message: nil, error_code: nil, operation_result: nil) + super(message || 'LDAP Error') + @error_code = error_code + @operation_result = operation_result + end + end +end diff --git a/lib/msf/core/exploit/remote/ldap/queries.rb b/lib/msf/core/exploit/remote/ldap/queries.rb index a8c50b6ffbd3..7e6a9030a18a 100755 --- a/lib/msf/core/exploit/remote/ldap/queries.rb +++ b/lib/msf/core/exploit/remote/ldap/queries.rb @@ -27,7 +27,7 @@ def perform_ldap_query(ldap, filter, attributes, base, schema_dn, scope: nil) end query_result_table = ldap.get_operation_result.table - validate_query_result!(query_result_table, filter) + validate_result!(query_result_table, filter) if results.nil? || results.empty? print_error("No results found for #{filter}.") @@ -38,7 +38,17 @@ def perform_ldap_query(ldap, filter, attributes, base, schema_dn, scope: nil) end def perform_ldap_query_streaming(ldap, filter, attributes, base, schema_dn, scope: nil) - attribute_properties = query_attributes_data(ldap, attributes.map(&:to_sym), schema_dn) + if attributes.nil? || schema_dn.nil? + attribute_properties = {} + else + begin + attribute_properties = query_attributes_data(ldap, attributes.map(&:to_sym), schema_dn) + rescue Msf::Exploit::Remote::LDAP::Error => e + wlog("Failed getting attribute properties: #{e}", error: e) + ensure + attribute_properties ||= {} + end + end scope ||= Net::LDAP::SearchScope_WholeSubtree result_count = 0 @@ -81,7 +91,10 @@ def generate_rex_tables(entry, format) when 'csv' print_line(tbl.to_csv) else - fail_with(Msf::Module::Failure::BadConfig, "Invalid format #{format} passed to generate_rex_tables!") + print_warning("Invalid format: #{format} Supported OUTPUT_FORMAT values are csv and table") + # Default to table output, seems reasonable to output something if we have it rather than blow up + print_status('Defaulting to table output') + print_line(tbl.to_s) end end @@ -165,33 +178,6 @@ def output_data_csv(entry) generate_rex_tables(entry, 'csv') end - def find_schema_dn(ldap, base) - results = ldap.search(attributes: ['objectCategory'], base: base, filter: '(objectClass=*)', scope: Net::LDAP::SearchScope_BaseObject) - validate_query_result!(ldap.get_operation_result.table) - if results.blank? - fail_with(Msf::Module::Failure::UnexpectedReply, "LDAP server didn't respond to our request to find the root DN!") - end - - # Double check that the entry has an instancetype attribute. - unless results[0].to_h.key?(:objectcategory) - fail_with(Failure::UnexpectedReply, "LDAP server didn't respond to the root DN request with the objectcategory attribute field!") - end - - object_category_raw = results[0][:objectcategory][0] - schema_dn = object_category_raw.gsub(/CN=[A-Za-z0-9-]+,/, '') - print_good("#{peer} Discovered schema DN: #{schema_dn}") - - schema_dn - end - - def find_schema_naming_context(ldap) - result = ldap.search(scope: 0, base: '', attributes: [:schemanamingcontext]) - if result.first && result.first[:schemanamingcontext] - return result.first[:schemanamingcontext].first - end - '' - end - def query_attributes_data(ldap, attributes, schema_dn) attribute_properties = {} @@ -206,8 +192,7 @@ def query_attributes_data(ldap, attributes, schema_dn) return unless filter.include?('LDAPDisplayName=') attributes_data = ldap.search(base: schema_dn, filter: filter, attributes: %i[LDAPDisplayName isSingleValued oMSyntax attributeSyntax]) - query_result_table = ldap.get_operation_result.table - validate_query_result!(query_result_table) + validate_result!(ldap.get_operation_result) attributes_data.each do |entry| ldap_display_name = entry[:ldapdisplayname][0].to_s.downcase.to_sym @@ -252,7 +237,8 @@ def normalize_entry(entry, attribute_properties) sid_data = Rex::Proto::MsDtyp::MsDtypSid.read(object_sid_raw) sid_string = sid_data.to_s rescue IOError => e - fail_with(Msf::Module::Failure::UnexpectedReply, "Failed to read SID. Error was #{e.message}") + elog("Failed to read SID. Error was #{e.message}") + next end normalized_attribute[0] = sid_string elsif attribute_property[:attributesyntax] == '2.5.5.10' # OctetString @@ -265,7 +251,8 @@ def normalize_entry(entry, attribute_properties) decoded_guid = Rex::Proto::MsDtyp::MsDtypGuid.read(bin_guid) decoded_guid_string = decoded_guid.get rescue IOError => e - fail_with(Msf::Module::Failure::UnexpectedReply, "Failed to read GUID. Error was #{e.message}") + elog("Failed to read GUID. Error was #{e.message}") + next end normalized_attribute[0] = decoded_guid_string end @@ -316,19 +303,24 @@ def show_output(entry, output_format) when 'json' output_json_data(entry) else - fail_with(Msf::Module::Failure::BadConfig, 'Supported OUTPUT_FORMAT values are csv, table and json') + print_warning("Invalid format: #{output_format} Supported OUTPUT_FORMAT values are csv, table and json") + # Default to table output, seems reasonable to output something if we have it rather than blow up + print_status('Defaulting to table output') + output_data_table(entry) end end - def run_queries_from_file(ldap, queries, base_dn, schema_dn, output_format) + def run_queries_from_file(ldap, queries, schema_dn, output_format, base_dn: nil) + base_dn ||= ldap.base_dn queries.each do |query| unless query['action'] && query['filter'] && query['attributes'] - fail_with(Msf::Module::Failure::BadConfig, "Each query in the query file must at least contain a 'action', 'filter' and 'attributes' attribute!") + print_warning "Each query in the query file must at least contain a 'action', 'filter' and 'attributes' attribute!" + next end attributes = query['attributes'] if attributes.nil? || attributes.empty? print_warning('At least one attribute needs to be specified per query in the query file for entries to work!') - break + next end filter = Net::LDAP::Filter.construct(query['filter']) print_status("Running #{query['action']}...") @@ -342,6 +334,15 @@ def run_queries_from_file(ldap, queries, base_dn, schema_dn, output_format) end end + def validate_result!(operation_result) + code = operation_result.table[:code] + if code == 0 + dlog('Operation was successful') + else + raise Msf::Exploit::Remote::LDAP::Error.new(error_code: code, operation_result: operation_result) + end + end + end end end diff --git a/lib/msf/core/feature_manager.rb b/lib/msf/core/feature_manager.rb index 7accb6718ca3..4c67e01fa423 100644 --- a/lib/msf/core/feature_manager.rb +++ b/lib/msf/core/feature_manager.rb @@ -26,6 +26,8 @@ class FeatureManager POSTGRESQL_SESSION_TYPE = 'postgresql_session_type' MYSQL_SESSION_TYPE = 'mysql_session_type' MSSQL_SESSION_TYPE = 'mssql_session_type' + LDAP_SESSION_TYPE = 'ldap_session_type' + DEFAULTS = [ { name: WRAPPED_TABLES, @@ -94,6 +96,13 @@ class FeatureManager default_value: true, developer_notes: 'Enabled in Metasploit 6.4.x' }.freeze, + { + name: LDAP_SESSION_TYPE, + description: 'When enabled will allow for the creation/use of LDAP sessions', + requires_restart: true, + default_value: false, + developer_notes: 'To be enabled by default after appropriate testing' + }.freeze, { name: DNS, description: 'When enabled allows configuration of DNS resolution behaviour in Metasploit', diff --git a/lib/msf/core/optional_session/ldap.rb b/lib/msf/core/optional_session/ldap.rb new file mode 100644 index 000000000000..eecfebe7ffd7 --- /dev/null +++ b/lib/msf/core/optional_session/ldap.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module Msf + module OptionalSession + module LDAP + include Msf::OptionalSession + + RHOST_GROUP_OPTIONS = %w[RHOSTS RPORT DOMAIN USERNAME PASSWORD THREADS] + REQUIRED_OPTIONS = %w[RHOSTS RPORT USERNAME PASSWORD THREADS] + + def initialize(info = {}) + super( + update_info( + info, + 'SessionTypes' => %w[ldap] + ) + ) + + if optional_session_enabled? + register_option_group(name: 'SESSION', + description: 'Used when connecting via an existing SESSION', + option_names: ['SESSION']) + register_option_group(name: 'RHOST', + description: 'Used when making a new connection via RHOSTS', + option_names: RHOST_GROUP_OPTIONS, + required_options: REQUIRED_OPTIONS) + + register_options( + [ + Msf::OptInt.new('SESSION', [ false, 'The session to run this module on' ]), + Msf::Opt::RHOST(nil, false), + Msf::Opt::RPORT(389, false) + ] + ) + + add_info('New in Metasploit 6.4 - This module can target a %grnSESSION%clr or an %grnRHOST%clr') + end + end + + def optional_session_enabled? + framework.features.enabled?(Msf::FeatureManager::LDAP_SESSION_TYPE) + end + + # @see #ldap_open + # @return [Object] The result of whatever the block that was + # passed in via the "block" parameter yielded. + def ldap_connect(opts = {}, &block) + if session && !opts[:base].blank? + session.client.base = opts[:base] + end + return yield session.client if session + + ldap_open(get_connect_opts.merge(opts), &block) + rescue ::StandardError => e + handle_error(e) + end + + # Create a new LDAP connection using Rex::Proto::LDAP::Client.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 [Rex::Proto::LDAP::Client] The LDAP connection handle to use for connecting to + # the target LDAP server. + def ldap_new(opts = {}) + if session && !opts[:base].blank? + session.client.base = opts[:base] + end + return yield session.client if session + + super + rescue ::StandardError => e + handle_error(e) + end + + private + + def handle_error(e) + case e + when ::Net::LDAP::ResponseMissingOrInvalidError + elog("LDAP Client response missing or invalid: #{e.class}", error: e) + if session + print_error("Killing session #{session.sid} due to missing or invalid response from the server.") + session.kill + end + else + elog("LDAP Client: #{e.class}", error: e) + # Re-raise other exceptions so they can be handled elsewhere + raise e + end + end + end + end +end diff --git a/lib/rex/post/ldap.rb b/lib/rex/post/ldap.rb new file mode 100644 index 000000000000..480e7e68cdc6 --- /dev/null +++ b/lib/rex/post/ldap.rb @@ -0,0 +1,3 @@ +# -*- coding: binary -*- + +require 'rex/post/ldap/ui' diff --git a/lib/rex/post/ldap/ui.rb b/lib/rex/post/ldap/ui.rb new file mode 100644 index 000000000000..b86581c17b9a --- /dev/null +++ b/lib/rex/post/ldap/ui.rb @@ -0,0 +1,3 @@ +# -*- coding: binary -*- + +require 'rex/post/ldap/ui/console' diff --git a/lib/rex/post/ldap/ui/console.rb b/lib/rex/post/ldap/ui/console.rb new file mode 100644 index 000000000000..c2b5fd868a5f --- /dev/null +++ b/lib/rex/post/ldap/ui/console.rb @@ -0,0 +1,137 @@ +# -*- coding: binary -*- + +require 'English' +require 'rex/post/session_compatible_modules' + +module Rex + module Post + module LDAP + module Ui + ### + # + # This class provides a shell driven interface to the LDAP client API. + # + ### + class Console + + include Rex::Ui::Text::DispatcherShell + include Rex::Post::SessionCompatibleModules + + # Dispatchers + require 'rex/post/ldap/ui/console/command_dispatcher' + require 'rex/post/ldap/ui/console/command_dispatcher/core' + require 'rex/post/ldap/ui/console/command_dispatcher/client' + + # + # Initialize the LDAP console. + # + # @param [Msf::Sessions::LDAP] session + def initialize(session) + super('%undLDAP%clr', '>', Msf::Config.ldap_session_history, nil, :ldap) + + # The ldap client context + self.session = session + self.client = session.client + + # Queued commands array + self.commands = [] + + # Point the input/output handles elsewhere + reset_ui + + enstack_dispatcher(Rex::Post::LDAP::Ui::Console::CommandDispatcher::Client) + enstack_dispatcher(Rex::Post::LDAP::Ui::Console::CommandDispatcher::Core) + enstack_dispatcher(Msf::Ui::Console::CommandDispatcher::LocalFileSystem) + + # Set up logging to whatever logsink 'core' is using + if !$dispatcher['ldap'] + $dispatcher['ldap'] = $dispatcher['core'] + end + end + + # + # Called when someone wants to interact with the LDAP client. It's + # assumed that init_ui has been called prior. + # + def interact(&block) + # Run queued commands + commands.delete_if do |ent| + run_single(ent) + true + end + + # Run the interactive loop + run do |line| + # Run the command + run_single(line) + + # If a block was supplied, call it, otherwise return false + if block + block.call + else + false + end + end + end + + # + # Queues a command to be run when the interactive loop is entered. + # + def queue_cmd(cmd) + commands << cmd + end + + # + # Runs the specified command wrapper in something to catch exceptions. + # + def run_command(dispatcher, method, arguments) + super + rescue Timeout::Error + log_error('Operation timed out.') + rescue Rex::InvalidDestination => e + log_error(e.message) + rescue ::Errno::EPIPE, ::OpenSSL::SSL::SSLError, ::IOError, Net::LDAP::ResponseMissingOrInvalidError + session.kill + rescue ::StandardError => e + log_error("Error running command #{method}: #{e.class} #{e}") + elog(e) + end + + # @param [Hash] opts + # @return [String] + def help_to_s(opts = {}) + super + format_session_compatible_modules + end + + # + # Logs that an error occurred and persists the callstack. + # + def log_error(msg) + print_error(msg) + + elog(msg, 'ldap') + + dlog("Call stack:\n#{$ERROR_POSITION.join("\n")}", 'ldap') + end + + # @return [Msf::Sessions::LDAP] + attr_reader :session + + # @return [Rex::Proto::LDAP::Client] + attr_reader :client # :nodoc: + + def format_prompt(val) + prompt = session.address.to_s + + substitute_colors("%undLDAP%clr (#{prompt}) > ", true) + end + + protected + + attr_writer :session, :client # :nodoc: # :nodoc: + attr_accessor :commands # :nodoc: + end + end + end + end +end diff --git a/lib/rex/post/ldap/ui/console/command_dispatcher.rb b/lib/rex/post/ldap/ui/console/command_dispatcher.rb new file mode 100644 index 000000000000..a50493a6e380 --- /dev/null +++ b/lib/rex/post/ldap/ui/console/command_dispatcher.rb @@ -0,0 +1,105 @@ +# -*- coding: binary -*- + +require 'English' +require 'rex/ui/text/dispatcher_shell' + +module Rex + module Post + module LDAP + module Ui + ### + # + # Base class for all command dispatchers within the LDAP console user + # interface. + # + ### + module Console::CommandDispatcher + include Msf::Ui::Console::CommandDispatcher::Session + + # + # Initializes an instance of the core command set using the supplied session and client + # for interactivity. + # + # @param [Rex::Post::LDAP::Ui::Console] console + def initialize(console) + super + @msf_loaded = nil + @filtered_commands = [] + end + + # + # Returns the LDAP client context. + # + # @return [Rex::Proto::LDAP::Client] + def client + console = shell + console.client + end + + # + # Returns the LDAP session context. + # + # @return [Msf::Sessions::LDAP] + def session + console = shell + console.session + end + + # + # Returns the commands that meet the requirements + # + def filter_commands(all, reqs) + all.delete_if do |cmd, _desc| + if reqs[cmd]&.any? { |req| !client.commands.include?(req) } + @filtered_commands << cmd + true + end + end + end + + def unknown_command(cmd, line) + if @filtered_commands.include?(cmd) + print_error("The \"#{cmd}\" command is not supported by this session type (#{session.session_type})") + return :handled + end + + super + end + + # + # Return the subdir of the `documentation/` directory that should be used + # to find usage documentation + # + def docs_dir + File.join(super, 'ldap_session') + end + + # + # Returns true if the client has a framework object. + # + # Used for firing framework session events + # + def msf_loaded? + return @msf_loaded unless @msf_loaded.nil? + + # if we get here we must not have initialized yet + + @msf_loaded = !session.framework.nil? + @msf_loaded + end + + # + # Log that an error occurred. + # + def log_error(msg) + print_error(msg) + + elog(msg, 'ldap') + + dlog("Call stack:\n#{$ERROR_POSITION.join("\n")}", 'ldap') + end + end + end + end + end +end diff --git a/lib/rex/post/ldap/ui/console/command_dispatcher/client.rb b/lib/rex/post/ldap/ui/console/command_dispatcher/client.rb new file mode 100644 index 000000000000..4935207cb2e6 --- /dev/null +++ b/lib/rex/post/ldap/ui/console/command_dispatcher/client.rb @@ -0,0 +1,123 @@ +# -*- coding: binary -*- + +module Rex + module Post + module LDAP + module Ui + ### + # + # Core LDAP client commands + # + ### + class Console::CommandDispatcher::Client + + include Rex::Post::LDAP::Ui::Console::CommandDispatcher + include Msf::Exploit::Remote::LDAP::Queries + + + OUTPUT_FORMATS = %w[table csv json] + VALID_SCOPES = %w[base single whole] + + @@query_opts = Rex::Parser::Arguments.new( + %w[-h --help] => [false, 'Help menu' ], + %w[-f --filter] => [true, 'Filter string for the query (default: (objectclass=*))'], + %w[-a --attributes] => [true, 'Comma separated list of attributes for the query'], + %w[-b --base-dn] => [true, 'Base dn for the query'], + %w[-s --scope] => [true, 'Scope for the query: `base`, `single`, `whole` (default: whole)'], + %w[-o --output-format] => [true, 'Output format: `table`, `csv` or `json` (default: table)'] + ) + + # + # List of supported commands. + # + def commands + cmds = { + 'query' => 'Run an LDAP query' + } + + reqs = {} + + filter_commands(cmds, reqs) + end + + # + # Client + # + def name + 'Client' + end + + # + # Query the LDAP server + # + def cmd_query(*args) + if args.include?('-h') || args.include?('--help') + cmd_query_help + return + end + + attributes = [] + filter = '(objectclass=*)' + base_dn = client.base_dn + schema_dn = client.schema_dn + scope = Net::LDAP::SearchScope_WholeSubtree + output_format = 'table' + @@query_opts.parse(args) do |opt, _idx, val| + case opt + when '-a', '--attributes' + attributes.push(*val.split(',')) + when '-f', '--filter' + filter = val + when '-b', '--base-dn' + base_dn = val + when '-s', '--scope' + scope = parse_scope(val) + raise ArgumentError, "Invalid scope provided: #{scope}, must be one of #{VALID_SCOPES}" if scope.nil? + when '-o', '--output-format' + if OUTPUT_FORMATS.include?(val) + output_format = val + else + raise ArgumentError, "Invalid output format: #{val}, must be one of #{OUTPUT_FORMATS}" + end + end + rescue StandardError => e + handle_error(e) + end + + perform_ldap_query_streaming(client, filter, attributes, base_dn, schema_dn, scope: scope) do |result, attribute_properties| + show_output(normalize_entry(result, attribute_properties), output_format) + end + end + + def cmd_query_tabs(_str, words) + return [] if words.length > 1 + + @@query_opts.option_keys + end + + def cmd_query_help + print_line 'Usage: query -f -a ' + print_line + print_line 'Run the query against the session.' + print @@query_opts.usage + end + + private + + def parse_scope(str) + case str.downcase + when 'base' + Net::LDAP::SearchScope_BaseObject + when 'single', 'one' + Net::LDAP::SearchScope_SingleLevel + when 'whole', 'sub' + Net::LDAP::SearchScope_WholeSubtree + else + nil + end + end + end + end + end + end +end diff --git a/lib/rex/post/ldap/ui/console/command_dispatcher/core.rb b/lib/rex/post/ldap/ui/console/command_dispatcher/core.rb new file mode 100644 index 000000000000..270d458197b1 --- /dev/null +++ b/lib/rex/post/ldap/ui/console/command_dispatcher/core.rb @@ -0,0 +1,61 @@ +# -*- coding: binary -*- + +require 'rex/post/ldap' + +module Rex + module Post + module LDAP + module Ui + ### + # + # Core LDAP client commands + # + ### + class Console::CommandDispatcher::Core + + include Rex::Post::LDAP::Ui::Console::CommandDispatcher + + # + # Initializes an instance of the core command set using the supplied session and client + # for interactivity. + # + # @param [Rex::Post::LDAP::Ui::Console] console + + # + # List of supported commands. + # + def commands + cmds = { + '?' => 'Help menu', + 'background' => 'Backgrounds the current session', + 'bg' => 'Alias for background', + 'exit' => 'Terminate the LDAP session', + 'help' => 'Help menu', + 'irb' => 'Open an interactive Ruby shell on the current session', + 'pry' => 'Open the Pry debugger on the current session', + 'sessions' => 'Quickly switch to another session' + } + + reqs = {} + + filter_commands(cmds, reqs) + end + + # + # Core + # + def name + 'Core' + end + + def unknown_command(cmd, line) + status = super + + status + end + + end + end + end + end +end diff --git a/lib/rex/proto/kerberos/client.rb b/lib/rex/proto/kerberos/client.rb index 4d0669d662bf..a4779ad177b6 100644 --- a/lib/rex/proto/kerberos/client.rb +++ b/lib/rex/proto/kerberos/client.rb @@ -45,7 +45,7 @@ def initialize(opts = {}) # @raise [RuntimeError] if the connection can not be created def connect return connection if connection - + raise ArgumentError, 'Missing remote address' unless self.host && self.port case protocol when 'tcp' self.connection = create_tcp_connection diff --git a/lib/rex/proto/ldap.rb b/lib/rex/proto/ldap.rb index 55173aa5c72a..3ac3ff6e85c4 100644 --- a/lib/rex/proto/ldap.rb +++ b/lib/rex/proto/ldap.rb @@ -107,6 +107,9 @@ def read_ber(syntax = nil) end # SASL buffer length length_bytes = read(4) + # The implementation in net-ldap returns nil if it doesn't read any data + return nil unless length_bytes + length = length_bytes.unpack('N')[0] # Now read the actual data @@ -182,7 +185,8 @@ def initialize(server) @conn = Rex::Socket::Tcp.create( 'PeerHost' => server[:host], 'PeerPort' => server[:port], - 'Proxies' => server[:proxies] + 'Proxies' => server[:proxies], + 'Timeout' => server[:connect_timeout] ) @conn.extend(SynchronousRead) diff --git a/lib/rex/proto/ldap/client.rb b/lib/rex/proto/ldap/client.rb new file mode 100644 index 000000000000..608edaa84770 --- /dev/null +++ b/lib/rex/proto/ldap/client.rb @@ -0,0 +1,104 @@ +require 'net/ldap' + +module Rex + module Proto + module LDAP + # This is a Rex Proto wrapper around the Net::LDAP client which is currently coming from the 'net-ldap' gem. + # The purpose of this wrapper is to provide 'peerhost' and 'peerport' methods to ensure the client interfaces + # are consistent between various session clients. + class Client < Net::LDAP + + # @return [Rex::Socket] + attr_reader :socket + + def initialize(args) + @base_dn = args[:base] + super + end + + # @return [Array] LDAP servers naming contexts + def naming_contexts + @naming_contexts ||= search_root_dse[:namingcontexts] + end + + # @return [String] LDAP servers Base DN + def base_dn + @base_dn ||= discover_base_dn + end + + # @return [String, nil] LDAP servers Schema DN, nil if one isn't found + def schema_dn + @schema_dn ||= discover_schema_naming_context + end + + # @return [String] The remote IP address that LDAP is running on + def peerhost + host + end + + # @return [Integer] The remote port that LDAP is running on + def peerport + port + end + + # @return [String] The remote peer information containing IP and port + def peerinfo + "#{peerhost}:#{peerport}" + end + + # https://github.com/ruby-ldap/ruby-net-ldap/issues/11 + # We want to keep the ldap connection open to use later + # but there's no built in way within the `Net::LDAP` library to do that + # so we're adding this function to do it instead + # @param connect_opts [Hash] Options for the LDAP connection. + def self._open(connect_opts) + client = new(connect_opts) + client._open + end + + # https://github.com/ruby-ldap/ruby-net-ldap/issues/11 + def _open + raise Net::LDAP::AlreadyOpenedError, 'Open already in progress' if @open_connection + + instrument 'open.net_ldap' do |payload| + @open_connection = new_connection + @socket = @open_connection.socket + payload[:connection] = @open_connection + payload[:bind] = @result = @open_connection.bind(@auth) + return self + end + end + + def discover_schema_naming_context + result = search(base: '', attributes: [:schemanamingcontext], scope: Net::LDAP::SearchScope_BaseObject) + if result.first && !result.first[:schemanamingcontext].empty? + schema_dn = result.first[:schemanamingcontext].first + ilog("#{peerinfo} Discovered Schema DN: #{schema_dn}") + return schema_dn + end + wlog("#{peerinfo} Could not discover Schema DN") + nil + end + + def discover_base_dn + unless naming_contexts + elog("#{peerinfo} Base DN cannot be determined, no naming contexts available") + return + end + + # NOTE: Find the first entry that starts with `DC=` as this will likely be the base DN. + result = naming_contexts.select { |context| context =~ /^([Dd][Cc]=[A-Za-z0-9-]+,?)+$/ } + .reject { |context| context =~ /(Configuration)|(Schema)|(ForestDnsZones)/ } + if result.blank? + elog("#{peerinfo} A base DN matching the expected format could not be found!") + return + end + base_dn = result[0] + + dlog("#{peerinfo} Discovered base DN: #{base_dn}") + base_dn + end + end + end + end +end diff --git a/modules/auxiliary/admin/ldap/ad_cs_cert_template.rb b/modules/auxiliary/admin/ldap/ad_cs_cert_template.rb index a0be46c2718a..d36c02845f0a 100644 --- a/modules/auxiliary/admin/ldap/ad_cs_cert_template.rb +++ b/modules/auxiliary/admin/ldap/ad_cs_cert_template.rb @@ -6,6 +6,7 @@ class MetasploitModule < Msf::Auxiliary include Msf::Exploit::Remote::LDAP + include Msf::OptionalSession::LDAP include Msf::Auxiliary::Report IGNORED_ATTRIBUTES = [ @@ -109,7 +110,7 @@ def run else print_status('Discovering base DN automatically') - unless (@base_dn = discover_base_dn(ldap)) + unless (@base_dn = ldap.base_dn) fail_with(Failure::NotFound, "Couldn't discover base DN!") end end diff --git a/modules/auxiliary/admin/ldap/rbcd.rb b/modules/auxiliary/admin/ldap/rbcd.rb index 7ff16fc7d048..5a4373b6ff13 100644 --- a/modules/auxiliary/admin/ldap/rbcd.rb +++ b/modules/auxiliary/admin/ldap/rbcd.rb @@ -6,6 +6,7 @@ class MetasploitModule < Msf::Auxiliary include Msf::Exploit::Remote::LDAP + include Msf::OptionalSession::LDAP ATTRIBUTE = 'msDS-AllowedToActOnBehalfOfOtherIdentity'.freeze @@ -138,7 +139,7 @@ def run else print_status('Discovering base DN automatically') - unless (@base_dn = discover_base_dn(ldap)) + unless (@base_dn = ldap.base_dn) print_warning("Couldn't discover base DN!") end end diff --git a/modules/auxiliary/admin/ldap/shadow_credentials.rb b/modules/auxiliary/admin/ldap/shadow_credentials.rb index 3a46a3cacaa6..c8fd36278ece 100644 --- a/modules/auxiliary/admin/ldap/shadow_credentials.rb +++ b/modules/auxiliary/admin/ldap/shadow_credentials.rb @@ -5,8 +5,9 @@ class MetasploitModule < Msf::Auxiliary - include Msf::Exploit::Remote::LDAP include Msf::Auxiliary::Report + include Msf::Exploit::Remote::LDAP + include Msf::OptionalSession::LDAP ATTRIBUTE = 'msDS-KeyCredentialLink'.freeze @@ -114,7 +115,9 @@ def run else print_status('Discovering base DN automatically') - unless (@base_dn = discover_base_dn(ldap)) + if (@base_dn = ldap.base_dn) + print_status("#{ldap.peerinfo} Discovered base DN: #{@base_dn}") + else print_warning("Couldn't discover base DN!") end end diff --git a/modules/auxiliary/admin/ldap/vmware_vcenter_vmdir_auth_bypass.rb b/modules/auxiliary/admin/ldap/vmware_vcenter_vmdir_auth_bypass.rb index 2856d35f4880..b5b6fa9b99e6 100644 --- a/modules/auxiliary/admin/ldap/vmware_vcenter_vmdir_auth_bypass.rb +++ b/modules/auxiliary/admin/ldap/vmware_vcenter_vmdir_auth_bypass.rb @@ -6,6 +6,7 @@ class MetasploitModule < Msf::Auxiliary include Msf::Exploit::Remote::LDAP + include Msf::OptionalSession::LDAP include Msf::Exploit::Remote::CheckModule def initialize(info = {}) @@ -42,6 +43,7 @@ def initialize(info = {}) 'DefaultAction' => 'Add', 'DefaultOptions' => { 'SSL' => true, + 'RPORT' => 636, # SSL/TLS 'CheckModule' => 'auxiliary/gather/vmware_vcenter_vmdir_ldap' }, 'Notes' => { @@ -53,10 +55,9 @@ def initialize(info = {}) ) register_options([ - Opt::RPORT(636), # SSL/TLS OptString.new('BASE_DN', [false, 'LDAP base DN if you already have it']), - OptString.new('NEW_USERNAME', [false, 'Username of admin user to add']), - OptString.new('NEW_PASSWORD', [false, 'Password of admin user to add']) + OptString.new('NEW_USERNAME', [true, 'Username of admin user to add']), + OptString.new('NEW_PASSWORD', [true, 'Password of admin user to add']) ]) end @@ -99,7 +100,7 @@ def run end ldap_connect do |ldap| - print_status("Bypassing LDAP auth in vmdir service at #{peer}") + print_status("Bypassing LDAP auth in vmdir service at #{ldap.peerinfo}") auth_bypass(ldap) print_status("Adding admin user #{new_username} with password #{new_password}") diff --git a/modules/auxiliary/gather/asrep.rb b/modules/auxiliary/gather/asrep.rb index 78c9ef7ffc92..15e12666dfb9 100644 --- a/modules/auxiliary/gather/asrep.rb +++ b/modules/auxiliary/gather/asrep.rb @@ -7,6 +7,7 @@ class MetasploitModule < Msf::Auxiliary include Msf::Exploit::Remote::Kerberos::Client include Msf::Exploit::Remote::LDAP include Msf::Exploit::Remote::LDAP::Queries + include Msf::OptionalSession::LDAP def initialize(info = {}) super( @@ -42,11 +43,16 @@ def initialize(info = {}) register_options( [ + Opt::RHOSTS(nil, true, 'The target KDC, see https://docs.metasploit.com/docs/using-metasploit/basics/using-metasploit.html'), OptPath.new('USER_FILE', [ false, 'File containing usernames, one per line' ], conditions: %w[ACTION == BRUTE_FORCE]), OptBool.new('USE_RC4_HMAC', [ true, 'Request using RC4 hash instead of default encryption types (faster to crack)', true]), OptString.new('Rhostname', [ true, "The domain controller's hostname"], aliases: ['LDAP::Rhostname']), ] ) + register_option_group(name: 'SESSION', + description: 'Used when connecting to LDAP over an existing SESSION', + option_names: %w[RHOSTS], + required_options: %w[SESSION RHOSTS]) register_advanced_options( [ OptEnum.new('LDAP::Auth', [true, 'The Authentication mechanism to use', Msf::Exploit::Remote::AuthOption::NTLM, Msf::Exploit::Remote::AuthOption::LDAP_OPTIONS]), @@ -99,11 +105,11 @@ def run_ldap ldap_connect do |ldap| validate_bind_success!(ldap) - unless (base_dn = discover_base_dn(ldap)) + unless (base_dn = ldap.base_dn) fail_with(Failure::UnexpectedReply, "Couldn't discover base DN!") end - schema_dn = find_schema_dn(ldap, base_dn) + schema_dn = ldap.schema_dn filter_string = ldap_query['filter'] attributes = ldap_query['attributes'] begin @@ -136,7 +142,8 @@ def roast(username) client_name: username, realm: datastore['DOMAIN'], offered_etypes: etypes, - rport: 88 + rport: 88, + rhost: datastore['RHOST'] ) hash = format_as_rep_to_john_hash(res.as_rep) print_line(hash) diff --git a/modules/auxiliary/gather/ldap_esc_vulnerable_cert_finder.rb b/modules/auxiliary/gather/ldap_esc_vulnerable_cert_finder.rb index e242e490e440..809972513a11 100644 --- a/modules/auxiliary/gather/ldap_esc_vulnerable_cert_finder.rb +++ b/modules/auxiliary/gather/ldap_esc_vulnerable_cert_finder.rb @@ -1,6 +1,7 @@ class MetasploitModule < Msf::Auxiliary include Msf::Exploit::Remote::LDAP + include Msf::OptionalSession::LDAP ADS_GROUP_TYPE_BUILTIN_LOCAL_GROUP = 0x00000001 ADS_GROUP_TYPE_GLOBAL_GROUP = 0x00000002 @@ -459,7 +460,7 @@ def run else print_status('Discovering base DN automatically') - unless (@base_dn = discover_base_dn(ldap)) + unless (@base_dn = ldap.base_dn) fail_with(Failure::NotFound, "Couldn't discover base DN!") end end diff --git a/modules/auxiliary/gather/ldap_hashdump.rb b/modules/auxiliary/gather/ldap_hashdump.rb index 65237c7b37af..118aa0f7a1f1 100644 --- a/modules/auxiliary/gather/ldap_hashdump.rb +++ b/modules/auxiliary/gather/ldap_hashdump.rb @@ -5,9 +5,10 @@ class MetasploitModule < Msf::Auxiliary - include Msf::Exploit::Remote::LDAP include Msf::Auxiliary::Scanner include Msf::Auxiliary::Report + include Msf::Exploit::Remote::LDAP + include Msf::OptionalSession::LDAP def initialize(info = {}) super( @@ -33,7 +34,8 @@ def initialize(info = {}) ], 'DefaultAction' => 'Dump', 'DefaultOptions' => { - 'SSL' => true + 'SSL' => true, + 'RPORT' => 636 }, 'Notes' => { 'Stability' => [CRASH_SAFE], @@ -44,7 +46,6 @@ def initialize(info = {}) ) register_options([ - Opt::RPORT(636), # SSL/TLS OptInt.new('MAX_LOOT', [false, 'Maximum number of LDAP entries to loot', nil]), OptInt.new('READ_TIMEOUT', [false, 'LDAP read timeout in seconds', 600]), OptString.new('BASE_DN', [false, 'LDAP base DN if you already have it']), @@ -68,7 +69,7 @@ def print_ldap_error(ldap) unless opres.error_message.to_s.empty? msg += " - #{opres.error_message}" end - print_error("#{peer} #{msg}") + print_error("#{ldap.peerinfo} #{msg}") end # PoC using ldapsearch(1): @@ -85,36 +86,28 @@ def run_host(ip) entries_returned = 0 - print_status("#{peer} Connecting...") ldap_new do |ldap| if ldap.get_operation_result.code == 0 - vprint_status("#{peer} LDAP connection established") + vprint_status("#{ldap.peerinfo} LDAP connection established") else # Even if we get "Invalid credentials" error, we may proceed with anonymous bind print_ldap_error(ldap) end + @rhost = ldap.peerhost + @rport = ldap.peerport + if (base_dn_tmp = datastore['BASE_DN']) - vprint_status("#{peer} User-specified base DN: #{base_dn_tmp}") + vprint_status("#{ldap.peerinfo} User-specified base DN: #{base_dn_tmp}") naming_contexts = [base_dn_tmp] else - vprint_status("#{peer} Discovering base DN(s) automatically") + vprint_status("#{ldap.peerinfo} Discovering base DN(s) automatically") - begin - # HACK: fix lack of read/write timeout in Net::LDAP - Timeout.timeout(@read_timeout) do - naming_contexts = get_naming_contexts(ldap) - end - rescue Timeout::Error - fail_with(Failure::TimeoutExpired, 'The timeout expired while reading naming contexts') - ensure - unless ldap.get_operation_result.code == 0 - print_ldap_error(ldap) - end - end + naming_contexts = ldap.naming_contexts + print_ldap_error(ldap) unless ldap.get_operation_result.code == 0 if naming_contexts.nil? || naming_contexts.empty? - vprint_warning("#{peer} Falling back to an empty base DN") + vprint_warning("#{ldap.peerinfo} Falling back to an empty base DN") naming_contexts = [''] end end @@ -123,14 +116,14 @@ def run_host(ip) @user_attr ||= datastore['USER_ATTR'] @user_attr ||= 'dn' - vprint_status("#{peer} Taking '#{@user_attr}' attribute as username") + vprint_status("#{ldap.peerinfo} Taking '#{@user_attr}' attribute as username") pass_attr ||= datastore['PASS_ATTR'] @pass_attr_array = pass_attr.split(/[,\s]+/).compact.reject(&:empty?).map(&:downcase) # Dump root DSE for useful information, e.g. dir admin if @max_loot.nil? || (@max_loot > 0) - print_status("#{peer} Dumping data for root DSE") + print_status("#{ldap.peerinfo} Dumping data for root DSE") ldap_search(ldap, 'root DSE', { ignore_server_caps: true, @@ -139,7 +132,7 @@ def run_host(ip) end naming_contexts.each do |base_dn| - print_status("#{peer} Searching base DN='#{base_dn}'") + print_status("#{ldap.peerinfo} Searching base DN='#{base_dn}'") entries_returned += ldap_search(ldap, base_dn, { base: base_dn }) @@ -165,7 +158,7 @@ def ldap_search(ldap, base_dn, args) attributes: %w[* + -] } Tempfile.create do |f| - f.write("# LDIF dump of #{peer}, base DN='#{base_dn}'\n") + f.write("# LDIF dump of #{ldap.peerinfo}, base DN='#{base_dn}'\n") f.write("\n") begin # HACK: fix lack of read/write timeout in Net::LDAP @@ -185,18 +178,18 @@ def ldap_search(ldap, base_dn, args) end end rescue Timeout::Error - print_error("#{peer} Host timeout reached while searching '#{base_dn}'") + print_error("#{ldap.peerinfo} Host timeout reached while searching '#{base_dn}'") return entries_returned ensure unless ldap.get_operation_result.code == 0 print_ldap_error(ldap) end if entries_returned > 0 - print_status("#{peer} #{entries_returned} entries, #{creds_found} creds found in '#{base_dn}'.") + print_status("#{ldap.peerinfo} #{entries_returned} entries, #{creds_found} creds found in '#{base_dn}'.") f.rewind pillage(f.read, base_dn) elsif ldap.get_operation_result.code == 0 - print_error("#{peer} No entries returned for '#{base_dn}'.") + print_error("#{ldap.peerinfo} No entries returned for '#{base_dn}'.") end end end @@ -204,7 +197,7 @@ def ldap_search(ldap, base_dn, args) end def pillage(ldif, base_dn) - vprint_status("#{peer} Storing LDAP data for base DN='#{base_dn}' in loot") + vprint_status("Storing LDAP data for base DN='#{base_dn}' in loot") ltype = base_dn.clone ltype.gsub!(/ /, '_') @@ -223,11 +216,11 @@ def pillage(ldif, base_dn) ) unless ldif_filename - print_error("#{peer} Could not store LDAP data in loot") + print_error('Could not store LDAP data in loot') return end - print_good("#{peer} Saved LDAP data to #{ldif_filename}") + print_good("Saved LDAP data to #{ldif_filename}") end def decode_pwdhistory(hash) @@ -253,7 +246,7 @@ def process_hash(entry, attr) module_fullname: fullname, origin_type: :service, address: @rhost, - port: rport, + port: @rport, protocol: 'tcp', service_name: 'ldap' } @@ -369,7 +362,7 @@ def process_hash(entry, attr) # highlight unresolved hashes hash_format = '{crypt}' if hash =~ /{crypt}/i - print_good("#{peer} Credentials (#{hash_format.empty? ? 'password' : hash_format}) found in #{attr}: #{dn}:#{hash}") + print_good("#{@rhost}:#{@rport} Credentials (#{hash_format.empty? ? 'password' : hash_format}) found in #{attr}: #{dn}:#{hash}") # known hash types should have been identified, # let's assume the rest are clear text passwords diff --git a/modules/auxiliary/gather/ldap_query.rb b/modules/auxiliary/gather/ldap_query.rb index d8f4bba7adb7..48a4fdad33a3 100644 --- a/modules/auxiliary/gather/ldap_query.rb +++ b/modules/auxiliary/gather/ldap_query.rb @@ -7,6 +7,7 @@ class MetasploitModule < Msf::Auxiliary include Msf::Exploit::Remote::LDAP include Msf::Exploit::Remote::LDAP::Queries + include Msf::OptionalSession::LDAP require 'json' require 'yaml' @@ -128,18 +129,11 @@ def run ldap_connect do |ldap| validate_bind_success!(ldap) - if (base_dn = datastore['BASE_DN']) - print_status("User-specified base DN: #{base_dn}") - else - print_status('Discovering base DN automatically') - - unless (base_dn = discover_base_dn(ldap)) - fail_with(Failure::UnexpectedReply, "Couldn't discover base DN!") - end - end - - schema_dn = find_schema_naming_context(ldap) + fail_with(Failure::UnexpectedReply, "Couldn't discover base DN!") unless ldap.base_dn + base_dn = ldap.base_dn + print_status("#{ldap.peerinfo} Discovered base DN: #{base_dn}") + schema_dn = ldap.schema_dn case action.name when 'RUN_QUERY_FILE' unless datastore['QUERY_FILE_PATH'] @@ -152,7 +146,7 @@ def run fail_with(Failure::BadConfig, "No queries loaded from #{datastore['QUERY_FILE_PATH']}!") end - run_queries_from_file(ldap, parsed_queries, base_dn, schema_dn, datastore['OUTPUT_FORMAT']) + run_queries_from_file(ldap, parsed_queries, schema_dn, datastore['OUTPUT_FORMAT']) return when 'RUN_SINGLE_QUERY' unless datastore['QUERY_FILTER'] && datastore['QUERY_ATTRIBUTES'] diff --git a/modules/auxiliary/gather/vmware_vcenter_vmdir_ldap.rb b/modules/auxiliary/gather/vmware_vcenter_vmdir_ldap.rb index a6600b7fd90d..fc9b95b2ede9 100644 --- a/modules/auxiliary/gather/vmware_vcenter_vmdir_ldap.rb +++ b/modules/auxiliary/gather/vmware_vcenter_vmdir_ldap.rb @@ -6,6 +6,7 @@ class MetasploitModule < Msf::Auxiliary include Msf::Exploit::Remote::LDAP + include Msf::OptionalSession::LDAP include Msf::Auxiliary::Report def initialize(info = {}) @@ -37,7 +38,8 @@ def initialize(info = {}) ], 'DefaultAction' => 'Dump', 'DefaultOptions' => { - 'SSL' => true + 'SSL' => true, + 'RPORT' => 636 }, 'Notes' => { 'Stability' => [CRASH_SAFE], @@ -48,7 +50,6 @@ def initialize(info = {}) ) register_options([ - Opt::RPORT(636), # SSL/TLS OptString.new('BASE_DN', [false, 'LDAP base DN if you already have it']) ]) end @@ -77,30 +78,30 @@ def run else print_status('Discovering base DN automatically') - unless (@base_dn = discover_base_dn(ldap)) + unless (@base_dn = ldap.base_dn) print_warning('Falling back on default base DN dc=vsphere,dc=local') end end - print_status("Dumping LDAP data from vmdir service at #{peer}") + print_status("Dumping LDAP data from vmdir service at #{ldap.peerinfo}") # A "-" meta-attribute will dump userPassword (hat tip Hynek) # https://github.com/vmware/lightwave/blob/3bc154f823928fa0cf3605cc04d95a859a15c2a2/vmdir/server/ldap-head/result.c#L647-L654 entries = ldap.search(base: base_dn, attributes: %w[* + -]) - end - # Look for an entry with a non-empty vmwSTSPrivateKey attribute - unless entries&.find { |entry| entry[:vmwstsprivatekey].any? } - print_error("#{peer} is NOT vulnerable to CVE-2020-3952") unless datastore['BIND_PW'].present? - print_error('Dump failed') - return Exploit::CheckCode::Safe - end + # Look for an entry with a non-empty vmwSTSPrivateKey attribute + unless entries&.find { |entry| entry[:vmwstsprivatekey].any? } + print_error("#{ldap.peerinfo} is NOT vulnerable to CVE-2020-3952") unless datastore['BIND_PW'].present? + print_error('Dump failed') + return Exploit::CheckCode::Safe + end - print_good("#{peer} is vulnerable to CVE-2020-3952") unless datastore['BIND_PW'].present? - pillage(entries) + print_good("#{ldap.peerinfo} is vulnerable to CVE-2020-3952") unless datastore['BIND_PW'].present? + pillage(entries) - # HACK: Stash discovered base DN in CheckCode reason - Exploit::CheckCode::Vulnerable(base_dn) + # HACK: Stash discovered base DN in CheckCode reason + Exploit::CheckCode::Vulnerable(base_dn) + end rescue Net::LDAP::Error => e print_error("#{e.class}: #{e.message}") Exploit::CheckCode::Unknown diff --git a/modules/auxiliary/scanner/ldap/ldap_login.rb b/modules/auxiliary/scanner/ldap/ldap_login.rb index 901052767c13..e9a7707273ee 100644 --- a/modules/auxiliary/scanner/ldap/ldap_login.rb +++ b/modules/auxiliary/scanner/ldap/ldap_login.rb @@ -11,6 +11,9 @@ class MetasploitModule < Msf::Auxiliary include Msf::Auxiliary::AuthBrute include Msf::Auxiliary::Scanner include Msf::Exploit::Remote::LDAP + include Msf::Sessions::CreateSessionOptions + include Msf::Auxiliary::CommandShell + def initialize(info = {}) super( update_info( @@ -37,12 +40,42 @@ def initialize(info = {}) ) # A password must be supplied unless doing anonymous login - deregister_options('BLANK_PASSWORDS') + options_to_deregister = %w[BLANK_PASSWORDS] + + if framework.features.enabled?(Msf::FeatureManager::LDAP_SESSION_TYPE) + add_info('The %grnCreateSession%clr option within this module can open an interactive session') + else + # Don't give the option to create a session unless ldap sessions are enabled + options_to_deregister << 'CreateSession' + end + + deregister_options(*options_to_deregister) + end + + def create_session? + # The CreateSession option is de-registered if LDAP_SESSION_TYPE is not enabled + # but the option can still be set/saved so check to see if we should use it + if framework.features.enabled?(Msf::FeatureManager::LDAP_SESSION_TYPE) + datastore['CreateSession'] + else + false + end end def run validate_connect_options! - super + results = super + logins = results.flat_map { |_k, v| v[:successful_logins] } + sessions = results.flat_map { |_k, v| v[:successful_sessions] } + print_status("Bruteforce completed, #{logins.size} #{logins.size == 1 ? 'credential was' : 'credentials were'} successful.") + return results unless framework.features.enabled?(Msf::FeatureManager::LDAP_SESSION_TYPE) + + if create_session? + print_status("#{sessions.size} LDAP #{sessions.size == 1 ? 'session was' : 'sessions were'} opened successfully.") + else + print_status('You can open an LDAP session with these credentials and %grnCreateSession%clr set to true') + end + results end def validate_connect_options! @@ -92,10 +125,13 @@ def run_host(ip) framework: framework, framework_module: self, realm_key: realm_key, - opts: opts + opts: opts, + use_client_as_proof: create_session? ) ) + successful_logins = [] + successful_sessions = [] scanner.scan! do |result| credential_data = result.to_h credential_data.merge!( @@ -105,6 +141,7 @@ def run_host(ip) protocol: 'tcp' ) if result.success? + successful_logins << result if opts[:ldap_auth] == Msf::Exploit::Remote::AuthOption::SCHANNEL # Schannel auth has no meaningful credential information to store in the DB print_brute level: :good, ip: ip, msg: "Success: 'Cert File #{opts[:ldap_cert_file]}'" @@ -112,10 +149,41 @@ def run_host(ip) create_credential_and_login(credential_data) print_brute level: :good, ip: ip, msg: "Success: '#{result.credential}'" end + successful_sessions << create_session(result, ip) if create_session? else invalidate_login(credential_data) vprint_error "#{ip}:#{rport} - LOGIN FAILED: #{result.credential} (#{result.status}: #{result.proof})" end end + { successful_logins: successful_logins, successful_sessions: successful_sessions } + end + + private + + def create_session(result, ip) + session_setup(result) + rescue StandardError => e + elog('Failed to setup the session', error: e) + print_brute level: :error, ip: ip, msg: "Failed to setup the session - #{e.class} #{e.message}" + result.connection.close unless result.connection.nil? + end + + # @param [Metasploit::Framework::LoginScanner::Result] result + # @return [Msf::Sessions::LDAP] + def session_setup(result) + return unless result.connection && result.proof + + # Create a new session + my_session = Msf::Sessions::LDAP.new(result.connection, { client: result.proof }) + + merge_me = { + 'USERPASS_FILE' => nil, + 'USER_FILE' => nil, + 'PASS_FILE' => nil, + 'USERNAME' => result.credential.public, + 'PASSWORD' => result.credential.private + } + + start_session(self, nil, merge_me, false, my_session.rstream, my_session) end end diff --git a/modules/auxiliary/scanner/mssql/mssql_login.rb b/modules/auxiliary/scanner/mssql/mssql_login.rb index 83e69d52977f..859b78d9644d 100644 --- a/modules/auxiliary/scanner/mssql/mssql_login.rb +++ b/modules/auxiliary/scanner/mssql/mssql_login.rb @@ -61,7 +61,9 @@ def run logins = results.flat_map { |_k, v| v[:successful_logins] } sessions = results.flat_map { |_k, v| v[:successful_sessions] } print_status("Bruteforce completed, #{logins.size} #{logins.size == 1 ? 'credential was' : 'credentials were'} successful.") - if datastore['CreateSession'] + return results unless framework.features.enabled?(Msf::FeatureManager::MSSQL_SESSION_TYPE) + + if create_session? print_status("#{sessions.size} MSSQL #{sessions.size == 1 ? 'session was' : 'sessions were'} opened successfully.") else print_status('You can open an MSSQL session with these credentials and %grnCreateSession%clr set to true') diff --git a/modules/auxiliary/scanner/mysql/mysql_login.rb b/modules/auxiliary/scanner/mysql/mysql_login.rb index e1bc9ba44b36..cec15d727f4b 100644 --- a/modules/auxiliary/scanner/mysql/mysql_login.rb +++ b/modules/auxiliary/scanner/mysql/mysql_login.rb @@ -65,7 +65,9 @@ def run logins = results.flat_map { |_k, v| v[:successful_logins] } sessions = results.flat_map { |_k, v| v[:successful_sessions] } print_status("Bruteforce completed, #{logins.size} #{logins.size == 1 ? 'credential was' : 'credentials were'} successful.") - if datastore['CreateSession'] + return results unless framework.features.enabled?(Msf::FeatureManager::MYSQL_SESSION_TYPE) + + if create_session? print_status("#{sessions.size} MySQL #{sessions.size == 1 ? 'session was' : 'sessions were'} opened successfully.") else print_status('You can open an MySQL session with these credentials and %grnCreateSession%clr set to true') diff --git a/modules/auxiliary/scanner/postgres/postgres_login.rb b/modules/auxiliary/scanner/postgres/postgres_login.rb index f12706b4009c..2ba18b8f0ff3 100644 --- a/modules/auxiliary/scanner/postgres/postgres_login.rb +++ b/modules/auxiliary/scanner/postgres/postgres_login.rb @@ -70,7 +70,9 @@ def run logins = results.flat_map { |_k, v| v[:successful_logins] } sessions = results.flat_map { |_k, v| v[:successful_sessions] } print_status("Bruteforce completed, #{logins.size} #{logins.size == 1 ? 'credential was' : 'credentials were'} successful.") - if datastore['CreateSession'] + return results unless framework.features.enabled?(Msf::FeatureManager::POSTGRESQL_SESSION_TYPE) + + if create_session? print_status("#{sessions.size} Postgres #{sessions.size == 1 ? 'session was' : 'sessions were'} opened successfully.") else print_status('You can open a Postgres session with these credentials and %grnCreateSession%clr set to true') diff --git a/modules/auxiliary/scanner/smb/smb_login.rb b/modules/auxiliary/scanner/smb/smb_login.rb index a5fb6bd67b5b..57056f9153c5 100644 --- a/modules/auxiliary/scanner/smb/smb_login.rb +++ b/modules/auxiliary/scanner/smb/smb_login.rb @@ -94,7 +94,9 @@ def run logins = results.flat_map { |_k, v| v[:successful_logins] } sessions = results.flat_map { |_k, v| v[:successful_sessions] } print_status("Bruteforce completed, #{logins.size} #{logins.size == 1 ? 'credential was' : 'credentials were'} successful.") - if datastore['CreateSession'] + return results unless framework.features.enabled?(Msf::FeatureManager::SMB_SESSION_TYPE) + + if create_session? print_status("#{sessions.size} SMB #{sessions.size == 1 ? 'session was' : 'sessions were'} opened successfully.") else print_status('You can open an SMB session with these credentials and %grnCreateSession%clr set to true') diff --git a/spec/acceptance/ldap_spec.rb b/spec/acceptance/ldap_spec.rb index 4c702020d37b..dbe17ae33c59 100644 --- a/spec/acceptance/ldap_spec.rb +++ b/spec/acceptance/ldap_spec.rb @@ -26,7 +26,7 @@ { name: 'auxiliary/gather/ldap_query', platforms: %i[linux osx windows], - targets: [:rhost], + targets: [:session, :rhost], skipped: false, action: 'run_query_file', datastore: { QUERY_FILE_PATH: 'data/auxiliary/gather/ldap_query/ldap_queries_default.yaml' }, @@ -39,7 +39,6 @@ /Running ENUM_ACCOUNTS.../, /Running ENUM_USER_SPNS_KERBEROAST.../, /Running ENUM_USER_PASSWORD_NOT_REQUIRED.../, - ] } } @@ -47,7 +46,7 @@ { name: 'auxiliary/gather/ldap_query', platforms: %i[linux osx windows], - targets: [:rhost], + targets: [:session, :rhost], skipped: false, action: 'enum_accounts', lines: { @@ -62,13 +61,11 @@ { name: 'auxiliary/gather/ldap_hashdump', platforms: %i[linux osx windows], - targets: [:rhost], + targets: [:session, :rhost], skipped: false, lines: { all: { required: [ - /Discovering base DN\(s\) automatically/, - /Dumping data for root DSE/, /Searching base DN='DC=ldap,DC=example,DC=com'/, /Storing LDAP data for base DN='DC=ldap,DC=example,DC=com' in loot/, /266 entries, 0 creds found in 'DC=ldap,DC=example,DC=com'./ @@ -79,19 +76,45 @@ { name: 'auxiliary/admin/ldap/shadow_credentials', platforms: %i[linux osx windows], - targets: [:rhost], + targets: [:session, :rhost], skipped: false, datastore: { TARGET_USER: 'administrator' }, lines: { all: { required: [ - /Discovering base DN automatically/, /Discovered base DN: DC=ldap,DC=example,DC=com/, /The msDS-KeyCredentialLink field is empty./ ] } } - } + }, + { + name: 'auxiliary/gather/ldap_esc_vulnerable_cert_finder', + platforms: %i[linux osx windows], + targets: [:session, :rhost], + skipped: false, + lines: { + all: { + required: [ + /Successfully queried/ + ] + } + } + }, + { + name: 'auxiliary/admin/ldap/rbcd', + platforms: %i[linux osx windows], + targets: [:session, :rhost], + skipped: false, + datastore: { DELEGATE_TO: 'administrator' }, + lines: { + all: { + required: [ + /The msDS-AllowedToActOnBehalfOfOtherIdentity field is empty./ + ] + } + } + }, ] } } @@ -338,7 +361,9 @@ def with_test_harness(module_test) end) use_module = "use #{module_test[:name]}" - run_module = "run session=#{session_id} Verbose=true" + run_command = module_test.key?(:action) ? module_test.fetch(:action) : 'run' + run_module = "#{run_command} session=#{session_id} #{target.datastore_options(default_module_datastore: default_module_datastore.merge(module_test.fetch(:datastore, {})))} Verbose=true" + replication_commands << use_module console.sendline(use_module) diff --git a/spec/lib/metasploit/framework/login_scanner/ldap_spec.rb b/spec/lib/metasploit/framework/login_scanner/ldap_spec.rb index 57ea17234df3..79fa89518015 100644 --- a/spec/lib/metasploit/framework/login_scanner/ldap_spec.rb +++ b/spec/lib/metasploit/framework/login_scanner/ldap_spec.rb @@ -43,7 +43,7 @@ let(:ldap) { spy } before(:each) do allow(subject).to receive(:ldap_connect_opts).and_return({}) - allow(subject).to receive(:ldap_open).and_yield(ldap) + allow(Rex::Proto::LDAP::Client).to receive(:_open).and_return(ldap) end it 'successfully authenticates' do diff --git a/spec/lib/msf/base/sessions/ldap_spec.rb b/spec/lib/msf/base/sessions/ldap_spec.rb new file mode 100644 index 000000000000..3f51c65a75a2 --- /dev/null +++ b/spec/lib/msf/base/sessions/ldap_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Msf::Sessions::LDAP do + let(:client) { instance_double(Rex::Proto::LDAP::Client) } + let(:opts) { { client: client } } + let(:console_class) { Rex::Post::LDAP::Ui::Console } + let(:user_input) { instance_double(Rex::Ui::Text::Input::Readline) } + let(:user_output) { instance_double(Rex::Ui::Text::Output::Stdio) } + let(:name) { 'name' } + let(:log_source) { "session_#{name}" } + let(:type) { 'ldap' } + let(:description) { 'LDAP' } + let(:can_cleanup_files) { false } + let(:address) { '192.0.2.1' } + let(:port) { 1337 } + let(:peer_info) { "#{address}:#{port}" } + + before(:each) do + allow(user_input).to receive(:intrinsic_shell?).and_return(true) + allow(user_input).to receive(:output=) + allow(client).to receive(:peerinfo).and_return(peer_info) + allow(client).to receive(:peerhost).and_return(address) + allow(client).to receive(:peerport).and_return(port) + end + + it_behaves_like 'client session' +end diff --git a/spec/lib/msf/core/exploit/remote/ldap_spec.rb b/spec/lib/msf/core/exploit/remote/ldap_spec.rb index b00dcc9c078c..adbddfcfacf1 100644 --- a/spec/lib/msf/core/exploit/remote/ldap_spec.rb +++ b/spec/lib/msf/core/exploit/remote/ldap_spec.rb @@ -3,15 +3,19 @@ require 'spec_helper' RSpec.describe Msf::Exploit::Remote::LDAP do + include_context 'Msf::Simple::Framework' subject do mod = ::Msf::Exploit.new mod.extend described_class - mod.send(:initialize) mod end + before(:each) do + allow(subject).to receive(:framework).and_return(framework) + end + let(:rhost) do 'rhost.example.com' end @@ -114,91 +118,4 @@ end end end - - describe '#get_naming_contexts' do - let(:ldap) do - instance_double(Net::LDAP) - end - context 'Could not retrieve root DSE' do - it do - expect(ldap).to receive(:search_root_dse).and_return(false) - expect(subject.get_naming_contexts(ldap)).to be(nil) - end - end - - context 'Empty naming contexts' do - let(:root_dse) do - { namingcontexts: [] } - end - it do - expect(ldap).to receive(:search_root_dse).and_return(root_dse) - expect(subject.get_naming_contexts(ldap)).to be(nil) - end - end - - context 'Naming contexts are present' do - - let(:naming_contexts) { - %w[context1 context2] - } - - let(:root_dse) do - { namingcontexts: naming_contexts } - end - - it do - expect(ldap).to receive(:search_root_dse).and_return(root_dse) - expect(subject.get_naming_contexts(ldap)).to be(naming_contexts) - end - end - end - - describe '#discover_base_dn' do - let(:ldap) do - instance_double(Net::LDAP) - end - - context 'No naming contexts' do - it do - expect(subject).to receive(:get_naming_contexts).with(ldap).and_return(nil) - expect(subject.discover_base_dn(ldap)).to be(nil) - end - end - - context 'Invalid naming contexts' do - let(:invalid_naming_contexts) do - %w[invalid1 invalid2] - end - it do - expect(subject).to receive(:get_naming_contexts).with(ldap).and_return(invalid_naming_contexts) - expect(subject.discover_base_dn(ldap)).to be(nil) - end - end - - context 'Valid naming contexts' do - let(:base_dn) do - 'DC=abcdef' - end - let(:valid_naming_contexts) do - [base_dn] - end - it do - expect(subject).to receive(:get_naming_contexts).with(ldap).and_return(valid_naming_contexts) - expect(subject.discover_base_dn(ldap)).to be(base_dn) - end - end - - context 'Valid naming contexts (lowercase dc)' do - let(:base_dn) do - 'dc=abcdef' - end - let(:valid_naming_contexts) do - [base_dn] - end - it do - expect(subject).to receive(:get_naming_contexts).with(ldap).and_return(valid_naming_contexts) - expect(subject.discover_base_dn(ldap)).to be(base_dn) - end - end - end end diff --git a/spec/lib/rex/proto/kerberos/client_spec.rb b/spec/lib/rex/proto/kerberos/client_spec.rb index 2d52e28718a1..1d396f44025c 100644 --- a/spec/lib/rex/proto/kerberos/client_spec.rb +++ b/spec/lib/rex/proto/kerberos/client_spec.rb @@ -22,8 +22,15 @@ def get_once(length, timeout = 10) end end + let(:rhost) { '127.0.0.1' } + let(:rport) { 88 } + subject(:client) do - described_class.new + opts = { + host: rhost, + port: rport, + } + described_class.new(opts) end let(:sample_asn1_request) do @@ -117,6 +124,40 @@ def get_once(length, timeout = 10) end end + describe '#connect' do + context 'when no host is specified' do + + subject do + opts = { + port: rport, + } + described_class.new(opts) + end + + it 'should raise an argument error' do + expect { subject.connect }.to raise_error(::ArgumentError) + end + end + + context 'when no port is specified' do + subject do + opts = { + host: rhost, + } + described_class.new(opts) + end + it 'should raise an argument error' do + expect { subject.connect }.not_to raise_error + end + end + + context 'when host and port are specified' do + it 'should not raise an error' do + expect { subject.connect }.not_to raise_error + end + end + end + describe "#recv_response" do context "when no connection" do it "raises RunitmeError" do @@ -154,4 +195,3 @@ def get_once(length, timeout = 10) end end end - diff --git a/spec/lib/rex/proto/ldap/client_spec.rb b/spec/lib/rex/proto/ldap/client_spec.rb new file mode 100644 index 000000000000..0342e762642a --- /dev/null +++ b/spec/lib/rex/proto/ldap/client_spec.rb @@ -0,0 +1,158 @@ +# -*- coding: binary -*- + +require 'spec_helper' +require 'rex/proto/ldap/client' + +RSpec.describe Rex::Proto::LDAP::Client do + let(:host) { '127.0.0.1' } + let(:port) { 1234 } + let(:info) { "#{host}:#{port}" } + + subject do + client = described_class.new(host: host, port: port) + client + end + + it_behaves_like 'session compatible client' + + let(:base_dn) { 'DC=ldap,DC=example,DC=com' } + let(:schema_dn) { 'CN=Schema,CN=Configuration,DC=ldap,DC=example,DC=com' } + + let(:root_dse_result_ldif) do + "dn: \n" \ + "namingcontexts: #{base_dn}\n" \ + "namingcontexts: CN=Configuration,DC=ldap,DC=example,DC=com\n" \ + "namingcontexts: CN=Schema,CN=Configuration,DC=ldap,DC=example,DC=com\n" \ + "namingcontexts: DC=DomainDnsZones,DC=ldap,DC=example,DC=com\n" \ + "namingcontexts: DC=ForestDnsZones,DC=ldap,DC=example,DC=com\n" \ + "supportedldapversion: 2\n" \ + "supportedldapversion: 3\n" \ + "supportedsaslmechanisms: GSS-SPNEGO\n" \ + "supportedsaslmechanisms: GSSAPI\n" \ + "supportedsaslmechanisms: NTLM\n" + end + + let(:schema_naming_context) do + "dn: \n" \ + "schemanamingcontext: #{schema_dn}\n" + end + + let(:empty_response) do + "dn: \n" + end + + let(:schema_naming_context_result) do + root_dse_dataset = Net::LDAP::Dataset.read_ldif(StringIO.new(schema_naming_context)) + root_dse_dataset.to_entries + end + + let(:root_dse_result) do + root_dse_dataset = Net::LDAP::Dataset.read_ldif(StringIO.new(root_dse_result_ldif)) + root_dse_dataset.to_entries[0] + end + + let(:empty_response_result) do + root_dse_dataset = Net::LDAP::Dataset.read_ldif(StringIO.new(empty_response)) + root_dse_dataset.to_entries + end + + describe '#naming_contexts' do + + before(:each) do + allow(subject).to receive(:search_root_dse).and_return(root_dse_result) + end + + it 'should cache the result' do + expect(subject).to receive(:search_root_dse) + subject.naming_contexts + expect(subject).not_to receive(:search_root_dse) + subject.naming_contexts + end + + context 'when no naming contexts are available' do + let(:root_dse_result_ldif) do + "dn: \n" \ + "supportedldapversion: 2\n" \ + "supportedldapversion: 3\n" \ + "supportedsaslmechanisms: GSS-SPNEGO\n" \ + "supportedsaslmechanisms: GSSAPI\n" \ + "supportedsaslmechanisms: NTLM\n" + end + + it 'returns an empty array' do + expect(subject.naming_contexts).to be_empty + end + end + + context 'when naming contexts are available' do + it 'contains naming contexts' do + expect(subject.naming_contexts).not_to be_empty + end + end + end + + describe '#base_dn' do + + before(:each) do + allow(subject).to receive(:search_root_dse).and_return(root_dse_result) + end + + it 'should cache the result' do + expect(subject).to receive(:discover_base_dn).and_call_original + subject.base_dn + expect(subject).not_to receive(:discover_base_dn) + subject.base_dn + end + + context 'when no naming contexts are available' do + let(:root_dse_result_ldif) do + "dn: \n" \ + "supportedldapversion: 2\n" \ + "supportedldapversion: 3\n" \ + "supportedsaslmechanisms: GSS-SPNEGO\n" \ + "supportedsaslmechanisms: GSSAPI\n" \ + "supportedsaslmechanisms: NTLM\n" + end + + it 'should not find the base dn' do + expect(subject.base_dn).to be_nil + end + end + + context 'when naming contexts are available' do + it 'contains naming contexts' do + expect(subject.base_dn).to eql(base_dn) + end + end + end + + describe '#schema_dn' do + + before(:each) do + allow(subject).to receive(:search).and_return(schema_naming_context_result) + end + + it 'should cache the result' do + expect(subject).to receive(:discover_schema_naming_context).and_call_original + subject.schema_dn + expect(subject).not_to receive(:discover_schema_naming_context) + subject.schema_dn + end + + context 'when the response does not contain the schema_dn' do + before(:each) do + allow(subject).to receive(:search).and_return(empty_response_result) + end + + it 'does not find the schema_dn' do + expect(subject.schema_dn).to be_nil + end + end + + context 'when the response does contain the schema_dn' do + it 'finds the schema_dn' do + expect(subject.schema_dn).to eql(schema_dn) + end + end + end +end diff --git a/test/ldap/docker-compose.yml b/test/ldap/docker-compose.yml index 1544a1670e25..6c4239e2751b 100644 --- a/test/ldap/docker-compose.yml +++ b/test/ldap/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.7' - services: ldap: tty: true diff --git a/test/smb/docker-compose.yml b/test/smb/docker-compose.yml index 10980403919b..69afa0894038 100644 --- a/test/smb/docker-compose.yml +++ b/test/smb/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.7' - services: samba: tty: true