metasploit-framework/modules/auxiliary/gather/ldap_query.rb

209 lines
9.1 KiB
Ruby

##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Auxiliary
include Msf::Exploit::Remote::LDAP
include Msf::Exploit::Remote::LDAP::Queries
require 'json'
require 'yaml'
def initialize(info = {})
actions, default_action = initialize_actions
super(
update_info(
info,
'Name' => 'LDAP Query and Enumeration Module',
'Description' => %q{
This module allows users to query an LDAP server using either a custom LDAP query, or
a set of LDAP queries under a specific category. Users can also specify a JSON or YAML
file containing custom queries to be executed using the RUN_QUERY_FILE action.
If this action is specified, then QUERY_FILE_PATH must be a path to the location
of this JSON/YAML file on disk.
Users can also run a single query by using the RUN_SINGLE_QUERY option and then setting
the QUERY_FILTER datastore option to the filter to send to the LDAP server and QUERY_ATTRIBUTES
to a comma separated string containing the list of attributes they are interested in obtaining
from the results.
As a third option can run one of several predefined queries by setting ACTION to the
appropriate value. These options will be loaded from the ldap_queries_default.yaml file
located in the MSF configuration directory, located by default at ~/.msf4/ldap_queries_default.yaml.
All results will be returned to the user in table, CSV or JSON format, depending on the value
of the OUTPUT_FORMAT datastore option. The characters || will be used as a delimiter
should multiple items exist within a single column.
},
'Author' => [
'Grant Willcox', # Original module author
],
'References' => [
],
'DisclosureDate' => '2022-05-19',
'License' => MSF_LICENSE,
'Actions' => actions,
'DefaultAction' => default_action,
'DefaultOptions' => {
'SSL' => false
},
'Notes' => {
'Stability' => [CRASH_SAFE],
'SideEffects' => [IOC_IN_LOGS],
'Reliability' => []
}
)
)
register_options([
Opt::RPORT(389), # Set to 636 for SSL/TLS
OptEnum.new('OUTPUT_FORMAT', [true, 'The output format to use', 'table', %w[csv table json]]),
OptString.new('BASE_DN', [false, 'LDAP base DN if you already have it']),
OptPath.new('QUERY_FILE_PATH', [false, 'Path to the JSON or YAML file to load and run queries from'], conditions: %w[ACTION == RUN_QUERY_FILE]),
OptString.new('QUERY_FILTER', [false, 'Filter to send to the target LDAP server to perform the query'], conditions: %w[ACTION == RUN_SINGLE_QUERY]),
OptString.new('QUERY_ATTRIBUTES', [false, 'Comma separated list of attributes to retrieve from the server'], conditions: %w[ACTION == RUN_SINGLE_QUERY])
])
end
def initialize_actions
user_config_file_path = File.join(::Msf::Config.config_directory, 'ldap_queries.yaml')
default_config_file_path = File.join(::Msf::Config.data_directory, 'auxiliary', 'gather', 'ldap_query', 'ldap_queries_default.yaml')
@loaded_queries = safe_load_queries(default_config_file_path) || []
if File.exist?(user_config_file_path)
@loaded_queries.concat(safe_load_queries(user_config_file_path) || [])
else
# If the user config file doesn't exist, then initialize it with a sample entry.
# Users can adjust this file to overwrite default actions to retrieve different attributes etc by default.
template = File.join(::Msf::Config.data_directory, 'auxiliary', 'gather', 'ldap_query', 'ldap_queries_template.yaml')
FileUtils.cp(template, user_config_file_path) if File.exist?(template)
end
# Combine the user settings with the default settings and then uniq them such that we only have one copy
# of each ACTION, however we use the user's custom settings if they have tweaked anything to prevent overriding
# their custom adjustments.
@loaded_queries = @loaded_queries.map { |h| [h['action'], h] }.to_h
@loaded_queries.select! do |_, entry|
if entry['action'].blank?
wlog('ldap query entry detected that was missing its action field')
return false
end
if %w[RUN_QUERY_FILE RUN_SINGLE_QUERY].include? entry['action']
wlog("ldap query entry detected that was using a reserved action name: #{entry['action']}")
return false
end
if entry['filter'].blank?
wlog('ldap query entry detected that was missing its filter field')
return false
end
unless entry['attributes'].is_a? Array
wlog('ldap query entry detected that was missing its attributes field')
return false
end
true
end
actions = []
@loaded_queries.each_value do |entry|
actions << [entry['action'], { 'Description' => entry['description'] || '' }]
end
actions << ['RUN_QUERY_FILE', { 'Description' => 'Execute a custom set of LDAP queries from the JSON or YAML file specified by QUERY_FILE.' }]
actions << ['RUN_SINGLE_QUERY', { 'Description' => 'Execute a single LDAP query using the QUERY_FILTER and QUERY_ATTRIBUTES options.' }]
actions.sort!
default_action = 'RUN_QUERY_FILE'
unless @loaded_queries.empty? # Aka there is more than just RUN_QUERY_FILE and RUN_SINGLE_QUERY in the actions list...
default_action = actions[0][0] # Get the first entry's action name and set this as the default action.
end
[actions, default_action]
end
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_dn(ldap, base_dn)
case action.name
when 'RUN_QUERY_FILE'
unless datastore['QUERY_FILE_PATH']
fail_with(Failure::BadConfig, 'When using the RUN_QUERY_FILE action, one must specify the path to the JSON/YAML file containing the queries via QUERY_FILE_PATH!')
end
print_status("Loading queries from #{datastore['QUERY_FILE_PATH']}...")
parsed_queries = safe_load_queries(datastore['QUERY_FILE_PATH']) || []
if parsed_queries.empty?
fail_with(Failure::BadConfig, "No queries loaded from #{datastore['QUERY_FILE_PATH']}!")
end
run_queries_from_file(ldap, parsed_queries, datastore['OUTPUT_FORMAT'])
return
when 'RUN_SINGLE_QUERY'
unless datastore['QUERY_FILTER'] && datastore['QUERY_ATTRIBUTES']
fail_with(Failure::BadConfig, 'When using the RUN_SINGLE_QUERY action, one must supply the QUERY_FILTER and QUERY_ATTRIBUTE datastore options!')
end
print_status("Sending single query #{datastore['QUERY_FILTER']} to the LDAP server...")
attributes = datastore['QUERY_ATTRIBUTES']
if attributes.empty?
fail_with(Failure::BadConfig, 'Attributes list is empty as we could not find at least one attribute to filter on!')
end
# Split attributes string into an array of attributes, splitting on the comma character.
# Also downcase for consistency with rest of the code since LDAP searches aren't case sensitive.
attributes = attributes.downcase.split(',')
# Strip out leading and trailing whitespace from the attributes before using them.
attributes.map(&:strip!)
filter_string = datastore['QUERY_FILTER']
query_base = base_dn
else
query = @loaded_queries[datastore['ACTION']].nil? ? @loaded_queries[default_action] : @loaded_queries[datastore['ACTION']]
fail_with(Failure::BadConfig, "Invalid action: #{datastore['ACTION']}") unless query
filter_string = query['filter']
attributes = query['attributes']
query_base = (query['base_dn_prefix'] ? [query['base_dn_prefix'], base_dn].join(',') : base_dn)
end
begin
filter = Net::LDAP::Filter.construct(filter_string)
rescue StandardError => e
fail_with(Failure::BadConfig, "Could not compile the filter #{filter_string}. Error was #{e}")
end
result_count = perform_ldap_query_streaming(ldap, filter, attributes, query_base, schema_dn) do |result, attribute_properties|
show_output(normalize_entry(result, attribute_properties), datastore['OUTPUT_FORMAT'])
end
if result_count == 0
print_error("No entries could be found for #{filter_string}!")
else
print_status("Query returned #{result_count} result#{result_count == 1 ? '' : 's'}.")
end
end
rescue Rex::ConnectionTimeout
fail_with(Failure::Unreachable, "Couldn't reach #{datastore['RHOST']}!")
rescue Net::LDAP::Error => e
fail_with(Failure::UnexpectedReply, "Could not query #{datastore['RHOST']}! Error was: #{e.message}")
end
attr_reader :loaded_queries # Queries loaded from the yaml config file
end