
Décodeur/encodeur pour les trames du protocole sans fil Ubiquiti AirMAX ; analyse des captures pcap/en direct, découverte de périphériques, recherche de firmware vulnérables, fabrication de paquets malformés et émulation de cibles AirMAX AC/M.
Décodeur et encodeur pour les trames du protocole de communication Ubiquiti AirMAX, couvrant les séries AC (firmware WA) et M (firmware XW/XM).
ksy/. La charge utile AC (sélectionnée selon la version, commutée sur msg_type, avec le démasquage XOR du deauth) est écrite à la main dans ac.py — cette logique ne convient pas à un Kaitai de simple parsing.| Variante | Décodage | Encodage | Itération pcap |
|---|---|---|---|
| AC | ✅ les 5 types de message (beacon, assoc req/resp, probe req, deauth) | ✅ inverse octet-exact + constructeurs + seal | ✅ |
| M | partiel (9 octets documentés ; le reste sous unknown_rest) | ✅ aller-retour de l'en-tête documenté + unknown_rest | ✅ |
| IE Routerboard.com (compagnon de M) | ✅ nom du périphérique + liste des sous-IE | n/a | ✅ |
Le format de transmission AC suit
docs/ac_wire_format.md
([P]-confirmé par rapport à ubnt_poll_host.ko). Tous les entiers multi-octets AC sont en big-endian.
pyrmax.ac.encode(AcPacket) -> bytes — inverse octet-exact écrit à la main de decode() (+ seal(), to_ie() et les constructeurs build_*).pyrmax.m.encode(MPacket) -> bytes — idem pour M (+ build_m, seal, to_ie).[open] — cap_flags carte de bits exacte, mixed_mode, / (assoc_req), / (assoc_resp), et le du deauth (extractible mais sa clé n'a pas encore été rétro-conçue, donc il ne peut pas être vérifié). Voir
§11-§12.Le paquet est fourni avec une CLI basée sur des sous-commandes. Lancez-la avec
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` et `discover` acceptent **soit** un fichier pcap/pcapng (positionnel)
**soit** une interface sans fil en direct via `-i / --iface IFACE`. Les deux sont
mutuellement exclusifs. `scan` accepte les mêmes options de source (plus un
mode `--active` qui ne s'applique qu'à une interface en direct) ; `emulate` est
uniquement en direct.```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 capture en direct suppose que l'interface est déjà en mode monitor sur le
canal d'intérêt — pyrmax ne le configure pas non plus. Elle nécessite
l'extension optionnelle [live] (pip install pyrmax[live]) qui installe
pcapy-ng. Appuyez sur Ctrl-C pour arrêter : parse indique combien de trames
ont été diffusées ; discover affiche le résumé agrégé des appareils à la sortie.
Codes de sortie (partagés par les deux commandes et les deux modes de source) : 0 en
cas de succès (y compris « aucune trame AirMAX » — un résultat valide), 1 pour
les erreurs de format de capture (mauvais type de liaison, fichier corrompu, impossible d'ouvrir
l'interface), 2 pour fichier manquant ou arguments de source invalides.
parse — vidage trame par trame```shpython -m pyrmax parse capture.pcap
Chaque trame de gestion 802.11 transportant un IE de fournisseur AirMAX produit un
bloc : paquet AC, paquet M, et tout IE compagnon Routerboard.com trouvé dans la même trame.```
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
Le même analyseur en une seule passe est exposé programmatiquement via pyrmax.pcap.iter_airmax(path) — il génère un AirmaxRecord(meta, ac, m, routerboard) pour chaque trame contenant de l'AirMAX, vous n'avez donc pas besoin de corréler à la main les IE M et Routerboard.
discover — résumé de l'appareil```shpython -m pyrmax discover capture.pcap
Les trames sont regroupées par MAC source 802.11, les pairs s'accumulent, et les observations AC payload / M payload / nom d'appareil Routerboard se regroupent en un seul bloc par appareil.```
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 même agrégation est aussi une fonction publique :```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)
## Utilisation
### Décoder un seul message AirMAX AC
Le décodeur attend les octets de l’IE Vendor Specific 802.11 **en partant de
l’OUI** — l’enveloppe IE (Element ID `0xDD` + Longueur) doit déjà être
retirée. `src_mac` / `dst_mac` sont extraits du SA / DA de la trame 802.11
externe.```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 est l'une de BEACON / ASSOC_REQ / ASSOC_RESP / PROBE_REQ /
DEAUTH. Les champs de fin conditionnés par la version (field_9c, rssi, fwname, txpower,
…) sont None lorsque la version de la trame est inférieure à leur seuil. Deauth
annule le XOR de src_mac avec le nonce jiffies avant la vérification d'intégrité et
expose le enc_token de 16 octets comme des octets opaques (sa clé n'est pas encore
inversée, il ne peut donc pas être vérifié).
Les corps qui portent des TLV de nom (beacon, assoc_req) préservent le flux —
y compris l'entrée Padding de fin — tel quel sur body.tlvs. radioname,
ssid et fwname sont exposés comme des propriétés de commodité sur le paquet ;
tout le reste demeure brut.```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}")
### Décodez un message AirMAX M
Même forme, surface plus petite — seuls 9 octets de la charge utile M sont documentés ;
le reste est préservé tel quel dans `unknown_rest`. **Remarque :** la charge utile M
elle-même n'a pas de champ SSID — pour cela, reportez-vous à l'IE SSID 802.11 standard
sur la balise ou la réponse de sonde environnante (affiché sous
`FrameMeta.ssid` lors de l'itération 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=…) est l'inverse exact au niveau octet de decode —
encode(decode(x)) == x pour une trame bien formée. Il sérialise le corps
(ré-applique les gardes de version et, pour deauth, le masque XOR src_mac), complète
le bloc AES, chiffre avec la clé dérivée de packet.src_mac, et émet
les octets à partir d'OUI. Enveloppez avec to_ie() pour une IE vendor complète 0xDD.
Les constructeurs build_* vous évitent d'assembler les corps imbriqués à la main :```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")
Pour le fuzzing / les PoC, `seal()` chiffre un texte clair **arbitraire** et construit l'en-tête externe — vous pouvez ainsi créer des payloads délibérément malformés (`msg_type` erroné, longueurs incorrectes, corps de sous-blocs) que l'encodeur structuré ne produirait jamais :```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
Structured encode lève EncodeError sur tout ce qui ne peut pas être transmis sur le fil
(une valeur TLV > 255 octets, un texte chiffré ≥ 0x101) ; seal est permissif par conception.
pyrmax.pcap trouve les IE de fournisseur AirMAX dans les trames de gestion 802.11,
extrait les MAC de l'en-tête 802.11 externe, et transmet le tout au
bon décodeur. Les fichiers .pcap et .pcapng sont détectés automatiquement. Les trames
qui échouent au décodage (mauvaise clé, corrompues, IE de fournisseur sans rapport) sont ignorées
silencieusement — l'itération ne s'arrête qu'à la fin du fichier (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` est une `FrameMeta(timestamp, src_mac, dst_mac, bssid)`. L'extraction canal/RSSI
à partir du radiotap figure sur la liste TODO.
### Récupérer le nom de l'appareil à partir d'une IE Routerboard.com
Les trames AirMAX M sont presque toujours accompagnées d'une IE de fabricant Mikrotik /
Routerboard.com (OUI `00:0C:42`) dans la même trame de gestion 802.11.
Sa sous-IE de sous-type 1 porte le nom de l'appareil.```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"
Le décodeur Routerboard est également disponible en version autonome — passez les données IE à partir de l'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)
Corrélerez les paquets Routerboard avec les paquets M dans la même capture en
faisant correspondre `meta.timestamp` + `meta.src_mac`.
### `scan` — trouver les appareils vulnérables
Sniffez une interface en mode moniteur (ou un pcap) pour les appareils AirMAX et signalez
ceux qui sont vulnérables. Nécessite l'extra `[scan]` (`scapy`) et, pour une capture en direct,
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 version détermine différemment la vulnérabilité selon la variante. Deux numéros de
version sont impliqués : la version de protocole wire-format portée dans
chaque IE AirMAX (y compris les balises), et la version du firmware AC
(fwname) qui ne circule que dans l'échange d'association.```
AirMAX AC ──> proto < 9 ? ──yes──────────────┐
│no ├──> VULNERABLE
▼ │
fw <= 8.7.20 ? ──yes───────────┘
├──no───────> PATCHED
└──unknown──> UNDETERMINED
AirMAX M ───> proto < 15 ? ──yes──> VULNERABLE └──no───────────> UNDETERMINED
Parce que `proto` est dans le beacon, les appareils **anciens** (AC epoch < 9, version M < 15) sont détectés **passivement**. Pour atteindre un verdict `PATCHED` pour AC, il faut la version du firmware, donc soit une association capturée, soit `--active` (le handshake actif — auth → assoc → lire le `fwname` de l'assoc-resp — est un échange de station Ubiquiti complet et fidèle à l'octet).
Options : `--channel N` (verrouiller un canal, sinon sauter), `--seconds N`,
`--cutoff X.Y.Z` (vulnérable à AC si `fw <= cutoff`, défaut `8.7.20`),
`--src MAC` (source pour la sonde active, ex. le pair PTP), `--vuln-only`,
`--no-set-channel`. Le code de sortie est **3** quand un appareil vulnérable est trouvé (pratique pour les scripts), sinon `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 — fausses cibles AirMAXÉmettez des balises en tant qu'un ou plusieurs faux appareils AirMAX AC/M et, pour AC, répondez au
handshake de découverte afin qu'un scanner actif lise la version du firmware
émulé. Nécessite l'extra [emulate] (scapy), une interface en mode monitor,
et root. Utile pour tester scan sans matériel réel.```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
Chaque `-d` (répétable) est `TYPE/VERSION[/SSID[/MAC]]`, séparé par `/` afin que les
deux-points du MAC soient sans danger :
- `ac/8.7.19` — AC moderne, firmware `8.7.19` (epoch 9, révèle `fwname`)
- `ac/9` — epoch de l'AC moderne, **aucune** chaîne de firmware → le scanner voit
`undetermined`
- `ac/7` — **ancien** AC, epoch au format filaire 7 (< 9) → vulnérable, détecté à partir de
la balise
- `m/14` — AirMAX M, version 14 (< 15) → vulnérable
- `m/15` — AirMAX M à l'epoch corrigé → indéterminé
Sans `-d`, une flotte de démonstration est émulée. Avec `--no-respond`, uniquement des balises (le
firmware AC ne divulguera alors rien). La version du firmware AC ne se trouve que dans la
`assoc-resp`, c'est pourquoi le répondeur existe.
### Tester les deux ensemble sur une même machine
`scripts/hwsim_testbed.sh` crée deux radios virtuelles via `mac80211_hwsim`
afin que vous puissiez exécuter `emulate` sur l'une et `scan` sur l'autre sans matériel :```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 fait tout de bout en bout — démarre les
radios, émule la flotte de 5 appareils ci-dessus, exécute scan --active, puis
arrête tout :```sh
sudo ./scripts/demo_5_devices.sh 36
### Gestion des erreurs
Les incohérences de schéma et les échecs d'intégrité lèvent `pyrmax.DecodeError`. La
cause la plus fréquente est une mauvaise clé (mauvais `src_mac` / `dst_mac` fourni à
`decode()` pour la trame concernée).```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) — installe dpkt
pour que pyrmax.pcap.iter_ac(path) / iter_m(path) puissent diffuser les paquets
depuis des captures .pcap ou .pcapng (type de liaison DLT_IEEE802_11_RADIO).
Le format est détecté automatiquement à partir du nombre magique du fichier.pip install pyrmax[live] — ajoute pcapy-ng pour la capture en direct depuis une
interface sans fil en mode moniteur. Utilisé par l'option -i / --iface de la CLI
et par le générateur programmatique pyrmax.pcap.iter_airmax_live(iface).pip install pyrmax[scan] — ajoute scapy pour la commande scan
(sniff/décodage en direct + la poignée de main force-assoc active).Installez plusieurs d'un coup, p.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
## Développement```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
La configuration se trouve dans pyproject.toml ([tool.pyright]):
typeCheckingMode = "basic" — détecte les problèmes structurels sans
se heurter à la frontière dpkt / kaitaistruct / pycryptodome (ces
paquets ne fournissent pas de stubs de types).src/pyrmax/_generated/ est exclu — les fichiers générés par Kaitai portent déjà
# type: ignore et sont écrasés à chaque exécution de
kaitai-struct-compiler.MGMT_Frame.src) sont franchies avec une annotation Any sur la liaison
locale plutôt qu'avec des commentaires ignore dispersés.Les fichiers Python générés sous src/pyrmax/_generated/ sont versionnés afin que le
paquet s'installe sans chaîne d'outils Kaitai. Pour régénérer après modification d'un
.ksy :```sh
kaitai-struct-compiler -t python --outdir src/pyrmax/_generated/ ksy/*.ksy
field_14field_9csta_field_68ic_6b8enc_tokenversion < 9 sur des captures réelles — le constructeur TX n'émet jamais que la version 9, donc les chemins de version inférieure (nom non balisé, champs de fin manquants) sont implémentés d'après la spécification mais non vérifiés sur le fil.unknown_rest) — actuellement opaque.pcap.FrameMeta : canal, RSSI, débit. Actuellement, seuls l'horodatage, les MAC et le BSSID sont renseignés.tests/samples/ — actuellement :
airmax_ac_beacon.pcap (1 trame, beacon) et
airmax_m_probe_response.pcap (1 trame, réponse de probe). D'autres variantes
(assoc req/resp, captures multi-trames) sont toujours les bienvenues.scan (force-association active) et emulate injectent via scapy (les extras [scan] / [emulate]). Voir
scan.py / emulate.py.pip install pyrmax[emulate] — ajoute scapy pour la commande emulate
(injecter des balises + répondre à la poignée de main de découverte en tant que faux appareils).