diff --git a/lib/msf/base/serializer/readable_text.rb b/lib/msf/base/serializer/readable_text.rb index 32d21ea62b1e..81e485469d42 100644 --- a/lib/msf/base/serializer/readable_text.rb +++ b/lib/msf/base/serializer/readable_text.rb @@ -571,7 +571,13 @@ def self.dump_generic_module(mod, indent = '') def self.dump_options(mod, indent = '', missing = false, advanced: false, evasion: false) filtered_options = mod.options.values.select { |opt| opt.advanced? == advanced && opt.evasion? == evasion } - options_grouped_by_conditions = filtered_options.group_by(&:conditions) + option_groups = mod.options.groups.map { |_name, group| group }.sort_by(&:name) + options_by_group = option_groups.map do |group| + [group, group.option_names.map { |name| mod.options[name] }.compact] + end.to_h + grouped_option_names = option_groups.flat_map(&:option_names) + remaining_options = filtered_options.reject { |option| grouped_option_names.include?(option.name) } + options_grouped_by_conditions = remaining_options.group_by(&:conditions) option_tables = [] @@ -587,6 +593,11 @@ def self.dump_options(mod, indent = '', missing = false, advanced: false, evasio end end + options_by_group.each do |group, options| + tbl = options_table(missing, mod, options, indent) + option_tables << "#{indent}#{group.description}:\n\n#{tbl}" + end + result = option_tables.join("\n\n") result end diff --git a/lib/msf/core/module/options.rb b/lib/msf/core/module/options.rb index 330adb0e4d09..4cd8d84cb900 100644 --- a/lib/msf/core/module/options.rb +++ b/lib/msf/core/module/options.rb @@ -67,4 +67,28 @@ def register_options(options, owner = self.class) self.options.add_options(options, owner) import_defaults(false) end + + # Registers a new option group, merging options by default + # + # @param name [String] Name for the group + # @param description [String] Description of the group + # @param option_names [Array] List of datastore option names + # @param merge [Boolean] whether to merge or overwrite the groups option names + def register_option_group(name:, description:, option_names: [], merge: true) + existing_group = options.groups[name] + if merge && existing_group + existing_group.description = description + existing_group.add_options(option_names) + else + option_group = Msf::OptionGroup.new(name: name, description: description, option_names: option_names) + options.add_group(option_group) + end + end + + # De-registers an option group by name + # + # @param name [String] Name for the group + def deregister_option_group(name:) + options.remove_group(name) + end end diff --git a/lib/msf/core/opt_condition.rb b/lib/msf/core/opt_condition.rb index fcfd2744a176..bb189031b383 100644 --- a/lib/msf/core/opt_condition.rb +++ b/lib/msf/core/opt_condition.rb @@ -3,9 +3,10 @@ module Msf module OptCondition # Check a condition's result - # @param [Msf::Module] mod The module module - # @param [Msf::OptBase] opt the option which has conditions present - # @return [String] + # @param [String] left_value The left hand side of the condition + # @param [String] operator The conditions comparison operator + # @param [String] right_value The right hand side of the condition + # @return [Boolean] def self.eval_condition(left_value, operator, right_value) case operator.to_sym when :== @@ -16,6 +17,8 @@ def self.eval_condition(left_value, operator, right_value) right_value.include?(left_value) when :nin !right_value.include?(left_value) + else + raise ArgumentError("Operator: #{operator} is invalid") end end diff --git a/lib/msf/core/option_container.rb b/lib/msf/core/option_container.rb index ca1613e7ee99..6ba0a70f2fb0 100644 --- a/lib/msf/core/option_container.rb +++ b/lib/msf/core/option_container.rb @@ -49,6 +49,7 @@ class OptionContainer < Hash # def initialize(opts = {}) self.sorted = [] + self.groups = {} add_options(opts) end @@ -313,14 +314,33 @@ def merge_sort(other_container) result.sort end + # Adds an option group to the container + # + # @param option_group [Msf::OptionGroup] + def add_group(option_group) + groups[option_group.name] = option_group + end + + # Removes an option group from the container by name + # + # @param group_name [String] + def remove_group(group_name) + groups.delete(group_name) + end + # # The sorted array of options. # attr_reader :sorted + # @return [Hash] + attr_reader :groups + protected attr_writer :sorted # :nodoc: + + attr_writer :groups end end diff --git a/lib/msf/core/option_group.rb b/lib/msf/core/option_group.rb new file mode 100644 index 000000000000..a5b29ac28bf4 --- /dev/null +++ b/lib/msf/core/option_group.rb @@ -0,0 +1,32 @@ +# -*- coding: binary -*- + +module Msf + class OptionGroup + + # @return [String] Name for the group + attr_accessor :name + # @return [String] Description to be displayed to the user + attr_accessor :description + # @return [Array] List of datastore option names + attr_accessor :option_names + + # @param name [String] Name for the group + # @param description [String] Description to be displayed to the user + # @param option_names [Array] List of datastore option names + def initialize(name:, description:, option_names: []) + self.name = name + self.description = description + self.option_names = option_names + end + + # @param option_name [String] Name of the datastore option to be added to the group + def add_option(option_name) + @option_names << option_name + end + + # @param option_names [Array] List of datastore option names to be added to the group + def add_options(option_names) + @option_names.concat(option_names) + end + end +end diff --git a/lib/msf/core/optional_session/mssql.rb b/lib/msf/core/optional_session/mssql.rb index e9aade926e37..7556ba64c97b 100644 --- a/lib/msf/core/optional_session/mssql.rb +++ b/lib/msf/core/optional_session/mssql.rb @@ -5,6 +5,8 @@ module OptionalSession module MSSQL include Msf::OptionalSession + RHOST_GROUP_OPTIONS = %w[RHOSTS RPORT DATABASE USERNAME PASSWORD THREADS] + def initialize(info = {}) super( update_info( @@ -14,6 +16,12 @@ def initialize(info = {}) ) if framework.features.enabled?(Msf::FeatureManager::MSSQL_SESSION_TYPE) + 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) register_options( [ Msf::OptInt.new('SESSION', [ false, 'The session to run this module on' ]), @@ -23,6 +31,7 @@ def initialize(info = {}) Msf::Opt::RPORT(1433, false) ] ) + add_info('New in Metasploit 6.4 - This module can target a %grnSESSION%clr or an %grnRHOST%clr') end end diff --git a/lib/msf/core/optional_session/mysql.rb b/lib/msf/core/optional_session/mysql.rb index d354a1365116..d5f77c41c57e 100644 --- a/lib/msf/core/optional_session/mysql.rb +++ b/lib/msf/core/optional_session/mysql.rb @@ -5,6 +5,8 @@ module OptionalSession module MySQL include Msf::OptionalSession + RHOST_GROUP_OPTIONS = %w[RHOSTS RPORT DATABASE USERNAME PASSWORD THREADS] + def initialize(info = {}) super( update_info( @@ -14,6 +16,12 @@ def initialize(info = {}) ) if framework.features.enabled?(Msf::FeatureManager::MYSQL_SESSION_TYPE) + 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) register_options( [ Msf::OptInt.new('SESSION', [ false, 'The session to run this module on' ]), @@ -21,8 +29,8 @@ def initialize(info = {}) Msf::Opt::RPORT(3306, false) ] ) - add_info('New in Metasploit 6.4 - This module can target a %grnSESSION%clr or an %grnRHOST%clr') + add_info('New in Metasploit 6.4 - This module can target a %grnSESSION%clr or an %grnRHOST%clr') end end diff --git a/lib/msf/core/optional_session/postgresql.rb b/lib/msf/core/optional_session/postgresql.rb index 93993fc7aa80..c0a82b7d2ae1 100644 --- a/lib/msf/core/optional_session/postgresql.rb +++ b/lib/msf/core/optional_session/postgresql.rb @@ -5,6 +5,8 @@ module OptionalSession module PostgreSQL include Msf::OptionalSession + RHOST_GROUP_OPTIONS = %w[RHOSTS RPORT DATABASE USERNAME PASSWORD THREADS] + def initialize(info = {}) super( update_info( @@ -12,7 +14,14 @@ def initialize(info = {}) 'SessionTypes' => %w[postgresql] ) ) + if framework.features.enabled?(Msf::FeatureManager::POSTGRESQL_SESSION_TYPE) + 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) register_options( [ Msf::OptInt.new('SESSION', [ false, 'The session to run this module on' ]), @@ -22,6 +31,7 @@ def initialize(info = {}) Msf::Opt::RPORT(5432, false) ] ) + add_info('New in Metasploit 6.4 - This module can target a %grnSESSION%clr or an %grnRHOST%clr') end end diff --git a/lib/msf/core/optional_session/smb.rb b/lib/msf/core/optional_session/smb.rb index 9a1c7cc0fa97..ecb8ee137a1f 100644 --- a/lib/msf/core/optional_session/smb.rb +++ b/lib/msf/core/optional_session/smb.rb @@ -5,6 +5,8 @@ module OptionalSession module SMB include Msf::OptionalSession + RHOST_GROUP_OPTIONS = %w[RHOSTS RPORT SMBDomain SMBUser SMBPass THREADS] + def initialize(info = {}) super( update_info( @@ -13,15 +15,21 @@ def initialize(info = {}) ) ) - if framework.features.enabled?(Msf::FeatureManager::SMB_SESSION_TYPE) + 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) register_options( [ Msf::OptInt.new('SESSION', [ false, 'The session to run this module on' ]), Msf::Opt::RHOST(nil, false), - Msf::Opt::RPORT(443, false) + Msf::Opt::RPORT(445, false) ] ) + add_info('New in Metasploit 6.4 - This module can target a %grnSESSION%clr or an %grnRHOST%clr') end end diff --git a/spec/lib/msf/base/serializer/readable_text_spec.rb b/spec/lib/msf/base/serializer/readable_text_spec.rb index f65073b296d8..f5d562c3c99f 100644 --- a/spec/lib/msf/base/serializer/readable_text_spec.rb +++ b/spec/lib/msf/base/serializer/readable_text_spec.rb @@ -182,6 +182,89 @@ def initialize TABLE end end + + context 'when some options are grouped' do + let(:group_name) { 'group_name' } + let(:group_description) { 'Used for example reasons' } + let(:option_names) { %w[RHOSTS SMBUser SMBDomain] } + let(:group) { Msf::OptionGroup.new(name: group_name, description: group_description, option_names: option_names) } + let(:aux_mod_with_grouped_options) do + mod = aux_mod_with_set_options.replicant + mod.options.add_group(group) + mod + end + + it 'should return the grouped options separate to the rest of the options' do + expect(described_class.dump_options(aux_mod_with_grouped_options, indent_string, false)).to match_table <<~TABLE + Name Current Setting Required Description + ---- --------------- -------- ----------- + FloatValue 5 no A FloatValue + NewOptionName yes An option with a new name. Aliases ensure the old and new names are synchronized + OptionWithModuleDefault false yes option with module default + RPORT 3000 yes The target port + baz baz_from_module yes baz option + fizz new_fizz yes fizz option + foo foo_from_framework yes Foo option + + + #{group_description}: + + Name Current Setting Required Description + ---- --------------- -------- ----------- + RHOSTS 192.0.2.2 yes The target host(s), see https://docs.metasploit.com/docs/using-metasploit/basics/using-metasploit.html + SMBDomain WORKGROUP yes The SMB username + SMBUser username yes The SMB username + TABLE + end + end + + context 'when there are multiple options groups' do + let(:group_name_1) { 'group_name_1' } + let(:group_description_1) { 'Used for example reasons_1' } + let(:option_names_1) {['RHOSTS']} + let(:group_name_2) { 'group_name_2' } + let(:group_description_2) { 'Used for example reasons_2' } + let(:option_names_2) { %w[SMBUser SMBDomain] } + + let(:group_1) { Msf::OptionGroup.new(name: group_name_1, description: group_description_1, option_names: option_names_1) } + let(:group_2) { Msf::OptionGroup.new(name: group_name_2, description: group_description_2, option_names: option_names_2) } + + let(:aux_mod_with_grouped_options) do + mod = aux_mod_with_set_options.replicant + mod.options.add_group(group_1) + mod.options.add_group(group_2) + mod + end + + it 'should return the grouped options separate to the rest of the options' do + expect(described_class.dump_options(aux_mod_with_grouped_options, indent_string, false)).to match_table <<~TABLE + Name Current Setting Required Description + ---- --------------- -------- ----------- + FloatValue 5 no A FloatValue + NewOptionName yes An option with a new name. Aliases ensure the old and new names are synchronized + OptionWithModuleDefault false yes option with module default + RPORT 3000 yes The target port + baz baz_from_module yes baz option + fizz new_fizz yes fizz option + foo foo_from_framework yes Foo option + + + #{group_description_1}: + + Name Current Setting Required Description + ---- --------------- -------- ----------- + RHOSTS 192.0.2.2 yes The target host(s), see https://docs.metasploit.com/docs/using-metasploit/basics/using-metasploit.html + + + #{group_description_2}: + + Name Current Setting Required Description + ---- --------------- -------- ----------- + SMBDomain WORKGROUP yes The SMB username + SMBUser username yes The SMB username + TABLE + end + end end describe '.dump_advanced_options' do diff --git a/spec/lib/msf/core/module/options_spec.rb b/spec/lib/msf/core/module/options_spec.rb new file mode 100644 index 000000000000..ac818a73999d --- /dev/null +++ b/spec/lib/msf/core/module/options_spec.rb @@ -0,0 +1,59 @@ +# -*- coding:binary -*- +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Msf::Module::Options do + subject(:mod) do + mod = ::Msf::Module.new + mod.extend described_class + mod + end + + describe '#register_option_group' do + let(:name) { 'name' } + let(:description) { 'description' } + let(:option_names) { %w[option1 option2] } + + context 'there are no registered option groups' do + it 'registers a new option group' do + subject.send(:register_option_group, name: name, description: description, option_names: option_names) + expect(subject.options.groups.length).to eq(1) + expect(subject.options.groups.keys).to include(name) + end + end + + context 'there is a registered option group' do + let(:existing_name) { 'existing_name' } + let(:existing_options) { ['existing_option_names'] } + let(:existing_description) { 'existing_description' } + before(:each) do + subject.send(:register_option_group, name: existing_name, description: existing_description, option_names: existing_options) + end + + it 'registers a an additional option group' do + subject.send(:register_option_group, name: name, description: description, option_names: option_names) + expect(subject.options.groups.length).to eq(2) + expect(subject.options.groups.keys).to include(name, existing_name) + end + + context 'when adding a group with the same name' do + it 'merges the option groups together' do + subject.send(:register_option_group, name: existing_name, description: description, option_names: option_names) + expect(subject.options.groups.length).to eq(1) + expect(subject.options.groups.keys).to include(existing_name) + expect(subject.options.groups[existing_name].option_names).to include(*existing_options, *option_names) + expect(subject.options.groups[existing_name].description).to eq(description) + end + + it 'overwrites the existing option group' do + subject.send(:register_option_group, name: existing_name, description: description, option_names: option_names, merge: false) + expect(subject.options.groups.length).to eq(1) + expect(subject.options.groups.keys).to include(existing_name) + expect(subject.options.groups[existing_name].option_names).to include(*option_names) + expect(subject.options.groups[existing_name].description).to eq(description) + end + end + end + end +end diff --git a/spec/lib/msf/core/option_container_spec.rb b/spec/lib/msf/core/option_container_spec.rb index 4fd2b9c09a3e..f8b4f8a2371f 100644 --- a/spec/lib/msf/core/option_container_spec.rb +++ b/spec/lib/msf/core/option_container_spec.rb @@ -181,4 +181,60 @@ end end end + + describe '#add_group' do + subject { described_class.new } + let(:group) { Msf::OptionGroup.new(name: 'name', description: 'description') } + + context 'when the container has no groups' do + it 'adds the group to the container' do + subject.add_group(group) + expect(subject.groups[group.name]).to be(group) + end + end + + context 'when the container has existing groups' do + let(:existing_group) { Msf::OptionGroup.new(name: 'existing', description: 'existing') } + before(:each) do + subject.add_group(existing_group) + end + it 'adds an additional group to the container' do + subject.add_group(group) + expect(subject.groups.length).to eql(2) + expect(subject.groups[group.name]).to be(group) + end + + context 'when adding a new group with an existing groups name' do + let(:group) { Msf::OptionGroup.new(name: existing_group.name, description: 'description') } + it 'overwrites the existing group' do + expect(subject.groups[group.name]).to be(existing_group) + subject.add_group(group) + expect(subject.groups.length).to eql(1) + expect(subject.groups[group.name]).to be(group) + end + end + end + end + + describe '#remove_group' do + subject { described_class.new } + + context 'when the container has no groups' do + it 'has no effect' do + expect { subject.remove_group('name') }.not_to raise_error + end + end + + context 'when the container has existing groups' do + let(:existing_group) { Msf::OptionGroup.new(name: 'existing', description: 'existing') } + before(:each) do + subject.add_group(existing_group) + end + + it 'removes the group' do + subject.remove_group(existing_group.name) + expect(subject.groups).to be_empty + end + end + end end diff --git a/spec/lib/msf/core/option_group_spec.rb b/spec/lib/msf/core/option_group_spec.rb new file mode 100644 index 000000000000..cc00cc31866e --- /dev/null +++ b/spec/lib/msf/core/option_group_spec.rb @@ -0,0 +1,50 @@ +# -*- coding:binary -*- +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Msf::OptionGroup do + subject { described_class.new(name: 'name', description: 'description') } + + describe '#add_option' do + let(:option_name) { 'option_name' } + context 'when the option group is empty' do + it 'adds the option' do + subject.add_option(option_name) + expect(subject.option_names.length).to eql(1) + expect(subject.option_names).to include(option_name) + end + end + + context 'when the option group contains options' do + subject { described_class.new(name: 'name', description: 'description', option_names: ['existing_option']) } + + it 'adds the option' do + subject.add_option(option_name) + expect(subject.option_names.length).to eql(2) + expect(subject.option_names).to include(option_name) + end + end + end + + describe '#add_options' do + let(:option_names) { %w[option_name1 option_name2] } + context 'when the option group is empty' do + it 'adds the option' do + subject.add_options(option_names) + expect(subject.option_names.length).to eql(2) + expect(subject.option_names).to include(*option_names) + end + end + + context 'when the option group contains options' do + subject { described_class.new(name: 'name', description: 'description', option_names: ['existing_option']) } + + it 'adds the option' do + subject.add_options(option_names) + expect(subject.option_names.length).to eql(3) + expect(subject.option_names).to include(*option_names) + end + end + end +end