diff --git a/lib/msf/core/post/file.rb b/lib/msf/core/post/file.rb index 386dfca636..ae68df49fd 100644 --- a/lib/msf/core/post/file.rb +++ b/lib/msf/core/post/file.rb @@ -702,6 +702,52 @@ module Msf::Post::File end alias cp_file copy_file + # + # Find writable directories under +path+ on a Unix system. + # + # Uses find's +-writable+ flag which checks effective access for the current user. + # + # @param path [String] Absolute base path to search from (default: '/') + # @param max_depth [Integer] Maximum directory depth to search (0 = base directory only) + # @param timeout [Integer] Maximum seconds for cmd_exec to wait (default: 15). + # Note: if the command times out, the remote find process may continue + # running and tie up the shell channel until it finishes. + # @return [Array, nil] Array of writable directory paths, or nil on failure + # + def find_writable_directories(path: '/', max_depth: 2, timeout: 15) + raise "`find_writable_directories' method does not support Windows systems" if session.platform == 'windows' + + path = path.to_s + raise ArgumentError, 'path must be an absolute path' unless path.start_with?('/') + + max_depth = max_depth.to_i + raise ArgumentError, 'max_depth must not be negative' if max_depth < 0 + + timeout = timeout.to_i + + if max_depth > 2 + print_warning("Large max_depth (#{max_depth}) may cause the find command to run for a long time and hang the session") + end + + escaped_path = session.escape_arg(path) + find_args = ["find #{escaped_path}"] + find_args << "-maxdepth #{max_depth}" + find_args << '-type d' + find_args << '-writable' + + find_args << '2>/dev/null' + cmd = find_args.join(' ') + exec_timeout = timeout > 0 ? timeout : 15 + + begin + cmd_exec(cmd, nil, exec_timeout).to_s.lines.map(&:strip).select { |p| p.start_with?('/') } + rescue ::StandardError => e + elog("Failed to find writable directories in #{path}", error: e) + print_error("Failed to find writable directories in #{path}") + nil + end + end + protected def _append_file_powershell(file_name, data) diff --git a/spec/lib/msf/core/post/file_spec.rb b/spec/lib/msf/core/post/file_spec.rb index 89085afdaa..ed034dc6be 100644 --- a/spec/lib/msf/core/post/file_spec.rb +++ b/spec/lib/msf/core/post/file_spec.rb @@ -1,4 +1,4 @@ -require 'rspec' +require 'spec_helper' RSpec.describe Msf::Post::File do subject do @@ -63,4 +63,103 @@ RSpec.describe Msf::Post::File do end end end + + describe '#find_writable_directories' do + subject do + described_mixin = described_class + klass = Class.new do + include described_mixin + attr_accessor :session + def cmd_exec(*_args); ''; end + def print_warning(_msg); end + def print_error(_msg); end + def elog(*_args); end + end + obj = klass.allocate + obj.session = double('session', platform: 'linux') + allow(obj.session).to receive(:escape_arg) { |arg| "'#{arg}'" } + obj + end + + context 'on Windows' do + before(:each) do + allow(subject.session).to receive(:platform).and_return('windows') + end + + it 'raises an error' do + expect { subject.find_writable_directories }.to raise_error(RuntimeError, /does not support Windows/) + end + end + + it 'raises an error for relative paths' do + expect { subject.find_writable_directories(path: 'relative/path') }.to raise_error(ArgumentError, /absolute path/) + end + + it 'raises an error for negative max_depth' do + expect { subject.find_writable_directories(max_depth: -1) }.to raise_error(ArgumentError, /max_depth must not be negative/) + end + + context 'on Unix' do + + it 'returns writable directories' do + allow(subject).to receive(:cmd_exec).and_return("/tmp\n/var/tmp\n") + expect(subject.find_writable_directories).to eq(['/tmp', '/var/tmp']) + end + + it 'filters out non-absolute paths and error lines' do + allow(subject).to receive(:cmd_exec).and_return("/tmp\nfind: permission denied\n/var/tmp\n") + expect(subject.find_writable_directories).to eq(['/tmp', '/var/tmp']) + end + + it 'returns an empty array when no directories are found' do + allow(subject).to receive(:cmd_exec).and_return('') + expect(subject.find_writable_directories).to eq([]) + end + + it 'passes the timeout to cmd_exec' do + expect(subject).to receive(:cmd_exec).with("find '/' -maxdepth 2 -type d -writable 2>/dev/null", nil, 15).and_return("/tmp\n") + subject.find_writable_directories + end + + it 'passes a custom timeout to cmd_exec' do + expect(subject).to receive(:cmd_exec).with("find '/' -maxdepth 2 -type d -writable 2>/dev/null", nil, 60).and_return("/tmp\n") + subject.find_writable_directories(timeout: 60) + end + + it 'uses default timeout when timeout is 0' do + expect(subject).to receive(:cmd_exec).with("find '/' -maxdepth 2 -type d -writable 2>/dev/null", nil, 15).and_return("/tmp\n") + subject.find_writable_directories(timeout: 0) + end + + it 'warns when max_depth is greater than 2' do + expect(subject).to receive(:print_warning).with(/Large max_depth/) + allow(subject).to receive(:cmd_exec).and_return("/tmp\n") + subject.find_writable_directories(max_depth: 5) + end + + it 'does not warn when max_depth is 2 or less' do + expect(subject).not_to receive(:print_warning) + allow(subject).to receive(:cmd_exec).and_return("/tmp\n") + subject.find_writable_directories(max_depth: 2) + end + + it 'passes -maxdepth 0 to search only the base directory' do + expect(subject).to receive(:cmd_exec).with("find '/' -maxdepth 0 -type d -writable 2>/dev/null", nil, 15).and_return("/\n") + expect(subject.find_writable_directories(max_depth: 0)).to eq(['/']) + end + + it 'uses custom path and max_depth' do + allow(subject).to receive(:print_warning) + expect(subject).to receive(:cmd_exec).with("find '/var' -maxdepth 3 -type d -writable 2>/dev/null", nil, 15).and_return("/var/tmp\n") + expect(subject.find_writable_directories(path: '/var', max_depth: 3)).to eq(['/var/tmp']) + end + + it 'returns nil on failure' do + allow(subject).to receive(:cmd_exec).and_raise(RuntimeError, 'connection failed') + allow(subject).to receive(:print_error) + allow(subject).to receive(:elog) + expect(subject.find_writable_directories).to be_nil + end + end + end end