
Decodificador/codificador para frames do protocolo sem fio AirMAX da Ubiquiti; analisar capturas pcap/ao vivo, descobrir dispositivos, procurar firmware vulnerável, forjar pacotes malformados e emular alvos AirMAX AC/M.
Decodificador e codificador para frames do protocolo wire Ubiquiti AirMAX, cobrindo tanto a série AC (firmware WA) quanto a M (firmware XW/XM).
ksy/. O payload AC
(controlado por versão, alternado em msg_type, com o deauth XOR-unmask) é
escrito à mão em ac.py — essa lógica não cabe em Kaitai somente-parse.| Variante | Decode | Encode | iteração de pcap |
|---|---|---|---|
| AC | ✅ todos os 5 tipos de msg (beacon, assoc req/resp, probe req, deauth) | ✅ inverso byte-exato + builders + seal | ✅ |
| M | parcial (9 bytes documentados; resto como unknown_rest) | ✅ round-trip da cabeça documentada + unknown_rest | ✅ |
| IE Routerboard.com (companheiro de M) | ✅ nome do dispositivo + lista de sub-IEs | n/a | ✅ |
O formato wire AC segue
docs/ac_wire_format.md
(confirmado [P]-contra ubnt_poll_host.ko). Todos os inteiros multi-byte AC são
big-endian.
pyrmax.ac.encode(AcPacket) -> bytes — inverso byte-exato escrito à mão
de decode() (+ seal(), to_ie(), e construtores build_*).pyrmax.m.encode(MPacket) -> bytes — o mesmo para M (+ build_m,
seal, to_ie).[open] — mapa de bits exato de cap_flags,
mixed_mode, / (assoc_req), /
(assoc_resp), e o do deauth (extraível, mas sua chave ainda não
foi reverse-engineered, então não pode ser verificado). Veja
§11-§12.O pacote inclui uma CLI dirigida por sub-comandos. Execute-a 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` e `discover` aceitam **ou** um arquivo pcap/pcapng (posicional)
**ou** uma interface sem fio ativa via `-i / --iface IFACE`. Os dois são
mutuamente exclusivos. `scan` aceita as mesmas opções de origem (além de um
modo `--active` que só se aplica a uma interface ativa); `emulate` é
apenas para interface ativa.```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
A captura ao vivo pressupõe que a interface já está em modo monitor no
canal de interesse — o pyrmax não configura nenhum dos dois. Ela requer o
extra opcional [live] (pip install pyrmax[live]), que inclui
pcapy-ng. Pressione Ctrl-C para parar: parse informa quantos quadros
processou; discover imprime o resumo agregado dos dispositivos ao sair.
Códigos de saída (compartilhados por ambos os comandos e ambos os modos de origem): 0 em caso de
sucesso (incluindo "nenhum quadro AirMAX" — um resultado válido), 1 para
erros de formato de captura (tipo de enlace incorreto, arquivo malformado, não foi possível abrir a
interface), 2 para arquivo ausente ou argumentos de origem inválidos.
parse — dump por quadro```shpython -m pyrmax parse capture.pcap
Cada quadro de gerenciamento 802.11 que carrega um IE de fornecedor AirMAX produz um bloco: pacote AC, pacote M e qualquer IE companheiro do Routerboard.com encontrado no mesmo quadro.```
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
O mesmo percorredor de passagem única é exposto programaticamente como
pyrmax.pcap.iter_airmax(path) — ele gera um
AirmaxRecord(meta, ac, m, routerboard) por quadro com AirMAX, então
você não precisa correlacionar manualmente os IEs M e Routerboard.
discover — resumo de dispositivos```shpython -m pyrmax discover capture.pcap
Os frames são agrupados por MAC de origem 802.11, os peers são acumulados, e as
observações de payload AC / payload M / nome de dispositivo Routerboard são consolidadas
em um único bloco 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
A mesma agregação também é uma função 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 uma única mensagem AirMAX AC
O decodificador espera os bytes da IE Específica do Fornecedor 802.11 **começando na
OUI** — o invólucro da IE (Element ID `0xDD` + Length) já deve ter sido
removido. `src_mac` / `dst_mac` são obtidos do SA / DA do quadro 802.11
externo.```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 é um de BEACON / ASSOC_REQ / ASSOC_RESP / PROBE_REQ /
DEAUTH. Campos de cauda controlados por versão (field_9c, rssi, fwname, txpower,
…) são None quando o version do frame está abaixo do seu limite. Deauth
desfaz o XOR de src_mac com o nonce de jiffies antes da verificação de integridade e
expõe o enc_token de 16 bytes como bytes opacos (sua chave ainda não foi
revertida, então não pode ser verificado).
Corpos que carregam TLVs de nome (beacon, assoc_req) preservam o fluxo —
incluindo a entrada Padding final — na íntegra em body.tlvs. radioname,
ssid e fwname são expostos como propriedades de conveniência no pacote;
todo o resto permanece bruto.```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 uma única mensagem AirMAX M
Mesma forma, superfície menor — apenas 9 bytes do payload M são documentados;
o restante é preservado verbatim em `unknown_rest`. **Nota:** o payload M
em si não possui campo SSID — para isso, veja a IE SSID padrão 802.11
no beacon ou resposta de probe adjacente (exposta como
`FrameMeta.ssid` ao iterar via `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=…) é o inverso exato em bytes de decode —
encode(decode(x)) == x para um frame bem formado. Ele serializa o corpo
(reaplicando as portas de versão e, para deauth, a máscara XOR de src_mac), faz
padding até o bloco AES, criptografa com a chave derivada de packet.src_mac e
emite os bytes a partir do OUI. Encapsule com to_ie() para uma IE de fornecedor
0xDD completa.
Os construtores build_* evitam que você monte os corpos aninhados manualmente:```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")
For fuzzing / PoCs, `seal()` criptografa texto simples **arbitrário** e constrói o cabeçalho externo — para que você possa criar payloads deliberadamente malformados (`msg_type` falso, comprimentos errados, corpos de sub-bloco) que o codificador estruturado nunca produziria:```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
O encode estruturado levanta EncodeError para qualquer coisa que não possa ir para o fio
(um valor TLV > 255 bytes, texto cifrado ≥ 0x101); seal é permissivo por design.
pyrmax.pcap encontra IEs de fornecedor AirMAX dentro de quadros de gerenciamento 802.11,
extrai os MACs do cabeçalho 802.11 externo e passa tudo para o
decodificador adequado. Tanto .pcap quanto .pcapng são detectados automaticamente. Quadros
que falham na decodificação (chave errada, corrompidos, IE de fornecedor não relacionado) são ignorados
silenciosamente — a iteração só para no 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` é um `FrameMeta(timestamp, src_mac, dst_mac, bssid)`. A extração de canal/RSSI do radiotap está na lista de TODO.
### Recuperar o nome do dispositivo a partir de uma IE Routerboard.com
Quadros AirMAX M são quase sempre acompanhados por uma IE de fornecedor Mikrotik / Routerboard.com (OUI `00:0C:42`) no mesmo quadro de gerenciamento 802.11. Sua sub-IE de subtipo 1 carrega o nome do 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"
O decodificador Routerboard também está disponível de forma independente — passe os dados do IE começando pelo 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)
Correlacione pacotes Routerboard com pacotes M na mesma captura
correspondendo a `meta.timestamp` + `meta.src_mac`.
### `scan` — encontre dispositivos vulneráveis
Capture o tráfego de uma interface em modo monitor (ou de um pcap)
para procurar dispositivos AirMAX e sinalize quais são vulneráveis. Requer o extra
`[scan]` (`scapy`) e, para captura ao 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
A versão determina a vulnerabilidade de forma diferente por variante. Dois números de
versão estão envolvidos: a versão do protocolo wire-format transportada em
cada AirMAX IE (incluindo beacons), e a versão do firmware AC
(fwname) que viaja apenas na troca de associação.```
AirMAX AC ──> proto < 9 ? ──yes──────────────┐
│no ├──> VULNERABLE
▼ │
fw <= 8.7.20 ? ──yes───────────┘
├──no───────> PATCHED
└──unknown──> UNDETERMINED
AirMAX M ───> proto < 15 ? ──yes──> VULNERABLE └──no───────────> UNDETERMINED
Como `proto` está no beacon, dispositivos **antigos** (epoch AC < 9, M
versão < 15) são detectados **passivamente**. Chegar a um veredito `PATCHED` para
AC exige a versão do firmware, portanto é necessário ter uma associação capturada
ou usar `--active` (o handshake ativo — auth → assoc → ler o assoc-resp
`fwname` — é uma troca de estação Ubiquiti completa e fiel byte a byte).
Flags: `--channel N` (fixar um canal, caso contrário alternar), `--seconds N`,
`--cutoff X.Y.Z` (vulnerável a AC se `fw <= cutoff`, padrão `8.7.20`),
`--src MAC` (origem para a sonda ativa, ex. o peer PTP), `--vuln-only`,
`--no-set-channel`. O código de saída é **3** quando qualquer dispositivo vulnerável é encontrado
(útil para scripts), caso contrário `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 — alvos AirMAX falsosEmita beacons como um ou mais dispositivos AirMAX AC/M falsos e, para AC, responda ao handshake de descoberta para que um scanner ativo leia a versão de firmware emulada. Requer o extra [emulate] (scapy), uma interface em modo monitor e root. Útil para testar scan sem 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` (repetível) é `TYPE/VERSION[/SSID[/MAC]]`, separado por `/`, de modo que os
dois-pontos do MAC ficam seguros:
- `ac/8.7.19` — AC moderno, firmware `8.7.19` (epoch 9, divulga `fwname`)
- `ac/9` — epoch do AC moderno, **sem** string de firmware → o scanner vê
`undetermined`
- `ac/7` — AC **antigo**, epoch 7 do wire-format (< 9) → vulnerável, detectado a partir
do beacon
- `m/14` — AirMAX M, versão 14 (< 15) → vulnerável
- `m/15` — AirMAX M no epoch corrigido → undetermined
Sem `-d`, uma frota de demonstração é emulada. `--no-respond` envia apenas beacons (o
firmware do AC então não divulgará a versão). A versão do firmware do AC só existe na
assoc-resp, e é por isso que o responder existe.
### Teste ambos juntos em uma máquina
`scripts/hwsim_testbed.sh` cria duas rádios virtuais via `mac80211_hwsim`
para que você possa executar `emulate` em uma e `scan` na outra sem 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 faz tudo de ponta a ponta — inicia as
rádios, emula a frota de 5 dispositivos acima, executa scan --active e
encerra:```sh
sudo ./scripts/demo_5_devices.sh 36
### Tratamento de erros
Incompatibilidades de esquema e falhas de integridade geram `pyrmax.DecodeError`. A
causa mais comum é uma chave errada (`src_mac` / `dst_mac` fornecidos a
`decode()` para o quadro em questão).```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] (ou uv sync --extra pcap) — puxa dpkt
para que pyrmax.pcap.iter_ac(path) / iter_m(path) possam transmitir pacotes de
capturas .pcap ou .pcapng (tipo de link DLT_IEEE802_11_RADIO).
O formato é detectado automaticamente pelo número mágico do arquivo.pip install pyrmax[live] — adiciona pcapy-ng para captura ao vivo de uma
interface sem fio em modo monitor. Usado pela flag -i / --iface da CLI
e pelo gerador programático pyrmax.pcap.iter_airmax_live(iface).pip install pyrmax[scan] — adiciona scapy para o comando scan
(sniff/decode ao vivo + o handshake ativo force-assoc).Instale vários de uma vez, ex.: 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
## Desenvolvimento```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]):
typeCheckingMode = "basic" — detecta problemas estruturais sem lutar com o limite dpkt / kaitaistruct / pycryptodome (esses pacotes não fornecem stubs de tipos).src/pyrmax/_generated/ é excluído — os arquivos gerados por Kaitai já contêm # type: ignore e são sobrescritos a cada execução do kaitai-struct-compiler.MGMT_Frame.src) são atravessados com uma anotação Any na ligação local, em vez de comentários de ignore espalhados.Os arquivos Python gerados em src/pyrmax/_generated/ são versionados para que o pacote seja instalado sem um toolchain Kaitai. Para regenerar após editar um .ksy:```sh
kaitai-struct-compiler -t python --outdir src/pyrmax/_generated/ ksy/*.ksy
field_14field_9csta_field_68ic_6b8enc_tokenversion < 9 contra capturas reais — o
builder TX só emite a versão 9, então os caminhos de versão inferior (nome
sem tag, campos de cauda ausentes) são implementados a partir da spec, mas
não verificados no wire.unknown_rest) — atualmente opaco.pcap.FrameMeta: canal, RSSI,
taxa. Atualmente apenas timestamp/MACs/BSSID são preenchidos.tests/samples/ — atualmente:
airmax_ac_beacon.pcap (1 frame, beacon) e
airmax_m_probe_response.pcap (1 frame, probe response). Mais variantes
(assoc req/resp, capturas multi-frame) ainda são bem-vindas.scan (force-assoc ativa) e emulate
injetam via scapy (os extras [scan] / [emulate]). Veja
scan.py / emulate.py.pip install pyrmax[emulate] — adiciona scapy para o comando emulate
(injetar beacons + responder ao handshake de descoberta como dispositivos falsos).