Extending Nmap: Custom Script Development and Community Contribution

Development Environment Setup

Before writing production-grade NSE scripts, you need a build environment that supports debugging and rapid iteration. Compiling Nmap from source ensures your scripts target the exact engine version you're testing against.

Source Compilation for Script Testing

# Clone and configure a debug-friendly build
git clone https://github.com/nmap/nmap.git
cd nmap
./configure --with-openssl --with-libssh2 --enable-debug
make -j$(nproc)
sudo make install

The --enable-debug flag preserves symbol tables and disables optimizations, allowing you to trace Lua execution through nmap --script-trace and inspect the NSE engine's internal state. For Lua-specific debugging, install luacheck for static analysis and busted for unit testing your script logic in isolation:

luarocks install luacheck
luarocks install busted

Create a .luacheckrc in your project root tuned to Nmap's Lua environment:

std = "nmap"  -- custom std definition
globals = {"nmap", "shortport", "stdnse", "table"}

Writing a Complete NSE Script: Cloud Metadata API Enumeration

Contemporary infrastructure exposes critical attack surfaces through cloud metadata APIs. The following script enumerates AWS IMDSv2 (Instance Metadata Service version 2), which requires a session token workflow—a perfect demonstration of protocol state machines and error handling.

Complete Script: aws-imdsv2-enum.nse

local http = require "http"
local json = require "json"
local shortport = require "shortport"
local stdnse = require "stdnse"
local string = require "string"

description = [[
Enumerates AWS IMDSv2 metadata endpoints. IMDSv2 requires
session-oriented access: a PUT for token acquisition, then
token-authenticated GET requests. This script implements the
full state machine, with strict timeout and error handling.
]]

author = "Your Name <[email protected]>"
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"
categories = {"discovery", "safe"}

-- Line 16: Rule targets HTTP services on port 80, 443, or any
-- service fingerprinted as "http"
portrule = shortport.http

-- Line 20: Configuration options with sensible defaults
local function get_options()
  return {
    max_retries = stdnse.get_script_args(SCRIPT_NAME .. ".max-retries") or 2,
    token_ttl = stdnse.get_script_args(SCRIPT_NAME .. ".token-ttl") or 300,
    path = stdnse.get_script_args(SCRIPT_NAME .. ".path") or "/"
  }
end

-- Line 28: IMDSv2 token acquisition (state: UNAUTHENTICATED → TOKEN_HELD)
local function acquire_token(host, port, ttl, max_retries)
  local request_opts = {
    header = {
      ["X-aws-ec2-metadata-token-ttl-seconds"] = tostring(ttl)
    }
  }
  
  -- Line 36: Structured retry loop with exponential backoff
  for attempt = 1, max_retries do
    local response = http.put(host, port, "/latest/api/token", request_opts)
    
    -- Line 40: Explicit nil-check prevents runtime errors on connection failure
    if response and response.status == 200 and response.body then
      return true, string.gsub(response.body, "%s+$", "")  -- trim trailing whitespace
    end
    
    -- Line 44: Backoff with jitter to avoid thundering herd
    if attempt < max_retries then
      stdnse.sleep(0.5 * attempt)
    end
  end
  
  return false, "Token acquisition failed after " .. max_retries .. " attempts"
end

-- Line 52: Authenticated metadata retrieval (state: TOKEN_HELD → DATA_RETRIEVED)
local function fetch_metadata(host, port, token, path)
  local request_opts = {
    header = {
      ["X-aws-ec2-metadata-token"] = token
    }
  }
  
  -- Line 60: Path traversal prevention—reject user input with ".."
  if string.match(path, "%.%.") then
    return false, "Invalid path: directory traversal detected"
  end
  
  local target_path = "/latest/meta-data" .. path
  local response = http.get(host, port, target_path, request_opts)
  
  -- Line 68: Distinguish HTTP errors from protocol-level failures
  if not response then
    return false, "Connection terminated without response"
  end
  
  if response.status ~= 200 then
    return false, string.format("HTTP %d: %s", response.status, response.status_line or "unknown")
  end
  
  return true, response.body
end

-- Line 78: Main action with comprehensive error propagation
action = function(host, port)
  local opts = get_options()
  stdnse.debug1("Targeting %s:%d with TTL=%d, retries=%d", 
    host.ip, port.number, opts.token_ttl, opts.max_retries)
  
  -- Line 83: State machine execution with early returns on failure
  local token_ok, token = acquire_token(host, port, opts.token_ttl, opts.max_retries)
  if not token_ok then
    stdnse.debug1("Token phase failed: %s", token)
    return nil  -- NSE convention: nil suppresses output for clean hosts
  end
  
  stdnse.debug2("Token acquired: %s...", string.sub(token, 1, 16))
  
  local fetch_ok, data = fetch_metadata(host, port, token, opts.path)
  if not fetch_ok then
    -- Line 93: Structured error reporting for script-debugging
    return string.format("ERROR: %s", data)
  end
  
  -- Line 97: Attempt JSON parsing, fallback to raw output
  local json_ok, parsed = json.parse(data)
  if json_ok then
    return parsed
  else
    return data
  end
end

Line-by-Line Analysis:

  • Lines 1–15: Module imports and metadata. The license field must declare GPLv2 compatibility or use the standard Nmap license reference.
  • Lines 20–26: stdnse.get_script_args() provides namespaced configuration, preventing collisions with other scripts' options.
  • Lines 28–50: The token acquisition implements a protocol state machine: unauthenticated → token request → token held. The retry logic handles transient network partitions common in cloud environments.
  • Lines 52–76: Metadata retrieval enforces input validation (line 60) and distinguishes failure modes: connection failures versus HTTP error codes versus successful responses.
  • Lines 78–101: The action function orchestrates state transitions and implements NSE's output conventions—return nil to remain silent on non-vulnerable targets, preventing output noise.

The nmap-dev Submission Process

Contributing scripts upstream requires navigating both technical and social barriers. The Nmap project maintains strict quality gates.

Code Review Standards

Submissions to [email protected] undergo threaded review by maintainers (primarily Fyodor, nnposter, and bonsaiviking). Common rejection patterns include:

| Issue | Frequency | Resolution | |-------|-----------|------------| | Missing newtargets script-arg documentation | Very common | Include all arguments in description | | Hardcoded timeouts without stdnse.get_timeout | Common | Respect Nmap's global timing templates | | Copyright assignment ambiguity | Occasional | Explicit GPLv2+ statement required | | Overly broad portrule matching | Common | Use shortport predicates, not raw port lists |

Documentation Requirements

Every script must include a description suitable for nmap --script-help. For complex protocols, attach a usage example in the mailing list post:

nmap --script aws-imdsv2-enum --script-args \
  aws-imdsv2-enum.path=/iam/security-credentials/ \
  -p 80,443 <target>

Licensing: NSE scripts inherit the Nmap license (GPLv2 with specific exceptions). Do not submit BSD or MIT-licensed work without explicit relicensing permission. The license field must reference https://nmap.org/book/man-legal.html or state license = "GPLv2".

Maintaining Scripts Through Nmap Version Updates

Nmap's NSE engine evolves, sometimes breaking backward compatibility. Track these deprecation vectors:

  • API deprecation: The brute library's Engine class replaced unpwdb iteration in Nmap 7.92. Scripts using old patterns emit warnings; update before removal.
  • Engine behavioral changes: Nmap 7.94 modified http.lua's redirect handling—max_redirects now defaults to 0 for scripts, breaking implicit following. Audit CHANGELOG before major releases.
  • Regression testing: Maintain a private test harness:
# Automated regression against local mock services
nmap --script your-script.nse \
  --script-args your-script.testmode=1 \
  -p 8080 127.0.0.1 \
  -oX regression-$(date +%Y%m%d).xml

Compare XML outputs across Nmap versions with ndiff or custom XPath queries.

Private Script Repositories

Not all scripts belong upstream. Organizational needs often require private distribution.

Repository Structure and Registration

/opt/nmap-custom-scripts/
├── scripts/
│   └── internal-vpn-scanner.nse
├── script.db          # generated; do not hand-edit
└── nse_main.lua      # optional: preload custom libraries

Register with Nmap's database:

# After adding .nse files, rebuild the script index
nmap --script-updatedb
# Verify registration
nmap --script-help internal-vpn-scanner

The --script-updatedb mechanism parses SCRIPT_NAME, categories, author, and description fields to populate script.db. Critical: This must run after every script addition or removal; otherwise Nmap's script engine won't resolve names.

Version Control Integration

Hook pre-commit to validate syntax and trigger --script-updatedb:

#!/bin/bash
# .git/hooks/pre-commit
for f in $(git diff --cached --name-only --diff-filter=ACM | grep "\.nse$"); do
  luacheck "$f" || exit 1
  cp "$f" /usr/share/nmap/scripts/
done
nmap --script-updatedb
git add /usr/share/nmap/scripts/script.db

Secure Deployment: Distribute via signed Git tags or internal package repositories. Never expose .nse files via unauthenticated HTTP—scripts execute with Nmap's privileges, and supply-chain attacks against NSE are trivial if interception occurs.

Alternative Engines: When NSE Reaches Its Limits

NSE's interpreted Lua architecture imposes inherent constraints. Recognize divergence points:

Performance Bottlenecks

| Scenario | NSE Limitation | Alternative | |----------|---------------|-------------| | High-throughput SYN scanning with complex state | Single-threaded Lua per host | masscan + dedicated analyzer | | Cryptographic heavy lifting (TLS cert parsing) | LuaJIT absent; pure Lua crypto slow | Rust/Go tools with libpcap integration | | Long-lived connections with async I/O | Cooperative multitasking only; no true epoll | Python asyncio or dedicated proxy |

Language Requirements: When existing libraries (e.g., Kubernetes client-go, AWS SDK v2) dwarf reimplementation effort, wrap Nmap for discovery and delegate to compiled tools:

# Nmap identifies the endpoint; specialized tool exploits it
nmap -p 10250 --open -oG - <cidr> | \
  awk '/Host/{print $2}' | \
  xargs -I{} kubelet-analyzer --node {}

However, resist premature abstraction. NSE's integration with Nmap's host discovery, OS fingerprinting, and output formats provides irreplaceable reconnaissance coherence. Fragmentation into toolchains sacrifices the unified data model that makes Nmap powerful.

The advanced practitioner contributes upstream where possible, maintains private repositories where necessary, and chooses alternative engines only when NSE's interpreted performance genuinely blocks the mission.