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

Lesson 4 of 10

Lua basics II: tables, strings, functions and errors

The tools you'll reach for in every script: tables, functions, string patterns, binary packing, error handling and modules.

40 minHands-on lab

Tables

The table is Lua’s only data structure. It’s an array, a dictionary, an object and a module all at once.

local ports = {22, 80, 443}                 -- array-style: keys 1, 2, 3
local host  = {ip = "10.0.0.5", up = true}  -- dictionary-style
local mixed = {"a", "b", key = "value", nested = {1, 2}}

print(ports[1])      --> 22      -- arrays start at 1, not 0!
print(host.ip)       --> 10.0.0.5   -- host.ip is sugar for host["ip"]
print(#ports)        --> 3
print(host.missing)  --> nil     -- absent keys are nil, not an error

The table library

local t = {}
table.insert(t, "a")           -- append
table.insert(t, 1, "first")    -- insert at position 1
table.remove(t)                -- remove last
table.sort(t)                  -- sort in place (strings/numbers)
table.sort(t, function(a, b) return a > b end)   -- custom order
print(table.concat({"a", "b", "c"}, ", "))       --> a, b, c
print(table.unpack({1, 2, 3}))                   --> 1  2  3

Common patterns:

-- append with #
results[#results + 1] = "found"

-- check membership with a "set" table
local wanted = {["server"] = true, ["x-powered-by"] = true}
if wanted[name] then ... end

-- copy-by-reference gotcha: tables are references
local a = {1, 2}
local b = a          -- same table!
b[1] = 99
print(a[1])          --> 99

Functions

Functions are ordinary values. They can be stored, passed around, and returned.

local function add(a, b) return a + b end

-- multiple return values (used constantly in NSE: status, result)
local function divide(a, b)
  if b == 0 then return nil, "division by zero" end
  return a // b, a % b
end
local q, r = divide(17, 5)      --> 3, 2
local x, err = divide(1, 0)     --> nil, "division by zero"

-- variable arguments
local function log(fmt, ...)
  print(("[%s] " .. fmt):format("nse", ...))
end
log("%s:%d", "10.0.0.5", 80)

-- closures capture local variables
local function counter()
  local n = 0
  return function() n = n + 1; return n end
end
local c = counter(); c(); print(c())    --> 2

The return nil, "error message" convention is everywhere in Nmap’s libraries. Functions signal failure by returning a false status (or nil) plus a reason, rather than raising exceptions.

Strings and patterns

Lua has its own pattern language. It’s similar to regular expressions but not the same. The main functions:

FunctionUse
s:find(pat, init, plain)Position of a match (plain = true disables patterns).
s:match(pat)First match, or its captures.
s:gmatch(pat)Iterator over all matches.
s:gsub(pat, repl)Replace matches; returns the new string and a count.

Pattern building blocks

PatternMatches
.any character
%d %a %w %sdigit, letter, alphanumeric, whitespace
%D %A %W %Sthe complements
[abc] [^abc] [a-z]character sets
* + ?0 or more, 1 or more, optional (greedy)
-0 or more, lazy (shortest)
^ $start / end of string
( )capture
%escape: %. is a literal dot, %- a literal dash
local banner = "ACMEQ/1.4.2 ready"

print(banner:match("^ACMEQ/(%d+%.%d+%.%d+)"))    --> 1.4.2
local maj, min, patch = banner:match("(%d+)%.(%d+)%.(%d+)")
print(maj, min, patch)                           --> 1  4  2

for word in ("a,b,c"):gmatch("[^,]+") do print(word) end   --> a  b  c

print(("  padded  "):match("^%s*(.-)%s*$"))       --> padded   (trim)
print(("10.0.0.5"):gsub("%.", "-"))              --> 10-0-0-5   3

string.format

print(string.format("%-15s %5d/%s", "10.0.0.5", 80, "tcp"))
print(string.format("%q", 'say "hi"\n'))     -- quoted, safe to log
print(string.format("%x %02X", 255, 10))     --> ff 0A

%s accepts anything via tostring, which makes it a safe way to log values that might be nil.

Binary data: string.pack and string.unpack

Network protocols are binary. Lua strings can hold any byte, and Lua 5.3+ gives you string.pack/string.unpack to build and parse them:

-- ">" big-endian, I2 = unsigned 16-bit, I4 = unsigned 32-bit, s1 = length-prefixed string
local packet = string.pack(">I2 I4 s1", 0x0001, 42, "hello")

local ver, id, name, nextpos = string.unpack(">I2 I4 s1", packet)
print(ver, id, name)          --> 1  42  hello

The older bin library in nselib is deprecated in favour of these functions. Prefer string.pack/string.unpack in new scripts.

Error handling

Lua has exceptions, but you’ll mostly use return values.

-- raise
error("something went wrong")
assert(tonumber(s), "expected a number")

-- catch with pcall (protected call)
local ok, result = pcall(function()
  return risky_thing()
end)
if not ok then
  print("failed: " .. tostring(result))   -- result is the error message
end

Rules of thumb for NSE:

  • Network problems are normal, not exceptional. Check the status a library call returns and bail out quietly.
  • Use pcall around code that parses untrusted input if a failure shouldn’t kill the whole script.
  • Uncaught errors end that script instance and print ERROR: Script execution failed (use -d to debug).

Modules and require

A Lua module is a file that returns a table. require loads it once and caches it.

local stdnse    = require "stdnse"      -- Nmap library: script arguments, debug, output
local shortport = require "shortport"

By convention you assign each library to a local at the top of your script. You’ll do this in every script.

Metatables in one minute

Many NSE libraries expose “classes” built with metatables. You need to recognise the pattern and use it, not write it:

local Counter = {}
Counter.__index = Counter            -- method lookup falls back to Counter

function Counter.new() return setmetatable({n = 0}, Counter) end
function Counter:incr() self.n = self.n + 1; return self end   -- ":" adds implicit self

local c = Counter.new()
c:incr():incr()
print(c.n)                           --> 2

vulns.Report:new(...) and brute.Engine:new(...) follow exactly this shape.

Lab

  1. parse_version(s): takes "1.4.2" and returns {1, 4, 2} (numbers), or nil, "bad version" if the string doesn’t match major.minor.patch.
  2. older_than(a, b): takes two parsed versions and returns true if a is older than b. Test with 1.4.2 vs 1.5.0, 1.10.0 vs 1.9.9 and equal versions.
  3. build_query(id, text): returns a packet made of a big-endian 16-bit id followed by a length-prefixed string. Unpack it again to confirm you get the same values back.
Solution for 1 and 2
local function parse_version(s)
  local a, b, c = s:match("^(%d+)%.(%d+)%.(%d+)$")
  if not a then return nil, "bad version" end
  return {tonumber(a), tonumber(b), tonumber(c)}
end

local function older_than(v, min)
  for i = 1, 3 do
    if v[i] ~= min[i] then return v[i] < min[i] end
  end
  return false
end

print(older_than(parse_version("1.4.2"), parse_version("1.5.0")))   --> true
print(older_than(parse_version("1.10.0"), parse_version("1.9.9")))  --> false

Comparing the parts as numbers is what makes 1.10.0 newer than 1.9.9. A string comparison would get it wrong.

Checkpoint

Why does ("1.10.0") < ("1.9.9") give the wrong answer?

Strings compare character by character, so “1.1…" sorts before “1.9…". Parse the numeric parts and compare those.

What is the pattern to match a literal dot?

%.. An unescaped . matches any character.