From 57458b2fa5d3548a6412635b69cabdb9afc6db1a Mon Sep 17 00:00:00 2001 From: h00die Date: Sun, 7 Jun 2026 18:08:24 -0400 Subject: [PATCH 1/7] handler detection WIP --- .../auxiliary/scanner/msf/handler_detect.md | 499 +++++++++++++ .../auxiliary/scanner/msf/handler_detect.rb | 683 ++++++++++++++++++ 2 files changed, 1182 insertions(+) create mode 100644 documentation/modules/auxiliary/scanner/msf/handler_detect.md create mode 100644 modules/auxiliary/scanner/msf/handler_detect.rb diff --git a/documentation/modules/auxiliary/scanner/msf/handler_detect.md b/documentation/modules/auxiliary/scanner/msf/handler_detect.md new file mode 100644 index 0000000000000..4043b5d4e9ec5 --- /dev/null +++ b/documentation/modules/auxiliary/scanner/msf/handler_detect.md @@ -0,0 +1,499 @@ +## Vulnerable Application + +This module detects Metasploit `exploit/multi/handler` listeners (and any other +**staged** `reverse_tcp` payload handler) on a network by abusing the staging +protocol. + +When a victim connects to a staged `reverse_tcp` handler, the handler +immediately transmits the second stage **without waiting for any client input** +- it "talks first". Almost no legitimate TCP service sends a large binary or +base64/zlib blob immediately on connect, which makes this behavior a reliable +fingerprint. + +The module recognizes several "handler talks first" behaviors: + +* **Windows native** stagers send a little-endian (`pack('V')`) length followed + by a binary stage (for meterpreter this is the `metsrv` reflective DLL, + ~200 KB+ for x64; for shells a small <1 KB stage). +* **Python** stagers send a big-endian (`pack('N')`) length followed by a + base64+zlib text stage. +* **PHP / Java** stagers send a big-endian (`pack('N')`) length followed by a + PHP-source / JAR stage. +* **Linux / OSX native** meterpreter stagers send the raw machine-code stage + with no length prefix at all. +* **Unix staged shells** send a tiny raw `execve("/bin/sh")` shellcode stage + (detected via the embedded `/bin/sh` string). +* **Reverse command shells** (`shell_reverse_tcp`, etc.): the + `Msf::Sessions::CommandShell` handler verifies the shell by sending + `echo ` on connect, which is a reliable Metasploit signature. +* **Java / Android (Dalvik)** stagers send a big-endian length prefix followed + by a jar/dex stage (detected via the `PK`/`META-INF` markers). + +When a port does not volunteer a stage (the handler waits for the client), the +module additionally sends an HTTP request to fingerprint Metasploit's +**HTTP-based handlers** (both plaintext and, optionally, over TLS): + +* **`reverse_http` / `reverse_https` Meterpreter handlers** answer `200 OK` with + the default `It works!` body to *any* unknown URI (a real server would return + `404`), with `Server: Apache`. +* **Rex HTTP servers** (`web_delivery`, the `fetch` payload servers, and most + exploit-module HTTP servers) return a distinctive Rex `404` page + (`

Not found

The requested URL ... was not found on this server.`). + +### Capturing operator AutoRunScript / follow-up commands + +When `ECHO_BACK` is enabled (default) and a reverse command-shell handler is +found, the module echoes the verification token back. This marks the shell +"valid" on the operator's console, after which the handler runs any configured +`InitialAutoRunScript` / `AutoRunScript` against what it believes is a live +session - and those commands are written down the socket to us. The module +captures and reports them, which can leak the operator's post-exploitation +automation. With no AutoRunScript configured, the handler simply stops after +verification (nothing further is sent). + +### Detectability summary by transport + +These figures come from spinning up one representative listener for every +distinct `(os/language x staged? x type x transport)` combination (203 of them, +collapsing arch variants that share a wire fingerprint) and scanning them. The +counts are the number of the 1090 total reverse payloads that map to each +transport class, and how many fall into a detectable class. + +| Transport class | Example payloads | Total | Detected | How | +|---|---|---:|---:|---| +| tcp | reverse_tcp (+dns/uuid-less variants) | 544 | 318 | talk-first stage / echo / binary burst | +| http | reverse_http / reverse_winhttp | 124 | 96 | HTTP probe -> It works! | +| https | reverse_https / reverse_winhttps | 108 | 86 | HTTPS probe -> It works! | +| tcp_rc4 | reverse_tcp_rc4 | 102 | 44 | RC4-encrypted stage (partial) | +| tcp_uuid | reverse_tcp_uuid | 81 | 47 | stage after 16-byte UUID | +| cmdshell | interpreter reverse shells | 42 | 23 | echo probe (timing) | +| tcp_ssl | reverse_tcp_ssl | 31 | 19 | SSL probe reads stage/echo | +| named_pipe | reverse_named_pipe (SMB) | 30 | 0 | SMB named pipe - not probed | +| udp | reverse_udp | 15 | 6 | UDP datagram -> stage | +| sctp | reverse_sctp | 13 | 0 | SCTP - not probed | + + +Notes on the partials/zeros: + +* **tcp_rc4 / tcp_uuid**: the RC4-encrypted stage looks like a high-entropy + binary burst (still flagged, lower confidence); UUID payloads prepend 16 + bytes before the length field. Both are caught when they fall back to the + generic "unsolicited binary burst" rule. +* **tcp_ssl**: caught by the SSL probe, which sends an HTTP request and then + classifies the reply, so it reads `reverse_https` (`It works!`), + `shell_reverse_tcp_ssl` (`echo ` over TLS) and the staged + `reverse_tcp_ssl` burst with a single round-trip. +* **named_pipe (SMB)** and **sctp**: not TCP or UDP, so this scanner does not + probe them - listed for completeness only. +* **silent stageless**: stageless payloads that wait for the client and are not + HTTP handlers (some meterpreter and shells) cannot be elicited and look like a + plain open port. + +The full per-type matrix (sorted by os/language, staged, type, transport) is in +the [Full payload matrix](#full-payload-matrix) appendix at the end of this +document. + +### Empirical validation (723 live listeners) + +The detector was validated end-to-end by starting a real `multi/handler` +listener for **every one of the 723 reverse payloads** that bind under +`multi/handler` (LPORT 10000+, scanned from a *separate*, lightly-loaded +console) and confirming what each is classified as: + +| Result | Count | | +|---|---:|---| +| Detected (passive probes off) | **673 / 723** | 93.1% | +| + `DEEP_PROBE` (double handlers + pingback) | **~698 / 723** | 96.5% | +| Not detected - *expected* (passive handlers) | 24 | see below | + +Confidence of the base 673: ~460 high, ~78 medium, ~135 low. Transports covered +include tcp, http(s), tcp_ssl, tcp_rc4, tcp_uuid, fetch (http/https/tftp) and +udp. + +`DEEP_PROBE` (on by default) actively provokes two families that are silent on a +single connection: + +* **`cmd/unix/reverse`, `reverse_openssl`, `reverse_ssl_double_telnet` (+ their + `php/unix/cmd` mirrors) (6)** use `Msf::Handler::ReverseTcpDouble` - they wait + for *two* connections, then write `echo ;` to both to pair them. The + scanner opens a second connection (plaintext and TLS) and reads that probe - + **high confidence**. +* **`pingback_*` (19)** read a 16-byte UUID then close, sending nothing. The + scanner writes 16 bytes and confirms the handler closes near-instantly with no + reply - **low confidence** (behavioral; any service that drops the connection + on 16 bytes of junk looks similar). + +The remaining 24 are genuinely passive and not elicitable with a simple probe: + +* **`powershell_reverse_tcp` / `_ssl` (20)** - the handler waits for the live + PowerShell to speak first; it stays silent for >22s to a UUID, a fake banner + and a null byte alike, so there is nothing to fingerprint. +* **mainframe z/OS shell (2)**, **`osx/aarch64/shell_reverse_tcp` (1)**, and a + stageless `cmd/unix/php/meterpreter_reverse_tcp` (1) - silent on a bare + connect. + +> Detection of reverse *shells* (`echo `) is timing-sensitive: the probe +> only fires once the handler treats the connection as a live shell. Under heavy +> load (many handlers in one console plus the scanner) that bootstrap is delayed +> and shells are missed. Run the scanner from a **separate console** and/or +> raise `FIRST_BYTE_WAIT` and they detect reliably - this accounted for the bulk +> of the gap between a first pass and the 673 total above. + +### Limitations + +* **Some passive handlers cannot be detected.** When the handler volunteers + nothing, is *not* an HTTP handler, and cannot be provoked by `DEEP_PROBE`, the + port just looks open. With `DEEP_PROBE` on, the `ReverseTcpDouble`/double-SSL + shells (`cmd/unix/reverse`, `reverse_openssl`, `reverse_ssl_double_telnet`) and + `pingback_*` are recovered; what is left is `powershell_reverse_tcp(_ssl)` + (waits for the live PowerShell to talk first) and a few stageless/exotic shells + - ~24 of 723. +* **Reverse-shell `echo` detection is timing-dependent.** The handler only + emits its `echo` probe once it treats the connection as a live shell, which is + delayed when the operator's console is heavily loaded. Scan from a *separate* + console and/or increase `FIRST_BYTE_WAIT` if shells are being missed. +* **Encrypted stages are low-confidence.** RC4 (`reverse_tcp_rc4`) and other + encrypted/stageless streams have no parseable framing, so they are only + flagged generically as an "unsolicited binary burst." +* The technique fingerprints the **transport/staging behavior**, not the exact + module. Several payloads share a family fingerprint (e.g. linux vs osx native + stages, or the legacy `reverse_nonx`/`reverse_ord` stager shellcode, are + indistinguishable on the wire), so the reported payload is a best-effort + family guess. + +### WARNING + +Connecting to a live handler causes it to send a stage and attempt to create a +session. On the operator's `msfconsole` this prints a `Sending stage (...)` +message and leaves a failed/dead session. This is an intentional, observable +side effect (`IOC_IN_LOGS`). + +### Setting up handlers for testing + +Start an `exploit/multi/handler` (or use `to_handler` / web_delivery) with a +staged payload, for example: + +``` +use exploit/multi/handler +set payload python/meterpreter/reverse_tcp +set lhost 127.0.0.1 +set lport 4445 +run -j + +set payload linux/x64/meterpreter/reverse_tcp +set lport 4446 +run -j + +set payload windows/x64/meterpreter/reverse_tcp +set lport 4448 +run -j +``` + +## Verification Steps + +1. Start one or more staged `reverse_tcp` handlers as shown above. +2. Start `msfconsole` +3. `use auxiliary/scanner/msf/handler_detect` +4. `set RHOSTS 127.0.0.1` +5. `set PORTS 4444-4464` +6. `run` +7. You should see `[+]` lines identifying each detected handler and the + fingerprinted payload family. + +## Options + +### PORTS + +The list of TCP ports to probe on each host, in the usual Metasploit portspec +format (e.g. `4444-4460,5555`). Defaults to `4444-4464`. + +### TIMEOUT + +The socket connect timeout in seconds. Default `1`. + +### FIRST_BYTE_WAIT + +How long (seconds) to wait for the handler to send the first stage/probe +bytes after connecting. Default `5`. Reverse-shell handlers emit their +`echo` verification probe a little late, so allow some slack; increase further +on slow/high-latency links. + +### IDLE_TIMEOUT + +How long (seconds) to wait for additional stage data before assuming the +stage is fully received. Default `0.75`. + +### MAX_STAGE_SIZE + +Maximum number of stage bytes to read while fingerprinting. Reading the full +stage allows the length-prefix to be matched exactly (high confidence). Default +`2097152` (2 MB). + +### HTTP_PROBE + +When a port does not volunteer a stage, send an HTTP request to fingerprint +Metasploit's HTTP-based handlers (`reverse_http`, `web_delivery`, `fetch`). +Default `true`. + +### HTTP_SSL_PROBE + +In addition to the plaintext HTTP probe, attempt an SSL/TLS request to catch +`reverse_https` / HTTPS `fetch` servers, and to read the stage/echo of +`reverse_tcp_ssl` handlers through the TLS handshake. Default `true`. + +### SCAN_UDP + +Also probe each port over UDP. A `reverse_udp` handler waits for any inbound +datagram and then sends the stage back, so a single probe datagram elicits the +same staging fingerprint. Default `false`. + +### ECHO_BACK + +When a command-shell `echo ` verification probe is seen, echo the token +back to mark the shell valid and capture any follow-up commands the handler +sends (for example an operator's `AutoRunScript`). Default `true`. + +### ECHO_FOLLOWUP_WAIT + +How long (seconds) to keep reading after echoing the token back, to capture +`AutoRunScript` / operator commands. Default `8`. + +### CONCURRENCY + +The number of concurrent ports to check per host. Default `10`. + +### DEEP_PROBE + +For ports that stay silent on the first connection, actively try to provoke +passive handlers before giving up (default `true`): + +* open a **second connection** (plaintext and, if `HTTP_SSL_PROBE`, TLS) to make + a `ReverseTcpDouble` handler pair them and emit its `echo` probe + (`cmd/unix/reverse`, `reverse_openssl`, `reverse_ssl_double_telnet`); +* write a **16-byte UUID** and watch for the immediate, reply-less close that + identifies a `pingback_*` handler. + +This opens a few extra connections per silent port. Set to `false` for the +quietest possible scan. + +## Scenarios + +### Example run against a host running several staged handlers + +``` +msf6 auxiliary(scanner/msf/handler_detect) > run +``` + +## Full payload matrix + +One row per distinct payload type (arch variants collapsed; the *Variants* column +is how many of the 1090 reverse payloads map to that row). Detection results are +empirical from live representative listeners. `n/t (no listener)` means that +representative did not start a TCP/UDP listener under `multi/handler` (needs +extra options, is SMB/SCTP, or is an exotic/unsupported OS). + +| OS / Language | Staged | Type | Transport | Variants | Detected | Method / Notes | +|---|---|---|---|---:|---|---| +| aix | single | shell | tcp | 1 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| android | staged | meterpreter | http | 1 | YES (tcp) | HTTP probe ("It works!") | +| android | staged | meterpreter | https | 1 | YES (tcp) | HTTPS probe ("It works!") | +| android | staged | meterpreter | tcp | 1 | YES (tcp) | BE length prefix (jar/dex) | +| android | staged | shell | http | 1 | YES (tcp) | HTTP probe ("It works!") | +| android | staged | shell | https | 1 | YES (tcp) | HTTPS probe ("It works!") | +| android | staged | shell | tcp | 1 | YES (tcp) | BE length prefix (jar/dex) | +| android | single | meterpreter | http | 1 | YES (tcp) | HTTP probe ("It works!") | +| android | single | meterpreter | https | 1 | YES (tcp) | HTTPS probe ("It works!") | +| android | single | meterpreter | tcp | 1 | YES (tcp) | binary burst, no prefix | +| apple_ios | single | meterpreter | http | 2 | YES (tcp) | HTTP probe ("It works!") | +| apple_ios | single | meterpreter | https | 2 | YES (tcp) | HTTPS probe ("It works!") | +| apple_ios | single | meterpreter | tcp | 2 | YES (tcp) | binary burst, no prefix | +| apple_ios | single | shell | tcp | 1 | YES (tcp) | echo probe | +| bsd | staged | shell | tcp | 2 | YES (tcp) | raw /bin/sh shellcode | +| bsd | single | other | tcp | 1 | YES (tcp) | binary burst, no prefix | +| bsd | single | shell | tcp | 7 | YES (tcp) | echo probe | +| bsdi | staged | shell | tcp | 1 | YES (tcp) | raw /bin/sh shellcode | +| bsdi | single | shell | tcp | 1 | YES (tcp) | echo probe | +| cmd/linux | staged | meterpreter | sctp | 3 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/linux | staged | meterpreter | tcp | 24 | YES (tcp) | LE length prefix (shell stage) | +| cmd/linux | staged | meterpreter | tcp_uuid | 3 | YES (tcp) | binary burst, no prefix | +| cmd/linux | staged | shell | sctp | 3 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/linux | staged | shell | tcp | 24 | YES (tcp) | LE length prefix (shell stage) | +| cmd/linux | staged | shell | tcp_uuid | 3 | YES (tcp) | raw /bin/sh shellcode | +| cmd/linux | single | meterpreter | http | 24 | YES (tcp) | HTTP probe ("It works!") | +| cmd/linux | single | meterpreter | https | 24 | YES (tcp) | HTTPS probe ("It works!") | +| cmd/linux | single | meterpreter | tcp | 24 | YES (tcp) | binary burst, no prefix | +| cmd/linux | single | other | tcp | 6 | no (silent) | handler waits for client (stageless) | +| cmd/linux | single | shell | tcp | 36 | YES (tcp) | echo probe | +| cmd/mainframe | single | shell | tcp | 1 | no (silent) | handler waits for client (stageless) | +| cmd/unix | staged | meterpreter | http | 1 | YES (tcp) | HTTP probe ("It works!") | +| cmd/unix | staged | meterpreter | https | 1 | YES (tcp) | HTTPS probe ("It works!") | +| cmd/unix | staged | meterpreter | tcp | 2 | YES (tcp) | BE length prefix (php) | +| cmd/unix | staged | meterpreter | tcp_ssl | 1 | YES (tcp) | BE length prefix (base64/zlib) | +| cmd/unix | staged | meterpreter | tcp_uuid | 2 | YES (tcp) | BE length prefix (php) | +| cmd/unix | single | meterpreter | http | 1 | YES (tcp) | HTTP probe ("It works!") | +| cmd/unix | single | meterpreter | https | 1 | YES (tcp) | HTTPS probe ("It works!") | +| cmd/unix | single | meterpreter | tcp | 2 | no (silent) | handler waits for client (stageless) | +| cmd/unix | single | other | cmdshell | 19 | no (silent) | handler waits for client (stageless) | +| cmd/unix | single | other | sctp | 1 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/unix | single | other | tcp | 6 | no (silent) | handler waits for client (stageless) | +| cmd/unix | single | other | tcp_ssl | 6 | YES (tcp) | echo probe | +| cmd/unix | single | shell | sctp | 1 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/unix | single | shell | tcp | 1 | no (silent) | handler waits for client (stageless) | +| cmd/unix | single | shell | tcp_ssl | 1 | YES (tcp) | echo probe | +| cmd/unix | single | shell | udp | 1 | no (silent) | handler waits for client (stageless) | +| cmd/windows | staged | custom | http | 16 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/windows | staged | custom | https | 16 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/windows | staged | custom | named_pipe | 8 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/windows | staged | custom | tcp | 26 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/windows | staged | custom | tcp_rc4 | 11 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/windows | staged | custom | tcp_uuid | 8 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/windows | staged | custom | udp | 3 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/windows | staged | dllinject | http | 6 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/windows | staged | dllinject | tcp | 21 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/windows | staged | dllinject | tcp_rc4 | 6 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/windows | staged | dllinject | tcp_uuid | 3 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/windows | staged | meterpreter | http | 17 | YES (tcp) | HTTPS probe ("It works!") | +| cmd/windows | staged | meterpreter | https | 17 | YES (tcp) | HTTPS probe ("It works!") | +| cmd/windows | staged | meterpreter | named_pipe | 8 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/windows | staged | meterpreter | tcp | 27 | YES (tcp) | LE length prefix (metsrv) | +| cmd/windows | staged | meterpreter | tcp_rc4 | 11 | YES (tcp) | binary burst, no prefix | +| cmd/windows | staged | meterpreter | tcp_ssl | 1 | YES (tcp) | BE length prefix (base64/zlib) | +| cmd/windows | staged | meterpreter | tcp_uuid | 9 | YES (tcp) | LE length prefix (metsrv) | +| cmd/windows | staged | patchupdllinject | tcp | 18 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/windows | staged | patchupdllinject | tcp_rc4 | 6 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/windows | staged | patchupdllinject | tcp_uuid | 3 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/windows | staged | patchupmeterpreter | tcp | 18 | YES (tcp) | LE length prefix (shell stage) | +| cmd/windows | staged | patchupmeterpreter | tcp_rc4 | 6 | YES (tcp) | binary burst, no prefix | +| cmd/windows | staged | patchupmeterpreter | tcp_uuid | 3 | YES (tcp) | LE length prefix (shell stage) | +| cmd/windows | staged | shell | tcp | 23 | YES (tcp) | LE length prefix (shell stage) | +| cmd/windows | staged | shell | tcp_rc4 | 11 | YES (tcp) | binary burst, no prefix | +| cmd/windows | staged | shell | tcp_uuid | 8 | YES (tcp) | LE length prefix (shell stage) | +| cmd/windows | staged | shell | udp | 3 | YES (udp) | LE length prefix (shell stage) | +| cmd/windows | staged | upexec | tcp | 18 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/windows | staged | upexec | tcp_rc4 | 6 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/windows | staged | upexec | tcp_uuid | 3 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/windows | staged | upexec | udp | 3 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/windows | staged | vncinject | http | 16 | YES (tcp) | HTTPS probe ("It works!") | +| cmd/windows | staged | vncinject | https | 10 | YES (tcp) | HTTPS probe ("It works!") | +| cmd/windows | staged | vncinject | tcp | 26 | YES (tcp) | binary burst, no prefix | +| cmd/windows | staged | vncinject | tcp_rc4 | 11 | YES (tcp) | binary burst, no prefix | +| cmd/windows | staged | vncinject | tcp_uuid | 8 | YES (tcp) | binary burst, no prefix | +| cmd/windows | single | meterpreter | http | 7 | YES (tcp) | HTTPS probe ("It works!") | +| cmd/windows | single | meterpreter | https | 7 | YES (tcp) | HTTPS probe ("It works!") | +| cmd/windows | single | meterpreter | tcp | 13 | YES (tcp) | binary burst, no prefix | +| cmd/windows | single | other | cmdshell | 3 | YES (tcp) | echo probe | +| cmd/windows | single | other | named_pipe | 6 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/windows | single | other | tcp | 26 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/windows | single | other | tcp_rc4 | 8 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/windows | single | other | tcp_uuid | 6 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/windows | single | shell | cmdshell | 1 | YES (tcp) | echo probe | +| cmd/windows | single | shell | named_pipe | 2 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/windows | single | shell | sctp | 1 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/windows | single | shell | tcp | 28 | no (silent) | handler waits for client (stageless) | +| cmd/windows | single | shell | tcp_rc4 | 3 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/windows | single | shell | tcp_ssl | 10 | no (silent) | handler waits for client (stageless) | +| cmd/windows | single | shell | tcp_uuid | 2 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| cmd/windows | single | shell | udp | 1 | YES (udp) | echo probe | +| firefox | single | shell | tcp | 1 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| generic | single | shell | tcp | 1 | YES (tcp) | echo probe | +| java | staged | meterpreter | http | 1 | YES (tcp) | HTTP probe ("It works!") | +| java | staged | meterpreter | https | 1 | YES (tcp) | HTTPS probe ("It works!") | +| java | staged | meterpreter | tcp | 1 | YES (tcp) | BE length prefix (jar/dex) | +| java | staged | shell | tcp | 1 | YES (tcp) | BE length prefix | +| java | single | shell | tcp | 2 | YES (tcp) | echo probe | +| linux | staged | meterpreter | sctp | 1 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| linux | staged | meterpreter | tcp | 8 | YES (tcp) | LE length prefix (shell stage) | +| linux | staged | meterpreter | tcp_uuid | 1 | YES (tcp) | binary burst, no prefix | +| linux | staged | shell | sctp | 1 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| linux | staged | shell | tcp | 8 | YES (tcp) | LE length prefix (shell stage) | +| linux | staged | shell | tcp_uuid | 1 | YES (tcp) | raw /bin/sh shellcode | +| linux | single | meterpreter | http | 9 | YES (tcp) | HTTP probe ("It works!") | +| linux | single | meterpreter | https | 9 | YES (tcp) | HTTPS probe ("It works!") | +| linux | single | meterpreter | tcp | 9 | no (silent) | handler waits for client (stageless) | +| linux | single | other | tcp | 2 | no (silent) | handler waits for client (stageless) | +| linux | single | shell | tcp | 12 | no (silent) | handler waits for client (stageless) | +| mainframe | single | shell | tcp | 1 | no (silent) | handler waits for client (stageless) | +| multi | staged | meterpreter | http | 1 | YES (tcp) | HTTP probe ("It works!") | +| multi | staged | meterpreter | https | 1 | YES (tcp) | HTTPS probe ("It works!") | +| netware | staged | shell | tcp | 1 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| nodejs | single | shell | tcp | 1 | YES (tcp) | echo probe | +| nodejs | single | shell | tcp_ssl | 1 | YES (tcp) | echo probe | +| osx | staged | meterpreter | tcp | 2 | YES (tcp) | binary burst, no prefix | +| osx | staged | meterpreter | tcp_uuid | 1 | YES (tcp) | binary burst, no prefix | +| osx | staged | shell | tcp | 2 | YES (tcp) | LE length prefix (shell stage) | +| osx | single | meterpreter | http | 2 | YES (tcp) | HTTP probe ("It works!") | +| osx | single | meterpreter | https | 2 | YES (tcp) | HTTPS probe ("It works!") | +| osx | single | meterpreter | tcp | 2 | YES (tcp) | binary burst, no prefix | +| osx | single | other | tcp | 4 | YES (tcp) | LE length prefix (metsrv) | +| osx | single | other | tcp_uuid | 1 | YES (tcp) | raw /bin/sh shellcode | +| osx | single | shell | tcp | 7 | no (silent) | handler waits for client (stageless) | +| php | staged | meterpreter | tcp | 1 | YES (tcp) | BE length prefix (php) | +| php | staged | meterpreter | tcp_uuid | 1 | YES (tcp) | BE length prefix (php) | +| php | single | meterpreter | tcp | 1 | YES (tcp) | binary burst, no prefix | +| php | single | other | cmdshell | 19 | YES (tcp) | echo probe | +| php | single | other | sctp | 1 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| php | single | other | tcp | 5 | no (silent) | handler waits for client (stageless) | +| php | single | other | tcp_ssl | 6 | YES (tcp) | echo probe | +| python | staged | meterpreter | http | 1 | YES (tcp) | HTTP probe ("It works!") | +| python | staged | meterpreter | https | 1 | YES (tcp) | HTTPS probe ("It works!") | +| python | staged | meterpreter | tcp | 1 | YES (tcp) | BE length prefix (base64/zlib) | +| python | staged | meterpreter | tcp_ssl | 1 | YES (tcp) | BE length prefix (base64/zlib) | +| python | staged | meterpreter | tcp_uuid | 1 | YES (tcp) | BE length prefix (base64/zlib) | +| python | single | meterpreter | http | 1 | YES (tcp) | HTTP probe ("It works!") | +| python | single | meterpreter | https | 1 | YES (tcp) | HTTPS probe ("It works!") | +| python | single | meterpreter | tcp | 1 | YES (tcp) | binary burst, no prefix | +| python | single | other | tcp | 1 | no (silent) | handler waits for client (stageless) | +| python | single | shell | sctp | 1 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| python | single | shell | tcp | 1 | YES (tcp) | echo probe | +| python | single | shell | tcp_ssl | 1 | YES (tcp) | echo probe | +| python | single | shell | udp | 1 | YES (udp) | echo probe | +| r | single | shell | tcp | 1 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| ruby | single | other | tcp | 1 | no (silent) | handler waits for client (stageless) | +| ruby | single | shell | tcp | 1 | YES (tcp) | echo probe | +| ruby | single | shell | tcp_ssl | 1 | YES (tcp) | echo probe | +| solaris | single | shell | tcp | 2 | YES (tcp) | echo probe | +| windows | staged | custom | http | 4 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| windows | staged | custom | https | 4 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| windows | staged | custom | named_pipe | 2 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| windows | staged | custom | tcp | 8 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| windows | staged | custom | tcp_rc4 | 3 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| windows | staged | custom | tcp_uuid | 2 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| windows | staged | custom | udp | 1 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| windows | staged | dllinject | http | 2 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| windows | staged | dllinject | tcp | 7 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| windows | staged | dllinject | tcp_rc4 | 2 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| windows | staged | dllinject | tcp_uuid | 1 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| windows | staged | meterpreter | http | 4 | YES (tcp) | HTTP probe ("It works!") | +| windows | staged | meterpreter | https | 4 | YES (tcp) | HTTPS probe ("It works!") | +| windows | staged | meterpreter | named_pipe | 2 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| windows | staged | meterpreter | tcp | 8 | YES (tcp) | HTTP probe ("It works!") | +| windows | staged | meterpreter | tcp_rc4 | 3 | YES (tcp) | binary burst, no prefix | +| windows | staged | meterpreter | tcp_uuid | 2 | YES (tcp) | LE length prefix (metsrv) | +| windows | staged | patchupdllinject | tcp | 6 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| windows | staged | patchupdllinject | tcp_rc4 | 2 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| windows | staged | patchupdllinject | tcp_uuid | 1 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| windows | staged | patchupmeterpreter | tcp | 6 | YES (tcp) | LE length prefix (shell stage) | +| windows | staged | patchupmeterpreter | tcp_rc4 | 2 | YES (tcp) | binary burst, no prefix | +| windows | staged | patchupmeterpreter | tcp_uuid | 1 | YES (tcp) | LE length prefix (shell stage) | +| windows | staged | shell | tcp | 7 | YES (tcp) | LE length prefix (shell stage) | +| windows | staged | shell | tcp_rc4 | 3 | no (silent) | handler waits for client (stageless) | +| windows | staged | shell | tcp_uuid | 2 | YES (tcp) | LE length prefix (shell stage) | +| windows | staged | shell | udp | 1 | YES (udp) | LE length prefix (shell stage) | +| windows | staged | upexec | tcp | 6 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| windows | staged | upexec | tcp_rc4 | 2 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| windows | staged | upexec | tcp_uuid | 1 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| windows | staged | upexec | udp | 1 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| windows | staged | vncinject | http | 4 | YES (tcp) | HTTP probe ("It works!") | +| windows | staged | vncinject | https | 2 | no (silent) | handler waits for client (stageless) | +| windows | staged | vncinject | tcp | 8 | YES (tcp) | HTTP probe ("It works!") | +| windows | staged | vncinject | tcp_rc4 | 3 | no (silent) | handler waits for client (stageless) | +| windows | staged | vncinject | tcp_uuid | 2 | no (silent) | handler waits for client (stageless) | +| windows | single | meterpreter | http | 2 | YES (tcp) | HTTP probe ("It works!") | +| windows | single | meterpreter | https | 2 | YES (tcp) | HTTPS probe ("It works!") | +| windows | single | meterpreter | tcp | 4 | YES (tcp) | binary burst, no prefix | +| windows | single | other | named_pipe | 2 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| windows | single | other | tcp | 10 | YES (tcp) | binary burst, no prefix | +| windows | single | other | tcp_rc4 | 3 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| windows | single | other | tcp_uuid | 2 | n/t (no listener) | did not bind (needs opts / non-TCP / exotic OS) | +| windows | single | shell | tcp | 4 | no (silent) | handler waits for client (stageless) | +| windows | single | shell | tcp_ssl | 2 | no (silent) | handler waits for client (stageless) | diff --git a/modules/auxiliary/scanner/msf/handler_detect.rb b/modules/auxiliary/scanner/msf/handler_detect.rb new file mode 100644 index 0000000000000..aaeff1e284fa1 --- /dev/null +++ b/modules/auxiliary/scanner/msf/handler_detect.rb @@ -0,0 +1,683 @@ +# frozen_string_literal: true + +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +class MetasploitModule < Msf::Auxiliary + include Msf::Exploit::Remote::Tcp + include Msf::Auxiliary::Report + include Msf::Auxiliary::Scanner + + def initialize + super( + 'Name' => 'Metasploit Payload Handler Detection (TCP/UDP/HTTP/HTTPS)', + 'Description' => %q{ + Detect Metasploit exploit/multi/handler listeners and other reverse + payload handlers by fingerprinting their wire behavior. Several + techniques are combined: + + * Staged reverse handlers "talk first": on connect they transmit the + stage with a 4-byte length prefix whose endianness identifies the + family + - little-endian (pack 'V') for Windows native (metsrv) + - big-endian (pack 'N') for Python/PHP/Java/Android + - Linux/OSX native stagers send the raw machine-code stage with + no length prefix, and unix staged shells send a tiny execve("/bin/sh") + shellcode. + * Reverse command shells "talk first" with an "echo " probe. + Echoing the token back marks the shell valid and can capture an + operator's AutoRunScript / follow-up commands. + * reverse_http(s) Meterpreter handlers answer any unknown URI with the + default "It works!" body and Server: Apache. HTTP servers + (web_delivery, fetch handlers, exploit module servers) return a + distinctive 404 page. + * reverse_tcp_ssl handlers stage over TLS; an SSL probe reads the + stage/echo through the handshake. + * reverse_udp handlers send the stage in response to any datagram, so + an optional UDP probe (SCAN_UDP) catches them too. + + Transports that are not TCP/UDP (reverse_sctp, reverse_named_pipe/SMB) + and silent stageless payloads that wait for the client cannot be + fingerprinted and appear as a silent open port. + }, + 'Author' => [ 'h00die' ], + 'License' => MSF_LICENSE, + 'Notes' => { + 'Stability' => [ CRASH_SAFE ], + 'SideEffects' => [ IOC_IN_LOGS ], # msf console will report sending stage or meterp connection, but then report its invalid + 'Reliability' => [] + } + ) + + register_options( + [ + OptString.new('PORTS', [ true, 'Ports to scan (e.g. 4444-4460,5555)', '4444-4464' ]), + OptFloat.new('TIMEOUT', [ true, 'The socket connect timeout in seconds', 1 ]), + OptFloat.new('FIRST_BYTE_WAIT', [ true, 'How long to wait (seconds) for the handler to send the first stage/probe bytes (shell handlers emit their "echo" probe a little later, so allow some slack)', 5 ]), + OptFloat.new('IDLE_TIMEOUT', [ true, 'How long to wait (seconds) for more stage data before giving up', 0.75 ]), + OptInt.new('MAX_STAGE_SIZE', [ true, 'Maximum number of stage bytes to read while fingerprinting', 2 * 1024 * 1024 ]), + # different types + OptBool.new('HTTP_PROBE', [ true, 'If a port waits for the client, send an HTTP request to fingerprint MSF HTTP handlers (reverse_http, web_delivery, fetch)', true ]), + OptBool.new('HTTP_SSL_PROBE', [ true, 'Also attempt an SSL/TLS HTTP probe (catches reverse_https and HTTPS fetch servers)', true ]), + OptBool.new('SCAN_UDP', [ true, 'Also probe each port over UDP (catches reverse_udp handlers)', false ]), + OptBool.new('ECHO_BACK', [ true, 'When a command-shell "echo " probe is seen, echo the token back to verify the shell and capture any follow-up commands', true ]), + OptFloat.new('ECHO_FOLLOWUP_WAIT', [ true, 'How long (seconds) to keep reading after echoing the token back, to capture AutoRunScript/operator commands', 8 ]), + OptInt.new('CONCURRENCY', [ true, 'The number of concurrent ports to check per host', 10 ]), + OptBool.new('DEEP_PROBE', [ true, 'For ports that stay silent, actively elicit passive handlers: open a 2nd connection (ReverseTcpDouble: cmd/unix/reverse, reverse_openssl, reverse_ssl_double_telnet) and send a 16-byte UUID (pingback) to provoke a response', true ]) + ] + ) + + deregister_options('RPORT') # in favor of ports so we can do bulk ones + end + + def timeout + datastore['TIMEOUT'].to_f + end + + def validate_ports + ports = Rex::Socket.portspec_crack(datastore['PORTS']) + raise Msf::OptionValidateError, ['PORTS'] if ports.empty? + + ports + end + + def run_host(ip) + ports = validate_ports + + # Hard per-port deadline (seconds) so one wedged port - e.g. a black-hole TLS + # handshake - can never stall its whole concurrency batch. It sums the + # worst-case blocking waits a single port can incur back-to-back: + # timeout -> the initial TCP connect() + # 3 * FIRST_BYTE_WAIT -> up to three sequential "wait for the first byte" + # reads on one port: the stage drain, the silent-port + # HTTP/double-handler fallback, and the UDP probe + # ECHO_FOLLOWUP_WAIT -> reading a shell's AutoRunScript output after echo-back + # + 5 -> fixed slack for TLS setup, idle-read tails, scheduling + # It is deliberately a loose over-estimate (those paths rarely all hit the same + # port), so the Timeout below only fires for genuinely stuck ports, not merely + # slow ones. With defaults: 1 + (3 * 5) + 8 + 5 = 29s. + port_deadline = timeout + (3 * datastore['FIRST_BYTE_WAIT'].to_f) + datastore['ECHO_FOLLOWUP_WAIT'].to_f + 5 + + until ports.empty? + threads = [] + begin + 1.upto(datastore['CONCURRENCY']) do + this_port = ports.shift + break unless this_port + + threads << framework.threads.spawn("Module(#{refname})-#{ip}:#{this_port}", false, this_port) do |port| + ::Timeout.timeout(port_deadline) do + check_port(ip, port, timeout) # with deep_prob on handles all protocols but udp. with it turned off only handles tcp + check_port_udp(ip, port, timeout) if datastore['SCAN_UDP'] + end + rescue ::Timeout::Error + vprint_warning("#{Rex::Socket.to_authority(ip, port)} - per-port timeout (#{port_deadline.round}s), skipping") + end + end + # Bounded join as a backstop in case Timeout cannot interrupt a blocked call. + threads.each { |t| t.join(port_deadline + 5) } + rescue ::Timeout::Error + ensure + threads.each do |x| + x.kill + rescue StandardError + nil + end + end + end + end + + def check_port(ip, port, timeout) + sock = nil + begin + sock = connect(false, + { + 'RHOST' => ip, + 'RPORT' => port, + 'ConnectTimeout' => timeout + }) + return unless sock + + buf = drain_stage(sock) + result = fingerprint(buf) + + if result[:match] + handle_match(ip, port, 'tcp', result, sock) + elsif buf.nil? || buf.empty? + # The server waited for us to speak first. That is the behavior of MSF's + # HTTP-based handlers (reverse_http/https, web_delivery, fetch servers), + # so try to fingerprint them over HTTP before giving up. + http = datastore['HTTP_PROBE'] ? http_fingerprint(sock, ip, port, timeout) : nil + if http + report_handler(ip, port, 'tcp', http) + elsif datastore['DEEP_PROBE'] && (deep = deep_probe(ip, port, sock)) + # deep_probe pairs a second connection (ReverseTcpDouble) / sends a + # UUID (pingback); our idle first socket was dropped inside it first. + sock = nil + report_handler(ip, port, 'tcp', deep) + else + vprint_status("#{Rex::Socket.to_authority(ip, port)} - Open, no unsolicited data and no Metasploit HTTP fingerprint (stageless reverse shell/meterpreter, or unrelated service)") + end + else + vprint_status("#{Rex::Socket.to_authority(ip, port)} - Talks first but does not look like a stage (banner: #{buf[0, 32].inspect})") + end + rescue ::Rex::ConnectionRefused + vprint_status("#{Rex::Socket.to_authority(ip, port)} - Connection refused") + rescue ::Rex::ConnectionError, ::IOError, ::Timeout::Error => e + vprint_status("#{Rex::Socket.to_authority(ip, port)} - Connection error (#{e.class})") + rescue ::Interrupt + raise $ERROR_INFO + rescue ::StandardError => e + vprint_error("#{Rex::Socket.to_authority(ip, port)} - #{e.class} #{e}") + ensure + begin + disconnect(sock) + rescue StandardError + nil + end + end + end + + # Actively provoke handlers that stay silent on a single plain connection. + # Returns a result hash or nil. `idle_sock` is our first (silent) connection, + # dropped up front so it does not interfere with a double handler's pairing. + def deep_probe(ip, port, idle_sock) + begin + disconnect(idle_sock) + rescue StandardError + nil + end + paired_connect_probe(ip, port, false) || + (datastore['HTTP_SSL_PROBE'] && ssl_paired_probe(ip, port)) || + pingback_probe(ip, port) || nil + end + + # Msf::Handler::ReverseTcpDouble (cmd/unix/reverse, reverse_openssl, + # reverse_ssl_double_telnet, ...) waits for TWO connections, then writes + # "echo ;" to both to pair them. Open a second connection and read + # both to elicit that probe. + def paired_connect_probe(ip, port, ssl) + socks = [] + 2.times do + socks << connect(false, { 'RHOST' => ip, 'RPORT' => port, 'SSL' => ssl, 'ConnectTimeout' => 3 }) + end + socks.compact.each do |s| + fp = fingerprint(drain_stage(s)) + next unless fp[:match] + + suffix = ssl ? ' over SSL/TLS (ReverseTcpDouble - paired connections)' : ' (ReverseTcpDouble - paired connections)' + return { payload: "#{fp[:payload]}#{suffix}", framing: fp[:framing], confidence: fp[:confidence], bytes: fp[:bytes] } + end + nil + rescue ::StandardError + nil + ensure + socks.each do |s| + disconnect(s) + rescue StandardError + nil + end + end + + # The double-SSL variants need the paired connections over TLS. Bound it with + # its own deadline (a Rex SSL socket can block on a silent peer). + def ssl_paired_probe(ip, port) + ::Timeout.timeout(12) { paired_connect_probe(ip, port, true) } + rescue ::StandardError + nil + end + + # A pingback handler (Msf::Sessions::Pingback) reads up to 16 bytes with a 1s + # timeout, then closes and sends nothing. Send 16 bytes and confirm the server + # closes promptly with no reply - a benign silent service would instead leave + # the read to time out. Low confidence by nature (any service that drops the + # connection on 16 bytes of junk looks similar). + def pingback_probe(ip, port) + s = connect(false, { 'RHOST' => ip, 'RPORT' => port, 'ConnectTimeout' => 3 }) + return nil unless s + + s.put("\x00" * 16) + t0 = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + data = begin + s.get_once(-1, 2) + rescue ::IOError + nil + end + elapsed = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - t0 + return nil unless (data.nil? || data.empty?) && elapsed < 1.0 + + { + payload: 'likely pingback handler (pingback_reverse_tcp family)', + framing: "handler closed the connection #{elapsed.round(2)}s after a 16-byte write, returning no data (reads a 16-byte UUID then closes)", + confidence: 'low' + } + rescue ::StandardError + nil + ensure + begin + disconnect(s) + rescue StandardError + nil + end + end + + # Probe a port over UDP. A reverse_udp handler waits in recvfrom() for any + # inbound datagram, then sends the stage back, so a single probe datagram + # elicits the same staging fingerprint as the TCP path. + def check_port_udp(ip, port, _timeout) + udp = nil + begin + udp = Rex::Socket::Udp.create( + 'PeerHost' => ip, + 'PeerPort' => port, + 'Context' => { 'Msf' => framework, 'MsfExploit' => self } + ) + udp.put("\x00") + buf = udp_drain(udp) + result = fingerprint(buf) + handle_match(ip, port, 'udp', result, nil) if result[:match] + rescue ::Rex::ConnectionError, ::IOError, ::Timeout::Error + rescue ::Interrupt + raise $ERROR_INFO + rescue ::StandardError => e + vprint_error("#{Rex::Socket.to_authority(ip, port)} (udp) - #{e.class} #{e}") + ensure + begin + udp.close + rescue StandardError + nil + end + end + end + + # Report a detected handler and, for command-shell echo probes, optionally + # echo the token back to verify the shell and capture follow-up commands. + def handle_match(ip, port, proto, result, sock) + report_handler(ip, port, proto, result) + + return unless result[:echo_token] && datastore['ECHO_BACK'] && sock + + followup = echo_back_capture(sock, result[:echo_token]) + if followup && !followup.empty? + print_good("#{Rex::Socket.to_authority(ip, port)} - Captured follow-up after shell verification (likely AutoRunScript / operator commands):") + followup.each_line { |line| print_line(" #{line.chomp}") } + loot_path = store_loot( + 'metasploit.handler.autorunscript', + 'text/plain', + ip, + followup, + "handler_followup_#{port}.txt", + "Commands an operator's AutoRunScript/InitialAutoRunScript ran against the connecting shell on #{ip}:#{port}" + ) + print_good("#{Rex::Socket.to_authority(ip, port)} - Saved captured commands to loot: #{loot_path}") + report_note(host: ip, port: port, type: 'msf.handler.followup', data: { 'commands' => followup.split("\n").reject(&:empty?), 'loot' => loot_path }, update: :unique_data) + else + vprint_status("#{Rex::Socket.to_authority(ip, port)} - No follow-up commands after echo-back (no AutoRunScript configured)") + end + end + + def report_handler(ip, port, proto, result) + bytes = result[:bytes] ? ", #{result[:bytes]} bytes" : '' + print_good("#{Rex::Socket.to_authority(ip, port)} - Metasploit handler detected (#{proto}): #{result[:payload]} (#{result[:confidence]} confidence#{bytes}) [#{result[:framing]}]") + report_service(host: ip, port: port, proto: proto, name: 'metasploit-handler', info: "#{result[:payload]} | #{result[:framing]} (#{result[:confidence]} confidence)") + end + + # Echo the verification token back, then keep reading to capture whatever the + # handler sends next (e.g. an operator's AutoRunScript / InitialAutoRunScript). + def echo_back_capture(sock, token) + sock.put("#{token}\n") + wait = datastore['ECHO_FOLLOWUP_WAIT'].to_f + idle = datastore['IDLE_TIMEOUT'].to_f + + buf = +'' + chunk = begin + sock.get_once(-1, wait) + rescue StandardError + nil + end + return buf if chunk.nil? || chunk.empty? + + buf << chunk + while buf.length < 65_536 + chunk = begin + sock.get_once(-1, idle) + rescue StandardError + nil + end + break if chunk.nil? || chunk.empty? + + buf << chunk + end + buf + rescue ::StandardError + buf + end + + # UDP equivalent of drain_stage using recvfrom (which returns [data, host, port]). + def udp_drain(udp) + first_wait = datastore['FIRST_BYTE_WAIT'].to_f + idle_wait = datastore['IDLE_TIMEOUT'].to_f + max_bytes = datastore['MAX_STAGE_SIZE'].to_i + + buf = +'' + data, = udp.recvfrom(65_535, first_wait) + return buf if data.nil? || data.empty? + + buf << data + while buf.length < max_bytes + data, = udp.recvfrom(65_535, idle_wait) + break if data.nil? || data.empty? + + buf << data + end + buf + end + + # Read whatever the server sends unprompted, up to MAX_STAGE_SIZE. + def drain_stage(sock) + first_wait = datastore['FIRST_BYTE_WAIT'].to_f + idle_wait = datastore['IDLE_TIMEOUT'].to_f + max_bytes = datastore['MAX_STAGE_SIZE'].to_i + + buf = +'' + chunk = begin + sock.get_once(-1, first_wait) + rescue StandardError + nil + end + return buf if chunk.nil? || chunk.empty? + + buf << chunk + while buf.length < max_bytes + chunk = begin + sock.get_once(-1, idle_wait) + rescue StandardError + nil + end + break if chunk.nil? || chunk.empty? + + buf << chunk + end + buf + end + + # Fingerprint MSF's HTTP-based handlers. Tries a plaintext request on the + # already-open socket first, then (optionally) an SSL request on a fresh + # connection for reverse_https / HTTPS fetch servers. Returns a result hash + # or nil. + def http_fingerprint(sock, ip, port, _timeout) + result = http_classify(http_exchange(sock), scheme: 'http') + return result if result + + return nil unless datastore['HTTP_SSL_PROBE'] + + ssl_sock = nil + begin + # The Rex connect timeout only bounds the TCP connect, not the TLS + # handshake read - so wrap the whole SSL probe in its own short deadline + # to keep a stalled handshake from eating the per-port budget. + ::Timeout.timeout(9) do + ssl_sock = connect(false, + { + 'RHOST' => ip, + 'RPORT' => port, + 'SSL' => true, + 'ConnectTimeout' => 3 + }) + # A single GET-and-read handles every SSL-fronted MSF handler: + # * reverse_https answers our request with the default "It works!" page + # * shell_reverse_tcp_ssl ignores the request and sends its unsolicited + # "echo " shell-verification probe over TLS + # * reverse_tcp_ssl (staged) has already pushed its stage on connect + # so send the request, then classify the reply as HTTP, then as a raw + # stage/echo. (A Rex SSL socket's get_once does not honor its read timeout + # against a silent peer, so we must speak first rather than blind-peek.) + raw = http_exchange(ssl_sock) + hc = http_classify(raw, scheme: 'https') + if hc + hc + elsif raw && !raw.empty? && (fp = fingerprint(raw))[:match] + { payload: "#{fp[:payload]} over SSL/TLS", framing: fp[:framing], confidence: fp[:confidence], bytes: fp[:bytes], echo_token: fp[:echo_token] } + end + end + rescue ::StandardError => e + vprint_error("#{Rex::Socket.to_authority(ip, port)} - SSL probe failed: #{e.class} #{e}") + nil + ensure + begin + disconnect(ssl_sock) + rescue StandardError + nil + end + end + end + + # Send a GET for a random path and return the raw HTTP response, or nil. + def http_exchange(sock) + return nil unless sock + + path = '/' + Rex::Text.rand_text_alphanumeric(8..16) + host = begin + sock.peerhost + rescue StandardError + 'localhost' + end + req = "GET #{path} HTTP/1.1\r\nHost: #{host}\r\nUser-Agent: Mozilla/5.0\r\nAccept: */*\r\nConnection: close\r\n\r\n" + sock.put(req) + + # An HTTP/TLS server that is going to answer answers fast; cap the wait so a + # non-answer (e.g. a plaintext GET to a TLS port) fails in ~2s, not 5s+. + first_wait = [datastore['FIRST_BYTE_WAIT'].to_f, 2.0].min + idle_wait = datastore['IDLE_TIMEOUT'].to_f + buf = +'' + chunk = sock.get_once(-1, first_wait) + return nil if chunk.nil? || chunk.empty? + + buf << chunk + while buf.length < 65_536 + chunk = begin + sock.get_once(-1, idle_wait) + rescue StandardError + nil + end + break if chunk.nil? || chunk.empty? + + buf << chunk + end + buf + rescue ::StandardError + nil + end + + # Classify a raw HTTP response against known MSF HTTP-server signatures. + def http_classify(raw, scheme: 'http') + return nil if raw.nil? || raw.empty? + return nil unless raw =~ %r{\AHTTP/1\.[01]\s+(\d{3})} + + code = ::Regexp.last_match(1).to_i + server = raw[/^Server:[ \t]*(.+?)\r?$/i, 1].to_s.strip + body = raw.partition("\r\n\r\n").last + server_str = server.empty? ? '(none)' : server + + # reverse_http(s) Meterpreter handler answers 200 OK with the default + # "It works!" body to ANY unknown URI (real servers would 404). + if code == 200 && body.include?('It works!') + return { + payload: "Metasploit reverse_#{scheme} Meterpreter handler (HTTP transport)", + framing: %(200 OK + default "It works!" body on an unknown URI; Server: #{server_str}), + confidence: 'high' + } + end + + # Rex HTTP server (web_delivery, fetch handler, exploit module servers) + # returns a distinctive 404 page on unknown URIs. + if body.include?('was not found on this server') && body.include?('

Not found

') + return { + payload: 'Metasploit Rex HTTP server (web_delivery / fetch handler / exploit module server)', + framing: "Rex 404 page on unknown URI; Server: #{server_str}", + confidence: 'high' + } + end + + nil + end + + # Classify the unsolicited data using the staging-protocol framing. + # + # The strongest, most reliable signal is "the 4-byte length the server + # declared up front was actually delivered" (body.length >= declared length). + # A benign service that happens to talk first will not satisfy this: a short + # text banner produces an enormous bogus length, and a service streaming lots + # of data is astronomically unlikely to have its first 4 bytes equal the + # number of bytes that follow. Note metsrv appends a config block after the + # stage, so body.length is frequently *larger* than the declared length. + def fingerprint(buf) + return { match: false } if buf.nil? || buf.empty? + + bytes = buf.length + return { match: false } if bytes < 8 + + body = buf[4..] || +'' + l_le = buf[0, 4].unpack1('V') + l_be = buf[0, 4].unpack1('N') + + # 1. Command-shell handler echo probe. Msf::Sessions::CommandShell#bootstrap + # verifies a reverse shell by sending "echo ". + # Catches stageless/staged reverse *shell* handlers that talk first. + if (md = buf.match(/\Aecho ([A-Za-z0-9]{8,24});?\s*\z/)) + return { + match: true, + payload: 'Metasploit command shell handler (reverse shell - "echo" verification probe)', + framing: 'unsolicited "echo " shell-verification command', + confidence: 'high', + bytes: bytes, + echo_token: md[1] + } + end + + # 2. Python staged: big-endian length prefix + base64(zlib(...)) text stage. + if base64_stage?(body) && l_be.positive? + confidence = (l_be == body.length) ? 'high' : 'medium' + return { + match: true, + payload: 'python/meterpreter/reverse_tcp (base64/zlib staged)', + framing: "4-byte big-endian length prefix (declared #{l_be}); base64/zlib stage", + confidence: confidence, + bytes: bytes + } + end + + # 3. Native staged, little-endian length prefix (Windows family). + if plausible_stage_len?(l_le) && body.length >= l_le + return { + match: true, + payload: windows_payload_guess(l_le), + framing: "4-byte little-endian length prefix (declared #{l_le}, #{bytes} bytes received)", + confidence: 'high', + bytes: bytes + } + end + + # 4. Native staged, big-endian length prefix but not base64 (php/java/uncommon). + if plausible_stage_len?(l_be) && body.length >= l_be + return { + match: true, + payload: big_endian_payload_guess(body), + framing: "4-byte big-endian length prefix (declared #{l_be}, #{bytes} bytes received)", + confidence: 'medium', + bytes: bytes + } + end + + # 5. Small unix execve stage (linux/bsd staged shell). These send raw + # /bin/sh shellcode with no length prefix, below the binary-burst floor. + # Gated to small buffers so large meterpreter stages (which may embed + # "/bin/sh") fall through to the generic native-stage branch instead. + if bytes < 4096 && (buf.include?('/bin/sh') || buf.include?('//sh')) + return { + match: true, + payload: 'native staged unix shell (execve /bin/sh shellcode, e.g. linux/*/shell/reverse_tcp)', + framing: "raw execve shellcode stage, no length prefix (#{bytes} bytes)", + confidence: 'medium', + bytes: bytes + } + end + + # 5b. Raw x86 stager shellcode with no length prefix. Legacy Windows stagers + # (reverse_nonx_tcp, reverse_ord_tcp, and the *_nonx/_ord shell, vncinject + # and patchupmeterpreter variants) push the stage as bare shellcode of only + # ~200-260 bytes starting with the classic block_api prelude (0xFC = cld) + # rather than a 4-byte length prefix, so they sit just under the generic + # binary-burst floor below. A benign text service cannot start with 0xFC. + if bytes >= 48 && buf.b.start_with?("\xFC".b) && printable_ratio(buf) < 0.9 + return { + match: true, + payload: 'Windows staged payload - raw stager shellcode (e.g. reverse_nonx_tcp/reverse_ord_tcp/shell)', + framing: "raw x86 stager shellcode, no length prefix (#{bytes} bytes, 0xFC prelude)", + confidence: 'medium', + bytes: bytes + } + end + + # 6. Raw stage with no length prefix (Linux/OSX native), an RC4-encrypted + # stage, or an encrypted stageless handler stream. Either way the server + # volunteered a burst of non-text data unprompted, which is highly abnormal + # for a benign service. The 128-byte floor still clears short binary banners + # while catching the small (~240B) RC4 and legacy stager bursts. + if bytes >= 128 && printable_ratio(buf) < 0.75 + return { + match: true, + payload: 'native staged meterpreter (linux/osx) or encrypted/stageless handler', + framing: "no length prefix; #{bytes} bytes of unsolicited binary data", + confidence: 'low', + bytes: bytes + } + end + + { match: false } + end + + # Refine a big-endian length-prefixed stage into its source family by content. + def big_endian_payload_guess(body) + sample = body[0, 65_536].to_s + if sample.include?(' 200_000 + 'windows/x64/meterpreter/reverse_tcp (native staged)' + else + 'windows/meterpreter/reverse_tcp (x86 native staged)' + end + end + + def printable_ratio(str) + return 0.0 if str.nil? || str.empty? + + str.count("\x20-\x7e\t\r\n").to_f / str.length + end +end From 0ccfd6fec67ad029acf85db5d25aa253dd03f820 Mon Sep 17 00:00:00 2001 From: h00die Date: Mon, 8 Jun 2026 20:55:59 -0400 Subject: [PATCH 2/7] add mermaid doc to markdown --- .../auxiliary/scanner/msf/handler_detect.md | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/documentation/modules/auxiliary/scanner/msf/handler_detect.md b/documentation/modules/auxiliary/scanner/msf/handler_detect.md index 4043b5d4e9ec5..56f5e3c0c72ff 100644 --- a/documentation/modules/auxiliary/scanner/msf/handler_detect.md +++ b/documentation/modules/auxiliary/scanner/msf/handler_detect.md @@ -51,6 +51,68 @@ captures and reports them, which can leak the operator's post-exploitation automation. With no AutoRunScript configured, the handler simply stops after verification (nothing further is sent). +### Detection flow + +Detection is a decision tree: classify the bytes a handler volunteers on connect +(`fingerprint`), and if it stays silent, fall back to active HTTP/TLS probes and +then `DEEP_PROBE`. The ordered checks below are "first match wins". UDP runs as a +separate pass when `SCAN_UDP` is set. + +```mermaid +flowchart TD + A["check_port: TCP connect"] -->|refused| R0["Closed / not a listener"] + A -->|connected| B["drain_stage: read unsolicited bytes
(up to FIRST_BYTE_WAIT)"] + B --> C{"server talked
first?"} + C -->|"yes (bytes)"| FP["fingerprint(buf)"] + C -->|"no (silent)"| SIL["silent-port fallbacks"] + + %% ---- fingerprint() ordered checks ---- + FP --> F1{"starts with
'echo TOKEN'?"} + F1 -->|yes| RShell["Command shell handler
(echo probe) — high"] + F1 -->|no| F2{"base64 + 4-byte
big-endian length?"} + F2 -->|yes| RPy["python meterpreter
(base64/zlib staged)"] + F2 -->|no| F3{"4-byte little-endian
length delivered?"} + F3 -->|yes| RWin["Windows native staged
(metsrv/shell) — high"] + F3 -->|no| F4{"4-byte big-endian
length delivered?"} + F4 -->|yes| RBE["php / java / android
staged — medium"] + F4 -->|no| F5{"small buf
with /bin/sh?"} + F5 -->|yes| RExec["unix execve staged shell"] + F5 -->|no| F6{"starts with 0xFC?"} + F6 -->|yes| RStager["Windows raw stager shellcode
(reverse_nonx/ord) — medium"] + F6 -->|no| F7{">=128 bytes,
mostly non-text?"} + F7 -->|yes| RNative["linux/osx native stage
or RC4/encrypted — low"] + F7 -->|no| RBanner["Talks first, not a stage
(unrelated service)"] + + RShell --> EB{"ECHO_BACK?"} + EB -->|yes| CAP["echo token back →
capture AutoRunScript → loot"] + + %% ---- silent-port fallbacks ---- + SIL --> H1["HTTP probe: GET random URI"] + H1 --> H1c{"200 + 'It works!'?"} + H1c -->|yes| RHttp["reverse_http handler"] + H1c -->|no| H2{"Rex 404 page?"} + H2 -->|yes| RRex["web_delivery / fetch server"] + H2 -->|no| HS["HTTPS probe: TLS + GET"] + HS --> HSc{"reply?"} + HSc -->|"'It works!'"| RHttps["reverse_https"] + HSc -->|"echo / stage"| RSsl["ssl shell / reverse_tcp_ssl"] + HSc -->|nothing| DP{"DEEP_PROBE on?"} + DP -->|no| SOpen["Open, no data
(stageless / powershell / not MSF)"] + DP -->|yes| D1["2nd TCP connection"] + D1 --> D1c{"echo on the pair?"} + D1c -->|yes| RDouble["ReverseTcpDouble
(cmd/unix/reverse)"] + D1c -->|no| D2["2 TLS connections"] + D2 --> D2c{"echo on the pair?"} + D2c -->|yes| RDoubleSsl["double-SSL handler"] + D2c -->|no| D3["send 16-byte UUID"] + D3 --> D3c{"fast reply-less
close?"} + D3c -->|yes| RPing["pingback — low"] + D3c -->|no| SOpen + + %% ---- UDP (independent, if SCAN_UDP) ---- + UDP["check_port_udp (if SCAN_UDP):
send datagram → fingerprint()"] --> RUdp["reverse_udp"] +``` + ### Detectability summary by transport These figures come from spinning up one representative listener for every From 1b93337c95e00d646d4111ab40eb21a9166843b7 Mon Sep 17 00:00:00 2001 From: h00die Date: Thu, 27 Aug 2026 12:18:55 -0400 Subject: [PATCH 3/7] fix http handler output and documentation to be cleaner --- .../auxiliary/scanner/msf/handler_detect.md | 142 +++++++++++++++++- .../auxiliary/scanner/msf/handler_detect.rb | 100 +++++++++--- 2 files changed, 217 insertions(+), 25 deletions(-) diff --git a/documentation/modules/auxiliary/scanner/msf/handler_detect.md b/documentation/modules/auxiliary/scanner/msf/handler_detect.md index 56f5e3c0c72ff..612a4d4f0989a 100644 --- a/documentation/modules/auxiliary/scanner/msf/handler_detect.md +++ b/documentation/modules/auxiliary/scanner/msf/handler_detect.md @@ -251,6 +251,83 @@ set lport 4448 run -j ``` +or use this `msfconsole.rc`: + +``` +setg ExitOnSession false +setg LHOST 127.0.0.1 +setg PayloadUUIDTracking false +use exploit/multi/handler +set payload windows/x64/meterpreter/reverse_tcp +set LPORT 4001 +run -j -z +set payload python/meterpreter/reverse_tcp +set LPORT 4002 +run -j -z +set payload linux/x64/shell_reverse_tcp +set LPORT 4003 +run -j -z +set payload windows/meterpreter/reverse_http +set LPORT 4004 +run -j -z +set payload windows/meterpreter/reverse_https +set LPORT 4005 +run -j -z +set payload python/meterpreter/reverse_tcp_ssl +set LPORT 4006 +run -j -z +set payload python/shell_reverse_udp +set LPORT 4007 +run -j -z +set payload windows/meterpreter/reverse_nonx_tcp +set LPORT 4008 +run -j -z +set payload cmd/unix/reverse +set LPORT 4009 +run -j -z +set payload windows/x64/pingback_reverse_tcp +set LPORT 4010 +run -j -z +# 4011: a reverse shell with an operator AutoRunScript - the scanner answers its +# echo probe, the AutoRunScript fires, and the commands it runs are captured + looted. +set payload linux/x64/shell_reverse_tcp +set LPORT 4011 +set AutoRunScript post/test/autorun_demo +run -j -z +``` + +Use this `~/.msf4/modules/post/test/autorun_demo.rb`: + +``` +class MetasploitModule < Msf::Post + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'AutoRunScript capture demo', + 'Description' => %q{ + Writes a short sequence of recon commands to a freshly opened shell. Used to + demonstrate auxiliary/scanner/msf/handler_detect ECHO_BACK follow-up capture: + when the scanner answers the shell's "echo" verification probe, the session is + treated as live, this AutoRunScript fires, and the scanner captures the commands + it sends - i.e. the operator's AutoRunScript content. + }, + 'License' => MSF_LICENSE, + 'Author' => ['h00die'], + 'Platform' => %w[win linux unix osx bsd], + 'SessionTypes' => %w[shell] + ) + ) + end + + def run + %W[whoami id hostname uname\ -a].each do |cmd| + session.shell_write("#{cmd}\n") + end + end +end +``` + ## Verification Steps 1. Start one or more staged `reverse_tcp` handlers as shown above. @@ -340,10 +417,71 @@ quietest possible scan. ## Scenarios -### Example run against a host running several staged handlers +### Example run against a host running the `msfconsole.rc` mentioned above ``` -msf6 auxiliary(scanner/msf/handler_detect) > run +msf > use auxiliary/scanner/msf/handler_detect +msf auxiliary(scanner/msf/handler_detect) > set RHOSTS 127.0.0.1 +RHOSTS => 127.0.0.1 +msf auxiliary(scanner/msf/handler_detect) > set PORTS 4001-4011 +PORTS => 4001-4011 +msf auxiliary(scanner/msf/handler_detect) > set SCAN_UDP true +SCAN_UDP => true +msf auxiliary(scanner/msf/handler_detect) > set verbose true +verbose => true +msf auxiliary(scanner/msf/handler_detect) > run +[*] 127.0.0.1 - 127.0.0.1:4007 - Connection refused +[*] 127.0.0.1 - 127.0.0.1:4008 - Metasploit handler detected (tcp): Windows staged payload - raw stager shellcode +[*] 127.0.0.1 - 127.0.0.1:4002 - Metasploit handler detected (tcp): staged payload with big-endian length prefix +[*] 127.0.0.1 - 127.0.0.1:4001 - Metasploit handler detected (tcp): windows/x64/meterpreter/reverse_tcp +[-] 127.0.0.1 - 127.0.0.1:4010 - SSL probe failed: Errno::ECONNRESET Connection reset by peer - SSL_connect +[*] 127.0.0.1 - 127.0.0.1:4003 - Metasploit handler detected (tcp): command shell handler +[*] 127.0.0.1 - 127.0.0.1:4007 - Metasploit handler detected (udp): command shell handler +[*] 127.0.0.1 - 127.0.0.1:4005 - Metasploit handler detected (tcp): reverse_https Meterpreter handler +[*] 127.0.0.1 - 127.0.0.1:4006 - Metasploit handler detected (tcp): python/meterpreter/reverse_tcp +[*] 127.0.0.1 - 127.0.0.1:4004 - Metasploit handler detected (tcp): reverse_http Meterpreter handler +[*] 127.0.0.1 - 127.0.0.1:4010 - Metasploit handler detected (tcp): likely pingback handler +[-] 127.0.0.1 - 127.0.0.1:4009 - SSL probe failed: OpenSSL::SSL::SSLError SSL_connect returned=1 errno=0 peeraddr=127.0.0.1:4009 state=SSLv3/TLS write client hello: wrong version number +[*] 127.0.0.1 - 127.0.0.1:4009 - Metasploit handler detected (tcp): command shell handler +[*] 127.0.0.1 - 127.0.0.1:4003 - No follow-up commands after echo-back (no AutoRunScript configured) +[*] 127.0.0.1 - 127.0.0.1:4011 - Metasploit handler detected (tcp): command shell handler +[+] 127.0.0.1 - 127.0.0.1:4011 - Captured follow-up after shell verification (likely AutoRunScript / operator commands): + whoami + id + hostname + uname -a +[+] 127.0.0.1 - 127.0.0.1:4011 - Saved captured commands to loot: /home/h00die/.msf4/loot/20260827120034_default_127.0.0.1_metasploit.handl_002043.txt +[+] 127.0.0.1 - Metasploit payload handlers detected on 127.0.0.1 (11 found) +============================================================ + + Port Proto Payload Detail Confidence Bytes Framing Notes + ---- ----- ------- ------ ---------- ----- ------- ----- + 4001 tcp windows/x64/meterpreter/rev x64 native staged high 249293 4-byte little-endian length prefix (declared 2 + erse_tcp 48902, 249293 bytes received) + 4002 tcp staged payload with big-end python/php/java family medium 23795 4-byte big-endian length prefix (declared 2340 + ian length prefix 4, 23795 bytes received) + 4003 tcp command shell handler high 20 unsolicited "echo " shell-verification + command + 4004 tcp reverse_http Meterpreter ha HTTP transport high 200 OK + default "It works!" body on an unknow + ndler n URI; Server: Apache + 4005 tcp reverse_https Meterpreter h HTTPS transport high 200 OK + default "It works!" body on an unknow + andler n URI; Server: Apache + 4006 tcp python/meterpreter/reverse_ over SSL/TLS; base64/zlib s high 23408 4-byte big-endian length prefix (declared 2340 + tcp taged 4); base64/zlib stage + 4007 udp command shell handler high 27 unsolicited "echo " shell-verification + command + 4008 tcp Windows staged payload - ra e.g. reverse_nonx_tcp/rever medium 216 raw x86 stager shellcode, no length prefix (21 + w stager shellcode se_ord_tcp/shell 6 bytes, 0xFC prelude) + 4009 tcp command shell handler ReverseTcpDouble paired con high 23 unsolicited "echo " shell-verification + nections command + 4010 tcp likely pingback handler pingback_reverse_tcp family low handler closed the connection 0.34s after a 16 + -byte write, returning no data (reads a 16-byt + e UUID then closes) + 4011 tcp command shell handler high 22 unsolicited "echo " shell-verification AutoRunScript/operator commands captured to loo + command t + +[*] 127.0.0.1 - Scanned 1 of 1 hosts (100% complete) +[*] Auxiliary module execution completed ``` ## Full payload matrix diff --git a/modules/auxiliary/scanner/msf/handler_detect.rb b/modules/auxiliary/scanner/msf/handler_detect.rb index aaeff1e284fa1..572f24f8029d6 100644 --- a/modules/auxiliary/scanner/msf/handler_detect.rb +++ b/modules/auxiliary/scanner/msf/handler_detect.rb @@ -86,6 +86,13 @@ def validate_ports def run_host(ip) ports = validate_ports + # Per-host sink for detected handlers, rendered as one Rex::Text::Table when + # the port sweep finishes (instead of long single-line hits). Scanner runs + # run_host on a replicant per host, so this is host-local; the lock guards + # the concurrent per-port worker threads below. + @host_results = [] + @host_results_lock = ::Mutex.new + # Hard per-port deadline (seconds) so one wedged port - e.g. a black-hole TLS # handshake - can never stall its whole concurrency batch. It sums the # worst-case blocking waits a single port can incur back-to-back: @@ -119,6 +126,8 @@ def run_host(ip) # Bounded join as a backstop in case Timeout cannot interrupt a blocked call. threads.each { |t| t.join(port_deadline + 5) } rescue ::Timeout::Error + # Swallowed: the per-port rescue above already reported the skip, and the + # ensure still needs to run so a wedged batch cannot leak live threads. ensure threads.each do |x| x.kill @@ -127,6 +136,8 @@ def run_host(ip) end end end + + print_host_results(ip) end def check_port(ip, port, timeout) @@ -207,8 +218,8 @@ def paired_connect_probe(ip, port, ssl) fp = fingerprint(drain_stage(s)) next unless fp[:match] - suffix = ssl ? ' over SSL/TLS (ReverseTcpDouble - paired connections)' : ' (ReverseTcpDouble - paired connections)' - return { payload: "#{fp[:payload]}#{suffix}", framing: fp[:framing], confidence: fp[:confidence], bytes: fp[:bytes] } + detail = ['ReverseTcpDouble paired connections', ('over SSL/TLS' if ssl), fp[:detail]].compact.join('; ') + return { payload: fp[:payload], detail: detail, framing: fp[:framing], confidence: fp[:confidence], bytes: fp[:bytes] } end nil rescue ::StandardError @@ -249,7 +260,8 @@ def pingback_probe(ip, port) return nil unless (data.nil? || data.empty?) && elapsed < 1.0 { - payload: 'likely pingback handler (pingback_reverse_tcp family)', + payload: 'likely pingback handler', + detail: 'pingback_reverse_tcp family', framing: "handler closed the connection #{elapsed.round(2)}s after a 16-byte write, returning no data (reads a 16-byte UUID then closes)", confidence: 'low' } @@ -279,6 +291,8 @@ def check_port_udp(ip, port, _timeout) result = fingerprint(buf) handle_match(ip, port, 'udp', result, nil) if result[:match] rescue ::Rex::ConnectionError, ::IOError, ::Timeout::Error + # Swallowed: a refused/filtered/closed UDP port is a non-finding while + # sweeping, so there is nothing to report and no port worth alarming on. rescue ::Interrupt raise $ERROR_INFO rescue ::StandardError => e @@ -295,7 +309,7 @@ def check_port_udp(ip, port, _timeout) # Report a detected handler and, for command-shell echo probes, optionally # echo the token back to verify the shell and capture follow-up commands. def handle_match(ip, port, proto, result, sock) - report_handler(ip, port, proto, result) + row = report_handler(ip, port, proto, result) return unless result[:echo_token] && datastore['ECHO_BACK'] && sock @@ -313,15 +327,46 @@ def handle_match(ip, port, proto, result, sock) ) print_good("#{Rex::Socket.to_authority(ip, port)} - Saved captured commands to loot: #{loot_path}") report_note(host: ip, port: port, type: 'msf.handler.followup', data: { 'commands' => followup.split("\n").reject(&:empty?), 'loot' => loot_path }, update: :unique_data) + @host_results_lock.synchronize { row[:notes] = 'AutoRunScript/operator commands captured to loot' } else vprint_status("#{Rex::Socket.to_authority(ip, port)} - No follow-up commands after echo-back (no AutoRunScript configured)") end end + # Record a detection in the per-host results sink (printed as a table by + # print_host_results when the host sweep finishes) and report the service. + # Returns the row so callers (handle_match) can annotate it afterwards. def report_handler(ip, port, proto, result) - bytes = result[:bytes] ? ", #{result[:bytes]} bytes" : '' - print_good("#{Rex::Socket.to_authority(ip, port)} - Metasploit handler detected (#{proto}): #{result[:payload]} (#{result[:confidence]} confidence#{bytes}) [#{result[:framing]}]") - report_service(host: ip, port: port, proto: proto, name: 'metasploit-handler', info: "#{result[:payload]} | #{result[:framing]} (#{result[:confidence]} confidence)") + vprint_status("#{Rex::Socket.to_authority(ip, port)} - Metasploit handler detected (#{proto}): #{result[:payload]}") + row = { + port: port, + proto: proto, + payload: result[:payload], + detail: result[:detail].to_s, + confidence: result[:confidence], + bytes: result[:bytes], + framing: result[:framing], + notes: '' + } + @host_results_lock.synchronize { @host_results << row } + svc_detail = row[:detail].empty? ? '' : " (#{row[:detail]})" + report_service(host: ip, port: port, proto: proto, name: 'metasploit-handler', info: "#{result[:payload]}#{svc_detail} | #{result[:framing]} (#{result[:confidence]} confidence)") + row + end + + # Render everything report_handler collected for this host as a single table. + def print_host_results(ip) + return if @host_results.empty? + + tbl = Rex::Text::Table.new( + 'Header' => "Metasploit payload handlers detected on #{ip} (#{@host_results.length} found)", + 'Indent' => 1, + 'Columns' => ['Port', 'Proto', 'Payload', 'Detail', 'Confidence', 'Bytes', 'Framing', 'Notes'] + ) + @host_results.sort_by { |r| [r[:port], r[:proto]] }.each do |r| + tbl << [r[:port], r[:proto], r[:payload], r[:detail], r[:confidence], r[:bytes], r[:framing], r[:notes]] + end + print_good(tbl.to_s) end # Echo the verification token back, then keep reading to capture whatever the @@ -439,7 +484,7 @@ def http_fingerprint(sock, ip, port, _timeout) if hc hc elsif raw && !raw.empty? && (fp = fingerprint(raw))[:match] - { payload: "#{fp[:payload]} over SSL/TLS", framing: fp[:framing], confidence: fp[:confidence], bytes: fp[:bytes], echo_token: fp[:echo_token] } + { payload: fp[:payload], detail: "over SSL/TLS; #{fp[:detail]}", framing: fp[:framing], confidence: fp[:confidence], bytes: fp[:bytes], echo_token: fp[:echo_token] } end end rescue ::StandardError => e @@ -505,7 +550,8 @@ def http_classify(raw, scheme: 'http') # "It works!" body to ANY unknown URI (real servers would 404). if code == 200 && body.include?('It works!') return { - payload: "Metasploit reverse_#{scheme} Meterpreter handler (HTTP transport)", + payload: "reverse_#{scheme} Meterpreter handler", + detail: "#{scheme.upcase} transport", framing: %(200 OK + default "It works!" body on an unknown URI; Server: #{server_str}), confidence: 'high' } @@ -515,7 +561,8 @@ def http_classify(raw, scheme: 'http') # returns a distinctive 404 page on unknown URIs. if body.include?('was not found on this server') && body.include?('

Not found

') return { - payload: 'Metasploit Rex HTTP server (web_delivery / fetch handler / exploit module server)', + payload: 'Metasploit Rex HTTP server', + detail: 'web_delivery / fetch handler / exploit module server', framing: "Rex 404 page on unknown URI; Server: #{server_str}", confidence: 'high' } @@ -549,7 +596,8 @@ def fingerprint(buf) if (md = buf.match(/\Aecho ([A-Za-z0-9]{8,24});?\s*\z/)) return { match: true, - payload: 'Metasploit command shell handler (reverse shell - "echo" verification probe)', + payload: 'command shell handler', + # detail: 'reverse shell - "echo" verification probe', framing: 'unsolicited "echo " shell-verification command', confidence: 'high', bytes: bytes, @@ -562,7 +610,8 @@ def fingerprint(buf) confidence = (l_be == body.length) ? 'high' : 'medium' return { match: true, - payload: 'python/meterpreter/reverse_tcp (base64/zlib staged)', + payload: 'python/meterpreter/reverse_tcp', + detail: 'base64/zlib staged', framing: "4-byte big-endian length prefix (declared #{l_be}); base64/zlib stage", confidence: confidence, bytes: bytes @@ -573,7 +622,7 @@ def fingerprint(buf) if plausible_stage_len?(l_le) && body.length >= l_le return { match: true, - payload: windows_payload_guess(l_le), + **windows_payload_guess(l_le), framing: "4-byte little-endian length prefix (declared #{l_le}, #{bytes} bytes received)", confidence: 'high', bytes: bytes @@ -584,7 +633,7 @@ def fingerprint(buf) if plausible_stage_len?(l_be) && body.length >= l_be return { match: true, - payload: big_endian_payload_guess(body), + **big_endian_payload_guess(body), framing: "4-byte big-endian length prefix (declared #{l_be}, #{bytes} bytes received)", confidence: 'medium', bytes: bytes @@ -598,7 +647,8 @@ def fingerprint(buf) if bytes < 4096 && (buf.include?('/bin/sh') || buf.include?('//sh')) return { match: true, - payload: 'native staged unix shell (execve /bin/sh shellcode, e.g. linux/*/shell/reverse_tcp)', + payload: 'native staged unix shell', + detail: 'execve /bin/sh shellcode, e.g. linux/*/shell/reverse_tcp', framing: "raw execve shellcode stage, no length prefix (#{bytes} bytes)", confidence: 'medium', bytes: bytes @@ -614,7 +664,8 @@ def fingerprint(buf) if bytes >= 48 && buf.b.start_with?("\xFC".b) && printable_ratio(buf) < 0.9 return { match: true, - payload: 'Windows staged payload - raw stager shellcode (e.g. reverse_nonx_tcp/reverse_ord_tcp/shell)', + payload: 'Windows staged payload - raw stager shellcode', + detail: 'e.g. reverse_nonx_tcp/reverse_ord_tcp/shell', framing: "raw x86 stager shellcode, no length prefix (#{bytes} bytes, 0xFC prelude)", confidence: 'medium', bytes: bytes @@ -629,7 +680,8 @@ def fingerprint(buf) if bytes >= 128 && printable_ratio(buf) < 0.75 return { match: true, - payload: 'native staged meterpreter (linux/osx) or encrypted/stageless handler', + payload: 'native staged meterpreter or encrypted/stageless handler', + detail: 'linux/osx native stage', framing: "no length prefix; #{bytes} bytes of unsolicited binary data", confidence: 'low', bytes: bytes @@ -640,15 +692,16 @@ def fingerprint(buf) end # Refine a big-endian length-prefixed stage into its source family by content. + # Returns the payload/detail fields for the result hash. def big_endian_payload_guess(body) sample = body[0, 65_536].to_s if sample.include?(' 200_000 - 'windows/x64/meterpreter/reverse_tcp (native staged)' + { payload: 'windows/x64/meterpreter/reverse_tcp', detail: 'x64 native staged' } else - 'windows/meterpreter/reverse_tcp (x86 native staged)' + { payload: 'windows/meterpreter/reverse_tcp', detail: 'x86 native staged' } end end From 17f81ef9c7332df2450ffe7b6fa98f5b3346862f Mon Sep 17 00:00:00 2001 From: h00die Date: Thu, 27 Aug 2026 12:25:43 -0400 Subject: [PATCH 4/7] handler detect output cleanup --- .../auxiliary/scanner/msf/handler_detect.rb | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/modules/auxiliary/scanner/msf/handler_detect.rb b/modules/auxiliary/scanner/msf/handler_detect.rb index 572f24f8029d6..748aafa21e535 100644 --- a/modules/auxiliary/scanner/msf/handler_detect.rb +++ b/modules/auxiliary/scanner/msf/handler_detect.rb @@ -120,7 +120,7 @@ def run_host(ip) check_port_udp(ip, port, timeout) if datastore['SCAN_UDP'] end rescue ::Timeout::Error - vprint_warning("#{Rex::Socket.to_authority(ip, port)} - per-port timeout (#{port_deadline.round}s), skipping") + vprint_warning("per-port timeout (#{port_deadline.round}s), skipping") end end # Bounded join as a backstop in case Timeout cannot interrupt a blocked call. @@ -169,19 +169,19 @@ def check_port(ip, port, timeout) sock = nil report_handler(ip, port, 'tcp', deep) else - vprint_status("#{Rex::Socket.to_authority(ip, port)} - Open, no unsolicited data and no Metasploit HTTP fingerprint (stageless reverse shell/meterpreter, or unrelated service)") + vprint_status('Open, no unsolicited data and no Metasploit HTTP fingerprint (stageless reverse shell/meterpreter, or unrelated service)') end else - vprint_status("#{Rex::Socket.to_authority(ip, port)} - Talks first but does not look like a stage (banner: #{buf[0, 32].inspect})") + vprint_status("Talks first but does not look like a stage (banner: #{buf[0, 32].inspect})") end rescue ::Rex::ConnectionRefused - vprint_status("#{Rex::Socket.to_authority(ip, port)} - Connection refused") + vprint_status('Connection refused') rescue ::Rex::ConnectionError, ::IOError, ::Timeout::Error => e - vprint_status("#{Rex::Socket.to_authority(ip, port)} - Connection error (#{e.class})") + vprint_status("Connection error (#{e.class})") rescue ::Interrupt raise $ERROR_INFO rescue ::StandardError => e - vprint_error("#{Rex::Socket.to_authority(ip, port)} - #{e.class} #{e}") + vprint_error("#{e.class} #{e}") ensure begin disconnect(sock) @@ -296,7 +296,7 @@ def check_port_udp(ip, port, _timeout) rescue ::Interrupt raise $ERROR_INFO rescue ::StandardError => e - vprint_error("#{Rex::Socket.to_authority(ip, port)} (udp) - #{e.class} #{e}") + vprint_error("(udp) - #{e.class} #{e}") ensure begin udp.close @@ -315,7 +315,7 @@ def handle_match(ip, port, proto, result, sock) followup = echo_back_capture(sock, result[:echo_token]) if followup && !followup.empty? - print_good("#{Rex::Socket.to_authority(ip, port)} - Captured follow-up after shell verification (likely AutoRunScript / operator commands):") + print_good('Captured follow-up after shell verification (likely AutoRunScript / operator commands):') followup.each_line { |line| print_line(" #{line.chomp}") } loot_path = store_loot( 'metasploit.handler.autorunscript', @@ -325,11 +325,11 @@ def handle_match(ip, port, proto, result, sock) "handler_followup_#{port}.txt", "Commands an operator's AutoRunScript/InitialAutoRunScript ran against the connecting shell on #{ip}:#{port}" ) - print_good("#{Rex::Socket.to_authority(ip, port)} - Saved captured commands to loot: #{loot_path}") + print_good("Saved captured commands to loot: #{loot_path}") report_note(host: ip, port: port, type: 'msf.handler.followup', data: { 'commands' => followup.split("\n").reject(&:empty?), 'loot' => loot_path }, update: :unique_data) @host_results_lock.synchronize { row[:notes] = 'AutoRunScript/operator commands captured to loot' } else - vprint_status("#{Rex::Socket.to_authority(ip, port)} - No follow-up commands after echo-back (no AutoRunScript configured)") + vprint_status('No follow-up commands after echo-back (no AutoRunScript configured)') end end @@ -337,7 +337,7 @@ def handle_match(ip, port, proto, result, sock) # print_host_results when the host sweep finishes) and report the service. # Returns the row so callers (handle_match) can annotate it afterwards. def report_handler(ip, port, proto, result) - vprint_status("#{Rex::Socket.to_authority(ip, port)} - Metasploit handler detected (#{proto}): #{result[:payload]}") + vprint_status("Metasploit handler detected (#{proto}): #{result[:payload]}") row = { port: port, proto: proto, @@ -488,7 +488,7 @@ def http_fingerprint(sock, ip, port, _timeout) end end rescue ::StandardError => e - vprint_error("#{Rex::Socket.to_authority(ip, port)} - SSL probe failed: #{e.class} #{e}") + vprint_error("SSL probe failed: #{e.class} #{e}") nil ensure begin From fe26d688130998120d94fd7797e9137351410d8b Mon Sep 17 00:00:00 2001 From: h00die Date: Fri, 28 Aug 2026 16:01:33 -0400 Subject: [PATCH 5/7] copilot review --- .../modules/auxiliary/scanner/msf/handler_detect.md | 12 ++++++------ modules/auxiliary/scanner/msf/handler_detect.rb | 5 ++++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/documentation/modules/auxiliary/scanner/msf/handler_detect.md b/documentation/modules/auxiliary/scanner/msf/handler_detect.md index 612a4d4f0989a..0eaa162e091ac 100644 --- a/documentation/modules/auxiliary/scanner/msf/handler_detect.md +++ b/documentation/modules/auxiliary/scanner/msf/handler_detect.md @@ -68,19 +68,19 @@ flowchart TD %% ---- fingerprint() ordered checks ---- FP --> F1{"starts with
'echo TOKEN'?"} - F1 -->|yes| RShell["Command shell handler
(echo probe) — high"] + F1 -->|yes| RShell["Command shell handler
(echo probe) - high"] F1 -->|no| F2{"base64 + 4-byte
big-endian length?"} F2 -->|yes| RPy["python meterpreter
(base64/zlib staged)"] F2 -->|no| F3{"4-byte little-endian
length delivered?"} - F3 -->|yes| RWin["Windows native staged
(metsrv/shell) — high"] + F3 -->|yes| RWin["Windows native staged
(metsrv/shell) - high"] F3 -->|no| F4{"4-byte big-endian
length delivered?"} - F4 -->|yes| RBE["php / java / android
staged — medium"] + F4 -->|yes| RBE["php / java / android
staged - medium"] F4 -->|no| F5{"small buf
with /bin/sh?"} F5 -->|yes| RExec["unix execve staged shell"] F5 -->|no| F6{"starts with 0xFC?"} - F6 -->|yes| RStager["Windows raw stager shellcode
(reverse_nonx/ord) — medium"] + F6 -->|yes| RStager["Windows raw stager shellcode
(reverse_nonx/ord) - medium"] F6 -->|no| F7{">=128 bytes,
mostly non-text?"} - F7 -->|yes| RNative["linux/osx native stage
or RC4/encrypted — low"] + F7 -->|yes| RNative["linux/osx native stage
or RC4/encrypted - low"] F7 -->|no| RBanner["Talks first, not a stage
(unrelated service)"] RShell --> EB{"ECHO_BACK?"} @@ -106,7 +106,7 @@ flowchart TD D2c -->|yes| RDoubleSsl["double-SSL handler"] D2c -->|no| D3["send 16-byte UUID"] D3 --> D3c{"fast reply-less
close?"} - D3c -->|yes| RPing["pingback — low"] + D3c -->|yes| RPing["pingback - low"] D3c -->|no| SOpen %% ---- UDP (independent, if SCAN_UDP) ---- diff --git a/modules/auxiliary/scanner/msf/handler_detect.rb b/modules/auxiliary/scanner/msf/handler_detect.rb index 748aafa21e535..356697f19889f 100644 --- a/modules/auxiliary/scanner/msf/handler_detect.rb +++ b/modules/auxiliary/scanner/msf/handler_detect.rb @@ -86,6 +86,9 @@ def validate_ports def run_host(ip) ports = validate_ports + if datastore['THREADS'].to_i > 1 && datastore['CONCURRENCY'].to_i > 1 + vprint_warning("THREADS (hosts) and CONCURRENCY (ports) multiply: total worker threads can reach ~#{datastore['THREADS'].to_i * datastore['CONCURRENCY'].to_i}") + end # Per-host sink for detected handlers, rendered as one Rex::Text::Table when # the port sweep finishes (instead of long single-line hits). Scanner runs # run_host on a replicant per host, so this is host-local; the lock guards @@ -323,7 +326,7 @@ def handle_match(ip, port, proto, result, sock) ip, followup, "handler_followup_#{port}.txt", - "Commands an operator's AutoRunScript/InitialAutoRunScript ran against the connecting shell on #{ip}:#{port}" + "Commands an operator's AutoRunScript/InitialAutoRunScript ran against the connecting shell on #{Rex::Socket.to_authority(ip, port)}" ) print_good("Saved captured commands to loot: #{loot_path}") report_note(host: ip, port: port, type: 'msf.handler.followup', data: { 'commands' => followup.split("\n").reject(&:empty?), 'loot' => loot_path }, update: :unique_data) From 8d3498bd744d6591db6fb505f6e1e6354f5b4037 Mon Sep 17 00:00:00 2001 From: h00die Date: Mon, 31 Aug 2026 11:34:26 -0400 Subject: [PATCH 6/7] handler detect review --- .../modules/auxiliary/scanner/msf/handler_detect.md | 10 ++++++---- modules/auxiliary/scanner/msf/handler_detect.rb | 13 ++++++++++++- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/documentation/modules/auxiliary/scanner/msf/handler_detect.md b/documentation/modules/auxiliary/scanner/msf/handler_detect.md index 0eaa162e091ac..86bac1e22824a 100644 --- a/documentation/modules/auxiliary/scanner/msf/handler_detect.md +++ b/documentation/modules/auxiliary/scanner/msf/handler_detect.md @@ -69,7 +69,7 @@ flowchart TD %% ---- fingerprint() ordered checks ---- FP --> F1{"starts with
'echo TOKEN'?"} F1 -->|yes| RShell["Command shell handler
(echo probe) - high"] - F1 -->|no| F2{"base64 + 4-byte
big-endian length?"} + F1 -->|no| F2{"base64 + plausible
big-endian length?"} F2 -->|yes| RPy["python meterpreter
(base64/zlib staged)"] F2 -->|no| F3{"4-byte little-endian
length delivered?"} F3 -->|yes| RWin["Windows native staged
(metsrv/shell) - high"] @@ -84,7 +84,7 @@ flowchart TD F7 -->|no| RBanner["Talks first, not a stage
(unrelated service)"] RShell --> EB{"ECHO_BACK?"} - EB -->|yes| CAP["echo token back →
capture AutoRunScript → loot"] + EB -->|yes| CAP["echo token back ->
capture AutoRunScript -> loot"] %% ---- silent-port fallbacks ---- SIL --> H1["HTTP probe: GET random URI"] @@ -110,7 +110,7 @@ flowchart TD D3c -->|no| SOpen %% ---- UDP (independent, if SCAN_UDP) ---- - UDP["check_port_udp (if SCAN_UDP):
send datagram → fingerprint()"] --> RUdp["reverse_udp"] + UDP["check_port_udp (if SCAN_UDP):
send datagram -> fingerprint()"] --> RUdp["reverse_udp"] ``` ### Detectability summary by transport @@ -399,7 +399,9 @@ How long (seconds) to keep reading after echoing the token back, to capture ### CONCURRENCY -The number of concurrent ports to check per host. Default `10`. +The number of concurrent ports to check per host. Default `10`. Must be at +least `1`; lower values are rejected at run time (they would spawn no worker +threads and the scan would never progress). ### DEEP_PROBE diff --git a/modules/auxiliary/scanner/msf/handler_detect.rb b/modules/auxiliary/scanner/msf/handler_detect.rb index 356697f19889f..2e0be8f2caf5a 100644 --- a/modules/auxiliary/scanner/msf/handler_detect.rb +++ b/modules/auxiliary/scanner/msf/handler_detect.rb @@ -76,6 +76,14 @@ def timeout datastore['TIMEOUT'].to_f end + # CONCURRENCY drives 1.upto() in run_host, so a value < 1 would spawn zero + # worker threads and leave the port queue forever unemptied (a silent + # busy-loop). Reject it up front with the standard option-validation error. + def validate + super + raise Msf::OptionValidateError, ['CONCURRENCY'] if datastore['CONCURRENCY'].to_i < 1 + end + def validate_ports ports = Rex::Socket.portspec_crack(datastore['PORTS']) raise Msf::OptionValidateError, ['PORTS'] if ports.empty? @@ -609,7 +617,10 @@ def fingerprint(buf) end # 2. Python staged: big-endian length prefix + base64(zlib(...)) text stage. - if base64_stage?(body) && l_be.positive? + # The prefix must be a plausible stage length: a benign base64 text blob + # reads its four leading ASCII bytes as a 700M+ "length", far outside the + # plausible range, while a real stage declares its exact base64 size. + if base64_stage?(body) && plausible_stage_len?(l_be) confidence = (l_be == body.length) ? 'high' : 'medium' return { match: true, From 1371d42031b5ef31d78e9e441dd260c08bee0cf1 Mon Sep 17 00:00:00 2001 From: h00die Date: Mon, 31 Aug 2026 12:42:42 -0400 Subject: [PATCH 7/7] handler detect review --- .../auxiliary/scanner/msf/handler_detect.md | 72 ++++++++----------- .../auxiliary/scanner/msf/handler_detect.rb | 10 ++- 2 files changed, 37 insertions(+), 45 deletions(-) diff --git a/documentation/modules/auxiliary/scanner/msf/handler_detect.md b/documentation/modules/auxiliary/scanner/msf/handler_detect.md index 86bac1e22824a..a045925c91202 100644 --- a/documentation/modules/auxiliary/scanner/msf/handler_detect.md +++ b/documentation/modules/auxiliary/scanner/msf/handler_detect.md @@ -432,55 +432,43 @@ SCAN_UDP => true msf auxiliary(scanner/msf/handler_detect) > set verbose true verbose => true msf auxiliary(scanner/msf/handler_detect) > run -[*] 127.0.0.1 - 127.0.0.1:4007 - Connection refused -[*] 127.0.0.1 - 127.0.0.1:4008 - Metasploit handler detected (tcp): Windows staged payload - raw stager shellcode -[*] 127.0.0.1 - 127.0.0.1:4002 - Metasploit handler detected (tcp): staged payload with big-endian length prefix -[*] 127.0.0.1 - 127.0.0.1:4001 - Metasploit handler detected (tcp): windows/x64/meterpreter/reverse_tcp -[-] 127.0.0.1 - 127.0.0.1:4010 - SSL probe failed: Errno::ECONNRESET Connection reset by peer - SSL_connect -[*] 127.0.0.1 - 127.0.0.1:4003 - Metasploit handler detected (tcp): command shell handler -[*] 127.0.0.1 - 127.0.0.1:4007 - Metasploit handler detected (udp): command shell handler -[*] 127.0.0.1 - 127.0.0.1:4005 - Metasploit handler detected (tcp): reverse_https Meterpreter handler -[*] 127.0.0.1 - 127.0.0.1:4006 - Metasploit handler detected (tcp): python/meterpreter/reverse_tcp -[*] 127.0.0.1 - 127.0.0.1:4004 - Metasploit handler detected (tcp): reverse_http Meterpreter handler -[*] 127.0.0.1 - 127.0.0.1:4010 - Metasploit handler detected (tcp): likely pingback handler -[-] 127.0.0.1 - 127.0.0.1:4009 - SSL probe failed: OpenSSL::SSL::SSLError SSL_connect returned=1 errno=0 peeraddr=127.0.0.1:4009 state=SSLv3/TLS write client hello: wrong version number -[*] 127.0.0.1 - 127.0.0.1:4009 - Metasploit handler detected (tcp): command shell handler -[*] 127.0.0.1 - 127.0.0.1:4003 - No follow-up commands after echo-back (no AutoRunScript configured) -[*] 127.0.0.1 - 127.0.0.1:4011 - Metasploit handler detected (tcp): command shell handler -[+] 127.0.0.1 - 127.0.0.1:4011 - Captured follow-up after shell verification (likely AutoRunScript / operator commands): +[*] 127.0.0.1 - Connection refused +[*] 127.0.0.1 - Metasploit handler detected (tcp): Windows staged payload - raw stager shellcode +[*] 127.0.0.1 - Metasploit handler detected (tcp): windows/x64/meterpreter/reverse_tcp +[*] 127.0.0.1 - Metasploit handler detected (tcp): python/meterpreter/reverse_tcp +[-] 127.0.0.1 - SSL probe failed: Errno::ECONNRESET Connection reset by peer - SSL_connect +[*] 127.0.0.1 - Metasploit handler detected (tcp): command shell handler +[*] 127.0.0.1 - Metasploit handler detected (udp): command shell handler +[*] 127.0.0.1 - Metasploit handler detected (tcp): reverse_https Meterpreter handler +[*] 127.0.0.1 - Metasploit handler detected (tcp): python/meterpreter/reverse_tcp +[*] 127.0.0.1 - Metasploit handler detected (tcp): reverse_http Meterpreter handler +[*] 127.0.0.1 - Metasploit handler detected (tcp): likely pingback handler +[-] 127.0.0.1 - SSL probe failed: Rex::ConnectionTimeout The connection with (127.0.0.1:4009) timed out. +[*] 127.0.0.1 - Metasploit handler detected (tcp): command shell handler +[*] 127.0.0.1 - No follow-up commands after echo-back (no AutoRunScript configured) +[*] 127.0.0.1 - Metasploit handler detected (tcp): command shell handler +[+] 127.0.0.1 - Captured follow-up after shell verification (likely AutoRunScript / operator commands): whoami id hostname uname -a -[+] 127.0.0.1 - 127.0.0.1:4011 - Saved captured commands to loot: /home/h00die/.msf4/loot/20260827120034_default_127.0.0.1_metasploit.handl_002043.txt +[+] 127.0.0.1 - Saved captured commands to loot: /home/h00die/.msf4/loot/20260831120955_default_127.0.0.1_metasploit.handl_073457.txt [+] 127.0.0.1 - Metasploit payload handlers detected on 127.0.0.1 (11 found) ============================================================ - Port Proto Payload Detail Confidence Bytes Framing Notes - ---- ----- ------- ------ ---------- ----- ------- ----- - 4001 tcp windows/x64/meterpreter/rev x64 native staged high 249293 4-byte little-endian length prefix (declared 2 - erse_tcp 48902, 249293 bytes received) - 4002 tcp staged payload with big-end python/php/java family medium 23795 4-byte big-endian length prefix (declared 2340 - ian length prefix 4, 23795 bytes received) - 4003 tcp command shell handler high 20 unsolicited "echo " shell-verification - command - 4004 tcp reverse_http Meterpreter ha HTTP transport high 200 OK + default "It works!" body on an unknow - ndler n URI; Server: Apache - 4005 tcp reverse_https Meterpreter h HTTPS transport high 200 OK + default "It works!" body on an unknow - andler n URI; Server: Apache - 4006 tcp python/meterpreter/reverse_ over SSL/TLS; base64/zlib s high 23408 4-byte big-endian length prefix (declared 2340 - tcp taged 4); base64/zlib stage - 4007 udp command shell handler high 27 unsolicited "echo " shell-verification - command - 4008 tcp Windows staged payload - ra e.g. reverse_nonx_tcp/rever medium 216 raw x86 stager shellcode, no length prefix (21 - w stager shellcode se_ord_tcp/shell 6 bytes, 0xFC prelude) - 4009 tcp command shell handler ReverseTcpDouble paired con high 23 unsolicited "echo " shell-verification - nections command - 4010 tcp likely pingback handler pingback_reverse_tcp family low handler closed the connection 0.34s after a 16 - -byte write, returning no data (reads a 16-byt - e UUID then closes) - 4011 tcp command shell handler high 22 unsolicited "echo " shell-verification AutoRunScript/operator commands captured to loo - command t + Port Proto Payload Detail Confidence Bytes Framing Notes + ---- ----- ------- ------ ---------- ----- ------- ----- + 4001 tcp windows/x64/meterpreter/reverse_tcp x64 native staged high 249293 4-byte little-endian length prefix (declared 248902, 249293 bytes received) + 4002 tcp python/meterpreter/reverse_tcp base64/zlib staged high 23799 4-byte big-endian length prefix (declared 23408, 23799 bytes received); base64/zlib stage + 4003 tcp command shell handler high 26 unsolicited "echo " shell-verification command + 4004 tcp reverse_http Meterpreter handler HTTP transport high 200 OK + default "It works!" body on an unknown URI; Server: Apache + 4005 tcp reverse_https Meterpreter handler HTTPS transport high 200 OK + default "It works!" body on an unknown URI; Server: Apache + 4006 tcp python/meterpreter/reverse_tcp over SSL/TLS; base64/zlib staged high 23412 4-byte big-endian length prefix (declared 23408, 23412 bytes received); base64/zlib stage + 4007 udp command shell handler high 29 unsolicited "echo " shell-verification command + 4008 tcp Windows staged payload - raw stager shellcode e.g. reverse_nonx_tcp/reverse_ord_tcp/shell medium 216 raw x86 stager shellcode, no length prefix (216 bytes, 0xFC prelude) + 4009 tcp command shell handler ReverseTcpDouble paired connections high 23 unsolicited "echo " shell-verification command + 4010 tcp likely pingback handler pingback_reverse_tcp family low handler closed the connection 0.33s after a 16-byte write, returning no data (reads a 16-byte UUID then closes) + 4011 tcp command shell handler high 24 unsolicited "echo " shell-verification command AutoRunScript/operator commands captured to loot [*] 127.0.0.1 - Scanned 1 of 1 hosts (100% complete) [*] Auxiliary module execution completed diff --git a/modules/auxiliary/scanner/msf/handler_detect.rb b/modules/auxiliary/scanner/msf/handler_detect.rb index 2e0be8f2caf5a..3e31851f890e8 100644 --- a/modules/auxiliary/scanner/msf/handler_detect.rb +++ b/modules/auxiliary/scanner/msf/handler_detect.rb @@ -620,13 +620,17 @@ def fingerprint(buf) # The prefix must be a plausible stage length: a benign base64 text blob # reads its four leading ASCII bytes as a 700M+ "length", far outside the # plausible range, while a real stage declares its exact base64 size. - if base64_stage?(body) && plausible_stage_len?(l_be) - confidence = (l_be == body.length) ? 'high' : 'medium' + # Only the declared l_be bytes are the stage - handlers can append a + # binary tail after it (a plain-TCP python handler sends ~400 extra + # bytes), so slice to the declared length or those stages fall through + # to the generic big-endian branch below. + if plausible_stage_len?(l_be) && base64_stage?(body[0, l_be]) + confidence = (body.length >= l_be) ? 'high' : 'medium' return { match: true, payload: 'python/meterpreter/reverse_tcp', detail: 'base64/zlib staged', - framing: "4-byte big-endian length prefix (declared #{l_be}); base64/zlib stage", + framing: "4-byte big-endian length prefix (declared #{l_be}, #{bytes} bytes received); base64/zlib stage", confidence: confidence, bytes: bytes }