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).
8.1 KiB
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
Infoblob built once at boot — MAC, fw version, capability bitmaps. Visible to anyone who issuesGET_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 SoftDeviceregion (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 fromswaits/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 — everyunsafeblock must justify itself.unsafeaudit (4 sites total):main.rs:105— one-timestatic mut INFOinit at boot, before any task spawns.hal/nrf52840.rs:102, 140, 151—read_volatile/write_volatileon MMIO registers (POWER.RESETREAS, FICR.DEVICEADDR). Standard.hal/rp2040.rs:68, 79— one-timestatic mut MAC_REFinit 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_u8returnsOptionandInvalidFieldis propagated); reserved bits inTxFlagsrejected; reserved byte inLrFhssConfigrejected. 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 = 255matches the chip'spayload_lenregister width; the receive buffer is sized exactly to it. RADIO_THROTTLEDbackpressure 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 terminalDeadstate that still answersPING/GET_INFO.