Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions lib/msf/core/post/file.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>, 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}"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Busybox' timeout is using timeout [-t SECS] [-s SIG] PROG ARGS :/

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than over-complicate it by trying to figure out which version of timeout is available, it may simply be better to strip out all the timeout/perl handling, pass the timeout argument to cmd_exec and print a warning if the depth is > 2.

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)
Expand Down
114 changes: 113 additions & 1 deletion spec/lib/msf/core/post/file_spec.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require 'rspec'
require 'msfenv'
require 'msf/core'

RSpec.describe Msf::Post::File do
subject do
Expand Down Expand Up @@ -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
Loading