Lua API
Everything available to Lua handler scripts
This page covers everything a Lua handler can use. The API is intentionally small, with one object for the connection, one for logging, one for capturing data.
Entrypoints
Section titled “Entrypoints”A script defines one or both of these global functions:
| Function | Protocol | Description |
|---|---|---|
handle(conn) |
TCP | Called once per connection, with the connection object |
handle_packet(data, peer) |
UDP | Called once per datagram; return a string to reply, nil to stay silent. peer is a table with the source address: ip, port, and addr (the ip:port string) |
function handle(conn) -- serve a TCP connectionend
function handle_packet(data, peer) -- process a UDP datagram log:info("datagram from " .. peer.addr) if data == "ping" then return "pong" end return nilendTCP scripts must define handle, UDP scripts must define handle_packet. A script that defines both works on either.
Connection
Section titled “Connection”The conn object is passed to handle and is the only way to talk to the client.
| Method | Returns | Description |
|---|---|---|
conn:read(n) |
string / nil |
Read up to n bytes, as soon as any data is available |
conn:read_line() |
string / nil |
Read until the next newline (the \n is included in the result) |
conn:read_until(delim) |
string / nil |
Read until the delimiter appears (the delimiter is included in the result) |
conn:write(data) |
Send a string to the client | |
conn:sleep(ms) |
Pause the script for ms milliseconds |
|
conn:remote() |
string | The client’s address, e.g. 192.168.1.10:53214 |
conn:local() |
string | The server’s address for this connection, e.g. 10.0.0.5:4444 |
conn:remote_ip() |
string / nil |
The client’s IP, e.g. 192.168.1.10 |
conn:remote_port() |
number / nil |
The client’s source port, e.g. 53214 |
conn:local_port() |
number / nil |
The listener’s port for this connection |
conn:sni() |
string / nil |
The domain the client asked for during a TLS handshake, or nil on plain connections |
conn:tls() |
table / nil |
TLS metadata in a table, formatted as { version = "TLS 1.3", cipher = "TLS_AES_128_GCM_SHA256" } |
conn:get/set/has/delete (key[, value]) |
see State | Per-connection state |
conn:close() |
Close the connection |
Reads return nil when the client closes the connection cleanly. This is the normal way to end a session, so the common pattern is:
while true do local line = conn:read_line() if not line then break end conn:write("received: " .. line)endFailed reads (timeouts, network errors) raise a Lua error, which ends the script for that connection and is logged by GoNetSim. You rarely need to handle these yourself; letting the error end the session is usually what you want.
read_until is handy for header-style protocols, for example, collecting everything up to a blank line in an HTTP request:
local headers = conn:read_until("\r\n\r\n")Sleeping
Section titled “Sleeping”Malware frequently alters its behaviour based on latency or timeout limits, and conn:sleep(ms) lets a handler pause before replying:
conn:write("banner\r\n")conn:sleep(1500) -- act like a slow linkconn:read_line()Sleeps count as script activity, not client inactivity: a read after a long sleep still gets the full idle timeout. A single sleep is capped at one hour, and sleeps end early (with an error) when GoNetSim shuts down.
TLS SNI
Section titled “TLS SNI”When a TCP listener runs behind TLS, conn:sni() returns the domain name the client asked for during the handshake. This lets one generic script behave differently per domain:
local domain = conn:sni()if domain == "update.example.com" then conn:write("...fake update response...")else conn:write("...generic response...")endsni() returns nil for plain (non-TLS) connections, and performs the TLS handshake itself if the script calls it before any reads. conn:tls() works the same way, returning nil on plain connections and a { version, cipher } table otherwise.
Binary Packing
Section titled “Binary Packing”Binary protocols are common in C2 traffic, and scripts get the standard string.pack & string.unpack functions for them: parsing integers, short ints and raw byte structures without tedious bitwise arithmetic.
local packet = string.pack(">I4", 1024) -- big-endian unsigned 32-bit intlocal n, pos = string.unpack("<i2", data) -- little-endian signed 16-bit intSupported formats cover the practical subset of the Lua 5.3 spec: integers (b, B, h, H, i/iN, l), floats (f, d), strings (s/sN, z, cN), x padding, and </> endianness. See the examples for a length-prefixed binary handler.
Scripts can remember values across connections through three state scopes:
| Scope | Object | Visible to |
|---|---|---|
conn |
conn (TCP only) |
This connection only |
handler |
handler |
Every connection served by this listener |
global |
global |
Every listener in the GoNetSim instance |
Each scope exposes the same four functions:
handler:set("count", "42") -- returns true, or false + a reasonlocal count = handler:get("count") -- nil when the key doesn't existlocal exists = handler:has("count")handler:delete("count") -- silently succeeds either wayThe state maps strings to binary-safe strings only.
For (memory) safety and security, keys are capped at 4 KiB, values at 1 MiB, and everything is limited under one total budget, 64MiB by default, configurable with state.total_limit. When a set() would push total state past the budget it fails gracefully by returning false plus a reason.
local seen = handler:get(conn:remote())if not seen then handler:set(conn:remote(), "1") conn:write("220 Welcome, new client\r\n")else conn:write("220 Welcome back\r\n")endLogging
Section titled “Logging”The log object writes to GoNetSim’s structured logs, tagged with the listener’s name:
log:info("client connected from " .. conn:remote())log:warn("unusual payload received")log:error("something went wrong")Lua’s built-in print also routes through the logger, so quick debugging works as expected.
Capture
Section titled “Capture”The capture object saves what the client sends to the artifacts directory (artifacts/<listener>/ by default). One file is written per connection:
capture:write("irc", line) -- saved under a "=== irc ===" section headercapture:write("", raw_bytes) -- written as-is, with no headerNamed sections make artifacts easier to read later, for example, tagging each captured line with the command it belongs to. Capture is enabled by default and can be turned off per listener or with --no-capture.
Sandbox
Section titled “Sandbox”Scripts run with a deliberately minimal standard library: the base, string, table and math libraries are available, and nothing else. There is no io, no os, and no require, so scripts cannot read files, run programs or communicate outside of their connection.
Everything a script needs to persist goes through capture, which lands in a predictable directory you control.
Timeouts
Section titled “Timeouts”Connections have an idle timeout (30 seconds by default, configurable per listener with read_timeout or per run with --timeout). If a client stops sending data for that long, the connection is closed. This does not need to be tracked in scripts
Further Reading
Section titled “Further Reading”- See the examples for complete, runnable handlers
- See the listeners reference for configuration & runtime flags
