diff --git a/nmap-service-probes b/nmap-service-probes index f8b9bc9ea..0f0a2d1aa 100644 --- a/nmap-service-probes +++ b/nmap-service-probes @@ -5279,8 +5279,25 @@ match zeo-monitor m|^ZEO monitor server version ([\w._-]+)\n.*\n\nStorage: \d+\n match zos-commserver m|^EZY1315E \d\d/\d\d/\d\d \d\d:\d\d:\d\d INVALID TRANID=\r\n\r\n PARTNER INET ADDR=[\d.]+ PORT= \d+ | p|IBM z/OS Communications Server| o|z/OS| cpe:/o:ibm:z%2fos/ # http://rfc.zeromq.org/spec:15 -# This is a backwards-compatible handshake -match zmtp m|^\xff\0\0\0\0\0\0\0\x01\x7f$| p/ZeroMQ ZMTP 2.0/ +# ZMTP signature: 0xFF + 8 padding bytes + 0x7F. Padding may contain identity +# length for backward compat (can be 0x0A = \n, so 's' flag is required). +# Original match required exactly 10 bytes ($), but 2.0 servers send more +# (revision + socket-type + identity frame). Use 's' flag for \n in padding. +match zmtp m|^\xff.{8}\x7f|s p/ZeroMQ ZMTP/ + +##############################NEXT PROBE############################## +# ZMTP 3.x (ZeroMQ Message Transport Protocol) +# https://rfc.zeromq.org/spec/23/ (ZMTP 3.0) +# https://rfc.zeromq.org/spec/37/ (ZMTP 3.1) +# 64-byte greeting: signature(10) + version(2) + mechanism(20) + as-server(1) + filler(31) +Probe TCP ZMTPGreeting q|\xff\0\0\0\0\0\0\0\x01\x7f\x03\x01NULL\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0| +rarity 5 +ports 41459,50051,56441,30000-65535 + +# ZMTP response: 64-byte greeting starting with signature 0xFF...0x7F +# Catches all ZMTP versions (2.0, 3.0, 3.1+). NSE script handles detailed parsing. +# 's' flag: padding bytes may contain 0x0A (\n) when identity length == 10. +match zmtp m|^\xff.{8}\x7f|s p/ZeroMQ ZMTP/ # http://www.space-walrus.com/games/Minebuilder # Very general, so leaving it here at the end diff --git a/scripts/zmtp-info.nse b/scripts/zmtp-info.nse new file mode 100644 index 000000000..58dea0c6a --- /dev/null +++ b/scripts/zmtp-info.nse @@ -0,0 +1,379 @@ +local nmap = require "nmap" +local shortport = require "shortport" +local stdnse = require "stdnse" +local string = require "string" + +description = [[ +Detects ZeroMQ ZMTP (ZeroMQ Message Transport Protocol) services by performing +a protocol handshake. Supports both ZMTP 2.0 and 3.x. Reports the protocol +version, security mechanism (NULL, PLAIN, or CURVE), socket type (REP, PULL, +ROUTER, PUB, etc.), and additional metadata properties. + +For ZMTP 3.x with NULL mechanism, performs a READY handshake to extract +metadata such as Socket-Type, Identity, and any custom properties. For ZMTP +2.0, extracts socket type and identity directly from the greeting. + +ZeroMQ is a high-performance asynchronous messaging library used in distributed +systems, microservices, financial trading, IoT, and ML/AI infrastructure. + +References: +* https://rfc.zeromq.org/spec/15/ (ZMTP 2.0) +* https://rfc.zeromq.org/spec/23/ (ZMTP 3.0) +* https://rfc.zeromq.org/spec/37/ (ZMTP 3.1) +]] + +--- +-- @usage +-- nmap --script zmtp-info -p 5555,41459 +-- nmap --script zmtp-info -sV +-- +-- @output +-- PORT STATE SERVICE VERSION +-- 5555/tcp open zmtp ZeroMQ ZMTP 3.1 (mechanism: NULL; socket: REP) +-- | zmtp-info: +-- | protocol: ZMTP +-- | version: 3.1 +-- | mechanism: NULL +-- | as-server: true +-- | socket-type: REP +-- |_ identity: worker-1 +-- +-- @output +-- PORT STATE SERVICE VERSION +-- 5556/tcp open zmtp ZeroMQ ZMTP 3.1 (mechanism: NULL; socket: ROUTER) +-- | zmtp-info: +-- | protocol: ZMTP +-- | version: 3.1 +-- | mechanism: NULL +-- | as-server: false +-- |_ socket-type: ROUTER +-- +-- @output +-- PORT STATE SERVICE VERSION +-- 5557/tcp open zmtp ZeroMQ ZMTP 2.0 (socket: PUB) +-- | zmtp-info: +-- | protocol: ZMTP +-- | version: 2.0 +-- |_ socket-type: PUB + +author = "Valentin Lobstein (Chocapikk)" +license = "Same as Nmap--See https://nmap.org/book/man-legal.html" +categories = {"default", "discovery", "safe", "version"} + +-- ZMTP 3.1 greeting: 64 bytes +-- Signature: 0xFF + 8 padding bytes + 0x7F +-- Version: 0x03 0x01 (ZMTP 3.1) +-- Mechanism: "NULL" + 16 zero bytes (20 bytes total) +-- As-server: 0x00 (client role) +-- Filler: 31 zero bytes +local ZMTP_GREETING = "\xff\x00\x00\x00\x00\x00\x00\x00\x01\x7f" -- signature + .. "\x03\x01" -- version 3.1 + .. "NULL\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" -- mechanism + .. "\x00" -- as-server = false + .. string.rep("\x00", 31) -- filler + +-- ZMTP 2.0 socket type byte to name mapping +local ZMTP2_SOCKET_TYPES = { + [0] = "PAIR", + [1] = "PUB", + [2] = "SUB", + [3] = "REQ", + [4] = "REP", + [5] = "DEALER", + [6] = "ROUTER", + [7] = "PULL", + [8] = "PUSH", +} + +-- NULL mechanism READY command +-- Frame: flags(1) + size(1) + "\x05READY" + metadata +local function build_ready_command(socket_type) + local name = "\x05READY" + local prop_name = "Socket-Type" + local prop_name_len = string.char(#prop_name) + local prop_val_len = string.char(0, 0, 0, #socket_type) + local metadata = prop_name_len .. prop_name .. prop_val_len .. socket_type + local body = name .. metadata + return "\x04" .. string.char(#body) .. body +end + +-- Parse metadata properties from a READY command body +-- Format: repeated [name-length(1) name value-length(4 BE) value] +local function parse_metadata(data) + local props = {} + local pos = 1 + while pos <= #data do + local name_len = string.byte(data, pos) + if not name_len then break end + pos = pos + 1 + if pos + name_len - 1 > #data then break end + local name = string.sub(data, pos, pos + name_len - 1) + pos = pos + name_len + if pos + 3 > #data then break end + local b1, b2, b3, b4 = string.byte(data, pos, pos + 3) + local val_len = b1 * 16777216 + b2 * 65536 + b3 * 256 + b4 + pos = pos + 4 + if val_len > 0 and pos + val_len - 1 > #data then break end + local value = "" + if val_len > 0 then + value = string.sub(data, pos, pos + val_len - 1) + end + pos = pos + val_len + props[name] = value + end + return props +end + +-- Parse a ZMTP command frame and return command name + body +local function parse_command_frame(data) + if #data < 2 then return nil, nil end + local flags = string.byte(data, 1) + local is_command = (flags == 0x04) or (flags == 0x06) + if not is_command then return nil, nil end + + local body + if flags == 0x04 then + -- Short frame + local size = string.byte(data, 2) + if #data < 2 + size then return nil, nil end + body = string.sub(data, 3, 2 + size) + else + -- Long frame (0x06) + if #data < 9 then return nil, nil end + local size = 0 + for i = 2, 9 do + size = size * 256 + string.byte(data, i) + end + if #data < 9 + size then return nil, nil end + body = string.sub(data, 10, 9 + size) + end + + if not body or #body < 1 then return nil, nil end + local cmd_name_len = string.byte(body, 1) + if #body < 1 + cmd_name_len then return nil, nil end + local cmd_name = string.sub(body, 2, 1 + cmd_name_len) + local cmd_data = string.sub(body, 2 + cmd_name_len) + return cmd_name, cmd_data +end + +-- Apply all metadata properties from READY to the output table +local function apply_metadata(props, output) + if props["Socket-Type"] then + output["socket-type"] = props["Socket-Type"] + end + if props["Identity"] and #props["Identity"] > 0 then + output["identity"] = props["Identity"] + end + if props["Resource"] and #props["Resource"] > 0 then + output["resource"] = props["Resource"] + end + -- Dump any additional custom properties + for name, value in pairs(props) do + local lname = name:lower() + if lname ~= "socket-type" and lname ~= "identity" and lname ~= "resource" then + if #value > 0 then + output[name] = value + end + end + end +end + +-- Parse ZMTP 2.0 greeting (after the 10-byte signature) +-- Format: revision(1) + socket-type(1), then identity frame follows +local function parse_zmtp2(response, output) + output.version = "2.0" + + if #response >= 12 then + local sock_type_byte = string.byte(response, 12) + local sock_name = ZMTP2_SOCKET_TYPES[sock_type_byte] + if sock_name then + output["socket-type"] = sock_name + else + output["socket-type"] = string.format("unknown(%d)", sock_type_byte) + end + end + + -- Identity frame: flags(1) + length(1) + data + -- flags byte at offset 13: 0x00 = final short frame + if #response >= 14 then + local id_flags = string.byte(response, 13) + local is_long = (id_flags % 4) >= 2 + if not is_long then + local id_len = string.byte(response, 14) + if id_len and id_len > 0 and #response >= 14 + id_len then + local identity = string.sub(response, 15, 14 + id_len) + output["identity"] = identity + end + end + end +end + +-- Parse ZMTP 3.x greeting (full 64-byte greeting) and perform READY handshake +local function parse_zmtp3(response, socket, output) + local minor = 0 + if #response >= 12 then + minor = string.byte(response, 12) + end + output.version = string.format("3.%d", minor) + + -- Parse mechanism (20 bytes at offset 13-32, null-padded string) + local mechanism + if #response >= 32 then + local mech_raw = string.sub(response, 13, 32) + mechanism = mech_raw:match("^(%Z+)") + if mechanism then + output.mechanism = mechanism + end + end + + -- Parse as-server flag (byte 33) + if #response >= 33 then + output["as-server"] = (string.byte(response, 33) == 1) + end + + -- For NULL mechanism, perform READY handshake to extract metadata + if mechanism == "NULL" then + local ready_cmd = build_ready_command("DEALER") + local status, err = socket:send(ready_cmd) + if not status then + stdnse.debug1("Failed to send READY: %s", err) + return + end + + local ready_data + -- Server may piggyback READY on the greeting (same TCP segment) + if #response > 64 then + ready_data = string.sub(response, 65) + stdnse.debug1("READY piggybacked on greeting (%d extra bytes)", #ready_data) + else + local ready_response + status, ready_response = socket:receive_bytes(2) + if status and ready_response and #ready_response > 0 then + ready_data = ready_response + end + end + + if ready_data and #ready_data > 0 then + local cmd_name, cmd_data = parse_command_frame(ready_data) + if cmd_name == "READY" and cmd_data then + local props = parse_metadata(cmd_data) + apply_metadata(props, output) + elseif cmd_name == "ERROR" and cmd_data then + output["error"] = cmd_data + stdnse.debug1("Server returned ERROR: %s", cmd_data) + end + end + end +end + +portrule = function(host, port) + if port.version and port.version.name == "zmtp" then + return true + end + return shortport.port_or_service( + {41459, 50051, 56441}, + {"zmtp", "zeromq"}, + "tcp", + "open" + )(host, port) +end + +action = function(host, port) + local socket = nmap.new_socket() + socket:set_timeout(5000) + + local status, err = socket:connect(host, port) + if not status then + stdnse.debug1("Failed to connect: %s", err) + return nil + end + + -- Send ZMTP 3.1 greeting (backwards-compatible with 2.0) + status, err = socket:send(ZMTP_GREETING) + if not status then + socket:close() + stdnse.debug1("Failed to send greeting: %s", err) + return nil + end + + -- Receive response. We ask for at least 10 bytes (the signature). + -- ZMTP 3.x servers typically send 64+ bytes, 2.0 servers send ~14 bytes. + -- CURVE/PLAIN servers may send only 10 bytes initially (partial greeting). + local response + status, response = socket:receive_bytes(10) + + if not status or #response < 10 then + socket:close() + stdnse.debug1("No valid response received") + return nil + end + + -- Validate ZMTP signature: first byte 0xFF, byte 10 is 0x7F + local sig_start = string.byte(response, 1) + local sig_end = string.byte(response, 10) + + if sig_start ~= 0xFF or sig_end ~= 0x7F then + socket:close() + stdnse.debug1("Not a ZMTP service (signature mismatch: %02x...%02x)", sig_start, sig_end) + return nil + end + + -- If we only got the 10-byte signature (partial greeting from CURVE/PLAIN + -- servers), try to receive more bytes for version info. + if #response == 10 then + stdnse.debug1("Got only signature (10 bytes), waiting for version...") + local s, more = socket:receive_bytes(1) + if s and more then + response = response .. more + end + end + + local output = stdnse.output_table() + output.protocol = "ZMTP" + + if #response < 11 then + -- Only got the signature, no version byte. Report as ZMTP detected. + stdnse.debug1("ZMTP signature confirmed but no version received (%d bytes)", #response) + else + local major = string.byte(response, 11) + + if major >= 3 then + -- ZMTP 3.x: try to get the full 64-byte greeting + if #response < 64 then + local s, more = socket:receive_bytes(1) + if s and more then + response = response .. more + end + end + parse_zmtp3(response, socket, output) + elseif major == 1 or major == 2 then + -- ZMTP 2.0 (revision byte 0x01) or ZMTP 2.1 (0x02) + parse_zmtp2(response, output) + else + output.version = string.format("%d.0", major) + stdnse.debug1("Unknown ZMTP major version: %d", major) + end + end + + socket:close() + + -- Update port version info for -sV output + port.version.name = "zmtp" + port.version.product = "ZeroMQ ZMTP" + if output.version then + port.version.version = output.version + end + local extra = {} + if output.mechanism then + extra[#extra + 1] = "mechanism: " .. output.mechanism + end + if output["socket-type"] then + extra[#extra + 1] = "socket: " .. output["socket-type"] + end + if #extra > 0 then + port.version.extrainfo = table.concat(extra, "; ") + end + nmap.set_port_version(host, port) + + return output +end