skip to content

For an in-house binary protocol on TCP port 9410, what does a minimal Wireshark 4.6 Lua dissector need, and how do you load it?

level: seniorimportance: nice to knowfreq 6%

answer

  1. a protocol object and its fields
  2. a function that fills the tree
  3. register it in a port table
  4. messages that span segments
  5. the personal plugins folder

basics

~20 s

A Proto object, ProtoField definitions assigned to its fields table, a dissector function that adds them to the tree, and DissectorTable.get("tcp.port"):add(9410, proto). The .lua file goes in the personal plugins folder and loads at startup.

solid answer

~40 s

Four pieces. `Proto("lsync", "Ledger Sync Protocol")` creates the protocol, whose lowercase name becomes its filter name. `ProtoField.uint8`, `ProtoField.uint16`, `ProtoField.bytes` and friends declare each field with a filter name such as `lsync.length`, and the list is assigned to `lsync.fields`. A function assigned to `lsync.dissector` receives the buffer, packet info and tree, sets the Protocol column and adds fields. Finally `DissectorTable.get("tcp.port"):add(9410, lsync)` registers it on the port; it must come after the dissector is assigned. Because TCP does not preserve message boundaries, a length-prefixed protocol should go through `dissect_tcp_pdus` so messages split across segments are reassembled. The file goes in the personal plugins folder (`~/.local/lib/wireshark/plugins` on Unix-like systems, `%APPDATA%\Wireshark\plugins` on Windows), and Help > About Wireshark > Folders shows the path in use.

code

lua · 25 lines
lua
local lsync = Proto("lsync", "Ledger Sync Protocol")
local f_version = ProtoField.uint8("lsync.version", "Version", base.DEC)
local f_type = ProtoField.uint8("lsync.type", "Message type", base.HEX,
  { [0x01] = "HELLO", [0x02] = "PUSH", [0x03] = "ACK" })
local f_length = ProtoField.uint16("lsync.length", "Body length", base.DEC)
local f_body = ProtoField.bytes("lsync.body", "Body")
lsync.fields = { f_version, f_type, f_length, f_body }
local HDR = 4
local function pdu_len(tvb, pinfo, offset)
  return HDR + tvb(offset + 2, 2):uint()
end
local function dissect_pdu(tvb, pinfo, tree)
  pinfo.cols.protocol = "LSYNC"
  local t = tree:add(lsync, tvb())
  t:add(f_version, tvb(0, 1))
  t:add(f_type, tvb(1, 1))
  t:add(f_length, tvb(2, 2))
  if tvb:len() > HDR then t:add(f_body, tvb(HDR)) end
  return tvb:len()
end
function lsync.dissector(tvb, pinfo, tree)
  dissect_tcp_pdus(tvb, tree, HDR, pdu_len, dissect_pdu)
  return tvb:len()
end
DissectorTable.get("tcp.port"):add(9410, lsync)

go deeper

for a junior

Recall that Wireshark can be extended with Lua scripts dropped into the plugins folder, and that a script can add a protocol with its own named fields.

for a middle

Explain the four pieces in order: Proto, ProtoField list assigned to fields, the dissector function, and DissectorTable registration after the dissector exists.

for a senior

Build one that survives real captures: dissect_tcp_pdus for split and coalesced messages, length checks, a port preference and Decode As support, and know how Lua errors surface.

for a principal

Treat in-house dissectors as maintained tooling: version them with the protocol definition, review them like code since they run on analysts' machines, and decide when Lua should graduate to a compiled dissector.

## Why write one When an in-house protocol has no built-in dissector, its payload shows as `Data` and you are left reading hex. A **Lua dissector** gives it a tree, named fields you can filter and graph, and a Protocol column entry, without compiling Wireshark. Wireshark 4.6 runs Lua 5.3 or 5.4 (releases after 4.2 moved to those from Lua 5.1 and 5.2), so native bitwise operators are available and very old scripts may need porting. ## The four pieces 1. **`Proto(name, description)`** creates the protocol. The name, lowercased, is its filter name; the description must be unique among all protocols, or the call fails. 2. **`ProtoField` constructors** declare fields: `ProtoField.uint8`, `uint16`, `uint32`, `string`, `bytes`, `ipv4` and more. The first argument is the **abbreviation used in filters** (`lsync.type`), the second the label shown in the tree; integer fields take a base (`base.DEC`, `base.HEX`) and an optional value-to-name table. Assign the list to `proto.fields`; fields left out are never registered with Wireshark, so they cannot be shown or filtered. 3. **The dissector function**, assigned as `function proto.dissector(tvb, pinfo, tree)`. It reads bytes through ranges such as `tvb(2, 2):uint()`, adds a subtree with `tree:add(proto, range)` and fields with `subtree:add(field, range)`, may set `pinfo.cols.protocol` or `pinfo.cols.info`, and returns how many bytes it consumed. 4. **Registration** in a dissector table: `DissectorTable.get("tcp.port")` returns the table, or `nil` if the name is wrong, and `:add(9410, proto)` takes a number, or a string range such as `"9410,9411"` for integer tables. Adding a `Proto` before its dissector function is assigned raises an error, because a protocol without a dissector cannot be added. ## Messages that span segments TCP delivers a byte stream, so one message may arrive in two segments, and one segment may hold three messages. The global `dissect_tcp_pdus(tvb, tree, min_header_size, get_len_func, dissect_func)` handles both. Wireshark reads the fixed header, asks `get_len_func(tvb, pinfo, offset)` for the full length, asks TCP to reassemble until that many bytes are present, then calls `dissect_func` once per message. It suits protocols whose length can be computed from a fixed-size header; it does not suit protocols such as HTTP or Telnet, whose length cannot be read from a fixed prefix. Without it, a parser that reads past the end of a short segment fails on every split message. ## Where the file goes and how it loads | Platform | Personal plugins folder | |---|---| | Unix-like | `~/.local/lib/wireshark/plugins` (older `$XDG_CONFIG_HOME/wireshark/plugins` still works for Lua) | | Windows | `%APPDATA%\Wireshark\plugins` | | Any | Help > About Wireshark, *Folders* tab, shows the folders actually in use | - At startup Wireshark loads `init.lua` if present, then every `.lua` file in the global and then the personal plugins folders, recursively, in byte-wise alphabetical order. - Order matters: a Lua dissector registering in a table created by another Lua file must load after it. - Lua runs after all compiled dissectors initialise, so it can add itself to built-in tables such as `tcp.port`. - For a single run, `tshark -X lua_script:lsync.lua` loads a script that is not installed. - The `enable_lua` variable in `init.lua` switches Lua support off; it is on by default. - To test without the GUI, `tshark -X lua_script:lsync.lua -r sample.pcapng -O lsync` prints the detailed tree for just the new protocol, which makes a quick check that every field lands at the right offset before the script is shared. ## Making it configurable and robust - **Port as a preference.** `lsync.prefs.ports = Pref.range("Ports", "9410", "TCP ports", 65535)` gives a setting in the protocol's preferences instead of a hard-coded number; re-register in `lsync.prefs_changed`. - **Decode As support.** `DissectorTable.get("tcp.port"):add_for_decode_as(lsync)` makes the protocol selectable in Analyze > Decode As for any port. - **Heuristic registration.** `lsync:register_heuristic("tcp", fn)`, where `fn` returns `true` only after a strict signature check, lets it find the protocol on any port. Weak checks cause false positives. - **Errors.** A Lua error does not crash Wireshark. The packet gets an Error-severity *Lua Error* expert item with the message, for example `Range is out of bounds` when the code reads past the buffer, and a *Lua Traceback* subtree. It is not shown as `[Malformed Packet]`, so look for the Lua error rather than expecting the malformed label. ## A note on trust A Lua plugin is code that runs with your account's rights every time Wireshark starts. Review scripts before installing them, keep them in version control with the profile that uses them, and never install one from a capture-sharing thread without reading it.

  • Your Lua dissector works on small messages but large ones show a Lua Error about a range out of bounds; why?
    The message is larger than one TCP segment, and the dissector reads the declared body length from a segment that holds only part of it. Wrap the parser in `dissect_tcp_pdus` with a length function computed from the fixed header, so TCP reassembles each message before your code sees it; also check the length before every read so short data fails cleanly.
  • The service moves between ports in different environments; how do you avoid editing the script each time?
    Expose the port as a preference with `Pref.range` on `proto.prefs` and re-register in `prefs_changed`, so each analyst sets it in the protocol's preferences. Add `add_for_decode_as` so the protocol can be mapped to any port from the Decode As dialog, and consider a strict heuristic with `register_heuristic` when the port is unpredictable.

saying these in an interview costs you the question

  • The second argument of a ProtoField constructor is the name used in display filters.
  • Fields work without being assigned to the Proto's fields table.
  • Each TCP segment always carries exactly one application message, so reassembly is unnecessary.
  • A Lua dissector error shows up as Malformed Packet, like a C dissector exception.
  • Lua dissectors need Wireshark to be recompiled before they load.