Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

pyrmax

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 on msg_type, with the deauth XOR-unmask) is hand-written in ac.py — that logic doesn't fit parse-only Kaitai.
  • Encode is hand-written to mirror the decode path field-for-field.

Status

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.

TODO

  • 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).
  • Pin down the [open] AC fieldscap_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.
  • Verify the 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.
  • 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) and airmax_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) and emulate commands inject via scapy (the [scan] / [emulate] extras). See scan.py / emulate.py.

Command-line tool

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 dump

python -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 summary

python -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)

Usage

Decode a single AirMAX AC message

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).

Walk the AC TLV list

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}")

Decode a single AirMAX M message

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 pending

Craft messages (encode)

encode(packet, dst_mac=…) is the byte-exact inverse of decodeencode(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_m

For 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 16

Structured encode raises EncodeError on anything that can't go on the wire (a TLV value > 255 bytes, ciphertext ≥ 0x101); seal is permissive by design.

Stream packets from a pcap or pcapng

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.

Recover the device name from a Routerboard.com IE

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.

scan — find vulnerable devices

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 --active

The 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

emulate — fake AirMAX targets

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/UndetM

Each -d (repeatable) is TYPE/VERSION[/SSID[/MAC]], /-separated so the MAC's colons are safe:

  • ac/8.7.19 — modern AC, firmware 8.7.19 (epoch 9, discloses fwname)
  • ac/9 — modern AC epoch, no firmware string → scanner sees undetermined
  • ac/7old AC, wire-format epoch 7 (< 9) → vulnerable, caught from the beacon
  • m/14 — AirMAX M, version 14 (< 15) → vulnerable
  • m/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.

Test both together on one machine

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 down

scripts/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 36

Error handling

Schema 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}")

Optional extras

  • pip install pyrmax[pcap] (or uv sync --extra pcap) — pulls in dpkt so pyrmax.pcap.iter_ac(path) / iter_m(path) can stream packets out of either .pcap or .pcapng captures (link type DLT_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 / --iface flag and by the programmatic pyrmax.pcap.iter_airmax_live(iface) generator.
  • pip install pyrmax[scan] — adds scapy for the scan command (live sniff/decode + the active force-assoc handshake).
  • pip install pyrmax[emulate] — adds scapy for the emulate command (inject beacons + answer the discovery handshake as fake devices).

Install several at once, e.g. uv sync --extra scan --extra emulate.

Layout

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

Development

uv sync                    # create .venv and install runtime + dev deps
uv run pytest              # run tests
uv run ruff check          # lint
uv run pyright             # static type check

Configuration 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: ignore and are overwritten on each kaitai-struct-compiler run.
  • Boundaries to dpkt that touch dynamic attributes (e.g. MGMT_Frame.src) are crossed with an Any annotation on the local binding rather than scattered ignore comments.

Regenerating Kaitai parsers

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

About

No description, website, or topics provided.

Resources

Stars

5 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages