From 381ceae2e30374ee9b1c2064400bdc286e5c5301 Mon Sep 17 00:00:00 2001 From: sjanusz-r7 Date: Tue, 19 Sep 2023 10:35:51 +0100 Subject: [PATCH] Add PostgreSQL session type --- Gemfile.lock | 3 +- .../framework/login_scanner/postgres.rb | 20 +- lib/msf/base/sessions/postgresql.rb | 145 +++++++++++ lib/msf_autoload.rb | 1 + lib/rex/post/postgresql.rb | 3 + lib/rex/post/postgresql/ui.rb | 3 + lib/rex/post/postgresql/ui/console.rb | 135 ++++++++++ .../ui/console/command_dispatcher.rb | 103 ++++++++ .../ui/console/command_dispatcher/client.rb | 240 ++++++++++++++++++ .../ui/console/command_dispatcher/core.rb | 50 ++++ metasploit-framework.gemspec | 2 + .../scanner/postgres/postgres_login.rb | 26 ++ 12 files changed, 728 insertions(+), 3 deletions(-) create mode 100644 lib/msf/base/sessions/postgresql.rb create mode 100644 lib/rex/post/postgresql.rb create mode 100644 lib/rex/post/postgresql/ui.rb create mode 100644 lib/rex/post/postgresql/ui/console.rb create mode 100644 lib/rex/post/postgresql/ui/console/command_dispatcher.rb create mode 100644 lib/rex/post/postgresql/ui/console/command_dispatcher/client.rb create mode 100644 lib/rex/post/postgresql/ui/console/command_dispatcher/core.rb diff --git a/Gemfile.lock b/Gemfile.lock index cd6e1311a4641..92b93ce26b16b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -60,6 +60,7 @@ PATH rb-readline recog redcarpet + reline (~> 0.4.1) rex-arch rex-bin_tools rex-core @@ -377,7 +378,7 @@ GEM nokogiri redcarpet (3.6.0) regexp_parser (2.8.1) - reline (0.3.8) + reline (0.4.1) io-console (~> 0.5) require_all (3.0.0) rex-arch (0.1.15) diff --git a/lib/metasploit/framework/login_scanner/postgres.rb b/lib/metasploit/framework/login_scanner/postgres.rb index b4d31cdf9b615..ae7fa14b438a2 100644 --- a/lib/metasploit/framework/login_scanner/postgres.rb +++ b/lib/metasploit/framework/login_scanner/postgres.rb @@ -11,6 +11,14 @@ module LoginScanner class Postgres include Metasploit::Framework::LoginScanner::Base + # @returns [Boolean] If a login is successful and this attribute is true - a PostgreSQL::Client instance is used as proof, + # and the socket is not immediately closed + attr_accessor :use_client_as_proof + + # @!attribute dispatcher + # @return [PostgreSQL::Dispatcher::Socket] + attr_accessor :dispatcher + DEFAULT_PORT = 5432 DEFAULT_REALM = 'template1' LIKELY_PORTS = [ DEFAULT_PORT ] @@ -42,7 +50,7 @@ def attempt_login(credential) begin pg_conn = Msf::Db::PostgresPR::Connection.new(db_name,credential.public,credential.private,uri) - rescue RuntimeError => e + rescue ::RuntimeError => e case e.to_s.split("\t")[1] when "C3D000" result_options.merge!({ @@ -70,8 +78,16 @@ def attempt_login(credential) end if pg_conn - pg_conn.close 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] = pg_conn + pg_conn = nil + else + pg_conn.close + end else result_options[:status] = Metasploit::Model::Login::Status::INCORRECT end diff --git a/lib/msf/base/sessions/postgresql.rb b/lib/msf/base/sessions/postgresql.rb new file mode 100644 index 0000000000000..e957bdaa138fe --- /dev/null +++ b/lib/msf/base/sessions/postgresql.rb @@ -0,0 +1,145 @@ +# -*- coding: binary -*- + +require 'rex/post/postgresql' + +class Msf::Sessions::PostgreSQL + # + # This interface supports basic interaction. + # + include Msf::Session::Basic + include Msf::Sessions::Scriptable + + # @return [Rex::Post::PostgreSQL::Ui::Console] The interactive console + attr_accessor :console + # @return [PostgreSQL::Client] + attr_accessor :client + attr_accessor :platform, :arch + + # @param[Rex::IO::Stream] rstream + # @param [Hash] opts + # @param opts [PostgreSQL::Client] :client + def initialize(rstream, opts = {}) + @client = opts.fetch(:client) + @console = ::Rex::Post::PostgreSQL::Ui::Console.new(self) + super(rstream, opts) + end + + def bootstrap(datastore = {}, handler = nil) + # this won't work after the rstream is initialized, so do it first + # @platform = 'windows' # Metasploit::Framework::Ssh::Platform.get_platform(ssh_connection) + # super + + session = self + session.init_ui(user_input, user_output) + + @info = "PostgreSQL #{datastore['USERNAME']} @ #{@peer_info}" + 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]}'") + session.execute_script(args.shift, *args) + end + end + + def type + self.class.type + end + + # + # @return [String] The type of the session + # + def self.type + 'PostgreSQL' + end + + # + # @return [Boolean] Can the session clean up after itself + def self.can_cleanup_files + false + end + + # + # @return [String] The session description + # + def desc + 'PostgreSQL' + end + + def address + return @address if @address + + @address, @port = @client.conn.peerinfo.split(':') + @address + end + + def port + return @port if @port + + @address, @port = @client.conn.peerinfo.split(':') + @port + end + + ## + # :category: Msf::Session::Interactive implementors + # + # Initializes the console's I/O handles. + # + def init_ui(input, output) + super(input, output) + + console.init_ui(input, output) + console.set_log_source(self.log_source) + 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 + + protected + + ## + # :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) { _interact_stream } + end + + ## + # :category: Msf::Session::Interactive implementors + # + def _interact_stream + framework.events.on_session_interact(self) + + console.framework = framework + # if framework.datastore['MeterpreterPrompt'] + # console.update_prompt(framework.datastore['MeterpreterPrompt']) + # end + # Call the console interaction of the smb 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_autoload.rb b/lib/msf_autoload.rb index 920decad7f626..e73c8d598f051 100644 --- a/lib/msf_autoload.rb +++ b/lib/msf_autoload.rb @@ -187,6 +187,7 @@ def custom_inflections 'cli' => 'CLI', 'sqlitei' => 'SQLitei', 'mysqli' => 'MySQLi', + 'postgresql' => 'PostgreSQL', 'postgresqli' => 'PostgreSQLi', 'ssh' => 'SSH', 'winrm' => 'WinRM', diff --git a/lib/rex/post/postgresql.rb b/lib/rex/post/postgresql.rb new file mode 100644 index 0000000000000..934ad12ae8dfc --- /dev/null +++ b/lib/rex/post/postgresql.rb @@ -0,0 +1,3 @@ +# -*- coding: binary -*- + +require 'rex/post/postgresql/ui' diff --git a/lib/rex/post/postgresql/ui.rb b/lib/rex/post/postgresql/ui.rb new file mode 100644 index 0000000000000..f22d97bfd9aa4 --- /dev/null +++ b/lib/rex/post/postgresql/ui.rb @@ -0,0 +1,3 @@ +# -*- coding: binary -*- + +require 'rex/post/postgresql/ui/console' diff --git a/lib/rex/post/postgresql/ui/console.rb b/lib/rex/post/postgresql/ui/console.rb new file mode 100644 index 0000000000000..bb1ce5a68be86 --- /dev/null +++ b/lib/rex/post/postgresql/ui/console.rb @@ -0,0 +1,135 @@ +# -*- coding: binary -*- + +module Rex + module Post + module PostgreSQL + module Ui + ### + # + # This class provides a shell driven interface to the PostgreSQL client API. + # + ### + class Console + include Rex::Ui::Text::DispatcherShell + + # Dispatchers + require 'rex/post/postgresql/ui/console/command_dispatcher' + require 'rex/post/postgresql/ui/console/command_dispatcher/core' + require 'rex/post/postgresql/ui/console/command_dispatcher/client' + + # + # Initialize the PostgreSQL console. + # + # @param [Msf::Sessions::PostgreSQL] session + def initialize(session) + # The postgresql client context + self.session = session + self.client = session.client + prompt = "%undPostgreSQL @ #{self.client.conn.remote_address.ip_address}:#{self.client.conn.remote_address.ip_port}%clr" + history_manager = self.session.framework&.history_manager&.with_context(name: :postgresql) + super(prompt, '>', history_manager, self.session&.framework, :postgresql) + + # Queued commands array + self.commands = [] + + # Point the input/output handles elsewhere + reset_ui + + enstack_dispatcher(::Rex::Post::PostgreSQL::Ui::Console::CommandDispatcher::Core) + enstack_dispatcher(::Rex::Post::PostgreSQL::Ui::Console::CommandDispatcher::Client) + + # Set up logging to whatever logsink 'core' is using + if ! $dispatcher['postgresql'] + $dispatcher['postgresql'] = $dispatcher['core'] + end + end + + # + # Called when someone wants to interact with the postgresql 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) + self.commands << cmd + end + + # + # Runs the specified command wrapper in something to catch meterpreter + # exceptions. + # + 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. + # + def log_error(msg) + print_error(msg) + + elog(msg, 'postgresql') + + dlog("Call stack:\n#{$@.join("\n")}", 'postgresql') + end + + # @return [Msf::Sessions::PostgreSQL] + attr_reader :session + + # @return [PostgreSQL::Client] + attr_reader :client # :nodoc: + + # TODO Should this belong elsewhere - it's required for prompt details + # @return [String] + attr_accessor :cwd + + def format_prompt(val) + # TODO: How can the currently selected DB/Table/Module impact the prompt? + super + end + + protected + + attr_writer :session # :nodoc: + attr_writer :client # :nodoc: + attr_accessor :commands # :nodoc: + + end + + end + end + end +end diff --git a/lib/rex/post/postgresql/ui/console/command_dispatcher.rb b/lib/rex/post/postgresql/ui/console/command_dispatcher.rb new file mode 100644 index 0000000000000..95a8aede686e4 --- /dev/null +++ b/lib/rex/post/postgresql/ui/console/command_dispatcher.rb @@ -0,0 +1,103 @@ +# -*- coding: binary -*- + +require 'rex/ui/text/dispatcher_shell' + +module Rex + module Post + module PostgreSQL + module Ui + ### + # + # Base class for all command dispatchers within the PostgreSQL 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::PostgreSQL::Ui::Console] console + def initialize(console) + super + @msf_loaded = nil + @filtered_commands = [] + end + + # + # Returns the PostgreSQL client context. + # + # @return [PostgreSQL::Client] + def client + console = shell + console.client + end + + # + # Returns the PostgreSQL session context. + # + # @return [Msf::Sessions::PostgreSQL] + 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, 'postgresql_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, 'postgresql') + + dlog("Call stack:\n#{$ERROR_POSITION.join("\n")}", 'postgresql') + end + end + end + end + end +end diff --git a/lib/rex/post/postgresql/ui/console/command_dispatcher/client.rb b/lib/rex/post/postgresql/ui/console/command_dispatcher/client.rb new file mode 100644 index 0000000000000..a06ea9b20f614 --- /dev/null +++ b/lib/rex/post/postgresql/ui/console/command_dispatcher/client.rb @@ -0,0 +1,240 @@ +# -*- coding: binary -*- + +require 'pathname' +require 'reline' + +module Rex + module Post + module PostgreSQL + module Ui + + ### + # + # Core PostgreSQL client commands + # + ### + class Console::CommandDispatcher::Client + + include Rex::Post::PostgreSQL::Ui::Console::CommandDispatcher + + # https://www.postgresql.org/docs/current/catalog-pg-database.html + # @type [Hash] + DEFAULT_COLUMN_MAPPINGS = { + 'oid' => 'Row ID', + 'datname' => 'Name', + 'datdba' => 'Owner ID', + 'encoding' => 'Encoding', + 'datlocprovider' => 'Locale Provider', + 'datistemplate' => 'Template?', + 'datallowconn' => 'Allow Connections?', + 'datconnlimit' => 'Connection Limit', + 'datfrozenxid' => 'Frozen XID', + 'datminmxid' => 'Multixact ID', + 'dattablespace' => 'Tablespace ID', + 'datcollate' => 'LC_COLLATE', + 'datctype' => 'LC_CTYPE', + 'daticulocale' => 'ICU Locale', + 'daticurules' => 'ICU Rules', + 'datcollversion' => 'Collation Version', + 'datacl' => 'Access Privileges' + }.freeze + + # + # Initializes an instance of the core command set using the supplied console + # for interactivity. + # + # @param [Rex::Post::PostgreSQL::Ui::Console] console + def initialize(console) + super + + @db_search_results = [] + end + + @@db_opts = Rex::Parser::Arguments.new( + ["-h", "--help"] => [false, 'Help menu' ], + ["-l", "--list"] => [ false, "List all databases"], + ["-t", "--tables"] => [ false, "List all tables in the current database"], + ["-i", "--interact"] => [ true, "Interact with the supplied database", "database"], + ) + + # + # List of supported commands. + # + def commands + cmds = { + 'db' => 'View the available databases and interact with one', + 'tables' => 'View the available tables in the currently selected DB', + 'sql' => 'Run a raw SQL query', + 'query' => 'Run a raw SQL query', + 'shell' => 'Enter a raw shell where SQL queries can be executed', + } + + reqs = {} + + filter_commands(cmds, reqs) + end + + def name + 'PostgreSQL Client' + end + + def help_args?(args) + return false unless args.instance_of?(::Array) + + args.include?('-h') || args.include?('--help') + end + + 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 + + def cmd_shell(*args) + if help_args?(args) + cmd_shell_help + return + end + + use_history = true + prompt_proc_before = ::Reline.prompt_proc + + ::Reline.prompt_proc = proc { |line_buffer| line_buffer.each_with_index.map { |_line, i| i > 0 ? 'SQL *> ' : 'SQL >> ' } } + + stop_words = %w[stop s exit e end quit q].freeze + + finished = false + until finished + begin + raw_query = ::Reline.readmultiline('SQL >> ', use_history) do |multiline_input| + if stop_words.include?(multiline_input.split.last) + finished = true + true + end + !multiline_input.split.last.end_with?('\\') + end + rescue ::Interrupt + finished = true + end + + if finished + print_status "Exiting Shell mode." + return + end + + formatted_query = raw_query.split.map { |word| word.chomp('\\') }.reject(&:empty?).compact.join(' ') + + print_status "Running SQL Command: '#{formatted_query}'" + self.cmd_query(formatted_query) + end + + ::Reline.prompt_proc = prompt_proc_before + end + + def cmd_query_help + print_line 'Usage: query' + print_line + print_line 'You can also use `sql`.' + print_line 'Run a raw SQL query on the target.' + print_line + end + + # + # @param [::Msf::Db::PostgresPR::Connection::Result] result The result of an SQL query to format. + # @param [Hash] mapped_columns + def format_result(result, mapped_columns = {}) + columns = ['#'] + columns.append(result.fields.map.each { |field| field[:name] }) + flat_columns = columns.flatten + columns_to_map = DEFAULT_COLUMN_MAPPINGS.merge(mapped_columns) + flat_mapped_columns = flat_columns.map { |col| columns_to_map[col] || col } + + ::Rex::Text::Table.new( + 'Header' => 'Query', + 'Indent' => 4, + 'Columns' => flat_mapped_columns, + 'Rows' => result.rows.map.each.with_index do |row, i| + [i, row].flatten + end + ) + end + + def cmd_query(*args) + help_out = args.include?('-h') || args.include?('--help') + self.cmd_query_help && return if help_out + + result = self.client.query(args.join(' ').to_s) + table = self.format_result(result) + + print_line table.to_s + end + + alias cmd_sql cmd_query + alias cmd_sql_help cmd_query_help + + # + # Open the Pry debugger on the current session + # + def cmd_db(*args) + if args.include?('-h') || args.include?('--help') + cmd_db_help + return + end + + method = :list + + # Parse options + @@db_opts.parse(args) do |opt, idx, val| + case opt + when '-l', '--list' + method = :list + when '-t', '--tables' + method = :tables + when '-i', '--interact' + method = :interact + end + end + + # Perform action + case method + when :tables + # TODO: Print all tables in the current DB + when :list + result = self.client.query('SELECT datname, datdba, encoding, datcollate, datctype, datistemplate FROM pg_database;') + print_line self.format_result(result).to_s + when :interact + + end + end + + def cmd_db_tabs(_str, words) + return [] if words.length > 1 + + @@db_opts.option_keys + end + + def cmd_db_help + print_line 'Usage: db' + print_line + print_line 'View the databases available on the remote target.' + print_line + end + + def cmd_tables + tables = self.client.query('SELECT * FROM information_schema.tables;') + print_line format_result(tables).to_s + end + + protected + + def print_no_db_selected + print_error("No active database selected") + nil + end + end + end + end + end +end diff --git a/lib/rex/post/postgresql/ui/console/command_dispatcher/core.rb b/lib/rex/post/postgresql/ui/console/command_dispatcher/core.rb new file mode 100644 index 0000000000000..c5c2930fba680 --- /dev/null +++ b/lib/rex/post/postgresql/ui/console/command_dispatcher/core.rb @@ -0,0 +1,50 @@ +# -*- coding: binary -*- + +module Rex + module Post + module PostgreSQL + module Ui + + ### + # + # Core SMB client commands + # + ### + class Console::CommandDispatcher::Core + + include Rex::Post::PostgreSQL::Ui::Console::CommandDispatcher + + # + # List of supported commands. + # + def commands + cmds = { + '?' => 'Help menu', + 'background' => 'Backgrounds the current session', + 'bg' => 'Alias for background', + 'exit' => 'Terminate the PostgreSQL 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 + + def name + 'Core' + end + + def unknown_command(cmd, line) + status = super + + status + end + end + end + end + end +end diff --git a/metasploit-framework.gemspec b/metasploit-framework.gemspec index 5246e0137f7ce..cb69b519630b1 100644 --- a/metasploit-framework.gemspec +++ b/metasploit-framework.gemspec @@ -244,4 +244,6 @@ Gem::Specification.new do |spec| # Do not use this to process untrusted PNG files! This is only to be used # to generate PNG files, not to parse untrusted PNG files. spec.add_runtime_dependency 'chunky_png' + + spec.add_runtime_dependency 'reline', '~> 0.4.1' end diff --git a/modules/auxiliary/scanner/postgres/postgres_login.rb b/modules/auxiliary/scanner/postgres/postgres_login.rb index 73a9334a46ed0..d01e687dc16f0 100644 --- a/modules/auxiliary/scanner/postgres/postgres_login.rb +++ b/modules/auxiliary/scanner/postgres/postgres_login.rb @@ -5,12 +5,14 @@ require 'metasploit/framework/credential_collection' require 'metasploit/framework/login_scanner/postgres' +require 'rex/post/postgresql' class MetasploitModule < Msf::Auxiliary include Msf::Exploit::Remote::Postgres include Msf::Auxiliary::AuthBrute include Msf::Auxiliary::Scanner include Msf::Auxiliary::Report + include Msf::Auxiliary::CommandShell # Creates an instance of this module. def initialize(info = {}) @@ -66,6 +68,7 @@ def run_host(ip) connection_timeout: 30, framework: framework, framework_module: self, + use_client_as_proof: datastore['CreateSession'] ) scanner.scan! do |result| @@ -80,6 +83,16 @@ def run_host(ip) create_credential_login(credential_data) print_good "#{ip}:#{rport} - Login Successful: #{result.credential}" + + if datastore['CreateSession'] == true + begin + postgresql_client = result.proof + session_setup(result, postgresql_client) + rescue ::StandardError => e + elog('Failed: ', error: e) + print_error(e) + end + end else invalidate_login(credential_data) vprint_error "#{ip}:#{rport} - LOGIN FAILED: #{result.credential} (#{result.status}: #{result.proof})" @@ -98,6 +111,19 @@ def rport datastore['RPORT'] end + def session_setup(result, client) + return unless (result && client) + rstream = client.conn + my_session = Msf::Sessions::PostgreSQL.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