
Decoder/encoder for Ubiquiti AirMAX wireless protocol frames; parse pcap/live captures, discover devices, scan for vulnerable firmware, craft malformed packets, and emulate AirMAX AC/M targets.
Decoder and encoder for Ubiquiti AirMAX wire-protocol frames, covering both the AC (WA firmware) and M (XW/XM firmware) series.
ksy/. The AC payload
(version-gated, switched on msg_type, with the deauth XOR-unmask) is
hand-written in ac.py — that logic doesn't fit parse-only Kaitai.| Variant | Decode | Encode | pcap iteration |
|---|---|---|---|
| AC | ✅ all 5 msg types (beacon, assoc req/resp, probe req, deauth) | ✅ byte-exact inverse + builders + seal | ✅ |
| M | partial (9 documented bytes; rest as unknown_rest) | ✅ round-trips the documented head + unknown_rest | ✅ |
| Routerboard.com IE (companion to M) | ✅ device name + sub-IE list | n/a | ✅ |
AC wire format follows
docs/ac_wire_format.md
([P]-confirmed against ubnt_poll_host.ko). All AC multi-byte integers are
big-endian.
pyrmax.ac.encode(AcPacket) -> bytes — hand-written byte-exact
inverse of decode() (+ seal(), to_ie(), and build_* constructors).pyrmax.m.encode(MPacket) -> bytes — same for M (+ build_m,
seal, to_ie).[open] AC fields — cap_flags exact bit map,
mixed_mode, field_14/field_9c (assoc_req), sta_field_68/ic_6b8
(assoc_resp), and the deauth enc_token (extractable but its key is not
yet reverse-engineered, so it can't be verified). See
docs/ac_wire_format.md §11-§12.version < 9 AC decoders against real captures — the
TX builder only ever emits version 9, so the lower-version (untagged
name, missing tail fields) paths are implemented from the spec but
unverified on the wire.unknown_rest) — currently opaque.pcap.FrameMeta: channel, RSSI,
rate. Currently only timestamp/MACs/BSSID are populated.tests/samples/ — currently:
airmax_ac_beacon.pcap (1 frame, beacon) and
airmax_m_probe_response.pcap (1 frame, probe response). More variants
(assoc req/resp, multi-frame captures) still welcome.scan (active force-assoc) and emulate
commands inject via scapy (the [scan] / [emulate] extras). See
scan.py / emulate.py.The package ships a sub-command driven CLI. Run it as
python -m pyrmax COMMAND ....
usage: python -m pyrmax [-h] [--version] COMMAND ...
COMMAND
parse Print each AirMAX frame in detail.
discover Summarize devices observed in the capture.
scan Live-scan for AirMAX devices and flag vulnerable firmware.
emulate Emulate AirMAX AC/M devices (fake targets for scanners).
parse and discover take either a pcap/pcapng file (positional)
or a live wireless interface via -i / --iface IFACE. The two are
mutually exclusive. scan accepts the same source options (plus an
--active mode that only applies to a live interface); emulate is
live-only.
python -m pyrmax parse capture.pcap # offline
python -m pyrmax discover capture.pcap
sudo python -m pyrmax parse -i wlan0mon # live (needs root)
sudo python -m pyrmax discover -i wlan0mon
Live capture assumes the interface is already in monitor mode on the
channel of interest — pyrmax does not configure either. It requires the
optional [live] extra (pip install pyrmax[live]) which pulls in
pcapy-ng. Press Ctrl-C to stop: parse reports how many frames it
streamed; discover prints the aggregated device summary at exit.
Exit codes (shared by both commands and both source modes): 0 on
success (including "no AirMAX frames" — a valid result), 1 for
capture-format errors (wrong link type, malformed file, can't open
interface), 2 for missing file or invalid source arguments.
parse — per-frame dumppython -m pyrmax parse capture.pcap
Each 802.11 management frame carrying an AirMAX vendor IE produces one block: AC packet, M packet, and any Routerboard.com companion IE found in the same frame.
Found 1 AirMAX frame(s) in capture.pcap: 0 AC, 1 M (1 with Routerboard companion).
=== Frame #0 [M] ts=1765494974.955358 ===
802.11 src=04:18:d6:0e:0c:42 dst=24:a4:3c:88:d8:22 bssid=04:18:d6:0e:0c:42
AirMAX M
version 15
msg_type BEACON (raw=1)
src_mac 04:18:d6:0e:0c:42
enable 1
unknown_rest b700000000000000000000040418d60e0c420000000000 (23B)
Routerboard.com IE
oui_type 0
unknown 0000
device_name 'AP Sur HY1315'
sub_ie subtype=1 (30B) 040000001f660902ff0f4150205375722048593133313500000000000000
The same single-pass walker is exposed programmatically as
pyrmax.pcap.iter_airmax(path) — it yields one
AirmaxRecord(meta, ac, m, routerboard) per AirMAX-bearing frame, so
you don't need to correlate M and Routerboard IEs by hand.
discover — device summarypython -m pyrmax discover capture.pcap
Frames are grouped by 802.11 source MAC, peers accumulated, and AC payload / M payload / Routerboard device-name observations roll up into a single block per device.
2 device(s) observed in capture.pcap across 12 AirMAX frame(s).
24:5a:4c:44:57:fd (AC)
radioname 'LB1'
ssid 'labalUBI2'
ac_msg_types BEACON
ac_version 9
cap_flags 0x0000003e
mixed_mode 0
frames 8
first seen 1767046123.708745
last seen 1767046129.012448
peers (broadcast only)
04:18:d6:0e:0c:42 (M)
device_name 'AP Sur HY1315'
msg_types BEACON
m_version 15
m_enable 1
frames 4
first seen 1765494974.955358
last seen 1765494980.341110
peers 24:a4:3c:88:d8:22
The same aggregation is also a public function:
from pyrmax.devices import summarize
from pyrmax.pcap import iter_airmax
devices = summarize(iter_airmax("capture.pcap"))
for mac, dev in devices.items():
print(mac.hex(":"), dev.device_name or dev.radioname, dev.peers)
The decoder expects the bytes of the 802.11 Vendor Specific IE starting at
the OUI — the IE wrapper (Element ID 0xDD + Length) must already be
stripped. src_mac / dst_mac are lifted from the outer 802.11 frame's
SA / DA.
from pyrmax import ac
packet = ac.decode(
data, # bytes starting at b"\x00\x27\x22"
src_mac="aa:bb:cc:dd:ee:ff", # accepts str ("aa:bb:..." or "aa-bb-..."
# or "aabbcc...") and raw 6-byte bytes
dst_mac="ff:ff:ff:ff:ff:ff", # optional — defaults to broadcast
)
packet.msg_type # <MsgType.BEACON: 1>
packet.version # 9 (the wire-format epoch / version gate)
packet.src_mac # b'\xaa\xbb\xcc\xdd\xee\xff' (integrity-checked)
packet.radioname # "lab-rx-1" (convenience prop, delegates to body)
packet.ssid # "NetA"
packet.cap_flags # 0x3e (None for msg types that have no cap_flags)
# The per-message-type fields live on packet.body, one of:
# BeaconBody | AssocReqBody | AssocRespBody | ProbeReqBody | DeauthBody
body = packet.body
if isinstance(body, ac.BeaconBody):
body.mac_0c # the radio's own MAC / BSSID
body.cap_flags # u32 capability bitfield (§11)
body.mixed_mode # u32 [open]