Decoder and encoder for Ubiquiti AirMAX wire-protocol frames, covering both the AC (WA firmware) and M (XW/XM firmware) series.
- Decode of the cleartext outer headers is driven by
Kaitai Struct schemas under
ksy/. The AC payload (version-gated, switched onmsg_type, with the deauth XOR-unmask) is hand-written inac.py— that logic doesn't fit parse-only Kaitai. - Encode is hand-written to mirror the decode path field-for-field.
| 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 ofdecode()(+seal(),to_ie(), andbuild_*constructors). -
pyrmax.m.encode(MPacket) -> bytes— same for M (+build_m,seal,to_ie). - Pin down the
[open]AC fields —cap_flagsexact bit map,mixed_mode,field_14/field_9c(assoc_req),sta_field_68/ic_6b8(assoc_resp), and the deauthenc_token(extractable but its key is not yet reverse-engineered, so it can't be verified). Seedocs/ac_wire_format.md§11-§12. - Verify the
version < 9AC 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. - Reverse-engineer the rest of the M payload (bytes 9+ in
unknown_rest) — currently opaque. - Surface radiotap metadata on
pcap.FrameMeta: channel, RSSI, rate. Currently only timestamp/MACs/BSSID are populated. - Real capture fixtures in
tests/samples/— currently:airmax_ac_beacon.pcap(1 frame, beacon) andairmax_m_probe_response.pcap(1 frame, probe response). More variants (assoc req/resp, multi-frame captures) still welcome. - Packet sending — the
scan(active force-assoc) andemulatecommands inject via scapy (the[scan]/[emulate]extras). Seescan.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 wlan0monLive 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.
python -m pyrmax parse capture.pcapEach 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.
python -m pyrmax discover capture.pcapFrames 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]msg_type is one of BEACON / ASSOC_REQ / ASSOC_RESP / PROBE_REQ /
DEAUTH. Version-gated tail fields (field_9c, rssi, fwname, txpower,
…) are None when the frame's version is below their threshold. Deauth
un-XORs src_mac with the jiffies nonce before the integrity check and
surfaces the 16-byte enc_token as opaque bytes (its key isn't reversed
yet, so it can't be verified).
Bodies that carry name TLVs (beacon, assoc_req) preserve the stream —
including the trailing Padding entry — verbatim on body.tlvs. radioname,
ssid, and fwname are surfaced as convenience properties on the packet;
everything else stays raw.
body = packet.body
for tlv in getattr(body, "tlvs", ()):
if tlv.tag == ac.TlvTag.PADDING:
continue
print(f"{tlv.tag.name:<10} ({len(tlv.data)} bytes): {tlv.data!r}")Same shape, smaller surface — only 9 bytes of the M payload are documented;
the rest is preserved verbatim on unknown_rest. Note: the M payload
itself has no SSID field — for that, look at the standard 802.11 SSID IE
on the enclosing beacon or probe response (surfaced as
FrameMeta.ssid when iterating via pyrmax.pcap).
from pyrmax import m
packet = m.decode(data, src_mac="aa:bb:cc:dd:ee:ff")
packet.version # 1
packet.msg_type # <MsgType.BEACON: 1>
packet.src_mac # b'\xaa\xbb\xcc\xdd\xee\xff'
packet.enable # 1
packet.unknown_rest # b'\xde\xad\xbe\xef...' # opaque, RE pendingencode(packet, dst_mac=…) is the byte-exact inverse of decode —
encode(decode(x)) == x for a well-formed frame. It serializes the body
(re-applying the version gates and, for deauth, the src_mac XOR-mask), pads to
the AES block, encrypts with the packet.src_mac-derived key, and emits
OUI-onward bytes. Wrap with to_ie() for a full 0xDD vendor IE.
The build_* constructors save you assembling the nested bodies by hand:
from pyrmax import ac
# A beacon (mac_0c defaults to src_mac; broadcast key, as beacons use)
pkt = ac.build_beacon(src_mac="24:5a:4c:44:57:fd",
radioname="LB1", ssid="labalUBI2", cap_flags=0x3e)
ie = ac.to_ie(ac.encode(pkt)) # full 802.11 vendor IE, ready to embed
# An authenticated deauth — the encoder XOR-masks the src with the jiffies nonce
deauth = ac.build_deauth(src_mac="24:5a:4c:44:57:fd", jiffies_nonce=0xdeadbeef)
raw = ac.encode(deauth, dst_mac="24:a4:3c:88:d8:22")
# Others: ac.build_assoc_req / build_assoc_resp / build_probe_req, and m.build_mFor fuzzing / PoCs, seal() encrypts arbitrary plaintext and builds the
outer header — so you can craft deliberately-malformed payloads (bogus
msg_type, wrong lengths, sub-block bodies) that the structured encoder would
never produce:
frame = ac.seal(b"\xde\xad\xbe\xef", src_mac="aa:bb:cc:dd:ee:ff",
dst_mac="ff:ff:ff:ff:ff:ff", msg_type=0xEE) # zero-padded to 16Structured encode raises EncodeError on anything that can't go on the wire
(a TLV value > 255 bytes, ciphertext ≥ 0x101); seal is permissive by design.
pyrmax.pcap finds AirMAX vendor IEs inside 802.11 management frames,
extracts the MACs from the outer 802.11 header, and feeds everything into
the right decoder. Both .pcap and .pcapng are auto-detected. Frames
that fail to decode (wrong key, garbled, unrelated vendor IE) are skipped
silently — iteration stops only at EOF.
from pyrmax import pcap
for meta, packet in pcap.iter_ac("capture.pcap"):
print(
f"{meta.timestamp:.3f} "
f"{meta.src_mac.hex(':')} → {meta.dst_mac.hex(':')} "
f"{packet.msg_type.name:<10} "
f"radio={packet.radioname!r} ssid={packet.ssid!r}"
)
for meta, packet in pcap.iter_m("capture.pcapng"):
print(
f"{meta.timestamp:.3f} "
f"{packet.msg_type.name:<10} "
f"src={packet.src_mac.hex(':')} enable={packet.enable}"
)meta is a FrameMeta(timestamp, src_mac, dst_mac, bssid). Channel/RSSI
extraction from radiotap is on the TODO list.
AirMAX M frames are almost always accompanied by a Mikrotik /
Routerboard.com vendor IE (OUI 00:0C:42) in the same 802.11 management
frame. Its subtype-1 sub-IE carries the device name.
from pyrmax import pcap
for meta, packet in pcap.iter_routerboard("capture.pcap"):
print(f"{meta.src_mac.hex(':')} → {packet.device_name!r}")
# "AP Sur HY1315"The Routerboard decoder is also available standalone — pass the IE data starting at the OUI:
from pyrmax import routerboard
packet = routerboard.decode(ie_data)
packet.device_name # "AP Sur HY1315"
packet.sub_ies # tuple of SubIe(subtype, data)Correlate Routerboard packets with M packets in the same capture by
matching meta.timestamp + meta.src_mac.
Sniff a monitor-mode interface (or a pcap) for AirMAX devices and flag
which are vulnerable. Needs the [scan] extra (scapy) and, for live
capture, root.
# offline — scan a capture (no root)
python -m pyrmax scan capture.pcap
# live, passive — read versions only from traffic that happens to fly
sudo python -m pyrmax scan -i wlan0mon --channel 36
# live, active — force AirMAX AC APs to disclose their firmware
sudo python -m pyrmax scan -i wlan0mon --channel 36 --activeThe version determines vulnerability differently per variant. Two version
numbers are involved: the wire-format protocol version carried in
every AirMAX IE (including beacons), and the AC firmware version
(fwname) which rides only in the association exchange.
AirMAX AC ──> proto < 9 ? ──yes──────────────┐
│no ├──> VULNERABLE
▼ │
fw <= 8.7.20 ? ──yes───────────┘
├──no───────> PATCHED
└──unknown──> UNDETERMINED
AirMAX M ───> proto < 15 ? ──yes──> VULNERABLE
└──no───────────> UNDETERMINED
Because proto is in the beacon, old devices (AC epoch < 9, M
version < 15) are caught passively. Reaching a PATCHED verdict for
AC needs the firmware version, so it needs either a captured association
or --active (the active handshake — auth → assoc → read the assoc-resp
fwname — is a full, byte-faithful Ubiquiti station exchange).
Flags: --channel N (lock one channel, else hop), --seconds N,
--cutoff X.Y.Z (AC-vulnerable if fw <= cutoff, default 8.7.20),
--src MAC (source for the active probe, e.g. the PTP peer), --vuln-only,
--no-set-channel. Exit code is 3 when any vulnerable device is found
(handy for scripting), else 0.
AirMAX: 5 device(s) (3 AC, 2 M), 2 vulnerable (AC fw <= 8.7.20 or protocol version below the fixed epoch).
1c:6a:1b:00:00:01 AC VulnAC ch36 v8.7.19 VULNERABLE rssi=-40dBm peers=0
1c:6a:1b:00:00:04 M VulnM ch36 v14 VULNERABLE rssi=-42dBm peers=0
1c:6a:1b:00:00:03 AC PatchedAC ch36 v8.7.24 patched rssi=-41dBm peers=0
1c:6a:1b:00:00:02 AC UndetAC ch36 epoch9 undetermined rssi=-41dBm peers=0
1c:6a:1b:00:00:05 M UndetM ch36 v15 undetermined rssi=-43dBm peers=0
Beacon as one or more fake AirMAX AC/M devices and, for AC, answer the
discovery handshake so an active scanner reads the emulated firmware
version. Needs the [emulate] extra (scapy), a monitor-mode interface,
and root. Useful for testing scan without real hardware.
sudo python -m pyrmax emulate -i wlan1mon --channel 36 \
-d ac/8.7.19/VulnAC -d ac/9/UndetAC -d ac/8.7.24/PatchedAC \
-d m/14/VulnM -d m/15/UndetMEach -d (repeatable) is TYPE/VERSION[/SSID[/MAC]], /-separated so the
MAC's colons are safe:
ac/8.7.19— modern AC, firmware8.7.19(epoch 9, disclosesfwname)ac/9— modern AC epoch, no firmware string → scanner seesundeterminedac/7— old AC, wire-format epoch 7 (< 9) → vulnerable, caught from the beaconm/14— AirMAX M, version 14 (< 15) → vulnerablem/15— AirMAX M at the fixed epoch → undetermined
With no -d, a demo fleet is emulated. --no-respond beacons only (AC
firmware then won't disclose). The AC firmware version only lives in the
assoc-resp, which is why the responder exists.
scripts/hwsim_testbed.sh creates two virtual radios via mac80211_hwsim
so you can run emulate on one and scan on the other with no hardware:
sudo ./scripts/hwsim_testbed.sh up 36 # prints EMU_IFACE / SCAN_IFACE
# ...run emulate on EMU_IFACE and scan on SCAN_IFACE (two terminals)...
sudo ./scripts/hwsim_testbed.sh downscripts/demo_5_devices.sh does the whole thing end-to-end — spins up the
radios, emulates the 5-device fleet above, runs scan --active, and tears
down:
sudo ./scripts/demo_5_devices.sh 36Schema mismatches and integrity failures raise pyrmax.DecodeError. The
most common cause is a wrong key (wrong src_mac / dst_mac supplied to
decode() for the frame at hand).
from pyrmax import ac, DecodeError
try:
packet = ac.decode(data, src_mac=src, dst_mac=dst)
except DecodeError as exc:
print(f"skipping frame: {exc}")pip install pyrmax[pcap](oruv sync --extra pcap) — pulls in dpkt sopyrmax.pcap.iter_ac(path)/iter_m(path)can stream packets out of either.pcapor.pcapngcaptures (link typeDLT_IEEE802_11_RADIO). Format is auto-detected from the file's magic number.pip install pyrmax[live]— adds pcapy-ng for live capture from a monitor-mode wireless interface. Used by the CLI's-i / --ifaceflag and by the programmaticpyrmax.pcap.iter_airmax_live(iface)generator.pip install pyrmax[scan]— adds scapy for thescancommand (live sniff/decode + the active force-assoc handshake).pip install pyrmax[emulate]— adds scapy for theemulatecommand (inject beacons + answer the discovery handshake as fake devices).
Install several at once, e.g. uv sync --extra scan --extra emulate.
pyrmax/
├── ksy/ # Kaitai Struct source schemas
│ ├── airmax_ac.ksy # AC cleartext outer header (payload decode is hand-written in ac.py)
│ ├── airmax_m.ksy # M outer (OUI marker + encrypted blob)
│ ├── airmax_m_payload.ksy # M decrypted payload (9 documented bytes)
│ └── routerboard.ksy # Mikrotik / Routerboard.com vendor IE
├── src/pyrmax/
│ ├── __init__.py
│ ├── ac.py # AC decode/encode API + AcPacket dataclass
│ ├── m.py # M decode/encode API + MPacket dataclass
│ ├── routerboard.py # Routerboard IE decoder + RouterboardPacket
│ ├── pcap.py # iter_ac / iter_m / iter_routerboard / iter_airmax / iter_airmax_live
│ ├── devices.py # summarize() — per-device aggregation
│ ├── vuln.py # firmware-version parse + is_vulnerable()
│ ├── scan.py # Scanner — live/pcap discovery + active handshake + vuln verdict
│ ├── emulate.py # Emulator — fake AC/M targets (scapy)
│ ├── __main__.py # `python -m pyrmax` CLI
│ ├── exceptions.py
│ ├── _crypto.py # AES-128-ECB + HMAC-SHA1 KDF (internal)
│ └── _generated/ # kaitai-struct-compiler output (committed)
├── scripts/
│ ├── hwsim_testbed.sh # two virtual radios (mac80211_hwsim) for scan<->emulate
│ └── demo_5_devices.sh # end-to-end 5-device emulate + scan demo
└── tests/
├── samples/ # raw frame captures (currently empty)
├── test_ac.py
├── test_m.py
├── test_crypto.py
├── test_pcap.py
├── test_routerboard.py
├── test_devices.py
├── test_cli.py
└── test_integration.py # real-capture round-trips
uv sync # create .venv and install runtime + dev deps
uv run pytest # run tests
uv run ruff check # lint
uv run pyright # static type checkConfiguration lives in pyproject.toml ([tool.pyright]):
typeCheckingMode = "basic"— catches structural issues without fighting the dpkt / kaitaistruct / pycryptodome boundary (those packages don't ship type stubs).src/pyrmax/_generated/is excluded — Kaitai-generated files already carry# type: ignoreand are overwritten on eachkaitai-struct-compilerrun.- Boundaries to dpkt that touch dynamic attributes (e.g.
MGMT_Frame.src) are crossed with anAnyannotation on the local binding rather than scattered ignore comments.
The generated Python files under src/pyrmax/_generated/ are committed so the
package installs without a Kaitai toolchain. To regenerate after editing a
.ksy:
kaitai-struct-compiler -t python --outdir src/pyrmax/_generated/ ksy/*.ksy