TL;DR Using Fuzzwario, we fuzzed the LoRaMac-node firmware and reproduced CVE-2022-39274, an integer underflow in the radio frame parsing logic. The bug can be triggered with a radio payload and leads to a large out-of-bounds memcpy.
Walkthrough: From Target to Bug
The goal here is not to drown you in emulator internals. Instead, we focus on the workflow and what Fuzzwario gives you in practice: quick setup, hardware-free execution, automatic peripheral modeling, and reproducible findings.
Step 1: Minimal Setup
In day-to-day use, you typically start from an ELF and let Fuzzwario generate an initial configuration.
Generate a starter config
fuzzwario start --elf firmware.elf
Here, we use the interactive fuzzwario start to initialize a project and generate a configuration.
A typical config for a Cortex-M firmware defines memory maps, exit hooks, and (if needed) small runtime patches to keep execution stable and fast.
Example: configuration excerpt (memory map + hooks)
# config.yml (excerpt)
memory_maps:
- name: text
address: 0x08000000
size: 0x10800
file: { path: firmware.bin }
type: rom
- name: ram
address: 0x20000000
size: 0x10000
type: ram
exit_hooks:
- symbol: arch_system_halt
- symbol: z_do_kernel_oops
- symbol: z_fatal_error
Step 2: Start a Fuzzing Run
Start a run and scale out to multiple cores. A finite duration is useful for demos and CI.
fuzzwario start
--cores all-physical \
--duration 30m \
--name "loramac-node-rx"
Hooking the Radio
The key to making a LoRa stack fuzzable without hardware is turning “radio reception” into something the fuzzer can control. Practically, that means intercepting the firmware’s reads from the radio’s SPI receive buffer and redirecting them into Fuzzwario’s input stream.
This is the mental model: the firmware still executes its normal driver logic, but the “hardware bytes” it receives are now bytes that can be mutated, minimized, and replayed.
Full-system execution
The firmware runs as-is, including its OS and drivers. That means bugs in “glue code” and stateful paths are in scope.
Hardware-free modeling
When the firmware touches peripherals, Fuzzwario can learn models to keep the execution moving and produce meaningful input bytes.
Early in the run, you typically see two things in the logs: (1) the system boots to a stable point and (2) models are generated for MMIO contexts so fuzzing can mutate “hardware responses” in a useful way.
Example: fuzzing log (boot + modeling)
01:20:18 INFO fuzzwario::runner - Running until first MMIO read from input ...
01:20:18 INFO modeling::fuzzware - Running Fuzzware modeling for ProgramContext { pc: 8001a9a } ...
01:20:19 INFO modeling::fuzzware - Fuzzware created model(s): [Passthrough { initial_value: 0 }]
01:20:20 INFO modeling::fuzzware - Running Fuzzware modeling for ProgramContext { pc: 8009eaa } ...
01:20:22 INFO modeling::fuzzware - Fuzzware created model(s): [Set { values: [0,4,8,c] }]
01:20:22 INFO fuzzwario::runner - First MMIO read from input found.
What does Set { values: [0,4,8,c] } represent? It’s a compact hardware model for a specific MMIO read site. Instead of returning arbitrary 32-bit values, Fuzzwario restricts the read to a small set of realistic responses. In practice, this often corresponds to a status register with a few meaningful bit patterns (e.g. “no flag set”, “one flag set”, “another flag set”, “both flags set”). Constraining reads this way keeps gives the fuzzer an optimized knob to flip branches.
Step 3: Coverage Starts Moving
With stable boot and modeled I/O, the fuzzer can iterate quickly. Coverage improvements show up as newly reached basic blocks/functions.
Example: fuzzing log (new coverage)
01:20:22 INFO fuzzer::fuzzer_main - Multi-core child #02 found 84 new basic block coverage with input #1
01:20:22 INFO fuzzer::fuzzer_main - 0800e41a New function covered: memcpy
01:20:22 INFO fuzzer::fuzzer_main - 0800e440 New function covered: memset
01:20:22 INFO fuzzer::fuzzer_main - 08002c60 New function covered: z_arm_fault_init
01:20:22 INFO fuzzer::fuzzer_main - 0800a2f0 New function covered: z_cstart
The Bug: CVE-2022-39274
The bug lives in the LoRaWAN MAC implementation (LoRaMac.c). ProcessRadioRxDone assumes that a received frame always contains at least one byte for the MAC header. When Size == 0, the code still advances pktHeaderLen and later subtracts it from Size, which causes an integer underflow in the memcpy length.
uint8_t *payload = RxDoneParams.Payload;
uint16_t size = RxDoneParams.Size;
uint8_t pktHeaderLen = 0;
// size can be 0, but header is still read
macHdr.Value = payload[pktHeaderLen++];
In a particular frame type, this then becomes an integer underflow in a memcpy length computation:
Underflow leading to huge copy
// size == 0, pktHeaderLen == 1
memcpy1(MacCtx.RxPayload, &payload[pktHeaderLen], size - pktHeaderLen);
// "size - pktHeaderLen" underflows and wraps to a huge copy length
Why this is a good fuzzing target. It sits at the boundary between “untrusted bytes” (radio frames) and stateful firmware logic. It’s also easy to miss in review because the normal-case frame parsing path always has a header.
What Fuzzwario Captures When the Firmware Breaks
When execution results in an exception, you want a reproducible input and enough context to start debugging. The run logs for this target include an emulator abort with a register snapshot.
Example: fuzzing log (abort + registers)
01:20:40 INFO emulator - QEMU abort / panic, collecting debug info in logfile...
01:20:40 INFO panic::debug_info - R0 = Ok(0800e520)
01:20:40 INFO panic::debug_info - R1 = Ok(00000000)
01:20:40 INFO panic::debug_info - R2 = Ok(003d0900)
01:20:40 INFO panic::debug_info - SP = Ok(200023b0)
01:20:40 INFO panic::debug_info - LR = Ok(0800932b)
01:20:40 INFO panic::debug_info - PC = Ok(08009eaa)
Depending on memory layout, the resulting out-of-bounds write can lead to a crash (denial of service) or potentially to controlled memory corruption.
In our internal experiments, this particular CVE showed up in under 30 minutes on the provided target configuration.
The Patch
The fix is quite simple: reject empty radio frames before touching the header.
// Abort on empty radio frames
if (size == 0) {
MacCtx.McpsIndication.Status = LORAMAC_EVENT_INFO_STATUS_ERROR;
PrepareRxDoneAbort();
return;
}
The upstream advisory includes the full analysis and references the patch that shipped in LoRaMac-node 4.7.0.
Reproducing and Sharing the Finding
A finding is only useful if it’s easy to reproduce. In Fuzzwario, saved corpus inputs can be replayed deterministically:
In this case, the interesting part is how small the triggering input can be. The triggering input is literally empty:
Inspect an input (hexdump)
fuzzwario input print 1
Input #1 (No Parent)
Input Size = 0 bytes (radio payload)
Clarification. The fuzzer-controlled input stream represents the radio payload bytes. A zero-length input therefore simulates a radio frame without payload, which triggers the edge case in the parsing logic.
Takeaway. The work is front-loaded: getting firmware to run and I/O to behave. Once that’s in place, Fuzzwario’s job is to push execution into the odd corners that humans don’t naturally exercise.
Notes and FAQ
Is this post claiming “push button, find CVEs”?
No. Embedded fuzzing still needs good target setup (correct memory mapping, stable boot, and sane models). What Fuzzwario changes is how quickly you can get from “firmware image” to “actionable input” without a lab full of hardware.
How does peripheral modeling help in practice?
Real firmware expects real hardware. Fuzzwario learns and reuses models for MMIO interactions so the firmware can keep making progress, while still exposing useful input bytes for the fuzzer to mutate.
What happens after a crash?
The goal is a tight loop: replay the exact input deterministically, minimize it if needed, and then use tracing or an attached debugger to confirm root cause. The important part is that you can get back to the same failing state on demand.
References: GitHub advisory (GHSA-7vv8-73pc-63c2).