
Codificador/decodificador para tramas del protocolo inalámbrico AirMAX de Ubiquiti; analiza capturas pcap/en vivo, descubre dispositivos, busca firmware vulnerable, crea paquetes malformados y emula objetivos AirMAX AC/M.
Decodificador y codificador de tramas del protocolo wire de Ubiquiti AirMAX, que cubre tanto la serie AC (firmware WA) como la serie M (firmware XW/XM).
ksy/. La carga útil de AC
(restringida por versión, conmutada por msg_type, con el desenmascarado XOR de deauth) está
escrita a mano en ac.py — esa lógica no encaja en Kaitai solo-parse.| Variante | Decodificación | Codificación | Iteración de pcap |
|---|---|---|---|
| AC | ✅ los 5 tipos de msg (beacon, assoc req/resp, probe req, deauth) | ✅ inverso byte-exacto + constructores + seal | ✅ |
| M | parcial (9 bytes documentados; el resto como unknown_rest) | ✅ hace round-trip de la cabecera documentada + unknown_rest | ✅ |
| IE de Routerboard.com (compañero de M) | ✅ nombre de dispositivo + lista de sub-IE | n/a | ✅ |
El formato wire de AC sigue
docs/ac_wire_format.md
([P]-confirmado contra ubnt_poll_host.ko). Todos los enteros multi-byte de AC son
big-endian.
pyrmax.ac.encode(AcPacket) -> bytes — inverso byte-exacto escrito a mano
de decode() (+ seal(), to_ie() y constructores build_*).pyrmax.m.encode(MPacket) -> bytes — lo mismo para M (+ build_m,
seal, to_ie).[open] de AC — mapa de bits exacto de cap_flags,
mixed_mode, / (assoc_req), /
(assoc_resp), y el de deauth (extraíble pero su clave aún no tiene
ingeniería inversa, por lo que no puede verificarse). Ver
§11-§12.El paquete incluye una CLI controlada por subcomandos. Ejecútalo como
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` y `discover` aceptan **ya sea** un archivo pcap/pcapng (posicional)
**o** una interfaz inalámbrica en vivo a través de `-i / --iface IFACE`. Ambas opciones son
mutuamente excluyentes. `scan` acepta las mismas opciones de origen (además de un
modo `--active` que solo se aplica a una interfaz en vivo); `emulate` es
exclusivo de interfaces en vivo.```sh
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
La captura en vivo asume que la interfaz ya está en modo monitor en el
canal de interés — pyrmax no configura ninguno de los dos. Requiere el
extra opcional [live] (pip install pyrmax[live]) que incorpora
pcapy-ng. Presione Ctrl-C para detener: parse informa cuántas tramas
transmitió; discover imprime el resumen agregado de dispositivos al salir.
Códigos de salida (compartidos por ambos comandos y ambos modos de origen): 0 en
caso de éxito (incluyendo "sin tramas AirMAX" — un resultado válido), 1 para
errores de formato de captura (tipo de enlace incorrecto, archivo malformado, no se puede abrir
la interfaz), 2 para archivo faltante o argumentos de origen no válidos.
parse — volcado por trama```shpython -m pyrmax parse capture.pcap
Cada trama de gestión 802.11 que transporta un IE de proveedor AirMAX produce un bloque: paquete AC, paquete M y cualquier IE acompañante de Routerboard.com que se encuentre en la misma trama.```
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
El mismo walker de una sola pasada se expone programáticamente como
pyrmax.pcap.iter_airmax(path) — produce un
AirmaxRecord(meta, ac, m, routerboard) por trama que contenga AirMAX, por lo
que no necesitas correlacionar manualmente los IEs de M y Routerboard.
discover — resumen del dispositivo```shpython -m pyrmax discover capture.pcap
Las tramas se agrupan por MAC de origen 802.11, los peers se acumulan, y las
observaciones de payload AC / payload M / nombre de dispositivo Routerboard se consolidan en
un solo bloque por dispositivo.```
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
La misma agregación también es una función pública:```python 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)
## Uso
### Decodificar un único mensaje AirMAX AC
El decodificador espera los bytes de la IE específica del proveedor 802.11 **a partir
del OUI** — el envoltorio de la IE (Element ID `0xDD` + Length) ya debe haber sido
eliminado. `src_mac` / `dst_mac` se extraen de los SA / DA de la trama 802.11
exterior.```python
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 es uno de BEACON / ASSOC_REQ / ASSOC_RESP / PROBE_REQ /
DEAUTH. Los campos de cola controlados por versión (field_9c, rssi, fwname, txpower,
…) son None cuando la version de la trama está por debajo de su umbral. Deauth
deshace el XOR de src_mac con el nonce de jiffies antes de la comprobación de integridad y
expone el enc_token de 16 bytes como bytes opacos (su clave no está invertida
aún, por lo que no se puede verificar).
Los cuerpos que llevan TLV de nombre (beacon, assoc_req) preservan el flujo —
incluida la entrada Padding final — tal cual en body.tlvs. radioname,
ssid y fwname se exponen como propiedades de conveniencia en el paquete;
todo lo demás permanece sin procesar.```python
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}")
### Decodificar un único mensaje AirMAX M
Misma forma, superficie más pequeña — solo se documentan 9 bytes de la carga útil M; el resto se conserva literalmente en `unknown_rest`. **Nota:** la carga útil M en sí no tiene campo SSID — para eso, consulta la IE SSID estándar 802.11 en la baliza o respuesta de sonda circundante (expuesta como `FrameMeta.ssid` al iterar mediante `pyrmax.pcap`).```python
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
encode(packet, dst_mac=…) es la inversa exacta en bytes de decode —
encode(decode(x)) == x para una trama bien formada. Serializa el cuerpo
(reaplicando las compuertas de versión y, para deauth, la máscara XOR de src_mac), rellena hasta
el bloque AES, cifra con la clave derivada de packet.src_mac y emite
los bytes a partir del OUI. Envuelve con to_ie() para una IE de proveedor completa 0xDD.
Los constructores build_* te ahorran ensamblar a mano los cuerpos anidados:```python
from pyrmax import ac
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
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")
Para fuzzing / PoCs, `seal()` cifra texto plano **arbitrario** y construye la
cabecera externa — para que puedas crear payloads deliberadamente malformados (`msg_type`
falso, longitudes incorrectas, cuerpos de sub-bloques) que el codificador estructurado
nunca produciría:```python
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
El encode estructurado lanza EncodeError ante cualquier cosa que no pueda enviarse por el cable
(un valor TLV > 255 bytes, texto cifrado ≥ 0x101); seal es permisivo por diseño.
pyrmax.pcap encuentra IEs de proveedor AirMAX dentro de tramas de gestión 802.11,
extrae las MAC de la cabecera 802.11 externa y alimenta todo al decodificador adecuado.
Tanto .pcap como .pcapng se detectan automáticamente. Las tramas
que no se pueden decodificar (clave incorrecta, datos corruptos, IE de proveedor no relacionado) se omiten
silenciosamente — la iteración solo se detiene al llegar a EOF.```python
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` es un `FrameMeta(timestamp, src_mac, dst_mac, bssid)`. La extracción de canal/RSSI
desde radiotap está en la lista TODO.
### Recuperar el nombre del dispositivo de una IE de Routerboard.com
Las tramas AirMAX M están casi siempre acompañadas de una IE de proveedor de Mikrotik /
Routerboard.com (OUI `00:0C:42`) en la misma trama de gestión
802.11. Su sub-IE de subtipo 1 contiene el nombre del dispositivo.```python
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"
El decodificador de Routerboard también está disponible de forma independiente: pase los datos de IE a partir de la OUI:```python from pyrmax import routerboard
packet = routerboard.decode(ie_data) packet.device_name # "AP Sur HY1315" packet.sub_ies # tuple of SubIe(subtype, data)
Correlaciona los paquetes Routerboard con los paquetes M en la misma captura haciendo coincidir `meta.timestamp` + `meta.src_mac`.
### `scan` — encontrar dispositivos vulnerables
Captura el tráfico de una interfaz en modo monitor (o un pcap) para buscar dispositivos AirMAX y marca cuáles son vulnerables. Requiere el extra `[scan]` (`scapy`) y, para captura en vivo, root.```sh
# 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
La versión determina la vulnerabilidad de manera diferente según la variante. Dos números de versión
están involucrados: la versión del protocolo de formato de cable transportada en
cada IE de AirMAX (incluidas las balizas), y la versión de firmware de AC
(fwname) que viaja solo en el intercambio de asociación.```
AirMAX AC ──> proto < 9 ? ──yes──────────────┐
│no ├──> VULNERABLE
▼ │
fw <= 8.7.20 ? ──yes───────────┘
├──no───────> PATCHED
└──unknown──> UNDETERMINED
AirMAX M ───> proto < 15 ? ──yes──> VULNERABLE └──no───────────> UNDETERMINED
Debido a que `proto` está en la baliza, los dispositivos **antiguos** (época AC < 9, M
versión < 15) se detectan **pasivamente**. Alcanzar un veredicto `PATCHED` para
AC requiere la versión del firmware, por lo que necesita una asociación capturada
o `--active` (el handshake activo — auth → assoc → leer el assoc-resp
`fwname` — es un intercambio de estación Ubiquiti completo y fiel a nivel de byte).
Flags: `--channel N` (bloquear un canal, si no saltar), `--seconds N`,
`--cutoff X.Y.Z` (vulnerable a AC si `fw <= cutoff`, por defecto `8.7.20`),
`--src MAC` (origen para la sonda activa, p. ej. el peer PTP), `--vuln-only`,
`--no-set-channel`. El código de salida es **3** cuando se encuentra algún dispositivo vulnerable
(útil para scripting), de lo contrario `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 — objetivos AirMAX falsosActúa como baliza de uno o más dispositivos AirMAX AC/M falsos y, para AC, responde al handshake de descubrimiento para que un escáner activo lea la versión de firmware emulada. Requiere el extra [emulate] (scapy), una interfaz en modo monitor y permisos de root. Útil para probar scan sin hardware real.```sh
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
Cada `-d` (repetible) es `TYPE/VERSION[/SSID[/MAC]]`, separado por `/` para que los dos puntos de la MAC sean seguros:
- `ac/8.7.19` — AC moderno, firmware `8.7.19` (epoch 9, revela `fwname`)
- `ac/9` — epoch de AC moderno, **sin** cadena de firmware → el escáner ve `undetermined`
- `ac/7` — AC **antiguo**, epoch de formato de trama 7 (< 9) → vulnerable, detectado desde la baliza
- `m/14` — AirMAX M, versión 14 (< 15) → vulnerable
- `m/15` — AirMAX M en el epoch corregido → undetermined
Sin `-d`, se emula una flota de demostración. `--no-respond` emite solo balizas (el firmware AC entonces no revelará la versión). La versión de firmware AC solo reside en la assoc-resp, por eso existe el responder.
### Probar ambos juntos en una sola máquina
`scripts/hwsim_testbed.sh` crea dos radios virtuales mediante `mac80211_hwsim` para que puedas ejecutar `emulate` en una y `scan` en la otra sin hardware:```sh
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 hace todo el proceso de principio a fin — levanta
los radios, emula la flota de 5 dispositivos mencionada, ejecuta scan --active
y finaliza:```sh
sudo ./scripts/demo_5_devices.sh 36
### Manejo de errores
Las discrepancias de esquema y los fallos de integridad lanzan `pyrmax.DecodeError`. La
causa más común es una clave incorrecta (`src_mac` / `dst_mac` incorrectos proporcionados a
`decode()` para la trama en cuestión).```python
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] (o uv sync --extra pcap) — incorpora dpkt
para que pyrmax.pcap.iter_ac(path) / iter_m(path) puedan transmitir paquetes
desde capturas .pcap o .pcapng (tipo de enlace DLT_IEEE802_11_RADIO).
El formato se detecta automáticamente a partir del número mágico del archivo.pip install pyrmax[live] — añade pcapy-ng para la captura en vivo desde una
interfaz inalámbrica en modo monitor. Se usa mediante la opción -i / --iface de la CLI
y mediante el generador programático pyrmax.pcap.iter_airmax_live(iface).pip install pyrmax[scan] — añade scapy para el comando scan
(sniff/decodificación en vivo + el handshake activo de force-assoc).Instala varios a la vez, p. ej. 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
## Desarrollo```sh
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]):
La configuración vive en pyproject.toml ([tool.pyright]):
typeCheckingMode = "basic" — catches structural issues without
fighting the dpkt / kaitaistruct / pycryptodome boundary (those
packages don't ship type stubs).
typeCheckingMode = "basic" — detecta problemas estructurales sin
pelear con el límite de dpkt / kaitaistruct / pycryptodome (esos
paquetes no incluyen stubs de tipos).
src/pyrmax/_generated/ is excluded — Kaitai-generated files already
carry # type: ignore and are overwritten on each kaitai-struct-compiler
run.
src/pyrmax/_generated/ está excluido — los archivos generados por Kaitai ya
llevan # type: ignore y se sobrescriben en cada ejecución de kaitai-struct-compiler.
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.
Los límites con dpkt que tocan atributos dinámicos (p. ej.
MGMT_Frame.src) se cruzan con una anotación en el enlace
local en lugar de comentarios de ignore dispersos.
The generated Python files under src/pyrmax/_generated/ are committed so the
package installs without a Kaitai toolchain. To regenerate after editing a
.ksy:
Los archivos Python generados en src/pyrmax/_generated/ están versionados para que el
paquete se instale sin una cadena de herramientas de Kaitai. Para regenerar después de editar un
.ksy:```sh
kaitai-struct-compiler -t python --outdir src/pyrmax/_generated/ ksy/*.ksy
field_14field_9csta_field_68ic_6b8enc_tokenversion < 9 contra capturas reales — el
constructor TX solo emite versión 9, por lo que las rutas de versiones inferiores (nombre
sin etiqueta, campos de cola ausentes) están implementadas según la especificación pero
sin verificar en el wire.unknown_rest) — actualmente opaco.pcap.FrameMeta: canal, RSSI,
tasa. Actualmente solo se rellenan timestamp/MACs/BSSID.tests/samples/ — actualmente:
airmax_ac_beacon.pcap (1 trama, beacon) y
airmax_m_probe_response.pcap (1 trama, respuesta de probe). Más variantes
(assoc req/resp, capturas multi-trama) aún son bienvenidas.scan (asociación forzada activa) y emulate
inyectan vía scapy (los extras [scan] / [emulate]). Ver
scan.py / emulate.py.pip install pyrmax[emulate] — añade scapy para el comando emulate
(inyectar balizas + responder al handshake de descubrimiento como dispositivos falsos).Any