From 0e9cad6d457232dd0b2a98664bce20e03dbd70ba Mon Sep 17 00:00:00 2001 From: cgranleese-r7 Date: Wed, 17 Jan 2024 15:35:47 +0000 Subject: [PATCH] Adds MySQL session type --- .../framework/login_scanner/mysql.rb | 17 +- lib/msf/base/config.rb | 11 ++ lib/msf/base/sessions/mysql.rb | 136 +++++++++++++++ lib/msf/core/exploit/remote/mysql.rb | 18 +- lib/msf/core/feature_manager.rb | 7 + lib/msf/core/optional_session.rb | 13 +- lib/msf/core/post/common.rb | 4 +- lib/msf_autoload.rb | 2 + lib/rex/post.rb | 1 + lib/rex/post/mysql.rb | 3 + lib/rex/post/mysql/ui.rb | 3 + lib/rex/post/mysql/ui/console.rb | 140 +++++++++++++++ .../mysql/ui/console/command_dispatcher.rb | 102 +++++++++++ .../ui/console/command_dispatcher/client.rb | 161 ++++++++++++++++++ .../ui/console/command_dispatcher/core.rb | 50 ++++++ .../ui/console/command_dispatcher/modules.rb | 100 +++++++++++ .../auxiliary/scanner/mysql/mysql_login.rb | 46 ++++- .../framework/login_scanner/mysql_spec.rb | 7 +- spec/lib/msf/base/sessions/mysql_spec.rb | 150 ++++++++++++++++ .../console/command_dispatcher/core_spec.rb | 33 ++++ 20 files changed, 995 insertions(+), 9 deletions(-) create mode 100644 lib/msf/base/sessions/mysql.rb create mode 100644 lib/rex/post/mysql.rb create mode 100644 lib/rex/post/mysql/ui.rb create mode 100644 lib/rex/post/mysql/ui/console.rb create mode 100644 lib/rex/post/mysql/ui/console/command_dispatcher.rb create mode 100644 lib/rex/post/mysql/ui/console/command_dispatcher/client.rb create mode 100644 lib/rex/post/mysql/ui/console/command_dispatcher/core.rb create mode 100644 lib/rex/post/mysql/ui/console/command_dispatcher/modules.rb create mode 100644 spec/lib/msf/base/sessions/mysql_spec.rb create mode 100644 spec/lib/rex/post/mysql/ui/console/command_dispatcher/core_spec.rb diff --git a/lib/metasploit/framework/login_scanner/mysql.rb b/lib/metasploit/framework/login_scanner/mysql.rb index 17ddcfd06f..be5307f32a 100644 --- a/lib/metasploit/framework/login_scanner/mysql.rb +++ b/lib/metasploit/framework/login_scanner/mysql.rb @@ -15,6 +15,10 @@ module Metasploit include Metasploit::Framework::LoginScanner::RexSocket include Metasploit::Framework::Tcp::Client + # @returns [Boolean] If a login is successful and this attribute is true - a MySQL::Client instance is used as proof, + # and the socket is not immediately closed + attr_accessor :use_client_as_proof + DEFAULT_PORT = 3306 LIKELY_PORTS = [3306] LIKELY_SERVICE_NAMES = ['mysql'] @@ -35,7 +39,7 @@ module Metasploit disconnect if self.sock connect - ::Mysql.connect(host, credential.public, credential.private, '', port, sock) + mysql_conn = ::Mysql.connect(host, credential.public, credential.private, '', port, sock) rescue ::SystemCallError, Rex::ConnectionError => e result_options.merge!({ @@ -64,8 +68,17 @@ module Metasploit }) end - unless result_options[:status] + if mysql_conn result_options[:status] = Metasploit::Model::Login::Status::SUCCESSFUL + + # This module no long owns the socket, return it as proof so the calling context can perform additional operations + # Additionally assign values to nil to avoid closing the socket etc automatically + if use_client_as_proof + result_options[:proof] = mysql_conn + nil + else + mysql_conn.close + end end ::Metasploit::Framework::LoginScanner::Result.new(result_options) diff --git a/lib/msf/base/config.rb b/lib/msf/base/config.rb index 12ae637292..7f2bca1d7d 100644 --- a/lib/msf/base/config.rb +++ b/lib/msf/base/config.rb @@ -228,6 +228,13 @@ class Config < Hash self.new.postgresql_session_history end + # Returns the full path to the MySQL session history file. + # + # @return [String] path to the history file. + def self.mysql_session_history + self.new.mysql_session_history + end + def self.pry_history self.new.pry_history end @@ -341,6 +348,10 @@ class Config < Hash config_directory + FileSep + "postgresql_session_history" end + def mysql_session_history + config_directory + FileSep + "mysql_session_history" + end + def pry_history config_directory + FileSep + "pry_history" end diff --git a/lib/msf/base/sessions/mysql.rb b/lib/msf/base/sessions/mysql.rb new file mode 100644 index 0000000000..de042137d7 --- /dev/null +++ b/lib/msf/base/sessions/mysql.rb @@ -0,0 +1,136 @@ +# -*- coding: binary -*- + +require 'rex/post/mysql' + +class Msf::Sessions::MySQL + + # This interface supports basic interaction. + include Msf::Session::Basic + include Msf::Sessions::Scriptable + + # @return [Rex::Post::MySQL::Ui::Console] The interactive console + attr_accessor :console + # @return [MySQL::Client] + attr_accessor :client + attr_accessor :platform, :arch + + # @param[Rex::IO::Stream] rstream + # @param [Hash] opts + def initialize(rstream, opts = {}) + @client = opts.fetch(:client) + self.console = ::Rex::Post::MySQL::Ui::Console.new(self) + super(rstream, opts) + end + + # @param [Hash] datastore + # @param [nil] handler + # @return [String] + def bootstrap(datastore = {}, handler = nil) + session = self + session.init_ui(user_input, user_output) + + @info = "MySQL #{datastore['USERNAME']} @ #{client.socket.peerinfo}" + 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 #{session.sid} (#{session.tunnel_to_s}) processing #{key} '#{datastore[key]}'") + execute_script(args.shift, *args) + end + end + + # @return [String] + def type + self.class.type + end + + # @return [String] The type of the session + def self.type + 'MySQL' + end + + # @return [Boolean] Can the session clean up after itself + def self.can_cleanup_files + false + end + + # @return [String] The session description + def desc + 'MySQL' + end + + # @return [Object] The peer address + def address + return @address if @address + + @address, @port = @client.socket.peerinfo.split(':') + @address + end + + # @return [Object] The peer host + def port + return @port if @port + + @address, @port = @client.socket.peerinfo.split(':') + @port + end + + # Initializes the console's I/O handles. + # + # @param [Object] input + # @param [Object] output + # @return [String] + def init_ui(input, output) + super(input, output) + + console.init_ui(input, output) + console.set_log_source(log_source) + end + + # Resets the console's I/O handles. + # + # @return [Object] + def reset_ui + console.unset_log_source + console.reset_ui + end + + + # Exit the console + # + # @return [TrueClass] + def exit + console.stop + end + + protected + + # Override the basic session interaction to use shell_read and + # shell_write instead of operating on rstream directly. + # + # @return [Object] + def _interact + framework.events.on_session_interact(self) + framework.history_manager.with_context(name: type.to_sym) { _interact_stream } + end + + # @return [Object] + def _interact_stream + framework.events.on_session_interact(self) + + console.framework = framework + # Call the console interaction of the mysql 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/mysql.rb b/lib/msf/core/exploit/remote/mysql.rb index d102d93de2..a7b8dcb024 100644 --- a/lib/msf/core/exploit/remote/mysql.rb +++ b/lib/msf/core/exploit/remote/mysql.rb @@ -33,6 +33,12 @@ module Exploit::Remote::MYSQL end def mysql_login(user='root', pass='', db=nil) + unless defined?(session).nil? || session.nil? + print_status("Using existing session #{session.sid}") + @mysql_handle = session.client + return true + end + disconnect if self.sock connect @@ -56,12 +62,21 @@ module Exploit::Remote::MYSQL return false end + vprint_good "#{rhost}:#{rport} MySQL - Logged in to '#{db}' with '#{user}':'#{pass}'" + return true end def mysql_logoff + # Don't log out if we are using a session. + if defined?(session) && session + vprint_status "#{rhost}:#{rport} MySQL - Skipping disconnecting from the session" + return + end + @mysql_handle = nil if @mysql_handle disconnect if self.sock + vprint_status "#{rhost}:#{rport} MySQL - Disconnected" end def mysql_login_datastore @@ -85,6 +100,8 @@ module Exploit::Remote::MYSQL print_error("Timeout: #{e.message}") return nil end + + vprint_status "#{rhost}:#{rport} MySQL - querying with '#{sql}'" res end @@ -236,4 +253,3 @@ module Exploit::Remote::MYSQL end end - diff --git a/lib/msf/core/feature_manager.rb b/lib/msf/core/feature_manager.rb index beb2d7441f..2ed4aa021d 100644 --- a/lib/msf/core/feature_manager.rb +++ b/lib/msf/core/feature_manager.rb @@ -24,6 +24,7 @@ module Msf HIERARCHICAL_SEARCH_TABLE = 'hierarchical_search_table' SMB_SESSION_TYPE = 'smb_session_type' POSTGRESQL_SESSION_TYPE = 'postgresql_session_type' + MYSQL_SESSION_TYPE = 'mysql_session_type' DEFAULTS = [ { name: WRAPPED_TABLES, @@ -74,6 +75,12 @@ module Msf name: POSTGRESQL_SESSION_TYPE, description: 'When enabled will allow for the creation/use of PostgreSQL sessions', requires_restart: true, + default_value: false, + }.freeze, + { + name: MYSQL_SESSION_TYPE, + description: 'When enabled will allow for the creation/use of MySQL sessions', + requires_restart: true, default_value: false }.freeze, { diff --git a/lib/msf/core/optional_session.rb b/lib/msf/core/optional_session.rb index 67777fd701..0152a6faea 100644 --- a/lib/msf/core/optional_session.rb +++ b/lib/msf/core/optional_session.rb @@ -9,6 +9,7 @@ module Msf::OptionalSession def initialize(info = {}) super + if framework.features.enabled?(Msf::FeatureManager::SMB_SESSION_TYPE) register_options( [ @@ -20,6 +21,16 @@ module Msf::OptionalSession add_info('New in Metasploit 6.4 - This module can target a %grnSESSION%clr or an %grnRHOST%clr') end + if framework.features.enabled?(Msf::FeatureManager::MYSQL_SESSION_TYPE) + register_options( + [ + Msf::OptInt.new('SESSION', [ false, 'The session to run this module on' ]), + Msf::Opt::RHOST(nil, false), + Msf::Opt::RPORT(nil, false) + ] + ) + end + if framework.features.enabled?(Msf::FeatureManager::POSTGRESQL_SESSION_TYPE) register_options( [ @@ -35,7 +46,7 @@ module Msf::OptionalSession end def session - return nil unless (framework.features.enabled?(Msf::FeatureManager::SMB_SESSION_TYPE) || framework.features.enabled?(Msf::FeatureManager::POSTGRESQL_SESSION_TYPE)) + return nil unless (framework.features.enabled?(Msf::FeatureManager::SMB_SESSION_TYPE) || framework.features.enabled?(Msf::FeatureManager::POSTGRESQL_SESSION_TYPE) || framework.features.enabled?(Msf::FeatureManager::MYSQL_SESSION_TYPE)) super end diff --git a/lib/msf/core/post/common.rb b/lib/msf/core/post/common.rb index ccdcbbb68a..e8d3c8ed62 100644 --- a/lib/msf/core/post/common.rb +++ b/lib/msf/core/post/common.rb @@ -30,7 +30,7 @@ module Msf::Post::Common session.sock.peerhost when 'shell', 'powershell' session.session_host - when 'postgresql' + when 'postgresql', 'mysql' session.address end rescue @@ -45,7 +45,7 @@ module Msf::Post::Common session.sock.peerport when 'shell', 'powershell' session.session_port - when 'postgresql' + when 'postgresql', 'mysql' session.port end rescue diff --git a/lib/msf_autoload.rb b/lib/msf_autoload.rb index e73c8d598f..e884672d54 100644 --- a/lib/msf_autoload.rb +++ b/lib/msf_autoload.rb @@ -41,6 +41,8 @@ class MsfAutoload 'Http' elsif basename == 'rftransceiver' && abspath.end_with?("#{__dir__}/rex/post/hwbridge/ui/console/command_dispatcher/rftransceiver.rb") 'RFtransceiver' + elsif basename == 'mysql' && abspath.end_with?("#{__dir__}/msf/base/sessions/mysql.rb") + 'MySQL' else super end diff --git a/lib/rex/post.rb b/lib/rex/post.rb index eb744e09ed..259b732919 100644 --- a/lib/rex/post.rb +++ b/lib/rex/post.rb @@ -4,6 +4,7 @@ require 'rex/post/meterpreter' require 'rex/post/smb' require 'rex/post/postgresql' +require 'rex/post/mysql' module Rex::Post diff --git a/lib/rex/post/mysql.rb b/lib/rex/post/mysql.rb new file mode 100644 index 0000000000..e7c134d4e0 --- /dev/null +++ b/lib/rex/post/mysql.rb @@ -0,0 +1,3 @@ +# -*- coding: binary -*- + +require 'rex/post/mysql/ui' diff --git a/lib/rex/post/mysql/ui.rb b/lib/rex/post/mysql/ui.rb new file mode 100644 index 0000000000..50c71d0e4b --- /dev/null +++ b/lib/rex/post/mysql/ui.rb @@ -0,0 +1,3 @@ +# -*- coding: binary -*- + +require 'rex/post/mysql/ui/console' diff --git a/lib/rex/post/mysql/ui/console.rb b/lib/rex/post/mysql/ui/console.rb new file mode 100644 index 0000000000..c658456b32 --- /dev/null +++ b/lib/rex/post/mysql/ui/console.rb @@ -0,0 +1,140 @@ +# -*- coding: binary -*- + +module Rex + module Post + module MySQL + module Ui + + # This class provides a shell driven interface to the MySQL client API. + class Console + include Rex::Ui::Text::DispatcherShell + + # Dispatchers + require 'rex/post/mysql/ui/console/command_dispatcher' + require 'rex/post/mysql/ui/console/command_dispatcher/core' + require 'rex/post/mysql/ui/console/command_dispatcher/client' + require 'rex/post/mysql/ui/console/command_dispatcher/modules' + + + # Initialize the MySQL console. + # + # @param [Msf::Sessions::MySQL] session + def initialize(session) + # The mysql client context + self.session = session + self.client = session.client + self.cwd = client.database + prompt = "%undMySQL @ #{client.socket.peerinfo} (#{cwd})%clr" + history_manager = Msf::Config.mysql_session_history + super(prompt, '>', history_manager, nil, :mysql) + + # Queued commands array + self.commands = [] + + # Point the input/output handles elsewhere + reset_ui + + enstack_dispatcher(::Rex::Post::MySQL::Ui::Console::CommandDispatcher::Core) + enstack_dispatcher(::Rex::Post::MySQL::Ui::Console::CommandDispatcher::Client) + enstack_dispatcher(::Rex::Post::MySQL::Ui::Console::CommandDispatcher::Modules) + + # Set up logging to whatever logsink 'core' is using + if ! $dispatcher['mysql'] + $dispatcher['mysql'] = $dispatcher['core'] + end + end + + # Called when someone wants to interact with the mysql client. It's + # assumed that init_ui has been called prior. + # + # @param [Proc] block + # @return [Integer] + 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. + # + # @param [Object] cmd + # @return [Object] + def queue_cmd(cmd) + self.commands << cmd + end + + # Runs the specified command wrapper in something to catch meterpreter + # exceptions. + # + # @param [Object] dispatcher + # @param [Object] method + # @param [Object] arguments + # @return [FalseClass] + def run_command(dispatcher, method, arguments) + begin + super + rescue ::Timeout::Error + log_error('Operation timed out.') + rescue ::Rex::InvalidDestination => e + log_error(e.message) + rescue ::Errno::EPIPE, ::OpenSSL::SSL::SSLError, ::IOError + self.session.kill + rescue ::StandardError => e + log_error("Error running command #{method}: #{e.class} #{e}") + elog(e) + end + end + + # Logs that an error occurred and persists the callstack. + # + # @param [Object] msg + # @return [Object] + def log_error(msg) + print_error(msg) + + elog(msg, 'mysql') + + dlog("Call stack:\n#{$@.join("\n")}", 'mysql') + end + + # @return [Msf::Sessions::MySQL] + attr_reader :session + + # @return [MySQL::Client] + attr_reader :client + + # @return [String] + attr_accessor :cwd + + # @param [Object] val + # @return [String] + def format_prompt(val) + @cwd ||= client.database + prompt = "%undMySQL @ #{client.socket.peerinfo} (#{@cwd})%clr > " + substitute_colors(prompt, true) + end + + protected + + attr_writer :session, :client # :nodoc: + attr_accessor :commands # :nodoc: + end + end + end + end +end diff --git a/lib/rex/post/mysql/ui/console/command_dispatcher.rb b/lib/rex/post/mysql/ui/console/command_dispatcher.rb new file mode 100644 index 0000000000..12f234f577 --- /dev/null +++ b/lib/rex/post/mysql/ui/console/command_dispatcher.rb @@ -0,0 +1,102 @@ +# -*- coding: binary -*- + +require 'rex/ui/text/dispatcher_shell' + +module Rex + module Post + module MySQL + module Ui + + # Base class for all command dispatchers within the MySQL 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::MySQL::Ui::Console] console + def initialize(console) + super + @msf_loaded = nil + @filtered_commands = [] + end + + # Returns the MySQL client context. + # + # @return [MySQL::Client] + def client + console = shell + console.client + end + + # Returns the MySQL session context. + # + # @return [Msf::Sessions::MySQL] + def session + console = shell + console.session + end + + # Returns the commands that meet the requirements + # + # @param [Object] all + # @param [Object] reqs + # @return [Object] + 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 + + # @param [Object] cmd + # @param [Object] line + # @return [Symbol, nil] + 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 + # + # @return [String] + def docs_dir + ::File.join(super, 'mysql_session') + end + + # Returns true if the client has a framework object. + # Used for firing framework session events + # + # @return [TrueClass, FalseClass] + 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. + # + # @param [Object] msg + # @return [Object] + def log_error(msg) + print_error(msg) + + elog(msg, 'mysql') + + dlog("Call stack:\n#{$ERROR_POSITION.join("\n")}", 'mysql') + end + end + end + end + end +end diff --git a/lib/rex/post/mysql/ui/console/command_dispatcher/client.rb b/lib/rex/post/mysql/ui/console/command_dispatcher/client.rb new file mode 100644 index 0000000000..dffc2a7ba4 --- /dev/null +++ b/lib/rex/post/mysql/ui/console/command_dispatcher/client.rb @@ -0,0 +1,161 @@ +# -*- coding: binary -*- + +require 'pathname' +require 'reline' + +module Rex + module Post + module MySQL + module Ui + + # Core MySQL client commands + class Console::CommandDispatcher::Client + + include Rex::Post::MySQL::Ui::Console::CommandDispatcher + + # Initializes an instance of the core command set using the supplied console + # for interactivity. + # + # @param [Rex::Post::MySQL::Ui::Console] console + def initialize(console) + super + + @db_search_results = [] + end + + # List of supported commands. + # + # @return [Hash{String->String}] + def commands + cmds = { + 'query' => 'Run a raw SQL query', + 'shell' => 'Enter a raw shell where SQL queries can be executed', + } + + reqs = {} + + filter_commands(cmds, reqs) + end + + # @return [String] + def name + 'MySQL Client' + end + + # @param [Object] args + # @return [FalseClass, TrueClass] + def help_args?(args) + return false unless args.instance_of?(::Array) + + args.include?('-h') || args.include?('--help') + end + + # @return [Object] + def cmd_shell_help + print_line 'Usage: shell' + print_line + print_line 'Go into a raw SQL shell where SQL queries can be executed.' + print_line 'To exit, type `exit`, `quit`, `end` or `stop`.' + print_line + end + + # @param [Array] args + # @return [Object] + def cmd_shell(*args) + if help_args?(args) + cmd_shell_help + return + end + + stop_words = %w[stop s exit e end quit q].freeze + + # Allow the user to query the DB in a loop. + finished = false + until finished + begin + # This needs to be here, otherwise the `ensure` block would reset it to the previous + # value after a single query, meaning future queries would have the default prompt_block. + prompt_proc_before = ::Reline.prompt_proc + ::Reline.prompt_proc = proc { |line_buffer| line_buffer.each_with_index.map { |_line, i| i > 0 ? 'SQL *> ' : 'SQL >> ' } } + + # This will loop until it receives `true`. + raw_query = ::Reline.readmultiline('SQL >> ', use_history = true) do |multiline_input| + # In the case only a stop word was input, exit out of the REPL shell + finished = multiline_input.split.count == 1 && stop_words.include?(multiline_input.split.last) + # Accept the input until the current line does not end with '\', similar to a shell + finished || multiline_input.split.empty? || !multiline_input.split.last&.end_with?('\\') + end + rescue ::Interrupt => _e + finished = true + ensure + ::Reline.prompt_proc = prompt_proc_before + end + + if finished + print_status 'Exiting Shell mode.' + return + end + + formatted_query = process_query(query: raw_query) + + unless formatted_query.empty? + print_status "Running SQL Command: '#{formatted_query}'" + cmd_query(formatted_query) + end + end + end + + # @return [Object] + def cmd_query_help + print_line 'Usage: query' + print_line + print_line 'Run a raw SQL query on the target.' + print_line 'Examples:' + print_line "\tquery SHOW DATABASES;" + print_line "\tquery USE information_schema;" + print_line "\tquery SELECT * FROM SQL_FUNCTIONS;" + print_line "\tquery SELECT version();" + print_line + end + + # @param [Array] result The result of an SQL query to format. + def format_result(result) + columns = ['#'] + + unless result.is_a?(Array) + result.fields.each { |field| columns.append(field.name) } + + ::Rex::Text::Table.new( + 'Header' => 'Query Result', + 'Indent' => 4, + 'Columns' => columns, + 'Rows' => result.map.each.with_index { |row, i| [i, row].flatten } + ) + end + end + + # @param [Array] args SQL query + # @return [Object] + def cmd_query(*args) + cmd_query_help && return if help_args?(args) + + query = args.join(' ').to_s + print_status("Sending statement: '#{query}'...") + result = client.query(query) || [] + + table = format_result(result) + print_line(table.to_s) + end + + # @param [String] query + # @return [String] + def process_query(query: '') + return '' if query.empty? + + query.lines.each.map { |line| line.chomp("\\\n").strip }.reject(&:empty?).compact.join(' ') + end + end + end + end + end +end diff --git a/lib/rex/post/mysql/ui/console/command_dispatcher/core.rb b/lib/rex/post/mysql/ui/console/command_dispatcher/core.rb new file mode 100644 index 0000000000..4f1a146203 --- /dev/null +++ b/lib/rex/post/mysql/ui/console/command_dispatcher/core.rb @@ -0,0 +1,50 @@ +# -*- coding: binary -*- + +module Rex + module Post + module MySQL + module Ui + + # Core MySQL client commands + class Console::CommandDispatcher::Core + + include Rex::Post::MySQL::Ui::Console::CommandDispatcher + + # List of supported commands. + # + # @return [Hash{String->String}] + def commands + cmds = { + '?' => 'Help menu', + 'background' => 'Backgrounds the current session', + 'bg' => 'Alias for background', + 'exit' => 'Terminate the MySQL 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 + + # @return [String] + def name + 'Core' + end + + # @param [Object] cmd + # @param [Object] line + # @return [Symbol, nil] + def unknown_command(cmd, line) + status = super + + status + end + end + end + end + end +end diff --git a/lib/rex/post/mysql/ui/console/command_dispatcher/modules.rb b/lib/rex/post/mysql/ui/console/command_dispatcher/modules.rb new file mode 100644 index 0000000000..bb0892b66e --- /dev/null +++ b/lib/rex/post/mysql/ui/console/command_dispatcher/modules.rb @@ -0,0 +1,100 @@ +# -*- coding: binary -*- + +require 'pathname' + +module Rex + module Post + module MySQL + module Ui + + # MySQL client commands for running modules + class Console::CommandDispatcher::Modules + + include Rex::Post::MySQL::Ui::Console::CommandDispatcher + + # List of supported commands. + # + # @return [Hash{String->String}] + def commands + cmds = { + 'run' => 'Run a module' + } + + reqs = {} + + filter_commands(cmds, reqs) + end + + # Modules + # + # @return [String] + def name + 'Modules' + end + + # @return [Object] + def cmd_run_help + print_line 'Usage: run' + print_line + print_line 'Run a module or script against the current session.' + print_line + print_line 'Example:' + print_line "\trun auxiliary/admin/mysql/mysql_enum" + print_line "\trun my_erb_script.rc" + print_line + end + + # Executes a module/script in the context of the MySQL session. + # + # @param [Array] args + # @return [TrueClass] + def cmd_run(*args) + if args.empty? || args.first == '-h' || args.first == '--help' + cmd_run_help + return true + end + + # Get the script name + begin + script_name = args.shift + # First try it as a module if we have access to the Metasploit + # Framework instance. If we don't, or if no such module exists, + # fall back to using the scripting interface. + if msf_loaded? && (mod = session.framework.modules.create(script_name)) + original_mod = mod + reloaded_mod = session.framework.modules.reload_module(original_mod) + + unless reloaded_mod + error = session.framework.modules.module_load_error_by_path[original_mod.file_path] + print_error("Failed to reload module: #{error}") + + return + end + + opts = '' + + opts << (args + [ "SESSION=#{session.sid}" ]).join(',') + result = reloaded_mod.run_simple( + 'LocalInput' => shell.input, + 'LocalOutput' => shell.output, + 'OptionStr' => opts + ) + + print_status("Session #{result.sid} created in the background.") if result.is_a?(Msf::Session) + else + # the rest of the arguments get passed in through the binding + session.execute_script(script_name, args) + end + rescue Msf::OptionValidateError => e + print_error(e.message) + elog('Option validation error:', error: e) + rescue StandardError => e + print_error("Error in script: #{script_name}") + elog("Error in script: #{script_name}", error: e) + end + end + end + end + end + end +end diff --git a/modules/auxiliary/scanner/mysql/mysql_login.rb b/modules/auxiliary/scanner/mysql/mysql_login.rb index a594eb4890..1f599cc6a2 100644 --- a/modules/auxiliary/scanner/mysql/mysql_login.rb +++ b/modules/auxiliary/scanner/mysql/mysql_login.rb @@ -10,8 +10,8 @@ class MetasploitModule < Msf::Auxiliary include Msf::Exploit::Remote::MYSQL include Msf::Auxiliary::Report include Msf::Auxiliary::AuthBrute - include Msf::Auxiliary::Scanner + include Msf::Auxiliary::CommandShell def initialize(info = {}) super(update_info(info, @@ -36,7 +36,20 @@ class MetasploitModule < Msf::Auxiliary Opt::Proxies ]) - deregister_options('PASSWORD_SPRAY') + options_to_deregister = %w[PASSWORD_SPRAY] + unless framework.features.enabled?(Msf::FeatureManager::MYSQL_SESSION_TYPE) + options_to_deregister << 'CreateSession' + end + deregister_options(*options_to_deregister) + end + + # @return [FalseClass] + def create_session? + if framework.features.enabled?(Msf::FeatureManager::MYSQL_SESSION_TYPE) + datastore['CreateSession'] + else + false + end end def target @@ -64,6 +77,7 @@ class MetasploitModule < Msf::Auxiliary send_delay: datastore['TCP::send_delay'], framework: framework, framework_module: self, + use_client_as_proof: create_session?, ssl: datastore['SSL'], ssl_version: datastore['SSLVersion'], ssl_verify_mode: datastore['SSLVerifyMode'], @@ -84,6 +98,17 @@ class MetasploitModule < Msf::Auxiliary create_credential_login(credential_data) print_brute :level => :good, :ip => ip, :msg => "Success: '#{result.credential}'" + + if create_session? + begin + mysql_client = result.proof + session_setup(result, mysql_client) + rescue ::StandardError => e + elog('Failed: ', error: e) + print_error(e) + result.proof.conn.close if result.proof&.conn + end + end else invalidate_login(credential_data) vprint_error "#{ip}:#{rport} - LOGIN FAILED: #{result.credential} (#{result.status}: #{result.proof})" @@ -149,6 +174,23 @@ class MetasploitModule < Msf::Auxiliary end end + # @param [Metasploit::Framework::LoginScanner::Result] result + # @param [::Mysql] client + # @return [Msf::Sessions::MySQL] + def session_setup(result, client) + return unless (result && client) + rstream = client.socket + my_session = Msf::Sessions::MySQL.new(rstream, { client: client }) + merging = { + 'USERPASS_FILE' => nil, + 'USER_FILE' => nil, + 'PASS_FILE' => nil, + 'USERNAME' => result.credential.public, + 'PASSWORD' => result.credential.private + } + + start_session(self, nil, merging, false, my_session.rstream, my_session) + end end diff --git a/spec/lib/metasploit/framework/login_scanner/mysql_spec.rb b/spec/lib/metasploit/framework/login_scanner/mysql_spec.rb index db06cf960c..c0d638bd85 100644 --- a/spec/lib/metasploit/framework/login_scanner/mysql_spec.rb +++ b/spec/lib/metasploit/framework/login_scanner/mysql_spec.rb @@ -4,6 +4,7 @@ require 'metasploit/framework/login_scanner/mysql' RSpec.describe Metasploit::Framework::LoginScanner::MySQL do let(:public) { 'root' } let(:private) { 'toor' } + let(:client) { instance_double(::Mysql) } let(:pub_blank) { Metasploit::Framework::Credential.new( paired: true, @@ -28,6 +29,10 @@ RSpec.describe Metasploit::Framework::LoginScanner::MySQL do ) } + before(:each) do + allow(client).to receive(:close).and_return(client) + end + subject(:login_scanner) { described_class.new } it_behaves_like 'Metasploit::Framework::LoginScanner::Base', has_realm_key: false, has_default_realm: false @@ -37,7 +42,7 @@ RSpec.describe Metasploit::Framework::LoginScanner::MySQL do context 'when the attempt is successful' do it 'returns a result object with a status of Metasploit::Model::Login::Status::SUCCESSFUL' do - expect(::Mysql).to receive(:connect).and_return "fake mysql handle" + expect(::Mysql).to receive(:connect).and_return(client) expect(login_scanner.attempt_login(pub_pri).status).to eq Metasploit::Model::Login::Status::SUCCESSFUL end end diff --git a/spec/lib/msf/base/sessions/mysql_spec.rb b/spec/lib/msf/base/sessions/mysql_spec.rb new file mode 100644 index 0000000000..25e5403cab --- /dev/null +++ b/spec/lib/msf/base/sessions/mysql_spec.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'mysql' + +RSpec.describe Msf::Sessions::MySQL do + let(:rstream) { instance_double(::Rex::Socket) } + let(:client) { instance_double(::Mysql) } + let(:opts) { { client: client } } + let(:console_class) { Rex::Post::MySQL::Ui::Console } + let(:user_input) { instance_double(Rex::Ui::Text::Input::Readline) } + let(:user_output) { instance_double(Rex::Ui::Text::Output::Stdio) } + let(:name) { 'mysql' } + let(:log_source) { "session_#{name}" } + let(:type) { 'MySQL' } + let(:description) { 'MySQL' } + let(:can_cleanup_files) { false } + let(:address) { '192.0.2.1' } + let(:port) { '3306' } + let(:peerinfo) { "#{address}:#{port}" } + let(:database) { 'database_name' } + + before(:each) do + allow(user_input).to receive(:output=) + allow(user_input).to receive(:intrinsic_shell?).and_return(true) + allow(rstream).to receive(:peerinfo).and_return(peerinfo) + allow(client).to receive(:socket).and_return(rstream) + allow(client).to receive(:database).and_return(database) + allow(::Mysql).to receive(:connect).and_return(client) + end + + subject(:session) do + mysql_session = described_class.new(rstream, opts) + mysql_session.user_input = user_input + mysql_session.user_output = user_output + mysql_session.name = name + mysql_session + end + + describe '.type' do + it 'should have the correct type' do + expect(described_class.type).to eq(type) + end + end + + describe '.can_cleanup_files' do + it 'should be able to cleanup files' do + expect(described_class.can_cleanup_files).to eq(can_cleanup_files) + end + end + + describe '#desc' do + it 'should have the correct description' do + expect(subject.desc).to eq(description) + end + end + + describe '#type' do + it 'should have the correct type' do + expect(subject.type).to eq(type) + end + end + + describe '#initialize' do + context 'without a client' do + let(:opts) { {} } + + it 'raises a KeyError' do + expect { subject }.to raise_exception(KeyError) + end + end + context 'with a client' do + it 'does not raise an exception' do + expect { subject }.not_to raise_exception + end + end + + it 'creates a new console' do + expect(subject.console).to be_a(console_class) + end + end + + describe '#bootstrap' do + subject { session.bootstrap } + + it 'keeps the sessions user input' do + expect { subject }.not_to change(session, :user_input).from(user_input) + end + + it 'keeps the sessions user output' do + expect { subject }.not_to change(session, :user_output).from(user_output) + end + + it 'sets the console input' do + expect { subject }.to change(session.console, :input).to(user_input) + end + + it 'sets the console output' do + expect { subject }.to change(session.console, :output).to(user_output) + end + + it 'sets the log source' do + expect { subject }.to change(session.console, :log_source).to(log_source) + end + end + + describe '#reset_ui' do + before(:each) do + session.bootstrap + end + + subject { session.reset_ui } + + it 'keeps the sessions user input' do + expect { subject }.not_to change(session, :user_input).from(user_input) + end + + it 'keeps the sessions user output' do + expect { subject }.not_to change(session, :user_output).from(user_output) + end + + it 'resets the console input' do + expect { subject }.to change(session.console, :input).from(user_input).to(nil) + end + + it 'resets the console output' do + expect { subject }.to change(session.console, :output).from(user_output).to(nil) + end + end + + describe '#exit' do + subject { session.exit } + + it 'exits the session' do + expect { subject }.to change(session.console, :stopped?).from(false).to(true) + end + end + + describe '#address' do + subject { session.address } + + it { is_expected.to eq(address) } + end + + describe '#port' do + subject { session.port } + + it { is_expected.to eq(port) } + end +end diff --git a/spec/lib/rex/post/mysql/ui/console/command_dispatcher/core_spec.rb b/spec/lib/rex/post/mysql/ui/console/command_dispatcher/core_spec.rb new file mode 100644 index 0000000000..1761d01a6b --- /dev/null +++ b/spec/lib/rex/post/mysql/ui/console/command_dispatcher/core_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rex/post/mysql/ui/console' +require 'mysql' + +RSpec.describe Rex::Post::MySQL::Ui::Console::CommandDispatcher::Core do + let(:rstream) { instance_double(::Rex::Socket) } + let(:client) { instance_double(::Mysql) } + let(:database) { 'database_name' } + let(:address) { '192.0.2.1' } + let(:port) { '3306' } + let(:peerinfo) { "#{address}:#{port}" } + let(:session) { Msf::Sessions::MySQL.new(rstream, { client: client }) } + let(:console) do + console = Rex::Post::MySQL::Ui::Console.new(session) + console.disable_output = true + console + end + + before(:each) do + allow(rstream).to receive(:peerinfo).and_return(peerinfo) + allow(client).to receive(:database).and_return(database) + allow(client).to receive(:socket).and_return(rstream) + allow(session).to receive(:console).and_return(console) + allow(session).to receive(:name).and_return('test client name') + allow(session).to receive(:sid).and_return('test client sid') + end + + subject(:command_dispatcher) { described_class.new(session.console) } + + it_behaves_like 'session command dispatcher' +end