Feat: Add ZMTP 2.0 support, ERROR parsing, and custom metadata dump

- Handle both ZMTP 2.0 (revision+socket-type+identity greeting) and 3.x
- Parse ERROR commands from server during NULL handshake
- Dump all metadata properties from READY, not just hardcoded ones
- Use receive_bytes(10) to support shorter 2.0 greetings
- Refactor into parse_zmtp2/parse_zmtp3 for cleaner version handling
This commit is contained in:
Valentin Lobstein 2026-02-11 06:59:28 +01:00
parent eddc0960fd
commit 8703877d7d
No known key found for this signature in database
GPG key ID: F053C2B15CE40815

View file

@ -5,26 +5,31 @@ local string = require "string"
description = [[
Detects ZeroMQ ZMTP (ZeroMQ Message Transport Protocol) services by performing
a ZMTP 3.x greeting and NULL mechanism handshake. Reports the protocol version,
security mechanism (NULL, PLAIN, or CURVE), socket type (REP, PULL, ROUTER,
PUB, etc.), and additional metadata properties.
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 41459,50051,56441 <target>
-- nmap --script zmtp-info -p 5555,41459 <target>
-- nmap --script zmtp-info -sV <target>
--
-- @output
-- PORT STATE SERVICE VERSION
-- 41459/tcp open zmtp ZeroMQ ZMTP 3.1 (mechanism: NULL)
-- 5555/tcp open zmtp ZeroMQ ZMTP 3.1 (mechanism: NULL; socket: REP)
-- | zmtp-info:
-- | protocol: ZMTP
-- | version: 3.1
@ -35,13 +40,21 @@ References:
--
-- @output
-- PORT STATE SERVICE VERSION
-- 56441/tcp open zmtp ZeroMQ ZMTP 3.1 (mechanism: NULL)
-- 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"
@ -59,18 +72,28 @@ local ZMTP_GREETING = "\xff\x00\x00\x00\x00\x00\x00\x00\x01\x7f" -- signature
.. "\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) + "READY" + metadata
-- For NULL mechanism, we just send READY with our Socket-Type
-- Frame: flags(1) + size(1) + "\x05READY" + metadata
local function build_ready_command(socket_type)
local name = "\x05READY"
-- Metadata: Socket-Type property
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
-- Short command frame: flags=0x04 (command), size
return "\x04" .. string.char(#body) .. body
end
@ -80,20 +103,21 @@ local function parse_metadata(data)
local props = {}
local pos = 1
while pos <= #data do
-- Property name length (1 byte)
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
-- Property value length (4 bytes big-endian)
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 pos + val_len - 1 > #data then break end
local value = string.sub(data, pos, pos + val_len - 1)
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
@ -107,7 +131,7 @@ local function parse_command_frame(data)
local is_command = (flags == 0x04) or (flags == 0x06)
if not is_command then return nil, nil end
local body_start, body
local body
if flags == 0x04 then
-- Short frame
local size = string.byte(data, 2)
@ -132,6 +156,116 @@ local function parse_command_frame(data)
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
@ -154,7 +288,7 @@ action = function(host, port)
return nil
end
-- Send ZMTP greeting
-- Send ZMTP 3.1 greeting (backwards-compatible with 2.0)
status, err = socket:send(ZMTP_GREETING)
if not status then
socket:close()
@ -162,11 +296,12 @@ action = function(host, port)
return nil
end
-- Receive 64-byte greeting response
-- Receive at least the 10-byte signature.
-- For ZMTP 3.x we typically get 64+ bytes, for 2.0 we get ~14 bytes.
local response
status, response = socket:receive_bytes(64)
status, response = socket:receive_bytes(10)
if not status or #response < 10 then
if not status or #response < 11 then
socket:close()
stdnse.debug1("No valid response received")
return nil
@ -185,71 +320,29 @@ action = function(host, port)
local output = stdnse.output_table()
output.protocol = "ZMTP"
-- Parse version
local major, minor
if #response >= 12 then
major = string.byte(response, 11)
minor = string.byte(response, 12)
output.version = string.format("%d.%d", major, minor)
end
local major = string.byte(response, 11)
-- 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
local as_server = string.byte(response, 33)
output["as-server"] = (as_server == 1)
end
-- For NULL mechanism, perform handshake to get metadata (Socket-Type, Identity, etc.)
if mechanism == "NULL" and major and major >= 3 then
-- Send READY command with Socket-Type=DEALER (most permissive)
local ready_cmd = build_ready_command("DEALER")
status, err = socket:send(ready_cmd)
if status then
local ready_data
-- The server may send its READY command in the same TCP segment as the greeting.
-- If we received more than 64 bytes, the extra bytes are the READY command.
if #response > 64 then
ready_data = string.sub(response, 65)
stdnse.debug1("READY piggybacked on greeting (%d extra bytes)", #ready_data)
else
-- READY arrived separately, read it now
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)
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
end
if major >= 3 then
-- ZMTP 3.x: ensure we have the full 64-byte greeting
if #response < 64 then
local s, more = socket:receive_bytes(64 - #response)
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
-- Unknown version, report what we know
output.version = string.format("%d.0", major)
stdnse.debug1("Unknown ZMTP major version: %d", major)
end
socket:close()
-- Update port version info
-- Update port version info for -sV output
port.version.name = "zmtp"
port.version.product = "ZeroMQ ZMTP"
if output.version then