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

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.

20 minHands-on lab

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
ElementRequiredPurpose
descriptionyesShown by --script-help.
author, licenseyesAttribution. The standard line is "Same as Nmap--See https://nmap.org/book/man-legal.html".
categoriesyesA table of categories (lesson 2).
a ruleyesportrule, hostrule, prerule or postrule.
actionyesThe function Nmap calls when the rule returns true.
dependenciesnoNames of scripts that should run before this one.
NSEDoc commentsstrongly 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:

FieldExampleMeaning
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.ostable or nilOS detection matches (-O).
port.number80Port number.
port.protocol"tcp"tcp, udp or sctp.
port.state"open"Port state.
port.service"http"Detected service name.
port.versiontablename, 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 valueResult
a stringPrinted as |_script-name: your string.
a tablePrinted as a tree, and emitted as structured XML with -oX.
nilNo output for this host/port.
table, stringThe 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

  1. Copy the skeleton above into hello.nse. Run it against 127.0.0.1 and confirm you get hello from 127.0.0.1:8000.
  2. Change it to return a table with ip, port, service and state. Run it and look at the tree.
  3. Run it with -oX - and find your table in the XML.
  4. Change the rule to shortport.port_or_service(9999, "nothing") and run it again. Nothing prints: your rule says don’t run. Restore it.
  5. Add a hostrule version, hello-host.nse, that prints host.name and host.ip once per host. Run with -sn and 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.