Finding CVE-2022-39274 in LoRaMac-node with Fuzzwario

This post walks through running fuzzwario on an embedded LoRaWAN stack and ending up with a real bug: CVE-2022-39274, a size validation issue in ProcessRadioRxDone that can lead to a massive out-of-bounds write.

Fuzzware
March 24, 2026
This post walks through running fuzzwario on an embedded LoRaWAN stack and ending up with a real bug: CVE-2022-39274, a size validation issue in ProcessRadioRxDone that can lead to a massive out-of-bounds write.
Subscribe to newsletter
By subscribing you agree to with our Privacy Policy.
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
Share this article

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.

Start fuzzing
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
Firmware ELF/BIN, unchanged Emulator Full-system execution Modeling Meaningful MMIO inputs Findings Reproducible inputs
A high-level Fuzzwario workflow.

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.

Root cause (simplified)
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.

Example: patch
// 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:

Replay an input by id
fuzzwario input replay 1

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).

Explore more articles

The latest industry news, interviews, technologies, and resources.