Skip to content

Fix three out-of-bounds / use-after-free issues (MSP parser, channel name, firmware lookup) - #638

Open
TekTrails wants to merge 3 commits into
hd-zero:mainfrom
TekTrails:fix/bounds-checks
Open

TekTrails wants to merge 3 commits into
hd-zero:mainfrom
TekTrails:fix/bounds-checks

Conversation

@TekTrails

@TekTrails TekTrails commented Nov 3, 2026 •

Copy link
Copy Markdown

Three small memory-safety fixes found while reading the code. All three are compiled on every target; none changes behaviour on valid input.

1. MSPv2 parser: unbounded payload size (src/core/elrs.c)

esp32_handler_process_byte() copies the 16-bit payload_size from the MSPv2 header, then writes every payload byte into packet.payload[], which is MSP_PORT_INBUF_SIZE (64) bytes. Nothing rejected a larger size, and the CRC is only checked after the payload, so one corrupted frame on the backpack UART can write up to 64 KiB past the buffer before being discarded.

The fix drops the frame as soon as the header announces more than 64 bytes.

In the same file, handle_osd() case 0x03 indexed elrs_osd_overlay[HD_VMAX][HD_HMAX] with row and col taken straight from the frame; the length was clamped against col but neither index was checked, and a payload shorter than 4 bytes read past the received data. Both are now checked.

Other MSP handlers read a few payload bytes without checking payload_size; with the 64-byte cap these reads stay inside the buffer (stale data, not an overflow) and are left alone here.

2. Out-of-range channel settings (src/core/settings.c, src/core/osd.c)

settings_load() reads scan.channel, source.hdzero_band and source.analog_channel from setting.ini without fully checking them:

  • analog_channel above ANALOG_CHANNEL_NUM reset scan.channel instead of analog_channel (copy-paste slip), and 0 was not rejected;
  • scan.channel 0 was not rejected;
  • hdzero_band was not checked at all.

These values index the channel name tables in channel2str(): analog_channel = 0 reads analog_channel_name[-1], and an hdzero_band other than 0 or 1 reads past hdzero_channel_name[2][]; the pointer is then handed to snprintf(). Each value is now checked on the long returned by ini_getl(), before it is narrowed to the field's type (257 in the uint8_t analog_channel would otherwise pass as 1), and reset to its default when out of range; scan.channel is read after hdzero_band, which HDZERO_CHANNEL_NUM depends on. In addition, channel2str() guards its analog index like the HDZero branch already does, returning "--" out of range. The analog path is reachable on Goggles 2 and BoxPro (the original Goggles hides the analog channel label).

3. page_version_find_latest_fw(): dirent name used after closedir() (src/ui/page_version.c)

The function kept a pointer to entry->d_name, called closedir(), and returned it. d_name lives in the DIR's internal buffer: it is reused as readdir() refills it and freed by closedir(), so the caller built a path from freed memory. It worked by luck (the block had not been reused yet). The name is now copied into a caller-provided buffer before the directory is closed.

Testing

  • Emulator build (-DEMULATOR_BUILD=ON -DHDZ_GOGGLE2=ON) compiles without new warnings.
  • clang-format clean with the repository's .clang-format.
  • Goggles 2 cross-build (-DHDZ_GOGGLE2=ON, Bootlin armv7 musl toolchain) compiles without new warnings.
  • On Goggles 2 hardware, inside our downstream build (this branch plus the ESP32 select() fix and our own UI patches, deployed from SD): the app boots, the ESP32 backpack comes up, its reader thread stays idle, Wi-Fi works, and the app sits at about half a core at idle. The new MSP size check dropped no frame during normal operation.
  • Firmware page (fix 3), on Goggles 2 hardware: a standalone ARM binary built from the exact fixed page_version_find_latest_fw() plus an exact copy of str_compare_versions(), run on-device against a FIRMWARE/ directory with 8 version-like subdirectories, 20000 iterations each followed by unrelated heap churn (128 allocations freed in between, to make the freed DIR buffer available for reuse and any use-after-free visible). Each iteration asserts the result equals the known-correct answer ("10.0.0", computed ahead of time against the unmodified str_compare_versions()), not just that it stays the same across iterations. Result: 20000/20000 correct, 0 mismatches.
  • Out-of-range setting.ini values (fix 2), on Goggles 2 hardware: checked two ways.
    • The real settings_get_long_bounded() logic against the real minIni parser (cross-compiled, run on-device) reading analog_channel=0, analog_channel=257, hdzero_band=2, scan.channel=0: all four clamp to their defaults (33, 33, 0, 1).
    • The same four values written into the live setting.ini on a running unit, app killed and restarted (warm, no full reboot): boots normally, no crash, log shows the usual startup sequence (intro, Wi-Fi scan, MQTT heartbeat).

…ites

esp32_handler_process_byte() copies the 16-bit payload_size from the MSPv2
header and then stores every payload byte into packet.payload[], which is
MSP_PORT_INBUF_SIZE (64) bytes. Nothing rejected a larger size, and the CRC
is only checked after the payload, so a single corrupted or malicious frame
on the backpack UART could write up to 64 KiB past the buffer into the
neighbouring statics before being discarded.

Drop the frame as soon as the header announces more than 64 bytes.

handle_osd() case 0x03 ("write string") also indexed elrs_osd_overlay
[HD_VMAX][HD_HMAX] with the row and column taken straight from the frame.
The length was clamped against col, but neither row nor col was checked,
and a payload shorter than 4 bytes read past the data received. Check the
size and both indices before writing.
settings_load() reads scan.channel, source.hdzero_band and
source.analog_channel from setting.ini without fully checking them:

  - analog_channel above ANALOG_CHANNEL_NUM reset scan.channel instead
    of analog_channel (copy-paste slip), and 0 was not rejected;
  - scan.channel 0 was not rejected;
  - hdzero_band was not checked at all.

These values index the channel name tables in channel2str():
analog_channel 0 reads analog_channel_name[-1], and an hdzero_band
other than 0 or 1 reads past hdzero_channel_name[2][].

Read the three of them through settings_get_long_bounded(), which
checks the long returned by ini_getl() before it is narrowed to the
field's type (257 stored in the uint8_t analog_channel would otherwise
pass as 1), and falls back to the default when out of range.
scan.channel is now read after hdzero_band, as HDZERO_CHANNEL_NUM
depends on it. channel2str() also guards its analog index like the
HDZero branch already does.
The function kept a pointer to entry->d_name, called closedir() and returned
that pointer. d_name lives in the DIR's internal buffer: it is reused as
readdir() refills it (so comparing against an earlier entry was already
unreliable) and freed by closedir(). The caller then formatted a path from
freed memory. It happened to work because the allocator had not reused the
block yet.

Copy the winning name into a caller-provided buffer before closing the
directory.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant