Lesson 9 of 10
Debugging NSE scripts
Find out why a script is silent, slow, or crashing, using -d, --script-trace, --packet-trace, debug logging and a repeatable checklist.
Debugging NSE has one twist: a script that fails, a script that finds nothing and a script that never ran can all look identical: a scan with no output for that port. Your first job is always to work out which one you’re dealing with.
The debugging toolbox
| Tool | Shows you |
|---|---|
-d (or -d2 … -d9) | Nmap and NSE debug output. Script errors get full stack traces. |
--script-trace | Every network read and write your script performs, in both directions. |
--packet-trace | Every packet Nmap sends and receives (broader: includes the port scan). |
stdnse.debug1(...) | Your own log lines, shown at -d and above. |
nsedebug.tostr(t) | Turns a table into a printable string. |
-oX - | The structured XML your script produces. |
--script-help | Confirms your NSEDoc parses and shows in the docs. |
Start with -d. Increase the level only when you need more.
nmap -d -p 8000 --script ./my-script.nse 127.0.0.1
nmap -d --script-trace -p 8000 --script ./my-script.nse 127.0.0.1
What a crash looks like
Without -d, an unhandled error is deliberately terse:
PORT STATE SERVICE
7080/tcp open empowerid
|_buggy-server: ERROR: Script execution failed (use -d to debug)
With -d you get the real message and a stack trace:
NSE: buggy-server against 127.0.0.1:7080 threw an error!
./buggy-server.nse:13: attempt to concatenate a nil value (field 'server')
stack traceback:
./buggy-server.nse:13: in function <./buggy-server.nse:11>
(...tail calls...)
Read it from the top: file, line number, then what went wrong. The line number points you straight at the bug.
Your own log lines
stdnse.debug1 through debug5 print only when the user runs with -d at that level or higher, so you can leave them in production scripts.
stdnse.debug1("connecting to %s:%d", host.ip, port.number)
stdnse.debug2("banner=%q", banner) -- %q shows hidden characters like \r\n
stdnse.debug3("headers=%s", nsedebug.tostr(resp.header))
Use %q when logging network data. It quotes the string and shows \r, \n and control bytes that would otherwise be invisible.
Lab: fix the broken script
local http = require "http"
local shortport = require "shortport"
description = "Prints the Server header"
author = "You"
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"
categories = {"safe"}
portrule = shortport.port_or_service(80, "http")
action = function(host, port)
local response = http.get(host, port, "/")
return "Server: " .. response.header.server
end
Python’s built-in web server always sends a Server header, so for this lab use a bare server that doesn’t. Save it as bare_server.py and leave it running:
import socketserver
class Handler(socketserver.BaseRequestHandler):
def handle(self):
self.request.recv(4096)
self.request.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
socketserver.ThreadingTCPServer.allow_reuse_address = True
with socketserver.ThreadingTCPServer(("127.0.0.1", 7080), Handler) as server:
server.serve_forever()
Keep the python3 -m http.server 8000 server from earlier running too.
Symptom 1: silence. Run nmap -p 8000 --script ./buggy-server.nse 127.0.0.1. The port is listed, but there’s no script output at all. Is that “ran and found nothing”, “crashed” or “never ran”?
Run it again with -d and look at the NSE lines. You’ll see the script loaded, and no error. Now compare the port number and its detected service name against the script’s rule.
Symptom 2: a crash. Fix the rule (switch it to shortport.http), then run against the bare server: nmap -p 7080 --script ./buggy-server.nse 127.0.0.1. What does the output say? Rerun with -d. What extra information do you get, and which line of your script does it point at?
Symptom 3: watching the traffic. Use --script-trace to watch the request and response on port 7080:
nmap --script-trace -p 7080 --script ./buggy-server.nse 127.0.0.1
You’ll see lines like the following. Look at the response and find the header the code assumed would be there.
NSE: TCP 127.0.0.1:20897 > 127.0.0.1:7080 | CONNECT
NSE: TCP 127.0.0.1:20897 > 127.0.0.1:7080 | 00000000: 47 45 54 20 2f 20 48 54 54 50 2f 31 2e 31 0d 0a GET / HTTP/1.1
NSE: TCP 127.0.0.1:20897 < 127.0.0.1:7080 | 00000000: 48 54 54 50 2f 31 2e 31 20 32 30 30 20 4f 4b 0d HTTP/1.1 200 OK
Diagnosis and fix
Bug 1: the rule. port_or_service(80, "http") selects port 80, or ports whose service is named exactly http. Nmap labels port 8000 as http-alt and 7080 as something else entirely, so the rule is false and the script never runs. That’s why Symptom 1 shows nothing at all, not even an error. Use shortport.http, which also recognises http-alt and other common web ports.
Bug 2: unchecked nil. http.get returns a table even on failure, and the Server header is optional: the bare server doesn’t send one. Concatenating nil raises “attempt to concatenate a nil value”. Against the Python server, which does send the header, the same script works, which is why bugs like this survive casual testing. Fixed version:
portrule = shortport.http
action = function(host, port)
local response = http.get(host, port, "/")
if not (response and response.status) then
return nil -- request failed: nothing to report
end
local server = response.header["server"]
if not server then return nil end -- no header: nothing to report
return "Server: " .. server
end
Note also that the header name is lower-case in the http library’s response table.
A repeatable checklist
When a script does nothing or the wrong thing, work down this list. Each step rules something out.
- Did it load? Run with
-d. A syntax error appears at startup (NSE: failed to initialize the script engine) with the file and line. - Did the rule match? Temporarily make it
return true(or print withstdnse.debug1at the start of the rule). If the script now runs, the rule was wrong. Check port number, protocol, state and the service name (-sVchanges it). - Did
actionstart? Addstdnse.debug1("action start")as its first line. Look for it under-d. - Did the network call succeed? Use
--script-trace, or log thestatus, erra library call returns. - Is the data what you expect? Log it with
%q, or withnsedebug.tostrfor tables. - Does it return what you think it returns? Run with
-oX -and check the XML. An earlyreturn nillooks like “nothing found”.
Common Lua and NSE mistakes
| Symptom | Likely cause |
|---|---|
attempt to concatenate a nil value | Joined a nil (missing header, failed match, absent argument). Check it or use tostring(). |
attempt to index a nil value | A function returned nil/failed and you used the result. Check status first. |
| Off-by-one results | Lua arrays start at 1. |
#t returns the wrong count | Table has holes or is a dictionary; loop with pairs. |
attempt to compare number with string | Script arguments are strings. Use tonumber. |
| Pattern doesn’t match | Unescaped . - %; expecting regex features like | or {2,3}. |
s.upper() fails, s:upper() works | Method calls need the colon. |
| Script runs once, then oddly never again | A global variable leaked between runs. Use local. |
| Scan hangs or is slow | A blocking loop, or no timeout on a network call. See lesson 10. |
Testing logic outside Nmap
Pure-Lua helper functions (version parsers, pattern extractors) can be tested with a standalone lua interpreter, which starts instantly and has no network in the way:
-- test_helpers.lua
local function parse_version(s) ... end -- paste the function under test
assert(parse_version("1.4.2")[2] == 4)
assert(parse_version("nope") == nil)
print("ok")
The habit of keeping logic in small local functions, separate from the network code, makes scripts far easier to test and debug.
Checkpoint
A script prints nothing. What's the first command you run and what are you looking for?
nmap -d …. You’re looking for whether the script was loaded, whether an error or “threw an error” line names it, and whether it appears in the list of scripts run against the port.
What's the difference between --script-trace and --packet-trace?
–script-trace shows only the network data that scripts read and write, which is what you want when debugging a script. –packet-trace shows every packet Nmap itself sends, including port-scan probes, which is far noisier.