#3491·nmap

ProFTPD Help probe: hard prefix match on incomplete TCP read drops version

Author: Spider-sunCreated Sep 14, 2026Updated Sep 14, 2026

Summary

The TCP Help probe in nmap-service-probes has a hard match on only the first line of a ProFTPD HELP reply (214-The following commands are recognized (* =>'s unimplemented):).

Because version detection applies match lines against the TCP buffer as data arrives, that prefix match can fire before the rest of the HELP table is read. Nmap then stops. The later, more specific rule that assigns v/1.2.10/ never sees a complete payload.

Observed result (same daemon, same port, seconds apart):

Recv buffer Rule that fires Output
Full HELP command table (~600 bytes) ProFTPD 1.2.10 match ftp ProFTPD 1.2.10
Banner + first 214- line only prefix match (no v/) ftp ProFTPD (no version)

This is not a flapping service. The 220 banner has no version string (custom welcome), so version depends entirely on the HELP fingerprint completing.

Still present in Nmap 7.991 (nmap-service-probes, Probe TCP Help).

Fingerprint lines (7.991)

Probe TCP Help (q|HELP\r\n|, totalwaitms 7500):

# specific — assigns version
match ftp m|^220 .*\r\n214-The following commands are recognized \(\* =>'s unimplemented\):\r\n CWD     XCWD    ... STOR    STOU    \r\n|s p/ProFTPD/ v/1.2.10/ ...

# shorter table — product only
match ftp m|^220 .*\r\n214-The following commands are recognized \(\* =>'s unimplemented\):\r\n CWD     ... HELP    \r\n|s p/ProFTPD/ ...

# prefix only — product only, too broad, HARD match
match ftp m|^220[ -].*\r\n214-The following commands are recognized \(\* =>'s unimplemented\):\r\n|s p/ProFTPD/ ...

In current 7.991 these are around lines 13346, 13348, and 13350.

Line 13350 is a prefix of 13346. Any time the first TCP read ends after the 214- header, 13350 wins and version detection is finished.

Reproduce locally (no external host)

Save as proftpd_help_split.py and run python3 proftpd_help_split.py. It listens on 127.0.0.1:2121, sends a generic 220 banner (no version), then splits the 1.2.10 HELP table after the 214- header.

python
#!/usr/bin/env python3
import socket
import threading
import time

BANNER = b"220 FTP server ready\r\n"
HELP_HEAD = (
    b"214-The following commands are recognized (* =>'s unimplemented):\r\n"
)
HELP_REST = (
    b" CWD     XCWD    CDUP    XCUP    SMNT*   QUIT    PORT    PASV    \r\n"
    b" EPRT    EPSV    ALLO*   RNFR    RNTO    DELE    MDTM    RMD     \r\n"
    b" XRMD    MKD     XMKD    PWD     XPWD    SIZE    SYST    HELP    \r\n"
    b" NOOP    FEAT    OPTS    AUTH*   CCC*    CONF*   ENC*    MIC*    \r\n"
    b" PBSZ*   PROT*   TYPE    STRU    MODE    RETR    STOR    STOU    \r\n"
    b" APPE    REST    ABOR    USER    PASS    ACCT*   REIN*   LIST    \r\n"
    b" NLST    STAT    SITE    MLSD    MLST    \r\n"
    b"214 Direct comments to [email protected]\r\n"
)
SPLIT_SEC = 0.4  # 0 = one write (version present); 0.4 = prefix match (no version)

def handle(conn):
    try:
        conn.sendall(BANNER)
        data = b""
        conn.settimeout(5)
        while b"\n" not in data:
            chunk = conn.recv(1024)
            if not chunk:
                return
            data += chunk
        if data.upper().startswith(b"HELP"):
            conn.sendall(HELP_HEAD)
            if SPLIT_SEC:
                time.sleep(SPLIT_SEC)
            conn.sendall(HELP_REST)
        time.sleep(0.2)
    except OSError:
        pass
    finally:
        conn.close()

def main():
    s = socket.socket()
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind(("127.0.0.1", 2121))
    s.listen(5)
    print("listening on 127.0.0.1:2121")
    while True:
        c, _ = s.accept()
        threading.Thread(target=handle, args=(c,), daemon=True).start()

if __name__ == "__main__":
    main()

Then:

nmap -Pn -sV -T4 -p2121 127.0.0.1

With SPLIT_SEC = 0.4 (and -T4), service detection often reports ProFTPD with no version. With SPLIT_SEC = 0, the same table is reported as ProFTPD 1.2.10. Adding --version-trace -d shows which Help match line won.

Suggested fix

The prefix rule (current line 13350) should not be a hard match. Options:

  1. Change it to softmatch so Nmap keeps reading until idle / totalwaitms and can still hit the v/1.2.10/ rule.
  2. Same for the intermediate product-only table match (line 13348), which has the same “stop early, no version” shape.
  3. Alternatively, do not apply hard match lines until the 214 multiline HELP reply is complete (ended by a 214 final line) or the socket goes idle.

I am happy to send a probes patch if that is preferred.

Environment

  • Nmap 7.991 (also observed on older 7.x service-probes with the same three Help lines)
  • macOS / Linux
  • Command: nmap -Pn -sV -T4