diff --git a/lib/msf/core/post/file.rb b/lib/msf/core/post/file.rb index 9d789ccdad064..4d047752e6fc2 100644 --- a/lib/msf/core/post/file.rb +++ b/lib/msf/core/post/file.rb @@ -702,6 +702,83 @@ def copy_file(src_file, dst_file) end alias cp_file copy_file + # + # Find directories writable by the current user under +path+ on a Unix system + # + # @param path [String] Base path to search from + # @param max_depth [Integer] Maximum directory depth to search + # @param timeout [Integer] Maximum seconds to run before aborting (default: 15). + # Uses the remote `timeout` utility (GNU coreutils) or `perl` alarm() to + # kill the find process server-side, preventing a long-running find from + # tying up the session. When neither is available, max_depth is capped at 1 + # to reduce the risk of a runaway search. + # @param user [String, nil] Find directories writable by this user instead of using -writable + # @param group [String, nil] Find directories writable by this group instead of using -writable + # @return [Array, nil] Array of writable directory paths, or nil on failure + # + def find_writable_directories(path: '/', max_depth: 2, timeout: 15, user: nil, group: nil) + raise "`find_writable_directories' method does not support Windows systems" if session.platform == 'windows' + + path = path.to_s + max_depth = max_depth.to_i + timeout = timeout.to_i + + find_args = ["find '#{path}'"] + find_args << "-maxdepth #{max_depth}" if max_depth > 0 + find_args << '-type d' + + if user || group + find_args << "-user '#{user}'" if user + find_args << "-group '#{group}'" if group + perm = "-#{user ? 'u' : ''}#{group ? 'g' : ''}=w" + find_args << "-perm #{perm}" + else + find_args << '-writable' + end + + find_args << '2>/dev/null' + find_cmd = find_args.join(' ') + + # Try to wrap the find command with a remote timeout mechanism so the + # process is killed server-side when the deadline expires. This prevents + # a slow find from consuming the shell channel after cmd_exec returns. + if timeout > 0 + if command_exists?('timeout') + # GNU coreutils timeout - common on Linux + cmd = "timeout #{timeout} #{find_cmd}" + exec_timeout = timeout + 5 + elsif command_exists?('perl') + # Perl alarm() - available on most BSD, macOS and Solaris systems + escaped_find_cmd = find_cmd.gsub("'", "'\\\\''") + cmd = "perl -e 'alarm(#{timeout}); exec(\"sh\", \"-c\", #{escaped_find_cmd.inspect})'" + exec_timeout = timeout + 5 + else + # No remote timeout mechanism available. Cap depth to limit the + # risk of a runaway search that poisons the shell channel. + safe_depth = [max_depth, 2].min + if max_depth > safe_depth + print_warning("Neither 'timeout' nor 'perl' found on target; limiting max_depth to #{safe_depth} to avoid hanging the session") + find_cmd = find_cmd.sub("-maxdepth #{max_depth}", "-maxdepth #{safe_depth}") + else + print_warning("Neither 'timeout' nor 'perl' found on target; a slow find may hang the session") + end + cmd = find_cmd + exec_timeout = timeout + end + else + cmd = find_cmd + exec_timeout = 15 + end + + 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 0d77c77f43e7d..786b1ba884f16 100644 --- a/spec/lib/msf/core/post/file_spec.rb +++ b/spec/lib/msf/core/post/file_spec.rb @@ -1,4 +1,5 @@ -require 'rspec' +require 'msfenv' +require 'msf/core' RSpec.describe Msf::Post::File do subject do @@ -31,4 +32,115 @@ end end end + + describe '#find_writable_directories' do + let(:session) { double('session') } + + before(:each) do + allow(subject).to receive(:session).and_return(session) + end + + context 'on Windows' do + before(:each) do + allow(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 + + context 'on Unix' do + before(:each) do + allow(session).to receive(:platform).and_return('linux') + allow(subject).to receive(:command_exists?).with('timeout').and_return(true) + allow(subject).to receive(:command_exists?).with('perl').and_return(false) + end + + 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 'wraps find with the remote timeout utility when available' do + expect(subject).to receive(:cmd_exec).with("timeout 15 find '/' -maxdepth 2 -type d -writable 2>/dev/null", nil, 20).and_return("/tmp\n") + subject.find_writable_directories + end + + it 'passes a custom timeout to the remote command and cmd_exec' do + expect(subject).to receive(:cmd_exec).with("timeout 60 find '/' -maxdepth 2 -type d -writable 2>/dev/null", nil, 65).and_return("/tmp\n") + subject.find_writable_directories(timeout: 60) + end + + it 'falls back to perl alarm when timeout is not available but perl is' do + allow(subject).to receive(:command_exists?).with('timeout').and_return(false) + allow(subject).to receive(:command_exists?).with('perl').and_return(true) + expect(subject).to receive(:cmd_exec) do |cmd, args, t| + expect(cmd).to match(/^perl -e 'alarm\(15\); exec\("sh", "-c",/) + expect(args).to be_nil + expect(t).to eq(20) + "/tmp\n" + end + subject.find_writable_directories + end + + it 'caps max_depth to 2 and warns when neither timeout nor perl is available' do + allow(subject).to receive(:command_exists?).with('timeout').and_return(false) + allow(subject).to receive(:command_exists?).with('perl').and_return(false) + expect(subject).to receive(:print_warning).with(/limiting max_depth to 2/) + 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(max_depth: 5) + end + + it 'warns without capping when max_depth is already safe and no timeout mechanism exists' do + allow(subject).to receive(:command_exists?).with('timeout').and_return(false) + allow(subject).to receive(:command_exists?).with('perl').and_return(false) + expect(subject).to receive(:print_warning).with(/slow find may hang/) + expect(subject).to receive(:cmd_exec).with("find '/' -maxdepth 1 -type d -writable 2>/dev/null", nil, 15).and_return("/tmp\n") + subject.find_writable_directories(max_depth: 1) + end + + it 'omits the remote timeout wrapper 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 'passes user flag when specified' do + allow(subject).to receive(:cmd_exec).with("timeout 15 find '/' -maxdepth 2 -type d -user 'nobody' -perm -u=w 2>/dev/null", nil, 20).and_return("/tmp\n") + expect(subject.find_writable_directories(user: 'nobody')).to eq(['/tmp']) + end + + it 'passes group flag when specified' do + allow(subject).to receive(:cmd_exec).with("timeout 15 find '/' -maxdepth 2 -type d -group 'staff' -perm -g=w 2>/dev/null", nil, 20).and_return("/tmp\n") + expect(subject.find_writable_directories(group: 'staff')).to eq(['/tmp']) + end + + it 'passes both user and group flags when specified' do + allow(subject).to receive(:cmd_exec).with("timeout 15 find '/' -maxdepth 2 -type d -user 'nobody' -group 'staff' -perm -ug=w 2>/dev/null", nil, 20).and_return("/tmp\n") + expect(subject.find_writable_directories(user: 'nobody', group: 'staff')).to eq(['/tmp']) + end + + it 'uses custom path and max_depth' do + allow(subject).to receive(:cmd_exec).with("timeout 15 find '/var' -maxdepth 5 -type d -writable 2>/dev/null", nil, 20).and_return("/var/tmp\n") + expect(subject.find_writable_directories(path: '/var', max_depth: 5)).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