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

Lesson 6 of 10

The NSE library toolbox

The libraries you'll use in nearly every script: stdnse, shortport, nmap sockets, comm, http and vulns. Plus where to find the rest.

45 minHands-on lab

Real scripts are short because the nselib libraries do the heavy lifting: sockets, protocols, parsing, reporting. Learn the handful below and you can read most of Nmap’s bundled scripts.

stdnse: the everyday helpers

local stdnse = require "stdnse"

-- script arguments: --script-args 'my-script.path=/admin,my-script.timeout=10'
local path    = stdnse.get_script_args(SCRIPT_NAME .. ".path") or "/"
local timeout = tonumber(stdnse.get_script_args(SCRIPT_NAME .. ".timeout")) or 5

-- debug output: only visible with -d (level 1) and up
stdnse.debug1("checking %s:%d", host.ip, port.number)
stdnse.debug2("raw response: %q", data)

-- ordered output table
local out = stdnse.output_table()

-- a timeout scaled to the user's -T timing template
local ms = stdnse.get_timeout(host, 5000)

-- sleeping (cooperatively: other scripts keep running)
stdnse.sleep(0.5)

Script arguments always arrive as strings. Convert with tonumber and validate. get_script_args also accepts several names and returns the first that is set, which lets users pass either my-script.path or a shared short name.

shortport: choosing what to run against

Covered in lesson 5. shortport.http, shortport.port_or_service, shortport.service and shortport.ssl cover nearly every rule you’ll need.

nmap: sockets and the registry

The nmap library gives you raw TCP/UDP sockets that cooperate with NSE’s scheduler.

local nmap = require "nmap"

local socket = nmap.new_socket()          -- TCP by default
socket:set_timeout(5000)                  -- milliseconds

-- nmap.new_try builds a helper that runs your cleanup and aborts on failure
local try = nmap.new_try(function() socket:close() end)

try(socket:connect(host, port))           -- host and port tables work directly
try(socket:send("HELLO\r\n"))
local line = try(socket:receive_lines(1)) -- first line
socket:close()

Socket methods return status, data_or_error. Wrapping them in try(...) unwraps the data on success and, on failure, calls your cleanup function and ends the script quietly. That’s usually what you want for a network hiccup.

Other socket calls: receive_bytes(n), receive_buf(delimiter, keeppattern) for reading until a delimiter, and nmap.new_socket("udp").

The registry is a table shared by every script in the scan:

nmap.registry[SCRIPT_NAME] = nmap.registry[SCRIPT_NAME] or {}
nmap.registry[SCRIPT_NAME].seen = (nmap.registry[SCRIPT_NAME].seen or 0) + 1

Use it to pass data between scripts or between calls, and namespace your keys.

comm: one-call network exchanges

For simple “connect, send something, read the reply” work, comm wraps all the socket code, including trying SSL when appropriate.

local comm = require "comm"

-- just read whatever the server says first (banner grab)
local status, banner = comm.get_banner(host, port, {lines = 1, timeout = 5000})

-- send a request and read the reply
local status, reply = comm.exchange(host, port, "VERSION\r\n", {lines = 1, timeout = 5000})

Both return true, data on success, and false, error_message on failure. Check the status before using the data.

http: talking to web servers

local http = require "http"

local resp = http.get(host, port, "/status")
if resp and resp.status == 200 then
  print(resp.body)
  print(resp.header["server"])       -- header names are lower-cased
end

http.head(host, port, "/")
http.post(host, port, "/login", nil, {user = "a", pass = "b"})

-- useful options
http.get(host, port, "/", {
  timeout = 5000,
  redirect_ok = false,               -- don't follow redirects
  header = {["Accept"] = "text/html"},
  max_body_size = 64 * 1024,         -- stop reading after 64 KiB
})

The response is a table with status (a number), header (lower-case keys), body, and cookies. On a network failure status is nil, so always test it before comparing. The library also caches responses and handles redirects, SSL, and the user agent (--script-args http.useragent=...).

vulns: reporting findings in the standard format

Vulnerability scripts share one output format so results look the same everywhere:

local vulns = require "vulns"

local vuln = {
  title = "Exposed .git directory",
  state = vulns.STATE.NOT_VULN,       -- start pessimistic, then update
  risk_factor = "Medium",
  description = [[The repository metadata is downloadable, which can expose source code and secrets.]],
}

local report = vulns.Report:new(SCRIPT_NAME, host, port)
-- ... run the check ...
vuln.state = vulns.STATE.VULN
return report:make_output(vuln)

make_output produces the familiar State: VULNERABLE block. States include VULN, NOT_VULN, LIKELY_VULN and EXPLOIT. By default only vulnerable results are shown.

A few more you’ll meet

LibraryWhat it’s for
stringauxstrsplit, strjoin and other string helpers.
tableauxTable helpers (copy, merge, membership).
json, base64, urlEncoding and parsing.
sslcert, tlsTLS handshakes and certificate details.
creds, unpwdb, bruteCredential storage, username/password lists, brute-force framework.
targetAdding newly discovered hosts to the scan.
nsedebugPretty-printing tables for debugging (nsedebug.tostr(t)).

Lab

  1. Use shortport.http as the rule.
  2. Use http.get and check resp and resp.status.
  3. Read a web-summary.path script argument with a default of /.
  4. Return a stdnse.output_table() containing path, status, server (only if present) and bytes.
  5. Log the request with stdnse.debug1, and confirm with -d that you can see it.
Solution
local http = require "http"
local shortport = require "shortport"
local stdnse = require "stdnse"

description = [[Summarises the response for a path on a web server.]]
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)
  local path = stdnse.get_script_args(SCRIPT_NAME .. ".path") or "/"
  stdnse.debug1("GET %s on %s:%d", path, host.ip, port.number)

  local resp = http.get(host, port, path)
  if not (resp and resp.status) then return nil end

  local out = stdnse.output_table()
  out.path = path
  out.status = resp.status
  out.server = resp.header["server"]      -- nil is fine: the field is simply omitted
  out.bytes = #resp.body
  return out
end

Run it: nmap -p 8000 --script ./web-summary.nse --script-args web-summary.path=/index.html 127.0.0.1

Checkpoint

A comm.get_banner call returns false, "TIMEOUT". What should your script do?

Log it with stdnse.debug1 and return nil. A timeout is a normal network outcome, not a script failure, and the scan shouldn’t print an error for it.

Why check resp.status rather than just resp?

http.get returns a table even when the request fails. On failure status is nil, so the table alone doesn’t prove you got a response.