← Nmap Scripting Engine (NSE): Writing Your Own Scripts

Lesson 7 of 10

Scenario 1: fingerprinting a custom TCP service

Write a complete script from scratch: connect to an in-house service, parse its banner, and flag outdated versions across a network.

60 minHands-on lab

The scenario

Your company runs an internal message-queue daemon, ACMEQ, on TCP port 5555. Version 1.5.0 fixed a serious bug, and you’ve been asked: which servers are still running something older?

No public Nmap script knows about ACMEQ, and nobody wants to log in to 400 hosts. When a client connects, the server announces itself:

ACMEQ/1.4.2 ready

That banner is all we need. This is the most common shape of custom NSE script: connect, read, parse, judge, report.

Step 1: build the lab target

We’ll fake the service with a few lines of Python. Save it as acmeq_server.py and leave it running:

import socketserver

class Handler(socketserver.BaseRequestHandler):
    def handle(self):
        self.request.sendall(b"ACMEQ/1.4.2 ready\r\n")

socketserver.ThreadingTCPServer.allow_reuse_address = True
with socketserver.ThreadingTCPServer(("127.0.0.1", 5555), Handler) as server:
    print("ACMEQ lab server on 127.0.0.1:5555")
    server.serve_forever()

Check it works before writing any NSE: nmap -p 5555 -sV 127.0.0.1 shows the port open. (The service name is Nmap’s guess from nmap-services. It doesn’t know ACMEQ.)

Step 2: decide when to run

We don’t want to trust Nmap’s service detection to recognise ACMEQ, since it can’t. So we select by port number, with a service name as a bonus for anyone running it on another port after adding a custom probe:

portrule = shortport.port_or_service(5555, "acmeq")

Step 3: the complete script

Save as acmeq-info.nse:

local comm      = require "comm"
local shortport = require "shortport"
local stdnse    = require "stdnse"

description = [[
Connects to an ACMEQ message-queue daemon, reads its banner and reports the
version. Flags releases older than the minimum supported version.
]]

---
-- @usage
-- nmap -p 5555 --script acmeq-info <target>
-- nmap -p 5555 --script acmeq-info --script-args acmeq-info.min=1.5.0 <target>
--
-- @args acmeq-info.min  Minimum acceptable version. Default: "1.5.0".
--
-- @output
-- PORT     STATE SERVICE
-- 5555/tcp open  freeciv
-- | acmeq-info:
-- |   version: 1.4.2
-- |   supported: false
-- |_  note: older than minimum 1.5.0

author = "Your Name"
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"
categories = {"discovery", "safe"}

portrule = shortport.port_or_service(5555, "acmeq")

local function parse_version(s)
  local a, b, c = s:match("^(%d+)%.(%d+)%.(%d+)$")
  if not a then return nil end
  return {tonumber(a), tonumber(b), tonumber(c)}
end

local function older_than(v, min)
  for i = 1, 3 do
    if v[i] ~= min[i] then return v[i] < min[i] end
  end
  return false
end

action = function(host, port)
  local min_str = stdnse.get_script_args(SCRIPT_NAME .. ".min") or "1.5.0"
  local min = parse_version(min_str)
  if not min then
    return "ERROR: acmeq-info.min must look like 1.5.0, got " .. min_str
  end

  local status, banner = comm.get_banner(host, port, {lines = 1, timeout = 5000})
  if not status then
    stdnse.debug1("no banner from %s:%d: %s", host.ip, port.number, tostring(banner))
    return nil
  end

  local version = banner:match("^ACMEQ/(%d+%.%d+%.%d+)")
  if not version then
    stdnse.debug1("banner is not ACMEQ: %q", banner)
    return nil
  end

  local out = stdnse.output_table()
  out.version = version
  out.supported = not older_than(parse_version(version), min)
  if not out.supported then
    out.note = ("older than minimum %s"):format(min_str)
  end
  return out
end

Step 4: run it

nmap -p 5555 --script ./acmeq-info.nse 127.0.0.1
nmap -p 5555 --script ./acmeq-info.nse --script-args acmeq-info.min=1.4.0 127.0.0.1
nmap -p 5555 --script ./acmeq-info.nse --script-args acmeq-info.min=banana 127.0.0.1

Expected results:

| acmeq-info:
|   version: 1.4.2
|   supported: false
|_  note: older than minimum 1.5.0

The second run (minimum 1.4.0) reports supported: true and no note. The third reports the argument error.

Walk-through: the decisions that matter

  • Validate arguments first. A typo in --script-args shouldn’t silently produce wrong answers. It should tell the user.
  • A failed connection returns nil. Across 400 hosts, most won’t run ACMEQ. Timeouts and refusals are normal and shouldn’t clutter output.
  • We check the banner really is ACMEQ before reporting. Something else could be listening on 5555.
  • Versions are compared numerically (lesson 4). 1.10.0 is newer than 1.9.9.
  • We return a table, so -oX gives you structured version and supported fields you can load into a spreadsheet or pipeline.
  • comm.get_banner does the socket work. No manual connect, receive, or cleanup.

Scan a whole estate

Because the script is just a portrule, it scales for free:

nmap -p 5555 --open --script ./acmeq-info.nse -iL servers.txt -oX acmeq.xml

Stretch exercises

  1. Ask the server directly. Make the mock server answer a VERSION\r\n command with VERSION 1.4.2\r\n, and change the script to use comm.exchange(host, port, "VERSION\r\n", ...). What happens if the server sends nothing back?
  2. A script argument for the port. Add acmeq-info.port so users can run against a non-standard port, and make the portrule use it. (Hint: rules can call stdnse.get_script_args too.)
  3. Report to Nmap’s version detection. Set port.version.name = "acmeq", port.version.product = "ACMEQ" and port.version.version = version, then call nmap.set_port_version(host, port) so -sV output includes it. Read --script-help for scripts in the version category for examples.
  4. Multiple failure modes. Make the mock server sleep 10 seconds before sending the banner. Does your script give up cleanly? What does -d show?

Checkpoint

Why does the script return nil when the banner doesn't match instead of printing "not ACMEQ"?

Scanning a network turns up plenty of unrelated services on the port. Printing a message for each would be noise. nil keeps the output limited to real findings, and stdnse.debug1 keeps the detail available with -d.

What would break if older_than compared version strings directly?

“1.10.0” < “1.9.9” is true for strings, so ACMEQ 1.10.0 would be wrongly flagged as outdated.