From 1f846a12b44d917e1ae78318266e42c15db9a898 Mon Sep 17 00:00:00 2001 From: adfoster-r7 Date: Tue, 28 Nov 2023 13:14:13 +0000 Subject: [PATCH] Add hierarchical search table support --- .../ldap_query/ldap_queries_default.yaml | 4 +- lib/msf/core/feature_manager.rb | 7 + lib/msf/core/modules/metadata/obj.rb | 24 +++ lib/msf/core/modules/metadata/search.rb | 6 +- .../ui/console/command_dispatcher/modules.rb | 159 +++++++++++++++--- .../ui/console/table_print/blank_formatter.rb | 17 ++ .../ui/console/table_print/rank_formatter.rb | 3 +- .../auxiliary/admin/kerberos/forge_ticket.rb | 2 +- .../table_print/rank_formatter_spec.rb | 3 + 9 files changed, 194 insertions(+), 31 deletions(-) create mode 100644 lib/msf/ui/console/table_print/blank_formatter.rb diff --git a/data/auxiliary/gather/ldap_query/ldap_queries_default.yaml b/data/auxiliary/gather/ldap_query/ldap_queries_default.yaml index 483ec7352271c..96aeee9e0103b 100644 --- a/data/auxiliary/gather/ldap_query/ldap_queries_default.yaml +++ b/data/auxiliary/gather/ldap_query/ldap_queries_default.yaml @@ -135,7 +135,7 @@ queries: - https://www.netspi.com/blog/technical/network-penetration-testing/exploiting-adidns/ - https://github.com/dirkjanm/krbrelayx/blob/master/dnstool.py - action: ENUM_DNS_ZONES - description: 'Dump info about DNS zones the server knows about using the dnsZone object class under the DC DomainDnsZones. This is needed as without this BASEDN prefix we often miss certain entries.' + description: 'Dump all known DNS zones using the dnsZone object class under the DC DomainDnsZones. Without A BASEDN prefix you can miss certain entries.' filter: '(objectClass=dnsZone)' base_dn_prefix: 'DC=DomainDnsZones' attributes: @@ -325,7 +325,7 @@ queries: references: - https://learn.microsoft.com/en-us/troubleshoot/windows-server/identity/useraccountcontrol-manipulate-account-properties - action: ENUM_USER_ASREP_ROASTABLE - description: 'Dump info about all users who are configured not to require kerberos pre-authentication and are therefore AS-REP roastable.' + description: 'Dump all users who are configured not to require kerberos pre-authentication, i.e. AS-REP roastable.' filter: '(&(samAccountType=805306368)(userAccountControl:1.2.840.113556.1.4.803:=4194304))' attributes: - cn diff --git a/lib/msf/core/feature_manager.rb b/lib/msf/core/feature_manager.rb index d58443ac3c614..69b55b61cf203 100644 --- a/lib/msf/core/feature_manager.rb +++ b/lib/msf/core/feature_manager.rb @@ -21,6 +21,7 @@ class FeatureManager METASPLOIT_PAYLOAD_WARNINGS = 'metasploit_payload_warnings' DEFER_MODULE_LOADS = 'defer_module_loads' DNS_FEATURE = 'dns_feature' + HIERARCHICAL_SEARCH_TABLE = 'hierarchical_search_table' DEFAULTS = [ { name: WRAPPED_TABLES, @@ -60,6 +61,12 @@ class FeatureManager description: 'When enabled, allows configuration of DNS resolution behaviour in Metasploit', requires_restart: false, default_value: false + }.freeze, + { + name: HIERARCHICAL_SEARCH_TABLE, + description: 'When enabled, the search table is enhanced to show details on module actions and targets', + requires_restart: false, + default_value: false }.freeze ].freeze diff --git a/lib/msf/core/modules/metadata/obj.rb b/lib/msf/core/modules/metadata/obj.rb index e6c01ef9727c1..0f1087c2a99cc 100644 --- a/lib/msf/core/modules/metadata/obj.rb +++ b/lib/msf/core/modules/metadata/obj.rb @@ -8,6 +8,8 @@ module Modules module Metadata class Obj + # @return [Hash] + attr_reader :actions # @return [String] attr_reader :name # @return [String] @@ -98,6 +100,15 @@ def initialize(module_instance, obj_hash = nil) @ref_name = module_instance.class.refname @needs_cleanup = module_instance.respond_to?(:needs_cleanup) && module_instance.needs_cleanup + if module_instance.respond_to?(:actions) + @actions = module_instance.actions.sort_by(&:name).map do |action| + { + 'name' => action.name, + 'description' => action.description + } + end + end + if module_instance.respond_to?(:autofilter_ports) @autofilter_ports = module_instance.autofilter_ports end @@ -171,6 +182,8 @@ def to_json(*args) 'needs_cleanup' => @needs_cleanup, } + data['actions'] = @actions if @actions + if @payload_type payload_data = { 'payload_type' => @payload_type, @@ -211,6 +224,7 @@ def path ####### def init_from_hash(obj_hash) + @actions = obj_hash['actions'] @name = obj_hash['name'] @fullname = obj_hash['fullname'] @aliases = obj_hash['aliases'] || [] @@ -257,6 +271,16 @@ def sort_platform_string end def force_encoding(encoding) + if @actions + # Encode the actions hashes, assumes that there are no nested hashes + @actions = @actions.map do |action| + action.map do |k, v| + new_key = k.dup.force_encoding(encoding) + new_value = v.is_a?(String) ? v.dup.force_encoding(encoding) : v + [new_key, new_value] + end.to_h + end + end @name = @name.dup.force_encoding(encoding) @fullname = @fullname.dup.force_encoding(encoding) @description = @description.dup.force_encoding(encoding) diff --git a/lib/msf/core/modules/metadata/search.rb b/lib/msf/core/modules/metadata/search.rb index 8d44a14c1a4ce..c1a98ed91b632 100644 --- a/lib/msf/core/modules/metadata/search.rb +++ b/lib/msf/core/modules/metadata/search.rb @@ -8,6 +8,7 @@ module Msf::Modules::Metadata::Search VALID_PARAMS = %w[ + action adapter aka arch @@ -139,7 +140,8 @@ def is_match(params, module_metadata) # free form text search will honor 'and' semantics, i.e. 'metasploit pro' will only match modules that contain both # words, and will return false when only one word is matched if keyword == 'text' - text_segments = [module_metadata.name, module_metadata.fullname, module_metadata.description] + module_metadata.references + module_metadata.author + (module_metadata.notes['AKA'] || []) + module_actions = (module_metadata.actions || []).flat_map { |action| action.values.map(&:to_s) } + text_segments = [module_metadata.name, module_metadata.fullname, module_metadata.description] + module_metadata.references + module_metadata.author + (module_metadata.notes['AKA'] || []) + module_actions if module_metadata.targets text_segments = text_segments + module_metadata.targets @@ -167,6 +169,8 @@ def is_match(params, module_metadata) regex = as_regex(search_term) case keyword + when 'action' + match = [keyword, search_term] if (module_metadata&.actions || []).any? { |action| action.any? { |k, v| k =~ regex || v =~ regex } } when 'aka' match = [keyword, search_term] if (module_metadata.notes['AKA'] || []).any? { |aka| aka =~ regex } when 'author', 'authors' diff --git a/lib/msf/ui/console/command_dispatcher/modules.rb b/lib/msf/ui/console/command_dispatcher/modules.rb index d451b47ea064b..7b59d22c01a1a 100644 --- a/lib/msf/ui/console/command_dispatcher/modules.rb +++ b/lib/msf/ui/console/command_dispatcher/modules.rb @@ -67,7 +67,10 @@ def initialize(driver) @dscache = {} @previous_module = nil @module_name_stack = [] + # Array of individual modules that have been searched for @module_search_results = [] + # Module search results, with additional metadata on what to do if the module is interacted with + @module_search_results_with_usage_metadata = [] @@payload_show_results = [] @dangerzone_map = nil end @@ -139,11 +142,18 @@ def print_module_info(mod, dump_json: false, show_doc: false) # Handles the index selection formatting def print_module_search_results_usage - index_usage = "use #{@module_search_results.length - 1}" - index_info = "info #{@module_search_results.length - 1}" - name_usage = "use #{@module_search_results.last.fullname}" + last_mod_with_usage_metadata = @module_search_results_with_usage_metadata.last + index_usage = "use #{@module_search_results_with_usage_metadata.length - 1}" + index_info = "info #{@module_search_results_with_usage_metadata.length - 1}" + name_usage = "use #{last_mod_with_usage_metadata[:mod].fullname}" - print("Interact with a module by name or index. For example %grn#{index_info}%clr, %grn#{index_usage}%clr or %grn#{name_usage}%clr\n\n") + additional_usage_message = "" + additional_usage_example = (last_mod_with_usage_metadata[:datastore] || {}).first + if framework.features.enabled?(Msf::FeatureManager::HIERARCHICAL_SEARCH_TABLE) && additional_usage_example + key, value = additional_usage_example + additional_usage_message = "\nAfter interacting with a module you can manually set a #{key} with %grnset #{key} '#{value}'%clr" + end + print("Interact with a module by name or index. For example %grn#{index_info}%clr, %grn#{index_usage}%clr or %grn#{name_usage}%clr#{additional_usage_message}\n\n") end # @@ -179,12 +189,16 @@ def cmd_info(*args) args.each do |arg| mod_name = arg + additional_datastore_values = nil + # Use a module by search index - index_from_list(@module_search_results, mod_name) do |mod| + index_from_list(@module_search_results_with_usage_metadata, mod_name) do |result| + mod = result&.[](:mod) next unless mod && mod.respond_to?(:fullname) - # Module cache object from @module_search_results + # Module cache object mod_name = mod.fullname + additional_datastore_values = result[:datastore] end # Ensure we have a reference name and not a path @@ -193,6 +207,9 @@ def cmd_info(*args) # Creates an instance of the module mod = framework.modules.create(name) + # If any additional datastore values were provided, set these values + mod.datastore.update(additional_datastore_values) unless additional_datastore_values.nil? + if mod.nil? print_error("Invalid module: #{name}") else @@ -386,18 +403,20 @@ def cmd_search_help 'stager' => 'Modules with a matching stager reference name', 'target' => 'Modules affecting this target', 'type' => 'Modules of a specific type (exploit, payload, auxiliary, encoder, evasion, post, or nop)', + 'action' => 'Modules with a matching action name or description', }.each_pair do |keyword, description| print_line " #{keyword.ljust 17}: #{description}" end print_line print_line "Supported search columns:" { - 'rank' => 'Sort modules by their exploitabilty rank', + 'rank' => 'Sort modules by their exploitability rank', 'date' => 'Sort modules by their disclosure date. Alias for disclosure_date', 'disclosure_date' => 'Sort modules by their disclosure date', 'name' => 'Sort modules by their name', 'type' => 'Sort modules by their type', 'check' => 'Sort modules by whether or not they have a check method', + 'action' => 'Sort modules by whether or not they have actions', }.each_pair do |keyword, description| print_line " #{keyword.ljust 17}: #{description}" end @@ -422,7 +441,7 @@ def cmd_search(*args) count = -1 search_terms = [] sort_attribute = 'name' - valid_sort_attributes = ['rank','disclosure_date','name','date','type','check'] + valid_sort_attributes = ['action', 'rank','disclosure_date','name','date','type','check'] reverse_sort = false ignore_use_exact_match = false @@ -449,7 +468,7 @@ def cmd_search(*args) end if args.empty? - if @module_search_results.empty? + if @module_search_results_with_usage_metadata.empty? cmd_search_help return false end @@ -470,7 +489,9 @@ def cmd_search(*args) @module_search_results = Msf::Modules::Metadata::Cache.instance.find(search_params) @module_search_results.sort_by! do |module_metadata| - if sort_attribute == 'check' + if sort_attribute == 'action' + module_metadata.actions&.any? ? 0 : 1 + elsif sort_attribute == 'check' module_metadata.check ? 0 : 1 elsif sort_attribute == 'disclosure_date' || sort_attribute == 'date' # Not all modules have disclosure_date, i.e. multi/handler @@ -491,7 +512,7 @@ def cmd_search(*args) end if ignore_use_exact_match && @module_search_results.length == 1 && - @module_search_results.first.fullname == match.strip + @module_search_results.first.fullname == match.strip return false end @@ -504,19 +525,76 @@ def cmd_search(*args) # Generate the table used to display matches tbl = generate_module_table('Matching Modules', search_terms, row_filter) + @module_search_results_with_usage_metadata = [] @module_search_results.each do |m| + @module_search_results_with_usage_metadata << { mod: m } + count += 1 tbl << [ - count += 1, - m.fullname, - m.disclosure_date.nil? ? '' : m.disclosure_date.strftime("%Y-%m-%d"), - m.rank, - m.check ? 'Yes' : 'No', - m.name, + count, + "#{m.fullname}", + m.disclosure_date.nil? ? '' : m.disclosure_date.strftime("%Y-%m-%d"), + m.rank, + m.check ? 'Yes' : 'No', + m.name, ] - end - if @module_search_results.length == 1 && use - used_module = @module_search_results.first.fullname + if framework.features.enabled?(Msf::FeatureManager::HIERARCHICAL_SEARCH_TABLE) + total_children_rows = (m.actions&.length || 0) + (m.targets&.length || 0) + (m.notes&.[]('AKA')&.length || 0) + show_child_items = total_children_rows > 1 + next unless show_child_items + + indent = "\xc2\xa0\xc2\xa0\\_ " + # Note: We still use visual indicators for blank values as it's easier to read + # We can't always use a generic formatter/styler, as it would be applied to the 'parent' rows too + blank_value = '.' + if (m.actions&.length || 0) > 1 + m.actions.each do |action| + @module_search_results_with_usage_metadata << { mod: m, datastore: { 'ACTION' => action['name'] } } + count += 1 + tbl << [ + count, + "#{indent}action: #{action['name']}", + blank_value, + blank_value, + blank_value, + action['description'], + ] + end + end + + if (m.targets&.length || 0) > 1 + m.targets.each do |target| + @module_search_results_with_usage_metadata << { mod: m, datastore: { 'TARGET' => target } } + count += 1 + tbl << [ + count, + "#{indent}target: #{target}", + blank_value, + blank_value, + blank_value, + blank_value + ] + end + end + + if (m.notes&.[]('AKA')&.length || 0) > 1 + m.notes['AKA'].each do |aka| + @module_search_results_with_usage_metadata << { mod: m } + count += 1 + tbl << [ + count, + "#{indent}AKA: #{aka}", + blank_value, + blank_value, + blank_value, + blank_value + ] + end + end + end + end + if @module_search_results_with_usage_metadata.length == 1 && use + used_module = @module_search_results_with_usage_metadata.first[:mod].fullname cmd_use(used_module, true) end rescue ArgumentError @@ -712,15 +790,19 @@ def cmd_use(*args) # Try to create an instance of the supplied module name mod_name = args[0] + additional_datastore_values = nil + # Use a module by search index - index_from_list(@module_search_results, mod_name) do |mod| + index_from_list(@module_search_results_with_usage_metadata, mod_name) do |result| + mod = result&.[](:mod) unless mod && mod.respond_to?(:fullname) print_error("Invalid module index: #{mod_name}") return false end - # Module cache object from @module_search_results + # Module cache object from @module_search_results_with_usage_metadata mod_name = mod.fullname + additional_datastore_values = result[:datastore] end # See if the supplied module name has already been resolved @@ -804,6 +886,12 @@ def cmd_use(*args) active_module.datastore.update(@dscache[active_module.fullname]) end + # If any additional datastore values were provided, set these values + unless additional_datastore_values.nil? + mod.datastore.update(additional_datastore_values) + print_status("Additionally setting #{additional_datastore_values.map { |k,v| "#{k} => #{v}" }.join(", ")}") + end + # Choose a default payload when the module is used, not run if mod.datastore['PAYLOAD'] print_status("Using configured payload #{mod.datastore['PAYLOAD']}") @@ -1479,6 +1567,7 @@ def show_favorites # :nodoc: end @module_search_results = filtered_results.flatten.sort_by(&:fullname) end + @module_search_results_with_usage_metadata = @module_search_results show_module_metadata('Favorites', fav_modules) print_module_search_results_usage @@ -1675,12 +1764,15 @@ def add_record(mod, count, compatible_mod) end def generate_module_table(type, search_terms = [], row_filter = nil) # :nodoc: + table_hierarchy_formatters = framework.features.enabled?(Msf::FeatureManager::HIERARCHICAL_SEARCH_TABLE) ? [Msf::Ui::Console::TablePrint::BlankFormatter.new] : [] + Table.new( Table::Style::Default, 'Header' => type, 'Prefix' => "\n", 'Postfix' => "\n", 'SearchTerm' => row_filter, + 'SortIndex' => -1, # For now, don't perform any word wrapping on the search table as it breaks the workflow of # copying module names in conjunction with the `use ` command 'WordWrap' => false, @@ -1694,14 +1786,31 @@ def generate_module_table(type, search_terms = [], row_filter = nil) # :nodoc: ], 'ColProps' => { 'Rank' => { - 'Formatters' => [Msf::Ui::Console::TablePrint::RankFormatter.new], - 'Stylers' => [Msf::Ui::Console::TablePrint::RankStyler.new] + 'Formatters' => [ + *table_hierarchy_formatters, + Msf::Ui::Console::TablePrint::RankFormatter.new + ], + 'Stylers' => [ + Msf::Ui::Console::TablePrint::RankStyler.new + ] }, 'Name' => { 'Stylers' => [Msf::Ui::Console::TablePrint::HighlightSubstringStyler.new(search_terms)] }, + 'Check' => { + 'Formatters' => [ + *table_hierarchy_formatters, + ] + }, + 'Disclosure Date' => { + 'Formatters' => [ + *table_hierarchy_formatters, + ] + }, 'Description' => { - 'Stylers' => [Msf::Ui::Console::TablePrint::HighlightSubstringStyler.new(search_terms)] + 'Stylers' => [ + Msf::Ui::Console::TablePrint::HighlightSubstringStyler.new(search_terms) + ] } } ) diff --git a/lib/msf/ui/console/table_print/blank_formatter.rb b/lib/msf/ui/console/table_print/blank_formatter.rb new file mode 100644 index 0000000000000..7eccd2b877175 --- /dev/null +++ b/lib/msf/ui/console/table_print/blank_formatter.rb @@ -0,0 +1,17 @@ +# -*- coding: binary -*- + +module Msf + module Ui + module Console + module TablePrint + class BlankFormatter + def format(value) + return '.' if value.blank? + + value + end + end + end + end + end +end diff --git a/lib/msf/ui/console/table_print/rank_formatter.rb b/lib/msf/ui/console/table_print/rank_formatter.rb index d0f1f476bef1a..329811d2a9ae2 100644 --- a/lib/msf/ui/console/table_print/rank_formatter.rb +++ b/lib/msf/ui/console/table_print/rank_formatter.rb @@ -5,9 +5,8 @@ module Ui module Console module TablePrint class RankFormatter - def format(rank) - if (rank.respond_to? :to_i) && (Msf::RankingName.key?(rank.to_i)) + if rank.present? && !rank.to_s.match?(/\D/) && Msf::RankingName.key?(rank.to_i) Msf::RankingName[rank.to_i] else rank diff --git a/modules/auxiliary/admin/kerberos/forge_ticket.rb b/modules/auxiliary/admin/kerberos/forge_ticket.rb index ddfc9441da8eb..7cc9195a065ba 100644 --- a/modules/auxiliary/admin/kerberos/forge_ticket.rb +++ b/modules/auxiliary/admin/kerberos/forge_ticket.rb @@ -34,7 +34,7 @@ def initialize(info = {}) 'Stability' => [CRASH_SAFE], 'SideEffects' => [IOC_IN_LOGS], 'Reliability' => [], - 'AKA' => ['Silver Ticket', 'Golden Ticket', 'diamond', 'sapphire', 'Ticketer', 'Klist'] + 'AKA' => ['Ticketer', 'Klist'] }, 'Actions' => [ ['FORGE_SILVER', { 'Description' => 'Forge a Silver Ticket' } ], diff --git a/spec/lib/msf/ui/console/table_print/rank_formatter_spec.rb b/spec/lib/msf/ui/console/table_print/rank_formatter_spec.rb index 01763eaa8b077..fcf8717a7e607 100644 --- a/spec/lib/msf/ui/console/table_print/rank_formatter_spec.rb +++ b/spec/lib/msf/ui/console/table_print/rank_formatter_spec.rb @@ -20,6 +20,9 @@ expect(formatter.format(42)).to eql 42 expect(formatter.format([])).to eql [] expect(formatter.format({})).to eql Hash.new + expect(formatter.format(nil)).to eql nil + expect(formatter.format('')).to eql '' + expect(formatter.format('.')).to eql '.' end end end