Start now →

Writing Your First NSE Script

By Roshan Rajbanshi · Published May 6, 2026 · 12 min read · Source: Level Up Coding
Ethereum
Writing Your First NSE Script

You don’t need to be a developer to write your own Nmap scripts. You just need 30 lines of Lua and something to automate.

Series: Nmap — The Tool You Think You Know | Part 3 of 16

In Part 2, we ran the scripts that ship with Nmap. Every one of them is a plain text .nse file sitting on your disk. You can open them, read them, copy them, and modify them. And once you understand the structure — which takes about 20 minutes — you can write your own.

This article walks through exactly that. No prior programming experience required. We’ll cover the minimum Lua you need, the NSE API that gives your script access to Nmap’s internals, and a complete working script built from scratch. We’ll also cover how to use AI tools to write scripts faster without writing bad code you don’t understand.

Why Write Your Own Scripts

The scripts that ship with Nmap cover common services and known vulnerabilities. But real targets aren’t always standard. You’ll hit:

Writing a script takes 20–40 minutes once you know the structure. That’s faster than manually running the same check 50 times across a /24.

The Minimum Lua You Need

NSE scripts are written in Lua, a lightweight scripting language. You don’t need to learn all of Lua. You only need a small subset to start. Here’s what actually matters:

Variables:

local host = "target"           -- string
local port = 80 -- number
local found = false -- boolean
local results = {} -- table (like a list)

Conditionals:

if port == 80 then
print("HTTP found")
elseif port == 443 then
print("HTTPS found")
else
print("Other port")
end

Loops:

-- Iterate over a table
for i, item in ipairs(results) do
print(i, item)
end
-- Count-based loop
for i = 1, 10 do
print(i)
end

Functions:

local function check_banner(response)
if response:match("Apache") then
return "Apache detected"
end
return nil
end

String matching — the most useful operation in NSE:

local response = "Server: Apache/2.4.6"
if response:match("Apache") then
-- found it
end
-- Extract a version number
local version = response:match("Apache/([%d%.]+)")
-- version = "2.4.6"

That’s it. Variables, conditionals, loops, functions, and string matching. You can look up the rest as needed.

The Structure of Most NSE Scripts

Open any script in /usr/share/nmap/scripts/ And you'll see a similar structure — five parts that appear in most scripts:

-- 1. METADATA
description = [[
What this script does, in plain English.
Used by --script-help.
]]
-- 2. CATEGORIES
categories = {"default", "safe"}
-- 3. IMPORTS
local nmap = require "nmap"
local stdnse = require "stdnse"
-- 4. RULE FUNCTION (when to run)
portrule = function(host, port)
return port.state == "open" and port.number == 80
end
-- 5. ACTION FUNCTION (what to do)
action = function(host, port)
return "Script ran successfully"
end

The rule function decides whether your script runs against a given host/port. If the rule matches, the script may run; if it does not, it is skipped.

The action function performs the script’s work and returns the result. The return value appears in Nmap’s output, usually as a string or table. Return nil If you do not want output.

That’s the core skeleton. Some scripts differ in metadata, dependencies, or how they handle execution — but this pattern covers the vast majority.

Three Script Types — Which One to Write

portrule scripts — run for ports that match the script's conditions, often open ports. This is the type you'll write most often. Use when your check needs a specific port.

portrule = function(host, port)
return port.state == "open" and port.number == 21
end

hostrule scripts — run once per host when host-level conditions are met. Use when your check is host-level, not port-level (checking hostname, OS, host attributes).

hostrule = function(host)
return host.os ~= nil -- only run if OS was detected
end

prerule scripts — often used for broadcast or pre-scan tasks, running before any host scanning starts. No host or port info is available yet.

prerule = function()
return true -- always run
end

For your first script, write a portrule script. It's the most common, most useful, and easiest to test.

The NSE Library API

NSE gives your scripts access to Nmap’s network capabilities through two main libraries:

nmap — Core network operations

local nmap = require "nmap"
-- Create a socket
local socket = nmap.new_socket()
-- Connect to target (use host.ip and port.number, not the objects directly)
local status, err = socket:connect(host.ip, port.number)
-- Send data
local status, err = socket:send("GET / HTTP/1.0\r\n\r\n")
-- Receive response
local status, response = socket:receive()
-- Close
socket:close()
💡 Raw sockets are lower-level and more error-prone than using protocol libraries like http. For HTTP work, use the http library shown later in this article — it handles headers, redirects, and encoding for you.

stdnse — Helper utilities

local stdnse = require "stdnse"
-- Print debug output (only visible with -d flag)
stdnse.debug(1, "Connecting to %s:%d", host.ip, port.number)
-- Format output as a table
local output = stdnse.output_table()
output["version"] = "Apache/2.4.6"
output["finding"] = "TRACE enabled"
return output
-- Sleep (useful for rate limiting)
stdnse.sleep(1)

stdnse.output_table() helps format structured output for Nmap — cleaner than building strings manually.

Writing a Banner-Grabbing Script From Scratch

Banner grabbing — connecting to a service and reading what it sends back — is the simplest useful thing you can write. Here’s a complete working script:

-- banner-grab.nse
-- Grabs the raw banner from any open TCP port
description = [[
Connects to an open port and reads the initial banner response.
Useful for fingerprinting services that respond immediately on connect.
]]
categories = {"discovery", "safe"}
local nmap = require "nmap"
local stdnse = require "stdnse"
-- Run against any open TCP port
portrule = function(host, port)
return port.state == "open" and port.protocol == "tcp"
end
action = function(host, port)
local socket = nmap.new_socket()
  -- Set timeout to 5 seconds
socket:set_timeout(5000)
  -- Attempt connection
local status, err = socket:connect(host.ip, port.number)
if not status then
return nil -- connection failed, return nothing
end
  -- Read the initial response if the service sends one
local status, response = socket:receive()
socket:close()
  if not status or not response then
return nil
end
  -- Clean up the response — remove control characters
response = response:gsub("[%c]", " "):gsub("%s+", " ")
  -- Only return if there's something meaningful
if #response > 3 then
return "Banner: " .. response
end
  return nil
end

Save this as banner-grab.nse in /usr/share/nmap/scripts/, run --script-updatedb, then:

nmap --script banner-grab -p 21,22,80 x.x.x.x
PORT   STATE    SERVICE
21/tcp open ftp
|_banner-grab: Banner: 220 (vsFTPd 3.0.5)
22/tcp open ssh
|_banner-grab: Banner: SSH-2.0-OpenSSH_8.2p1 Ubuntu-4ubuntu0.13
80/tcp filtered http

A working script. 30 lines. Attempts to read a banner from any open TCP port that sends one and returns it cleanly formatted.

Using AI to Write NSE Scripts

AI tools are useful here — not to write entire scripts blindly, but to handle the parts that would otherwise require deep Lua knowledge. The key is prompting correctly and verifying the output.

The right prompt structure:

I'm writing an NSE script for Nmap. It should:
- Run against open port [X] using portrule
- Connect to the service and [describe what to check]
- Return [describe what you want in the output]
- Use the nmap and stdnse libraries
- Be safe (no exploitation, no crashes)
Write only the action function and portrule first. 
I'll handle the metadata myself.

What to ask AI for specifically:

What to verify yourself:

What not to trust AI with blindly:

One concrete test: after getting AI-generated code, open a real NSE script that does something similar (check /usr/share/nmap/scripts/) and compare the structure line by line. If the two scripts look structurally different, something is probably wrong.

A More Useful Script — HTTP Header Audit

Let’s build something that finds a real misconfiguration — missing security headers on HTTP services:

-- http-header-audit.nse
-- Checks for missing security headers on HTTP services
description = [[
Checks whether common HTTP security headers are present.
Missing headers like X-Frame-Options, CSP, and HSTS are
common findings in web application security assessments.
]]
categories = {"discovery", "safe"}
local http = require "http"
local stdnse = require "stdnse"
portrule = function(host, port)
return port.state == "open" and
(port.number == 80 or port.number == 443 or
port.number == 8080 or port.number == 8443)
end
action = function(host, port)
-- Make HTTP request using NSE's http library
local response = http.get(host, port, "/")
  if not response or not response.header then
return nil
end
  local missing = {}
local headers = response.header
  -- Check for each security header
local checks = {
"x-frame-options",
"x-content-type-options",
"x-xss-protection",
"strict-transport-security",
"content-security-policy",
}
  for _, header in ipairs(checks) do
if not headers[header] then
table.insert(missing, header)
end
end
  if #missing == 0 then
return "No missing security headers detected"
end
  local output = stdnse.output_table()
output["Missing Headers"] = table.concat(missing, ", ")
output["Count"] = tostring(#missing) .. " missing"
return output
end

This uses the http library, which handles HTTP logic better than raw sockets — including redirects, cookies, and encoding.

nmap --script http-header-audit -p 80,443 x.x.x.x
PORT   STATE SERVICE
80/tcp open http
| http-header-audit:
| Missing Headers: x-frame-options, x-content-type-options, x-xss-protection, strict-transport-security, content-security-policy
|_ Count: 5 missing

A cleanly formatted result from a script you wrote yourself.

Testing and Debugging

Test banner-grab against a local lab target:

nmap --script banner-grab -p 21,22,80 x.x.x.x
banner-grab in action — FTP and SSH banners grabbed cleanly. Port 80 filtered so no banner — script returned nil correctly.

Test http-header-audit against localhost:

sudo service apache2 start
nmap --script http-header-audit -p 80 127.0.0.1
http-header-audit against local Apache — 5 missing security headers, stdnse.output_table formatting working as expected.

Apache’s default install often lacks common security headers, making it a useful local test target.

Use -d for debug output:

nmap --script banner-grab -p 22 x.x.x.x -d
d debug mode — NSE: Starting banner-grab and NSE: Finished banner-grab lifecycle visible alongside the SSH banner result.

All stdnse.debug() calls become visible. Essential for understanding what's happening inside your script.

Use --script-trace to see raw network activity:

nmap --script http-header-audit -p 80 127.0.0.1 --script-trace 2>&1 | grep -E "NSE:|SEND|RCVD|GET|HTTP/"
NSE: TCP 127.0.0.1:52736 > 127.0.0.1:80 | CONNECT
NSE: TCP 127.0.0.1:52736 > 127.0.0.1:80 | SEND
NSE: TCP 127.0.0.1:52736 < 127.0.0.1:80 | HTTP/1.1 400 Bad Request
NSE: TCP 127.0.0.1:52736 > 127.0.0.1:80 | CLOSE
NSE: TCP 127.0.0.1:52748 > 127.0.0.1:80 | CONNECT
NSE: TCP 127.0.0.1:52748 > 127.0.0.1:80 | 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:52748 > 127.0.0.1:80 | SEND
NSE: TCP 127.0.0.1:52748 < 127.0.0.1:80 | HTTP/1.1 200 OK
NSE: TCP 127.0.0.1:52748 > 127.0.0.1:80 | CLOSE
— script-trace filtered — two connection cycles, 400 Bad Request then 200 OK. The http library behavior visible at the wire level.

Two connections are visible. The first attempt results in 400 Bad Request — the http library may attempt different connection behavior depending on the target and port. The second sends a plain GET / HTTP/1.1 and gets 200 OK — That's the response the script reads headers from.

Common errors and fixes:

Error                          Cause                        Fix
----------------------------- --------------------------- ------------------------------------------
Script not found Database not updated sudo nmap --script-updatedb
attempt to index nil value Variable is nil before use Add: if not var then return nil end
Script runs but returns return nil hit before Add stdnse.debug() to trace execution
nothing output
connect: connection refused Port not open / wrong IP Check host.ip not host in connect call
Script runs on wrong ports Rule too broad Tighten the portrule condition

Adding Your Script Properly

# Copy to scripts directory
sudo cp banner-grab.nse /usr/share/nmap/scripts/
sudo cp http-header-audit.nse /usr/share/nmap/scripts/
# Update the database
sudo nmap --script-updatedb
# Verify both are registered
grep "banner-grab\|http-header-audit" /usr/share/nmap/scripts/script.db
Both scripts registered in script.db — discovery and safe categories confirmed for both.

Once indexed, your script can be called by name like any built-in script. It shows up in --script-help, responds to category filters, and can be selected by category if it is categorized correctly.

Where to Find Community Scripts

The built-in scripts are not the only option. Two sources worth knowing:

GitHub: Search GitHub for Nmap NSE scripts — hundreds of community scripts exist for specific services, CVEs, and frameworks. Before running any community script:

  1. Read the full source — it’s plain text, Lua
  2. Check what it connects to and what it sends
  3. Run it against your own lab first

nmap-vulners: A widely used community script — as covered in Part 2, it cross-references service versions against CVE databases. Install:

sudo wget -O /usr/share/nmap/scripts/vulners.nse https://raw.githubusercontent.com/vulnersCom/nmap-vulners/master/vulners.nse
sudo nmap --script-updatedb

What’s Next

You now have the full NSE picture — how scripts work, how to read them, and how to write them. The banner grabber and header audit script you built here are useful on real assessments.

In Part 4, we apply all of this to the most common target you’ll hit — web servers. HTTP NSE scripts in depth: http-enum, http-shellshock, http-wordpress-enum, and a full chained HTTP recon workflow that takes a web server from "port 80 open" to a complete picture in one command.

Part 4Link will be live once published

🔒 Legal reminder: Only scan systems you own or have written permission to test. scanme.nmap.org is Nmap's official test server — always legal to use for script testing. For everything else, stick to TryHackMe, HackTheBox, INE Labs, or your own machines.

Part of the series: Nmap — The Tool You Think You Know


Writing Your First NSE Script was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.

This article was originally published on Level Up Coding and is republished here under RSS syndication for informational purposes. All rights and intellectual property remain with the original author. If you are the author and wish to have this article removed, please contact us at [email protected].

NexaPay — Accept Card Payments, Receive Crypto

No KYC · Instant Settlement · Visa, Mastercard, Apple Pay, Google Pay

Get Started →