Files
swaits 975713636b docs(audit): add Mythos security audit and harden esp-println on UART boards
Audit conducted at v1.5.2 (commit 688938d). Findings:
- 0 Critical / 0 High / 0 Medium / 2 Low / 1 Info
- Realistic attack surface = host transport + OTA RX; both traced
  end-to-end with no exploitable defects found.

Hardening applied as part of audit follow-up:
- esp-println now uses default-features=false; output target is
  selected per-board. UART-host boards (heltec_v3_uart,
  elecrow_thinknode_m2, lilygo_tbeam classic) get `no-op` so
  defmt/panic output cannot corrupt frames on UART0 (the host
  protocol wire). Native-USB boards keep `auto` so espflash-monitor
  still works.
- README documents the trust model: no host-transport auth, no
  RF-regulatory enforcement, MAC exposed via USB iSerialNumber +
  GET_INFO.mcu_uid (intentional, needed by donglora-mux for
  multi-dongle disambiguation).
2026-05-13 13:19:59 -06:00

8.1 KiB
Raw Permalink Blame History

Phase 1 — Reconnaissance

Project

donglora v1.5.2 — Embedded Rust firmware. Single binary per board, compiled with mutually-exclusive cargo features. Exposes the LoRa radio over a host transport (USB CDC-ACM or UART) using a COBS-framed binary protocol (DongLoRa Protocol v2).

Languages / runtime

  • Rust 1.94 stable for ARM (thumbv6m, thumbv7em); Rust esp fork for Xtensa (esp32, esp32-s3). no_std, no_main, panic = abort, LTO fat, opt-level 3.
  • No allocator. All collections are fixed-capacity heapless::Vec<_, N>.
  • Async runtime: embassy-executor 0.10 (Cortex-M) / esp-rtos 0.3 (Xtensa). Three long-lived tasks per board.
  • Statecharts: hsmc 0.6 drive the host, radio, and display tasks.

Supported boards (one feature → one binary)

Board MCU Radio Host transport
heltec_mesh_node_t114 nRF52840 SX1262 USB CDC-ACM (native)
heltec_v3 ESP32-S3 SX1262 USB CDC-ACM (native)
heltec_v3_uart ESP32-S3 SX1262 UART0 via CP2102
heltec_v4 ESP32-S3 SX1262 USB CDC-ACM (native)
elecrow_thinknode_m2 ESP32-S3 SX1262 UART0 via CP2102
lilygo_tbeam ESP32 SX1276 UART0 via CP2102
lilygo_tbeam_supreme ESP32-S3 SX1262 USB CDC-ACM (native)
rak_wisblock_4631 nRF52840 SX1262 USB CDC-ACM (native)
waveshare_rp2040_lora RP2040 SX1262 USB CDC-ACM (native)
wio_tracker_l1 nRF52840 SX1262 USB CDC-ACM (native)

Entry points (untrusted-byte ingress)

There is exactly one network-facing entry point per board image: the host transport (USB CDC-ACM or UART, never both in the same build).

Module Inbound bytes from
src/host/usb.rs USB CDC-ACM (host OS, kernel-side)
src/host/uart.rs UART0 (CP2102 → host OS)
src/radio.rs (RX path) LoRa over-the-air (any peer in RF range, on configured freq+sync)

There is no networking (TCP/UDP/HTTP/etc.), no filesystem, no shell, no storage. The radio is the only RF surface; OTA RX packets become a DeviceMessage::Rx(...) forwarded to the host with tag = 0 — the firmware itself does not interpret payload bytes.

USB descriptors / VID:PID 1209:5741 (composite IAD device). The CDC-ACM serial-number string is the device's MAC address as 12-char uppercase hex (src/host/usb.rs:184-189).

Wire format (one path from byte → command)

raw bytes
  → FrameDecoder (donglora-protocol 1.2.0 frame.rs)
      COBS-delimited, CRC16/CCITT-FALSE-protected, payload ≤ MAX_PAYLOAD_FIELD (275 B)
  → dispatch_frame (host/framing.rs)
      rejects tag=0 with async ERR(EFRAME)
      calls Command::parse(type_id, payload)
  → InboundEvent::Command { tag, cmd } via CommandChannel (depth 8)
  → radio task statechart (radio.rs)

Egress (DeviceMessage::Ok|Err|Rx|TxDone) is symmetric: built by the radio task, sent through OutboundChannel (depth 64), encoded by encode_outbound, written packet-by-packet.

Commands accepted from host: PING, GET_INFO, SET_CONFIG, TX, RX_START, RX_STOP (only these type_id bytes; anything else → ERR(EUNKNOWN_CMD)).

Trust boundaries

Boundary Direction Treated as
USB / UART host → firmware inbound bytes trusted (it's the user's PC)
LoRa RF peer → firmware (RX payload) inbound bytes untrusted, but only memcpy'd to host — firmware never interprets
firmware → SX1262/SX1276 via SPI outbound trusted (own peripheral)

The firmware is intentionally a transparent radio bridge: there is no auth, no crypto, no session state, no key material on device. The radio's freq, power, sync_word, etc. are whatever the host sets (within hardware limits enforced by validate_lora).

Auth / session layer

None. Anything that can write CDC-ACM bytes to the device can drive the radio. Per design — the device is a "dongle," ownership is physical-attachment.

The protocol's inactivity watchdog (PROTOCOL.md §3.4, 1 s) drops the radio to Unconfigured if the host stops sending bytes — this is liveness/cleanup, not auth. USB-side this is gated on DTR (USB CDC line state); UART has no DTR so it relies purely on the watchdog.

Data at rest

None of security interest.

  • Static Info blob built once at boot — MAC, fw version, capability bitmaps. Visible to anyone who issues GET_INFO.
  • No credentials, tokens, keys, PII.
  • Persistent storage: only board-specific bootloader settings region; firmware never writes to flash at runtime (see ld/*.x — app region is read-only post-flash).
  • nRF52840 boards' s140 SoftDevice region (0x01000–0x26000) preserved at flash time; firmware never touches it.

Dependencies (Cargo.lock, security-relevant subset)

  • donglora-protocol 1.2.0 — own crate, the wire codec (frame, command, modulation, info, events). Fuzz/mutation-tested in sibling repo (/home/swaits/Code/donglora-org/protocol/).
  • ucobs 0.3 — COBS encode/decode, no_std, very small.
  • lora-phy — git fork from swaits/lora-rs branch=fixed (carries CAD IRQ-clear fixes pending upstream PRs #428/#431/#432). All SX126x/SX127x SPI primitives.
  • hsmc 0.6 — own crate (statecharts).
  • embassy-* 0.5/0.6/0.10/0.8 — well-known embedded async runtime.
  • defmt 0.3, panic-probe 1.0 (Cortex-M boards) / esp-backtrace 0.19 + esp-println 0.17 (Xtensa boards).
  • heapless 0.9 — fixed-capacity collections.

No HTTP, JSON, TLS, SQL, template, regex, deserialization-of-user-data dependencies.

Deployment context

Firmware is flashed via UF2 (Cortex-M) or espflash (Xtensa). No OTA update path on the device itself — re-flashing requires physical USB. Production builds run with DEFMT_LOG unset → defmt strips info/debug/warn at compile time; only error! survives.

Existing security controls / hardening already in place

  • #![cfg_attr(not(test), no_std)] / panic = abort — no panic unwinding to abuse.
  • #![deny(clippy::undocumented_unsafe_blocks)] in Cargo.toml — every unsafe block must justify itself.
  • unsafe audit (4 sites total):
    • main.rs:105 — one-time static mut INFO init at boot, before any task spawns.
    • hal/nrf52840.rs:102, 140, 151 — read_volatile / write_volatile on MMIO registers (POWER.RESETREAS, FICR.DEVICEADDR). Standard.
    • hal/rp2040.rs:68, 79 — one-time static mut MAC_REF init at boot.
  • Memory layout (ld/nrf52840-memory.x) preserves the factory SoftDevice region; UF2-only flashing prevents accidental overwrite.
  • CRC-16/CCITT-FALSE on every frame, COBS framing — corrupt bytes are caught by either layer before reaching Command::parse.
  • Strict typed parsing: every enum field rejects out-of-range bytes (from_u8 returns Option and InvalidField is propagated); reserved bits in TxFlags rejected; reserved byte in LrFhssConfig rejected.
  • validate_lora (radio.rs:2125-2145): freq within board's chip-supported range, SF ∈ {5..=12} and in board bitmap, BW in board bitmap, TX power within board range, preamble ≥ 6.
  • Hardware-limit clamping: MAX_OTA_PAYLOAD = 255 matches the chip's payload_len register width; the receive buffer is sized exactly to it.
  • RADIO_THROTTLED backpressure flag prevents queue floods if the host is unresponsive.
  • State-machine cancel-safety: cancel-unsafe lora-phy futures (lora.tx, lora.cad, LoRa::new, lora.init) are hosted in chart states whose only event handlers are emitted by the future itself on natural completion — chart cannot drop them.
  • Reset-and-recovery state machine with bounded attempts (MAX_RECOVERY_ATTEMPTS = 4) and a terminal Dead state that still answers PING/GET_INFO.