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.
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-argsshouldn’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.0is newer than1.9.9. - We return a table, so
-oXgives you structuredversionandsupportedfields you can load into a spreadsheet or pipeline. comm.get_bannerdoes 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
- Ask the server directly. Make the mock server answer a
VERSION\r\ncommand withVERSION 1.4.2\r\n, and change the script to usecomm.exchange(host, port, "VERSION\r\n", ...). What happens if the server sends nothing back? - A script argument for the port. Add
acmeq-info.portso users can run against a non-standard port, and make theportruleuse it. (Hint: rules can callstdnse.get_script_argstoo.) - Report to Nmap’s version detection. Set
port.version.name = "acmeq",port.version.product = "ACMEQ"andport.version.version = version, then callnmap.set_port_version(host, port)so-sVoutput includes it. Read--script-helpfor scripts in theversioncategory for examples. - Multiple failure modes. Make the mock server sleep 10 seconds before sending the banner. Does your script give up cleanly? What does
-dshow?
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.