
Ubiquiti AirMAX 무선 프로토콜 프레임용 디코더/인코더; pcap/실시간 캡처를 파싱하고, 장치를 발견하며, 취약한 펌웨어를 스캔하고, 변형 패킷을 제작하며, AirMAX AC/M 대상을 에뮬레이션합니다.
Ubiquiti AirMAX 와이어 프로토콜 프레임용 디코더 및 인코더로, AC(WA 펌웨어) 및 M(XW/XM 펌웨어) 시리즈를 모두 다룹니다.
ksy/ 아래의
Kaitai Struct 스키마에 의해 처리됩니다. AC 페이로드
(msg_type에 따라 전환되고 버전에 따라 제한되며, deauth XOR 언마스크를 포함)는
ac.py에 수기로 작성되어 있습니다 — 이 로직은 파싱 전용 Kaitai에는 맞지 않습니다.| 변형 | 디코드 | 인코드 | pcap 반복 |
|---|
| AC | ✅ 5가지 메시지 유형 모두 (beacon, assoc req/resp, probe req, deauth) | ✅ 바이트 단위 정확 역연산 + 빌더 + seal | ✅ |
| M | 부분적 (문서화된 9바이트; 나머지는 unknown_rest로) | ✅ 문서화된 헤더 + unknown_rest 왕복 지원 | ✅ |
| Routerboard.com IE (M의 동반) | ✅ 장치 이름 + 하위 IE 목록 | n/a | ✅ |
AC 와이어 형식은
docs/ac_wire_format.md를 따릅니다
([P]가 ubnt_poll_host.ko에 대해 확인함). 모든 AC 멀티바이트 정수는
빅엔디언입니다.
pyrmax.ac.encode(AcPacket) -> bytes — 손으로 작성된
decode()의 바이트 단위 정확 역연산 (+ seal(), to_ie(), build_* 생성자).pyrmax.m.encode(MPacket) -> bytes — M에 대한 동일 기능 (+ build_m,
seal, to_ie).[open] AC 필드 확정 — cap_flags 정확한 비트맵,
mixed_mode, field_14/field_9c (assoc_req), sta_field_68/ic_6b8
(assoc_resp), 그리고 deauth enc_token (추출은 가능하지만 키가 아직
역분석되지 않아 검증할 수 없음).
docs/ac_wire_format.md §11-§12 참조.version < 9 AC 디코더 검증 —
TX 빌더는 항상 버전 9만 생성하므로 하위 버전(태그 없는
이름, 누락된 꼬리 필드) 경로는 사양에 따라 구현되었지만
실제 와이어에서는 검증되지 않았습니다.unknown_rest의 9바이트 이후) —
현재 불투명함.pcap.FrameMeta에 radiotap 메타데이터 노출: channel, RSSI,
rate. 현재는 타임스탬프/MAC/BSSID만 채워집니다.tests/samples/의 실제 캡처 픽스처 — 현재:
airmax_ac_beacon.pcap (1프레임, beacon) 및
airmax_m_probe_response.pcap (1프레임, probe response). 추가 변형
(assoc req/resp, 다중 프레임 캡처)은 언제든 환영합니다.scan(능동 강제 assoc) 및 emulate
명령은 scapy를 통해 주입합니다 ([scan] / [emulate] 엑스트라).
scan.py / emulate.py 참조.이 패키지는 하위 명령 기반 CLI를 제공합니다. 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`와 `discover`는 pcap/pcapng 파일(위치 인자) **또는**
`-i / --iface IFACE`를 통한 라이브 무선 인터페이스 **중 하나**를 받습니다. 이 두 가지는
상호 배타적입니다. `scan`은 동일한 소스 옵션을 허용합니다(라이브 인터페이스에만 적용되는
`--active` 모드 포함); `emulate`는
라이브 전용입니다.```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
라이브 캡처는 관심 채널의 인터페이스가 이미 모니터 모드라고 가정합니다. pyrmax는 어느 것도 구성하지 않습니다. 선택적 [live] 엑스트라(pip install pyrmax[live])가 필요하며, 이를 통해 pcapy-ng가 함께 설치됩니다. Ctrl-C를 눌러 중지합니다. parse는 스트리밍한 프레임 수를 보고하고, discover는 종료 시 집계된 장치 요약을 출력합니다.
종료 코드(두 명령과 두 소스 모드 모두 공통): 성공 시 0("no AirMAX frames" 포함 — 유효한 결과), 캡처 형식 오류 시 1(잘못된 링크 타입, 손상된 파일, 인터페이스를 열 수 없음), 파일 누락 또는 잘못된 소스 인수 시 2.
parse — 프레임별 덤프```shpython -m pyrmax parse capture.pcap
AirMAX 벤더 IE를 포함하는 각 802.11 관리 프레임은 하나의
블록, 즉 AC 패킷, M 패킷 및 동일 프레임에서 발견되는 모든 Routerboard.com 컴패니언 IE를
생성합니다.```
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
동일한 단일 패스 워커는 프로그래밍 방식으로
pyrmax.pcap.iter_airmax(path)로 노출됩니다. 이는 AirMAX를 포함하는 프레임마다
AirmaxRecord(meta, ac, m, routerboard)를 하나씩 생성하므로
M 및 Routerboard IE를 수동으로 연관시킬 필요가 없습니다.
discover — 장치 요약```shpython -m pyrmax discover capture.pcap
프레임은 802.11 소스 MAC별로 그룹화되고, 피어가 누적되며, AC 페이로드 / M 페이로드 / Routerboard 장치 이름 관측값은 장치별 단일 블록으로 통합됩니다.```
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
동일한 집계는 또한 공용 함수입니다:```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)
## 사용법
### 단일 AirMAX AC 메시지 디코딩
디코더는 802.11 Vendor Specific IE의 바이트가 **OUI에서
시작하는** — IE 래퍼(Element ID `0xDD` + Length)는 이미 제거되어
있어야 합니다. `src_mac` / `dst_mac`는 외부 802.11 프레임의 SA / DA에서
가져옵니다.```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는 BEACON / ASSOC_REQ / ASSOC_RESP / PROBE_REQ /
DEAUTH 중 하나입니다. 버전 게이트된 꼬리 필드(field_9c, rssi, fwname, txpower,
…)는 프레임의 version이 해당 임계값보다 낮을 때 None입니다. Deauth는
무결성 검사 전에 src_mac를 jiffies nonce와 XOR 해제하고,
16바이트 enc_token을 불투명한 바이트로 노출합니다(해당 키는 아직
역산되지 않았으므로 검증할 수 없습니다).
이름 TLV를 담는 본문(beacon, assoc_req)은 후행 Padding 항목을 포함한
스트림을 body.tlvs에 그대로 보존합니다. radioname,
ssid, fwname은 패킷의 편의 속성으로 제공되고,
그 외의 모든 것은 원래 상태로 유지됩니다.```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}")
### 단일 AirMAX M 메시지 디코딩
동일한 형태, 더 작은 표면 — M 페이로드의 9바이트만 문서화되어 있습니다;
나머지는 `unknown_rest`에 그대로 보존됩니다. **참고:** M 페이로드
자체에는 SSID 필드가 없습니다 — SSID 필드를 보려면 표준 802.11 SSID IE를
이를 포함하는 비콘 또는 프로브 응답에서 확인하세요(
`pyrmax.pcap`으로 반복 처리할 때 `FrameMeta.ssid`로 표시됩니다).```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=…)는 decode의 바이트 단위 정확한 역함수입니다 —
잘 구성된 프레임의 경우 encode(decode(x)) == x입니다. 본문을 직렬화하고(버전 게이트를 다시 적용하고, deauth의 경우 src_mac XOR 마스크를 다시 적용), AES 블록으로 패딩한 다음 packet.src_mac에서 파생된 키로 암호화하고 OUI 이후 바이트를 내보냅니다. 전체 0xDD 벤더 IE를 얻으려면 to_ie()로 감쌉니다.
build_* 생성자를 사용하면 중첩된 본문을 수동으로 조립하지 않아도 됩니다:```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")
퍼징 / PoC의 경우 `seal()`은 **임의의** 평문을 암호화하고
외부 헤더를 구성합니다. 따라서 구조화된 인코더가 절대 생성하지 못하는
의도적으로 형식이 잘못된 페이로드(가짜 `msg_type`, 잘못된 길이, 하위 블록 본문 등)를
제작할 수 있습니다:```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
구조화된 encode는 와이어로 전송할 수 없는 모든 것
(TLV 값 > 255바이트, 암호문 ≥ 0x101)에 대해 EncodeError를 발생시킵니다. seal은 설계상 허용적입니다.
pyrmax.pcap은 802.11 관리 프레임 내부에서 AirMAX 벤더 IE를 찾고,
외부 802.11 헤더에서 MAC을 추출하며, 모든 것을 올바른 디코더로 전달합니다.
.pcap 및 .pcapng는 자동으로 감지됩니다. 디코딩에 실패한 프레임
(잘못된 키, 손상된 프레임, 관련 없는 벤더 IE)은 조용히 건너뜁니다 —
반복은 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`는 `FrameMeta(timestamp, src_mac, dst_mac, bssid)`입니다. Channel/RSSI
추출은 radiotap에서 TODO 목록에 있습니다.
### Routerboard.com IE에서 장치 이름 복구
AirMAX M 프레임에는 거의 항상 Mikrotik /
Routerboard.com 벤더 IE (OUI `00:0C:42`)가 동일한 802.11 관리
프레임에 함께 포함됩니다. 해당 IE의 subtype-1 하위 IE에는 장치 이름이 포함됩니다.```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"
Routerboard 디코더는 독립형으로도 사용할 수 있습니다 — OUI에서 시작하는 IE 데이터를 전달하세요:```python 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 패킷과 동일한 캡처의 M 패킷을 `meta.timestamp` + `meta.src_mac` 매칭으로 연관시키세요.
### `scan` — 취약한 장치 찾기
모니터 모드 인터페이스(또는 pcap)를 스니핑하여 AirMAX 장치를 찾고 취약한 장치를 표시합니다. `[scan]` 추가 기능(`scapy`)이 필요하며, 라이브 캡처에는 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
버전이 취약성을 결정하는 방식은 변형(variant)마다 다릅니다. 두 개의
버전 번호가 관련됩니다: 와이어 형식 프로토콜 버전은 모든
AirMAX IE(비콘 포함)에 포함되고, AC 펌웨어 버전
(fwname)은 연결(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
`proto`가 비콘에 있으므로 **오래된** 장치(AC epoch < 9, M
version < 15)는 **수동적으로** 탐지됩니다. AC에 대해 `PATCHED` 판정을 얻으려면
펌웨어 버전이 필요하므로, 캡처된 association 또는
`--active`(능동 핸드셰이크 — auth → assoc → assoc-resp
`fwname` 읽기 — 는 완전하고 바이트 단위로 충실한 Ubiquiti 스테이션 교환)가 필요합니다.
플래그: `--channel N` (채널 하나를 고정, 그렇지 않으면 호핑), `--seconds N`,
`--cutoff X.Y.Z` (`fw <= cutoff`이면 AC 취약, 기본값 `8.7.20`),
`--src MAC` (능동 프로브의 소스, 예: PTP 피어), `--vuln-only`,
`--no-set-channel`. 취약한 장치가 하나라도 발견되면 종료 코드는 **3**
(스크립팅에 유용), 그 외에는 `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 — 가짜 AirMAX 대상하나 이상의 가짜 AirMAX AC/M 장치로 비콘을 보내고, AC의 경우 활성 스캐너가 에뮬레이션된 펌웨어 버전을 읽을 수 있도록 디스커버리 핸드셰이크에 응답합니다. [emulate] 추가 기능(scapy), 모니터 모드 인터페이스 및 루트 권한이 필요합니다. 실제 하드웨어 없이 scan을 테스트하는 데 유용합니다.```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
각 `-d`(반복 가능)는 `TYPE/VERSION[/SSID[/MAC]]` 형식이며, `/`로 구분되므로 MAC의 콜론이 안전합니다:
- `ac/8.7.19` — 최신 AC, 펌웨어 `8.7.19` (epoch 9, `fwname` 공개)
- `ac/9` — 최신 AC epoch, 펌웨어 문자열 **없음** → 스캐너가 `undetermined`로 인식
- `ac/7` — **구형** AC, 와이어 포맷 epoch 7 (< 9) → 취약, 비콘에서 감지됨
- `m/14` — AirMAX M, 버전 14 (< 15) → 취약
- `m/15` — 고정 epoch의 AirMAX M → undetermined
`-d`가 없으면 데모 플릿이 에뮬레이션됩니다. `--no-respond`는 비콘만 보냅니다(그러면 AC 펌웨어가 공개하지 않습니다). AC 펌웨어 버전은 assoc-resp에만 존재하므로 responder가 필요한 것입니다.
### 한 머신에서 둘을 함께 테스트
`scripts/hwsim_testbed.sh`는 `mac80211_hwsim`을 통해 두 개의 가상 라디오를 생성하므로 하드웨어 없이 하나에서 `emulate`를, 다른 하나에서 `scan`을 실행할 수 있습니다:```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는 전체 작업을 처음부터 끝까지 수행합니다 — 라디오를 시작하고,
위의 5개 장치 플릿을 에뮬레이션하고, scan --active를 실행한 다음,
정리합니다:```sh
sudo ./scripts/demo_5_devices.sh 36
### 오류 처리
스키마 불일치 및 무결성 실패는 `pyrmax.DecodeError`를 발생시킵니다. 가장
흔한 원인은 잘못된 키입니다(해당 프레임에 대해
`decode()`에 제공된 잘못된 `src_mac` / `dst_mac`).```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] (또는 uv sync --extra pcap) — dpkt를 불러와서
pyrmax.pcap.iter_ac(path) / iter_m(path)가 .pcap 또는 .pcapng 캡처
(링크 타입 DLT_IEEE802_11_RADIO)에서 패킷을 스트리밍할 수 있게 합니다.
형식은 파일의 매직 넘버로 자동 감지됩니다.pip install pyrmax[live] — 모니터 모드 무선 인터페이스에서 라이브 캡처를 위한
pcapy-ng를 추가합니다. CLI의 -i / --iface 플래그와 프로그래밍 방식의
pyrmax.pcap.iter_airmax_live(iface) 생성기에서 사용됩니다.pip install pyrmax[scan] — scan 명령을 위한 scapy를 추가합니다
(라이브 스니핑/디코딩 + 능동적인 force-assoc 핸드셰이크).pip install pyrmax[emulate] — emulate 명령을 위한 scapy를 추가합니다
(비콘 주입 + 가짜 장치로 디스커버리 핸드셰이크에 응답).한 번에 여러 개를 설치할 수 있습니다(예: 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
## 개발```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
설정은 pyproject.toml ([tool.pyright])에 있습니다:
typeCheckingMode = "basic" — 구조적 문제를 잡아내면서 dpkt / kaitaistruct /
pycryptodome 경계와 씨름하지 않습니다(이 패키지들은
타입 스텁을 제공하지 않습니다).src/pyrmax/_generated/는 제외됩니다 — Kaitai 생성 파일에는 이미
# type: ignore가 포함되어 있으며 kaitai-struct-compiler가
실행될 때마다 덮어쓰여집니다.MGMT_Frame.src)은 로컬 바인딩의 Any 애너테이션으로 처리하고,
여기저기 흩어진 ignore 주석은 사용하지 않습니다.src/pyrmax/_generated/ 아래의 생성된 Python 파일은 커밋되어 있으므로
패키지는 Kaitai 툴체인 없이도 설치할 수 있습니다.
.ksy를 편집한 후 다시 생성하려면:```sh
kaitai-struct-compiler -t python --outdir src/pyrmax/_generated/ ksy/*.ksy