Files
firmware/test/test_fuzz_decode/test_main.cpp
Tom d846780a9b More fuzz tests and small fixes for the findings (#10864)
* first pass tests

* more tests

* Fix two crafted-admin-packet crashes found by the E5 fuzzer

Both are reachable from an authorized admin (local from==0, admin channel,
or PKC) - remote DoS:

1. SIGFPE in LoRa config validation. A set_config LoRaConfig with
   use_preset=false and bandwidth=0 makes freqSlotWidth 0, so numFreqSlots
   is 0 and `hash(name) % numFreqSlots` (RadioInterface.cpp) divides by
   zero. Guard the modulo; the existing channel_num check then rejects/
   clamps the config.

2. Stack overflow in Channels::getKey. A SECONDARY channel at the primary
   slot with an empty PSK recursed into getKey(primaryIndex) forever. Skip
   the primary-key borrow when chIndex == primaryIndex.

Re-enable the E5 admin fuzzer to hit both triggers again (use_preset both
ways incl. bandwidth 0, plus the set_channel tag) as regression guards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Correct fuzz-test invariants after the crash fixes

- E5 admin fuzz: node eviction under a filling NodeDB is legitimate, so
  assert only the bounded-count invariant, not that a specific seed node
  survives 6000 mutating ops.
- TMM blitz: scope off the nodeinfo direct-response send path (it needs a
  fully-wired MeshService/phone queue the fixture doesn't provide; the
  deterministic directResponse tests cover it). The crafted-nodenum
  rate/unknown/position cache stress is unchanged.

clod helped too

* realistic tests

* test: dedup fuzz RNG into shared test/support/DeterministicRng.h

The four in-tree fuzz suites (test_fuzz_decode, test_fuzz_packets,
test_hop_scaling, test_traffic_management) each carried a byte-identical
copy of the seeded 64-bit LCG (rngSeed/rngNext/rngByte/rngRange). Hoist
it into one shared header so there is a single generator to reason about
and no risk of the copies drifting. static inline keeps per-suite state
per translation unit and avoids -Wunused-function for suites that don't
use every helper. Also corrects a stale comment in test_traffic_management
(the blitz's nodeinfo direct-response path is intentionally left off).

No behavioral change: same constants, same per-suite seeds.

clod helped too

* test: fuzz uncovered ProtobufModule handlers and the MQTT downlink ingress

Extend the in-tree fuzz coverage to packet sources that previously had
none:

- test_fuzz_packets E8/E9/E10: drive PositionModule, DeviceTelemetryModule
  and NeighborInfoModule at handleReceivedProtobuf directly (via using-shims,
  bypassing the ProtobufModule reply/send path so no router is needed). The
  fixture already stands up nodeDB/service/channels, and nodeStatus/powerStatus
  are auto-initialized in main.cpp, so no new globals are required. Adds a
  shared fuzzRxHeader() helper for crafting adversarial RX packet headers.
- test_fuzz_decode: add meshtastic_KeyVerification to the decode table. The
  KeyVerification and StoreForward handler paths are documented as decode-level
  only, with the concrete reason each is intrinsic (private-state gating /
  PSRAM + self-pointer wiring), not a fixture gap.
- test_mqtt: test_receiveFuzzServiceEnvelope blitzes the non-RF broker-push
  ingress (onReceiveProto) two ways - raw garbage bytes that must fail envelope
  decode cleanly, and a well-formed ServiceEnvelope wrapping a crafted inner
  MeshPacket over crafted channel_id/gateway_id - exercising the channel match,
  isFromUs, XEdDSA receive policy and perhapsDecode chain. Adds a deliverRaw()
  passthrough to MQTTUnitTest.

All under the coverage env (ASan/LSan). No firmware/src changes. Full sweep
GREEN 27/27, 544 cases.

clod helped too

* Harden LoRa/channel config against crafted admin messages; consolidate test helpers

Production (review findings on the hot-fuzz crash fixes):
- Clamp bandwidth at the source (clampBandwidthKHz) in checkOrClampConfigLora
  and applyModemConfig so numFreqSlots can never be 0 for any consumer; a
  bandwidth-0 set_config previously passed validation and re-armed the SIGFPE
  on the next applyModemConfig.
- Guard applyModemConfig's hash % numFreqSlots (the validator's sibling modulo
  was fixed earlier but this one was still unguarded).
- Enforce the primary-channel invariant in Channels::onConfigChanged: a config
  demoting every slot now re-promotes the stale SECONDARY slot (keeping its
  key) or restores the default channel if the slot is DISABLED, instead of
  leaving every getPrimaryIndex() reader on a non-primary slot. The getKey
  recursion guard stays as defense-in-depth.

Tests:
- New test/support/MockMeshService.h and AdminModuleTestShim.h replace four
  byte-identical mocks and three divergent admin shims (test_mqtt's capturing
  mock is genuinely different and stays).
- DeterministicRng.h: add rngFill() (replaces 14 hand-rolled fill loops) and
  rngEdgeNodeNum() (unifies the three NodeNum boundary pools).
- Extract fuzzChannelSettings() shared by the set_channel case and fuzzBeacon.
- fuzzBeacon: the un-terminated branch now fills the whole buffer with non-NUL
  bytes so the strnlen bound is actually stressed (~50% of iterations, not ~4%).
- E6 beacon fuzz: replace the TEST_ASSERT_TRUE(true) tautology with real
  invariants (handler never consumes; offers land in lastReceivedOffer keyed
  to the sender).
- Trim the seven over-long comment blocks flagged against the 1-2 line rule;
  the FINDINGS trailer moves to this commit message (see production notes).

Full native suite GREEN 27/27 under the coverage (ASan/LSan) env.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Clamp UTF-8 char length in the emote walkers; add test_fuzz_emotes

A TEXT_MESSAGE payload is opaque protobuf bytes, so PB_VALIDATE_UTF8 never
screens it - invalid UTF-8 and truncated multi-byte lead bytes reach the
emote/width render path verbatim. EmoteRenderer's walkers advanced by
utf8CharLen(lead) without clamping to the bytes actually remaining, so a
truncated lead (e.g. a lone 0xF0, which claims 4 bytes) near the end of the
buffer made getUtf8ChunkWidth's memcpy read past the string. ASan confirms a
heap-buffer-overflow READ from measureStringWithEmotes.

Add utf8CharLenClamped() and use it at every walk site (width measure,
truncation cut-loop, and the draw-path text-run/chunk builders); the one
already-guarded site (matchAtIgnoringModifiers) is unchanged.

New test/test_fuzz_emotes drives measureStringWithEmotes and truncateToWidth
over adversarial byte strings (biased to embed/end in truncated multi-byte
leads) in exact-sized heap buffers so any over-read is a hard ASan fault. Its
headless display uses a synthetic font (firstChar 0, fontData centered in a
large buffer) so the stock OLEDDisplay::getStringWidth - which indexes the
font jump table with a signed char and over-reads for any byte >= 0x80 - does
not mask the finding. native-suite-count bumped 27 -> 28.

Full native suite GREEN 28/28 under the coverage (ASan/LSan) env.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Keep emote width measurement in-bounds for non-ASCII bytes

OLEDDisplay::getStringWidth (the utf8=false path EmoteRenderer uses on default
builds) indexes the font jump table by (c - firstChar) with a signed char and
no bounds check, so any byte outside printable ASCII - high bytes from UTF-8
text, but also a stray control byte like 0x0A - reads outside the font array.
On-device this reads adjacent flash and returns a garbage width; under ASan the
test_fuzz_emotes fuzzer flags it as a global-buffer-overflow, and it made the
non-ASCII width measurement meaningless either way.

The OLED driver is a pinned upstream dependency, so guard it firmware-side in
EmoteRenderer's getStringWidth helper: measure a sanitized copy where any byte
outside [0x20, 0x7E] counts as a '?' placeholder. Printable ASCII is unchanged
and the UA/RU lookup path is untouched.

test_fuzz_emotes now drives a real ArialMT font instead of the synthetic
in-bounds font it needed before this fix, so the suite exercises the true
production width path (utf8CharLen clamp + this sanitizer) end to end. The same
fuzzer tripped the global-buffer-overflow before this change.

Full native suite GREEN 28/28 under the coverage (ASan/LSan) env.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:19:01 -05:00

362 lines
14 KiB
C++

// Adversarial fuzzing of the protobuf decoders and the UTF-8 sanitizer - the "crash a node with a
// crafted packet" and "character-encoding crash" attack surface.
//
// These are the FIXTURE-FREE fuzzers: they exercise pure functions (pb_decode_from_bytes and the
// meshUtils UTF-8 helpers) with no NodeDB / channel / crypto bring-up. The heavier packet-path
// fuzzers live in test/test_fuzz_packets.
//
// The suite runs under the default `coverage` env (AddressSanitizer + LeakSanitizer); any
// out-of-bounds read/write, use-after-free, or leak on adversarial input turns the run RED. Inputs
// come from a deterministic seeded LCG so a failure always reproduces from the printed seed.
//
// Group D1 protobuf decode fuzz - every mesh-facing message type, random + protobuf-shaped bytes
// Group D2 UTF-8 sanitizer fuzz - sanitizeUtf8 / clampLongName / pb_string_length
//
// Note on PB_VALIDATE_UTF8: the global build flag makes nanopb reject a malformed-UTF-8 `string`
// field at decode, so decode of e.g. a User/Waypoint with a bad name returns *false*. That is a
// PASS here - the contract under test is crash-freedom, not decode success.
#include "MeshTypes.h" // include BEFORE TestUtil.h
#include "TestUtil.h"
#include <unity.h>
#include "mesh-pb-constants.h"
#include "mesh/generated/meshtastic/admin.pb.h"
#include "mesh/generated/meshtastic/channel.pb.h"
#include "mesh/generated/meshtastic/config.pb.h"
#include "mesh/generated/meshtastic/mesh.pb.h"
#include "mesh/generated/meshtastic/mesh_beacon.pb.h"
#include "mesh/generated/meshtastic/module_config.pb.h"
#include "mesh/generated/meshtastic/mqtt.pb.h"
#include "mesh/generated/meshtastic/storeforward.pb.h"
#include "mesh/generated/meshtastic/telemetry.pb.h"
#include "meshUtils.h"
#include <cstdio>
#include <cstring>
#include <pb_decode.h>
// Deterministic RNG (rngSeed/rngNext/rngByte/rngRange) - shared seeded LCG.
#include "support/DeterministicRng.h"
static constexpr uint64_t BASE_SEED = 0x00C0FFEEULL;
static constexpr unsigned DECODE_ITERS = 3000; // per type, per pass (ASan-instrumented, keep bounded)
// ---------------------------------------------------------------------------
// Group D1 - protobuf decode fuzz
// ---------------------------------------------------------------------------
// Union so one buffer holds any decoded type with correct size/alignment; pb_release walks it with
// the matching descriptor after each iteration (a no-op for STATIC-only messages, but correct if a
// build ever compiles a malloc-backed field via PB_ENABLE_MALLOC).
union AnyMsg {
meshtastic_Data data;
meshtastic_MeshPacket meshPacket;
meshtastic_User user;
meshtastic_Position position;
meshtastic_Telemetry telemetry;
meshtastic_RouteDiscovery routeDiscovery;
meshtastic_Waypoint waypoint;
meshtastic_NeighborInfo neighborInfo;
meshtastic_Routing routing;
meshtastic_AdminMessage adminMessage;
meshtastic_StoreAndForward storeAndForward;
meshtastic_MeshBeacon meshBeacon;
meshtastic_ServiceEnvelope serviceEnvelope;
meshtastic_ModuleConfig moduleConfig;
meshtastic_Config config;
meshtastic_Channel channel;
meshtastic_ChannelSettings channelSettings;
meshtastic_KeyVerification keyVerification;
};
struct FuzzType {
const char *name;
const pb_msgdesc_t *fields;
};
static const FuzzType FUZZ_TYPES[] = {
{"Data", &meshtastic_Data_msg},
{"MeshPacket", &meshtastic_MeshPacket_msg},
{"User", &meshtastic_User_msg},
{"Position", &meshtastic_Position_msg},
{"Telemetry", &meshtastic_Telemetry_msg},
{"RouteDiscovery", &meshtastic_RouteDiscovery_msg},
{"Waypoint", &meshtastic_Waypoint_msg},
{"NeighborInfo", &meshtastic_NeighborInfo_msg},
{"Routing", &meshtastic_Routing_msg},
{"AdminMessage", &meshtastic_AdminMessage_msg},
{"StoreAndForward", &meshtastic_StoreAndForward_msg},
{"MeshBeacon", &meshtastic_MeshBeacon_msg}, // beacon offer: char[101] message + PSK-bearing ChannelSettings
{"ServiceEnvelope", &meshtastic_ServiceEnvelope_msg}, // MQTT downlink wrapper - unusual (non-RF) ingress
{"ModuleConfig", &meshtastic_ModuleConfig_msg}, // admin set_module_config payload union
{"Config", &meshtastic_Config_msg}, // admin set_config payload union
{"Channel", &meshtastic_Channel_msg}, // admin set_channel payload
{"ChannelSettings", &meshtastic_ChannelSettings_msg},
{"KeyVerification", &meshtastic_KeyVerification_msg}, // PKI key-verification handshake payload
};
static const size_t NUM_FUZZ_TYPES = sizeof(FUZZ_TYPES) / sizeof(FUZZ_TYPES[0]);
static size_t writeVarint(uint8_t *buf, size_t cap, size_t n, uint64_t v)
{
do {
if (n >= cap)
break;
uint8_t byte = v & 0x7F;
v >>= 7;
if (v)
byte |= 0x80;
buf[n++] = byte;
} while (v);
return n;
}
// Generate protobuf-*shaped* noise: a run of (tag, payload) pairs with valid and invalid wire types.
// Reaches decoder states (submessage length prefixes, packed fields, bad wire types) that pure random
// bytes rarely hit, so it stresses the parser far deeper than raw noise alone.
static size_t genProtoish(uint8_t *buf, size_t cap)
{
size_t n = 0;
int fields = (int)rngRange(14);
for (int f = 0; f < fields && n + 32 < cap; f++) {
uint32_t fieldnum = 1 + rngRange(48);
uint32_t wire = rngRange(8); // 0,1,2,5 are valid; 3,4,6,7 exercise wire-type rejection
uint32_t tag = (fieldnum << 3) | (wire & 7);
n = writeVarint(buf, cap, n, tag);
switch (wire & 7) {
case 0: // varint
n = writeVarint(buf, cap, n, ((uint64_t)rngNext() << 32) | rngNext());
break;
case 1: // 64-bit
for (int i = 0; i < 8 && n < cap; i++)
buf[n++] = rngByte();
break;
case 2: { // length-delimited (string/bytes/submessage) - random and sometimes lying length
uint32_t L = rngRange(24);
n = writeVarint(buf, cap, n, L);
for (uint32_t i = 0; i < L && n < cap; i++)
buf[n++] = rngByte();
break;
}
case 5: // 32-bit
for (int i = 0; i < 4 && n < cap; i++)
buf[n++] = rngByte();
break;
default: // invalid wire type - decoder must reject cleanly
break;
}
}
return n;
}
// Feed every type `iters` random buffers; the only contract is crash-freedom / ASan-clean.
static void decodeFuzzPass(bool protoish, uint64_t seed)
{
rngSeed(seed);
AnyMsg out;
uint8_t buf[512];
unsigned long total = 0;
for (size_t ti = 0; ti < NUM_FUZZ_TYPES; ti++) {
for (unsigned k = 0; k < DECODE_ITERS; k++) {
size_t len;
if (protoish) {
len = genProtoish(buf, sizeof(buf));
} else {
len = rngRange(sizeof(buf) + 1);
rngFill(buf, len);
}
memset(&out, 0, sizeof(out));
// Return value intentionally ignored: true or false are both acceptable. What must never
// happen is an out-of-bounds access, and ASan is watching for exactly that.
(void)pb_decode_from_bytes(buf, len, FUZZ_TYPES[ti].fields, &out);
pb_release(FUZZ_TYPES[ti].fields, &out);
total++;
}
}
// Reaching here means no ASan fault fired across every iteration.
TEST_ASSERT_EQUAL_UINT32((uint32_t)(NUM_FUZZ_TYPES * DECODE_ITERS), total);
}
void test_D1a_decode_fuzz_random(void)
{
printf(" seed=0x%llx\n", (unsigned long long)(BASE_SEED ^ 0x1111));
decodeFuzzPass(/*protoish=*/false, BASE_SEED ^ 0x1111);
}
void test_D1b_decode_fuzz_protobuf_shaped(void)
{
printf(" seed=0x%llx\n", (unsigned long long)(BASE_SEED ^ 0x2222));
decodeFuzzPass(/*protoish=*/true, BASE_SEED ^ 0x2222);
}
// ---------------------------------------------------------------------------
// Group D2 - UTF-8 sanitizer fuzz
// ---------------------------------------------------------------------------
// Independent strict UTF-8 validator - deliberately NOT sanitizeUtf8, so a bug in sanitizeUtf8 can't
// mask itself. Validates [s, first NUL) exactly as a strict decoder would (rejects overlong,
// surrogates, > U+10FFFF, truncated, stray continuation/lead bytes).
static bool isValidUtf8(const char *s)
{
const uint8_t *p = (const uint8_t *)s;
while (*p) {
uint8_t c = *p;
int seqLen;
uint32_t cp, minCp;
if (c < 0x80) {
p++;
continue;
} else if ((c & 0xE0) == 0xC0) {
seqLen = 2;
cp = c & 0x1F;
minCp = 0x80;
} else if ((c & 0xF0) == 0xE0) {
seqLen = 3;
cp = c & 0x0F;
minCp = 0x800;
} else if ((c & 0xF8) == 0xF0) {
seqLen = 4;
cp = c & 0x07;
minCp = 0x10000;
} else {
return false; // invalid lead (continuation byte as lead, or 0xF8+)
}
for (int i = 1; i < seqLen; i++) {
if ((p[i] & 0xC0) != 0x80) // truncated / bad continuation (embedded NUL ends the loop above)
return false;
cp = (cp << 6) | (p[i] & 0x3F);
}
if (cp < minCp || cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF))
return false;
p += seqLen;
}
return true;
}
// Run the full sanitizeUtf8 contract against a raw buffer of size `cap`.
static void assertSanitizeContract(char *buf, size_t cap)
{
char before[512];
TEST_ASSERT_TRUE(cap <= sizeof(before));
sanitizeUtf8(buf, cap);
// 1. Always NUL-terminated within the buffer.
TEST_ASSERT_EQUAL_UINT8_MESSAGE(0, (uint8_t)buf[cap - 1], "sanitizeUtf8 must force a trailing NUL");
// 2. Length never exceeds bufSize-1.
TEST_ASSERT_TRUE_MESSAGE(strlen(buf) <= cap - 1, "sanitized string overran the buffer");
// 3. Output re-validates as UTF-8 by an independent validator.
TEST_ASSERT_TRUE_MESSAGE(isValidUtf8(buf), "sanitizeUtf8 left invalid UTF-8 behind");
// 4. Idempotent: a second pass changes nothing and reports no replacement.
memcpy(before, buf, cap);
bool replacedAgain = sanitizeUtf8(buf, cap);
TEST_ASSERT_FALSE_MESSAGE(replacedAgain, "sanitizeUtf8 is not idempotent");
TEST_ASSERT_EQUAL_MEMORY_MESSAGE(before, buf, cap, "second sanitize mutated an already-clean buffer");
}
// Every single byte value as a 1-char "string" in a 2-byte buffer.
void test_D2a_utf8_exhaustive_single_byte(void)
{
for (int b = 0; b < 256; b++) {
char buf[2] = {(char)b, (char)b}; // deliberately not NUL-terminated
assertSanitizeContract(buf, sizeof(buf));
}
}
// Every 2-byte lead+continuation pair (covers overlong C0/C1, valid, and bad continuations).
void test_D2b_utf8_exhaustive_two_byte(void)
{
for (int lead = 0xC0; lead <= 0xFF; lead++) {
for (int cont = 0x00; cont <= 0xFF; cont++) {
char buf[4] = {(char)lead, (char)cont, (char)lead, (char)cont};
assertSanitizeContract(buf, sizeof(buf));
}
}
}
// Tiny buffers (cap 1..4) exercise the truncated-sequence-at-end paths.
void test_D2c_utf8_tiny_buffers(void)
{
rngSeed(BASE_SEED ^ 0x3333);
for (size_t cap = 1; cap <= 4; cap++) {
for (unsigned k = 0; k < 4000; k++) {
char buf[8];
for (size_t i = 0; i < cap; i++)
buf[i] = (char)rngByte();
assertSanitizeContract(buf, cap);
}
}
}
// Randomized buffers of random size, biased toward high bytes to stress multibyte paths.
void test_D2d_utf8_random(void)
{
printf(" seed=0x%llx\n", (unsigned long long)(BASE_SEED ^ 0x4444));
rngSeed(BASE_SEED ^ 0x4444);
for (unsigned k = 0; k < 40000; k++) {
char buf[128];
size_t cap = 1 + rngRange(sizeof(buf));
for (size_t i = 0; i < cap; i++) {
// ~60% high bytes so multibyte lead/continuation logic gets hammered.
buf[i] = (rngRange(100) < 60) ? (char)(0x80 + rngRange(0x80)) : (char)rngByte();
}
assertSanitizeContract(buf, cap);
}
}
// clampLongName: a 25-byte buffer with random content and emoji straddling the 24-byte cut.
void test_D2e_clamp_long_name(void)
{
rngSeed(BASE_SEED ^ 0x5555);
for (unsigned k = 0; k < 20000; k++) {
char buf[MAX_LONG_NAME_BYTES + 1 + 8]; // extra slack; clampLongName only touches [0, 24]
for (size_t i = 0; i < sizeof(buf); i++)
buf[i] = (char)rngByte();
clampLongName(buf);
TEST_ASSERT_TRUE_MESSAGE(strlen(buf) <= MAX_LONG_NAME_BYTES, "clampLongName exceeded the byte cap");
TEST_ASSERT_TRUE_MESSAGE(isValidUtf8(buf), "clampLongName left invalid UTF-8");
}
}
// pb_string_length: never over-reads, result is a valid content length within max_len.
void test_D2f_pb_string_length(void)
{
rngSeed(BASE_SEED ^ 0x6666);
for (unsigned k = 0; k < 20000; k++) {
uint8_t buf[64];
size_t maxLen = 1 + rngRange(sizeof(buf));
rngFill(buf, maxLen);
size_t len = pb_string_length((const char *)buf, maxLen);
TEST_ASSERT_TRUE_MESSAGE(len <= maxLen, "pb_string_length returned > max_len");
if (len > 0)
TEST_ASSERT_NOT_EQUAL_MESSAGE(0, buf[len - 1], "pb_string_length pointed past the last content byte");
}
}
// ---------------------------------------------------------------------------
void setUp(void) {}
void tearDown(void) {}
void setup()
{
initializeTestEnvironment();
UNITY_BEGIN();
printf("\n=== Group D1: protobuf decode fuzz ===\n");
RUN_TEST(test_D1a_decode_fuzz_random);
RUN_TEST(test_D1b_decode_fuzz_protobuf_shaped);
printf("\n=== Group D2: UTF-8 sanitizer fuzz ===\n");
RUN_TEST(test_D2a_utf8_exhaustive_single_byte);
RUN_TEST(test_D2b_utf8_exhaustive_two_byte);
RUN_TEST(test_D2c_utf8_tiny_buffers);
RUN_TEST(test_D2d_utf8_random);
RUN_TEST(test_D2e_clamp_long_name);
RUN_TEST(test_D2f_pb_string_length);
exit(UNITY_END());
}
void loop() {}