Lesson 5 of 10
Anatomy of an NSE script
The required fields, the four rule types, what host and port contain, and how return values become scan output.
An NSE script is a Lua file ending in .nse. Nmap loads it, reads a handful of well-known global variables, and calls your functions at the right moment.
The skeleton
local shortport = require "shortport"
local stdnse = require "stdnse"
description = [[
One or two sentences on what the script does and what it reports.
]]
---
-- @usage
-- nmap -p 80 --script my-script <target>
--
-- @args my-script.path The path to request. Default: "/"
--
-- @output
-- PORT STATE SERVICE
-- 80/tcp open http
-- |_my-script: hello from 10.0.0.5:80
author = "Your Name"
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"
categories = {"discovery", "safe"}
portrule = shortport.http
action = function(host, port)
return ("hello from %s:%d"):format(host.ip, port.number)
end
| Element | Required | Purpose |
|---|---|---|
description | yes | Shown by --script-help. |
author, license | yes | Attribution. The standard line is "Same as Nmap--See https://nmap.org/book/man-legal.html". |
categories | yes | A table of categories (lesson 2). |
| a rule | yes | portrule, hostrule, prerule or postrule. |
action | yes | The function Nmap calls when the rule returns true. |
dependencies | no | Names of scripts that should run before this one. |
| NSEDoc comments | strongly recommended | @usage, @args, @output, and so on, turned into documentation. |
Rules
A rule is a function returning true or false. Nmap calls it to decide whether to run your action.
-- Once per port. Receives the host and port tables.
portrule = function(host, port)
return port.protocol == "tcp" and port.state == "open" and port.number == 5555
end
-- Once per host.
hostrule = function(host)
return host.os ~= nil -- only hosts where OS detection produced something
end
-- Before / after the whole scan. No arguments.
prerule = function() return true end
postrule = function() return true end
You’ll rarely write a portrule by hand. shortport provides tested predicates:
portrule = shortport.http -- http/https services and typical web ports
portrule = shortport.port_or_service(5555, "acmeq") -- by port number or by detected service name
portrule = shortport.port_or_service({80, 8080}, {"http", "http-alt"})
portrule = shortport.service("ssh") -- service name only (needs -sV)
portrule = shortport.ssl -- SSL/TLS-wrapped ports
port_or_service is the safest default: it still fires when -sV didn’t run or couldn’t identify the service.
The host and port tables
Nmap passes rich tables to your rule and action. The fields you’ll use most:
| Field | Example | Meaning |
|---|---|---|
host.ip | "10.0.0.5" | Target IP address. |
host.name | "web01.example.com" | Reverse-DNS name (may be empty). |
host.targetname | "web01" | The name the user typed, if any. |
host.os | table or nil | OS detection matches (-O). |
port.number | 80 | Port number. |
port.protocol | "tcp" | tcp, udp or sctp. |
port.state | "open" | Port state. |
port.service | "http" | Detected service name. |
port.version | table | name, product, version, extrainfo, tunnel from -sV. |
SCRIPT_NAME is another global Nmap sets for you: the script’s name without .nse. Use it to namespace script arguments and registry keys.
Returning output
What action returns becomes the scan output.
| Return value | Result |
|---|---|
| a string | Printed as |_script-name: your string. |
| a table | Printed as a tree, and emitted as structured XML with -oX. |
nil | No output for this host/port. |
| table, string | The table goes to XML, the string is shown in normal output. |
A string is fine for one-liners. For anything with more than one field, return a table: the console output is still readable, and the XML output that other tools consume is properly structured.
action = function(host, port)
local out = stdnse.output_table() -- an ordered table, so fields print in insertion order
out.service = port.service
out.reachable = true
out.ports = {80, 443}
return out
end
| my-script:
| service: http
| reachable: true
| ports:
| 80
|_ 443
Returning nil is how a script says “nothing to report”. This also means a bug that returns nil early looks exactly like a script that legitimately found nothing. Lesson 9 shows how to tell them apart.
Documenting with NSEDoc
The --- block of -- comments above the fields isn’t decoration. It’s parsed into the documentation on nmap.org and by nmap --script-help. Fill in @usage, @args and @output, and add @xmloutput when you return a table. A script without them is unfinished.
Running your script
nmap -p 8000 --script ./hello.nse 127.0.0.1 # by path: always works
nmap -p 8000 --script-help ./hello.nse # check your docs render
Lab
- Copy the skeleton above into
hello.nse. Run it against127.0.0.1and confirm you gethello from 127.0.0.1:8000. - Change it to return a table with
ip,port,serviceandstate. Run it and look at the tree. - Run it with
-oX -and find your table in the XML. - Change the rule to
shortport.port_or_service(9999, "nothing")and run it again. Nothing prints: your rule says don’t run. Restore it. - Add a
hostruleversion,hello-host.nse, that printshost.nameandhost.iponce per host. Run with-snand then with-p 8000.
Checkpoint
What's the difference between returning nil and returning an empty string?
nil means “no output”: Nmap prints nothing for this script. An empty string still counts as output and typically produces an empty result line.
Why namespace script arguments as SCRIPT_NAME .. ".arg"?
Several scripts can be run in the same scan. Prefixing with the script name stops arguments colliding and lets users target one script with –script-args my-script.path=/x.