diff --git a/Gemfile.lock b/Gemfile.lock index 30a078c221d2..61bc42ae5f71 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -81,7 +81,7 @@ PATH rex-zip ruby-macho ruby-mysql - ruby_smb (~> 3.3.0) + ruby_smb (~> 3.3.3) rubyntlm rubyzip sinatra @@ -474,8 +474,8 @@ GEM ruby-progressbar (1.13.0) ruby-rc4 (0.1.5) ruby2_keywords (0.0.5) - ruby_smb (3.3.2) - bindata + ruby_smb (3.3.3) + bindata (= 2.4.15) openssl-ccm openssl-cmac rubyntlm diff --git a/lib/msf/base/sessions/smb.rb b/lib/msf/base/sessions/smb.rb index 7179d7fb5c5a..5d9366dd25cf 100644 --- a/lib/msf/base/sessions/smb.rb +++ b/lib/msf/base/sessions/smb.rb @@ -13,6 +13,8 @@ class Msf::Sessions::SMB attr_accessor :console # @return [RubySMB::Client] The SMB client attr_accessor :client + # @return [Rex::Proto::SMB::SimpleClient] + attr_accessor :simple_client attr_accessor :platform, :arch attr_reader :framework @@ -21,6 +23,7 @@ class Msf::Sessions::SMB # @option opts [RubySMB::Client] :client def initialize(rstream, opts = {}) @client = opts.fetch(:client) + @simple_client = ::Rex::Proto::SMB::SimpleClient.new(client.dispatcher.tcp_socket, client: client) self.console = Rex::Post::SMB::Ui::Console.new(self) super(rstream, opts) end diff --git a/lib/rex/ntpath.rb b/lib/rex/ntpath.rb new file mode 100644 index 000000000000..ddb18ddaf92f --- /dev/null +++ b/lib/rex/ntpath.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Rex + module Ntpath + + # @param [String] path The path to convert into a valid ntpath format + def self.as_ntpath(path) + Pathname.new(path) + .cleanpath + .each_filename + .drop_while { |file| file == '.' } + .join('\\') + end + end +end diff --git a/lib/rex/post/smb/ui/console.rb b/lib/rex/post/smb/ui/console.rb index 6cf64d332ac4..ba10e781e0da 100644 --- a/lib/rex/post/smb/ui/console.rb +++ b/lib/rex/post/smb/ui/console.rb @@ -36,6 +36,7 @@ def initialize(session) # The ruby smb client context self.session = session self.client = session.client + self.simple_client = session.simple_client # Queued commands array self.commands = [] @@ -125,6 +126,9 @@ def log_error(msg) # @return [RubySMB::Client] attr_reader :client # :nodoc: + # @return [Rex::Proto::SMB::SimpleClient] + attr_reader :simple_client + # @return [RubySMB::SMB2::Tree] attr_accessor :active_share @@ -134,7 +138,7 @@ def log_error(msg) def format_prompt(val) if active_share share_name = active_share.share[/[^\\].*$/, 0] - cwd = self.cwd.blank? ? '' : "\\#{self.cwd}" + cwd = self.cwd.blank? ? '' : "\\#{Rex::Ntpath.as_ntpath(self.cwd)}" prompt = "#{share_name}#{cwd}" else prompt = session.address.to_s @@ -145,9 +149,8 @@ def format_prompt(val) protected - attr_writer :session, :client # :nodoc: # :nodoc: + attr_writer :session, :client, :simple_client # :nodoc: # :nodoc: attr_accessor :commands # :nodoc: - end end end diff --git a/lib/rex/post/smb/ui/console/command_dispatcher.rb b/lib/rex/post/smb/ui/console/command_dispatcher.rb index 1fd99dd137ed..b145de0cf65c 100644 --- a/lib/rex/post/smb/ui/console/command_dispatcher.rb +++ b/lib/rex/post/smb/ui/console/command_dispatcher.rb @@ -36,6 +36,14 @@ def client console.client end + # + # Returns the smb simple client. + # + # @return [Rex::Proto::SMB::SimpleClient] + def simple_client + shell.simple_client + end + # # Returns the smb session context. # diff --git a/lib/rex/post/smb/ui/console/command_dispatcher/shares.rb b/lib/rex/post/smb/ui/console/command_dispatcher/shares.rb index 5837e5a86181..9d488812a7d1 100644 --- a/lib/rex/post/smb/ui/console/command_dispatcher/shares.rb +++ b/lib/rex/post/smb/ui/console/command_dispatcher/shares.rb @@ -1,6 +1,8 @@ # -*- coding: binary -*- require 'pathname' +require 'rex/post/file' +require 'filesize' module Rex module Post @@ -16,7 +18,7 @@ class Console::CommandDispatcher::Shares include Rex::Post::SMB::Ui::Console::CommandDispatcher # - # Initializes an instance of the core command set using the supplied console + # Initializes an instance of the shares command set using the supplied console # for interactivity. # # @param [Rex::Post::SMB::Ui::Console] console @@ -48,6 +50,26 @@ def initialize(console) ['-h', '--help'] => [false, 'Help menu' ] ) + @@upload_opts = Rex::Parser::Arguments.new( + ['-h', '--help'] => [false, 'Help menu' ] + ) + + @@download_opts = Rex::Parser::Arguments.new( + ['-h', '--help'] => [false, 'Help menu' ] + ) + + @@delete_opts = Rex::Parser::Arguments.new( + ['-h', '--help'] => [false, 'Help menu' ] + ) + + @@mkdir_opts = Rex::Parser::Arguments.new( + ['-h', '--help'] => [false, 'Help menu' ] + ) + + @@rmdir_opts = Rex::Parser::Arguments.new( + ['-h', '--help'] => [false, 'Help menu' ] + ) + # # List of supported commands. # @@ -58,7 +80,12 @@ def commands 'dir' => 'List all files in the current directory (alias for ls)', 'pwd' => 'Print the current remote working directory', 'cd' => 'Change the current remote working directory', - 'cat' => 'Read the file at the given path' + 'cat' => 'Read the file at the given path', + 'upload' => 'Upload a file', + 'download' => 'Download a file', + 'delete' => 'Delete a file', + 'mkdir' => 'Make a new directory', + 'rmdir' => 'Delete a directory' } reqs = {} @@ -157,9 +184,24 @@ def cmd_ls(*args) return print_no_share_selected unless active_share - files = active_share.list(directory: as_ntpath(shell.cwd)) + remote_path = '' + + @@delete_opts.parse(args) do |_opt, idx, val| + case idx + when 0 + remote_path = val + else + print_warning('Too many parameters') + cmd_ls_help + return + end + end + + full_path = Rex::Ntpath.as_ntpath(Pathname.new(shell.cwd).join(remote_path).to_s) + + files = active_share.list(directory: full_path) table = Rex::Text::Table.new( - 'Header' => 'Shares', + 'Header' => "ls #{full_path}", 'Indent' => 4, 'Columns' => [ '#', 'Type', 'Name', 'Created', 'Accessed', 'Written', 'Changed', 'Size'], 'Rows' => files.map.with_index do |file, i| @@ -255,12 +297,11 @@ def cmd_cd(*args) return print_no_share_selected unless active_share path = args[0] - # TODO: Needs better normalization - new_path = as_ntpath(Pathname.new(shell.cwd).join(path).to_s) + native_path = Pathname.new(shell.cwd).join(path).to_s + new_path = Rex::Ntpath.as_ntpath(native_path) begin response = active_share.open_directory(directory: new_path) directory = RubySMB::SMB2::File.new(name: new_path, tree: active_share, response: response, encrypt: @tree_connect_encrypt_data) - directory.close rescue RubySMB::Error::UnexpectedStatusCode => e # Special case this error to provide better feedback to the user # since I think trying to `cd` to a non-existent directory is pretty likely to accidentally happen @@ -274,9 +315,11 @@ def cmd_cd(*args) print_error('Unknown error occurred while trying to change directory') elog(e) return + ensure + directory.close if directory end - shell.cwd = new_path + shell.cwd = native_path end def cmd_cat_help @@ -295,14 +338,14 @@ def cmd_cat(*args) return end - return print_no_share_selected if !active_share + return print_no_share_selected unless active_share path = args[0] - new_path = as_ntpath(Pathname.new(shell.cwd).join(path).to_s) + new_path = Rex::Ntpath.as_ntpath(Pathname.new(shell.cwd).join(path).to_s) begin - file = active_share.open_file(filename: new_path) + file = simple_client.open(new_path, 'o') result = file.read print_line(result) rescue StandardError => e @@ -320,7 +363,209 @@ def cmd_cat(*args) def cmd_cd_tabs(_str, words) return [] if words.length > 1 - @@cat_opts.option_keys + @@cd_opts.option_keys + end + + def cmd_upload(*args) + if args.include?('-h') || args.include?('--help') + cmd_upload_help + return + end + + return print_no_share_selected unless active_share + + local_path = nil + remote_path = nil + + @@upload_opts.parse(args) do |_opt, idx, val| + case idx + when 0 + local_path = val + when 1 + remote_path = val + else + print_warning('Too many parameters') + cmd_upload_help + return + end + end + + if local_path.blank? + print_error('No local path given') + return + end + + remote_path = Rex::Post::File.basename(local_path) if remote_path.nil? + full_path = Rex::Ntpath.as_ntpath(Pathname.new(shell.cwd).join(remote_path).to_s) + + upload_file(full_path, local_path) + + print_good("#{local_path} uploaded to #{full_path}") + end + + def cmd_upload_tabs(str, words) + tab_complete_filenames(str, words) + end + + def cmd_upload_help + print_line 'Usage: upload ' + print_line + print_line 'Upload a file to the remote target.' + print @@upload_opts.usage + end + + def cmd_download(*args) + if args.include?('-h') || args.include?('--help') + cmd_download_help + return + end + + return print_no_share_selected unless active_share + + remote_path = nil + local_path = nil + + @@download_opts.parse(args) do |_opt, idx, val| + case idx + when 0 + remote_path = val + when 1 + local_path = val + else + print_warning('Too many parameters') + cmd_download_help + return + end + end + + if remote_path.blank? + print_error('No remote path given') + return + end + + local_path = Rex::Post::File.basename(remote_path) if local_path.nil? + full_path = Rex::Ntpath.as_ntpath(Pathname.new(shell.cwd).join(remote_path).to_s) + + download_file(local_path, full_path) + + print_good("Downloaded #{full_path} to #{local_path}") + end + + def cmd_download_help + print_line 'Usage: download ' + print_line + print_line 'Download a file from the remote target.' + print @@download_opts.usage + end + + def cmd_delete(*args) + if args.include?('-h') || args.include?('--help') + cmd_delete_help + return + end + remote_path = nil + + @@delete_opts.parse(args) do |_opt, idx, val| + case idx + when 0 + remote_path = val + else + print_warning('Too many parameters') + cmd_delete_help + return + end + end + + full_path = Rex::Ntpath.as_ntpath(Pathname.new(shell.cwd).join(remote_path).to_s) + fd = simple_client.open(full_path, 'o') + fd.delete + print_good("Deleted #{full_path}") + end + + def cmd_delete_help + print_line 'Usage: delete ' + print_line + print_line 'Delete a file from the remote target.' + print @@delete_opts.usage + end + + def cmd_mkdir(*args) + if args.include?('-h') || args.include?('--help') + cmd_mkdir_help + return + end + + return print_no_share_selected unless active_share + + remote_path = nil + + @@mkdir_opts.parse(args) do |_opt, idx, val| + case idx + when 0 + remote_path = val + else + print_warning('Too many parameters') + cmd_mkdir_help + return + end + end + + full_path = Rex::Ntpath.as_ntpath(Pathname.new(shell.cwd).join(remote_path).to_s) + + response = active_share.open_directory(directory: full_path, disposition: RubySMB::Dispositions::FILE_CREATE) + directory = RubySMB::SMB2::File.new(name: full_path, tree: active_share, response: response, encrypt: @tree_connect_encrypt_data) + print_good("Directory #{full_path} created") + ensure + directory.close if directory + end + + def cmd_mkdir_help + print_line 'Usage: mkdir ' + print_line + print_line 'Create a directory on the remote target.' + print @@mkdir_opts.usage + end + + def cmd_rmdir(*args) + if args.include?('-h') || args.include?('--help') + cmd_rmdir_help + return + end + + return print_no_share_selected unless active_share + + remote_path = nil + + @@rmdir_opts.parse(args) do |_opt, idx, val| + case idx + when 0 + remote_path = val + else + print_warning('Too many parameters') + cmd_rmdir_help + return + end + end + + full_path = Rex::Ntpath.as_ntpath(Pathname.new(shell.cwd).join(remote_path).to_s) + + response = active_share.open_directory(directory: full_path, write: true, delete: true, desired_delete: true) + directory = RubySMB::SMB2::File.new(name: full_path, tree: active_share, response: response, encrypt: @tree_connect_encrypt_data) + status = directory.delete + if status == WindowsError::NTStatus::STATUS_SUCCESS + print_good("Deleted #{full_path}") + else + print_error("Error deleting #{full_path}: #{status.name}, #{status.description}") + end + ensure + directory.close if directory + end + + def cmd_rmdir_help + print_line 'Usage: rmdir ' + print_line + print_line 'Delete a directory from the remote target.' + print @@rmdir_opts.usage end protected @@ -330,12 +575,56 @@ def print_no_share_selected nil end - def as_ntpath(path) - Pathname.new(path) - .cleanpath - .each_filename - .drop_while { |file| file == '.' || file == '..' } - .join('\\') + # Upload a local file to the target + # @param dest_file [String] The path for the destination file + # @param src_file [String] The path for the source file + def upload_file(dest_file, src_file) + buf_size = 8 * 1024 * 1024 + begin + dest_fd = simple_client.open(dest_file, 'wct', write: true) + src_fd = ::File.open(src_file, "rb") + src_size = src_fd.stat.size + offset = 0 + while (buf = src_fd.read(buf_size)) + offset = dest_fd.write(buf, offset) + percent = offset / src_size.to_f * 100.0 + msg = "Uploaded #{Filesize.new(offset).pretty} of " \ + "#{Filesize.new(src_size).pretty} (#{percent.round(2)}%)" + print_status(msg) + end + ensure + src_fd.close unless src_fd.nil? + dest_fd.close unless dest_fd.nil? + end + end + + # Download a remote file from the target + # @param dest_file [String] The path for the destination file + # @param src_file [String] The path for the source file + def download_file(dest_file, src_file) + buf_size = 8 * 1024 * 1024 + src_fd = simple_client.open(src_file, 'o') + # Make the destination path if necessary + dir = ::File.dirname(dest_file) + ::FileUtils.mkdir_p(dir) if dir && !::File.directory?(dir) + dst_fd = ::File.new(dest_file, "wb") + + offset = 0 + src_size = client.open_files[src_fd.file_id].size + begin + while offset < src_size + data = src_fd.read(buf_size, offset) + dst_fd.write(data) + offset += data.length + percent = offset / src_size.to_f * 100.0 + msg = "Downloaded #{Filesize.new(offset).pretty} of " \ + "#{Filesize.new(src_size).pretty} (#{percent.round(2)}%)" + print_status(msg) + end + ensure + src_fd.close unless src_fd.nil? + dst_fd.close unless dst_fd.nil? + end end end end diff --git a/lib/rex/proto/smb/simple_client.rb b/lib/rex/proto/smb/simple_client.rb index 1b192064de6b..3a19a20d803e 100644 --- a/lib/rex/proto/smb/simple_client.rb +++ b/lib/rex/proto/smb/simple_client.rb @@ -214,6 +214,7 @@ def open(path, perm, chunk_size = 48000, read: true, write: false) end file_id = self.client.open(path, mode, read: true, write: write || perm.include?('w')) + else mode = UTILS.open_mode_to_mode(perm) access = UTILS.open_mode_to_access(perm) diff --git a/lib/rex/proto/smb/simple_client/open_file.rb b/lib/rex/proto/smb/simple_client/open_file.rb index 9273d67b355a..891e6c768946 100644 --- a/lib/rex/proto/smb/simple_client/open_file.rb +++ b/lib/rex/proto/smb/simple_client/open_file.rb @@ -30,35 +30,36 @@ def close end def read_ruby_smb(length, offset, depth = 0) + file_size = client.open_files[client.last_file_id].size + file_size_remaining = file_size - offset if length.nil? - max_size = client.open_files[client.last_file_id].size - fptr = offset + max_size = file_size_remaining + else + max_size = [length, file_size_remaining].min + end - chunk = [max_size, chunk_size].min + fptr = offset + chunk = [max_size, chunk_size].min - data = client.read(file_id, fptr, chunk).pack('C*') - fptr = data.length + data = client.read(file_id, fptr, chunk).pack('C*') + fptr += data.length - while data.length < max_size - if (max_size - data.length) < chunk - chunk = max_size - data.length - end - data << client.read(file_id, fptr, chunk).pack('C*') - fptr = data.length - end - else - begin - data = client.read(file_id, offset, length).pack('C*') - rescue RubySMB::Error::UnexpectedStatusCode => e - if e.message == 'STATUS_PIPE_EMPTY' && depth < 20 - data = read_ruby_smb(length, offset, depth + 1) - else - raise e - end + while data.length < max_size + if (max_size - data.length) < chunk + chunk = max_size - data.length end + new_data = client.read(file_id, fptr, chunk).pack('C*') + data << new_data + fptr += new_data.length end data + rescue RubySMB::Error::UnexpectedStatusCode => e + if e.message == 'STATUS_PIPE_EMPTY' && depth < 20 + read_ruby_smb(max_size, offset, depth + 1) + else + raise e + end end def read_rex_smb(length, offset) @@ -139,6 +140,7 @@ def write(data, offset = 0) fptr += cl chunk = data.slice!(0, chunk_size) end + fptr end end end diff --git a/lib/rex/proto/smb/simple_client/open_pipe.rb b/lib/rex/proto/smb/simple_client/open_pipe.rb index e93fba5890da..2062c26cbaf2 100644 --- a/lib/rex/proto/smb/simple_client/open_pipe.rb +++ b/lib/rex/proto/smb/simple_client/open_pipe.rb @@ -21,6 +21,36 @@ def read_buffer(length, offset=0) @buff.slice!(0, length) end + def read_ruby_smb(length, offset, depth = 0) + if length.nil? + max_size = client.open_files[client.last_file_id].size + fptr = offset + + chunk = [max_size, chunk_size].min + + data = client.read(file_id, fptr, chunk).pack('C*') + fptr = data.length + + while data.length < max_size + if (max_size - data.length) < chunk + chunk = max_size - data.length + end + data << client.read(file_id, fptr, chunk).pack('C*') + fptr = data.length + end + else + begin + client.read(file_id, offset, length).pack('C*') + rescue RubySMB::Error::UnexpectedStatusCode => e + if e.message == 'STATUS_PIPE_EMPTY' && depth < 20 + read_ruby_smb(length, offset, depth + 1) + else + raise e + end + end + end + end + def read(length = nil, offset = 0) case self.mode when 'trans' diff --git a/metasploit-framework.gemspec b/metasploit-framework.gemspec index 8a46d53024f7..45893f3356d3 100644 --- a/metasploit-framework.gemspec +++ b/metasploit-framework.gemspec @@ -147,7 +147,7 @@ Gem::Specification.new do |spec| spec.add_runtime_dependency 'net-ssh' spec.add_runtime_dependency 'ed25519' # Adds ed25519 keys for net-ssh spec.add_runtime_dependency 'bcrypt_pbkdf' - spec.add_runtime_dependency 'ruby_smb', '~> 3.3.0' + spec.add_runtime_dependency 'ruby_smb', '~> 3.3.3' spec.add_runtime_dependency 'net-imap' # Used in Postgres auth for its SASL stringprep implementation spec.add_runtime_dependency 'net-ldap' spec.add_runtime_dependency 'net-smtp' diff --git a/spec/lib/rex/ntpath_spec.rb b/spec/lib/rex/ntpath_spec.rb new file mode 100644 index 000000000000..6084755754fc --- /dev/null +++ b/spec/lib/rex/ntpath_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'rspec' + +RSpec.describe Rex::Ntpath do + + describe '#as_ntpath' do + let(:valid_windows_path) { 'some\\path\\that\\is\\valid' } + + [ + 'some\\path\\that\\is\\valid', + 'some/path/that/is/valid', + 'some/./path/that/./is/valid', + 'some/extra/../path/that/extra/../is/valid', + '/some/path/that/is/valid' + ].each do |path| + context "when the path is #{path}" do + it 'formats it as a valid ntpath' do + formatted_path = described_class.as_ntpath(path) + expect(formatted_path).to eq valid_windows_path + end + end + end + end +end diff --git a/spec/lib/rex/post/smb/ui/console/command_dispatcher/core_spec.rb b/spec/lib/rex/post/smb/ui/console/command_dispatcher/core_spec.rb index 5860974dbdf1..62cc319cc368 100644 --- a/spec/lib/rex/post/smb/ui/console/command_dispatcher/core_spec.rb +++ b/spec/lib/rex/post/smb/ui/console/command_dispatcher/core_spec.rb @@ -4,7 +4,10 @@ require 'rex/post/smb/ui/console/command_dispatcher/core' RSpec.describe Rex::Post::SMB::Ui::Console::CommandDispatcher::Core do - let(:client) { instance_double(RubySMB::Client) } + let(:client) { instance_double(RubySMB::Client, dispatcher: dispatcher) } + let(:simple_client) { instance_double(Rex::Proto::SMB::SimpleClient) } + let(:dispatcher) { instance_double(RubySMB::Dispatcher::Socket, tcp_socket: socket) } + let(:socket) { instance_double(IO) } let(:session) { Msf::Sessions::SMB.new(nil, { client: client }) } let(:console) do console = Rex::Post::SMB::Ui::Console.new(session) @@ -13,6 +16,7 @@ end before(:each) do + allow(Rex::Proto::SMB::SimpleClient).to receive(:new).and_return(simple_client) allow(session).to receive(:client).and_return(client) allow(session).to receive(:console).and_return(console) allow(session).to receive(:name).and_return('test client name') diff --git a/spec/lib/rex/post/smb/ui/console/command_dispatcher/shares_spec.rb b/spec/lib/rex/post/smb/ui/console/command_dispatcher/shares_spec.rb deleted file mode 100644 index 0fb75d734eec..000000000000 --- a/spec/lib/rex/post/smb/ui/console/command_dispatcher/shares_spec.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require 'rex/post/smb/ui/console' -require 'rex/post/smb/ui/console/command_dispatcher/shares' - -RSpec.describe Rex::Post::SMB::Ui::Console::CommandDispatcher::Shares do - let(:client) { instance_double(RubySMB::Client) } - let(:session) { Msf::Sessions::SMB.new(nil, { client: client }) } - let(:console) do - console = Rex::Post::SMB::Ui::Console.new(session) - console.disable_output = true - console - end - - before(:each) do - allow(session).to receive(:client).and_return(client) - 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) } - - describe '#as_ntpath' do - let(:valid_windows_path) { 'some\\path\\that\\is\\valid' } - - [ - 'some\\path\\that\\is\\valid', - 'some/path/that/is/valid', - 'some/./path/that/./is/valid', - 'some/extra/../path/that/extra/../is/valid', - '/some/path/that/is/valid' - ].each do |path| - context "when the path is #{path}" do - it 'formats it as a valid ntpath' do - formatted_path = subject.send(:as_ntpath, path) - expect(formatted_path).to eq valid_windows_path - end - end - end - end -end