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>
This commit is contained in:
Tom
2026-07-06 13:19:01 -05:00
committed by GitHub
co-authored by GitHub Claude Fable 5
parent 6c7ee8afc7
commit d846780a9b
16 changed files with 977 additions and 114 deletions
+26 -7
View File
@@ -15,8 +15,19 @@ static inline int getStringWidth(OLEDDisplay *display, const char *text, size_t
#if defined(OLED_UA) || defined(OLED_RU) #if defined(OLED_UA) || defined(OLED_RU)
return display->getStringWidth(text, len, true); return display->getStringWidth(text, len, true);
#else #else
(void)len; // OLEDDisplay::getStringWidth (utf8=false) indexes the font jump table by (c - firstChar) with a
return display->getStringWidth(text); // signed char and no bounds check, so any byte outside printable ASCII (high bytes, but also
// control bytes like a stray 0x0A) reads outside the font array. Measure a sanitized copy in which
// unrepresentable bytes count as a '?' placeholder; printable ASCII is passed through unchanged.
char safe[64];
if (len > sizeof(safe) - 1)
len = sizeof(safe) - 1;
for (size_t i = 0; i < len; i++) {
const uint8_t c = static_cast<uint8_t>(text[i]);
safe[i] = (c >= 0x20 && c <= 0x7E) ? static_cast<char>(c) : '?';
}
safe[len] = '\0';
return display->getStringWidth(safe, len, false);
#endif #endif
} }
@@ -31,6 +42,14 @@ size_t utf8CharLen(uint8_t c)
return 1; return 1;
} }
// Bytes the UTF-8 char at s[pos] occupies, clamped to what actually remains. A truncated multi-byte
// lead (e.g. a lone 0xF0) near the end otherwise makes a walker read past the buffer - the payload of
// a TEXT message is opaque bytes, so such truncated sequences are attacker-reachable.
static inline size_t utf8CharLenClamped(const char *s, size_t pos, size_t len)
{
return std::min(utf8CharLen(static_cast<uint8_t>(s[pos])), len - pos);
}
static inline bool isPossibleEmoteLead(uint8_t c) static inline bool isPossibleEmoteLead(uint8_t c)
{ {
// All supported emoji labels in emotes.cpp are currently in these UTF-8 lead ranges. // All supported emoji labels in emotes.cpp are currently in these UTF-8 lead ranges.
@@ -209,7 +228,7 @@ static LineMetrics analyzeLineInternal(OLEDDisplay *display, const char *line, s
continue; continue;
} }
const size_t charLen = utf8CharLen(static_cast<uint8_t>(line[i])); const size_t charLen = utf8CharLenClamped(line, i, lineLen);
if (display) if (display)
metrics.width += getUtf8ChunkWidth(display, line + i, charLen); metrics.width += getUtf8ChunkWidth(display, line + i, charLen);
i += charLen; i += charLen;
@@ -257,7 +276,7 @@ static int appendTextSpanAndMeasure(OLEDDisplay *display, int cursorX, int fontY
while (pos < len) { while (pos < len) {
size_t chunkLen = 0; size_t chunkLen = 0;
while (pos + chunkLen < len) { while (pos + chunkLen < len) {
const size_t charLen = utf8CharLen(static_cast<uint8_t>(text[pos + chunkLen])); const size_t charLen = utf8CharLenClamped(text, pos + chunkLen, len);
if (chunkLen + charLen >= sizeof(chunk)) if (chunkLen + charLen >= sizeof(chunk))
break; break;
chunkLen += charLen; chunkLen += charLen;
@@ -328,7 +347,7 @@ size_t truncateToWidth(OLEDDisplay *display, const char *line, char *out, size_t
continue; continue;
} }
const size_t charLen = utf8CharLen(static_cast<uint8_t>(line[i])); const size_t charLen = utf8CharLenClamped(line, i, lineLen);
tokenWidth = getUtf8ChunkWidth(display, line + i, charLen); tokenWidth = getUtf8ChunkWidth(display, line + i, charLen);
advance = charLen; advance = charLen;
} }
@@ -417,11 +436,11 @@ void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const char *line,
if (findEmoteAt(line, lineLen, next, nextMatchLen, emoteSet, emoteCount) != nullptr) if (findEmoteAt(line, lineLen, next, nextMatchLen, emoteSet, emoteCount) != nullptr)
break; break;
next += utf8CharLen(static_cast<uint8_t>(line[next])); next += utf8CharLenClamped(line, next, lineLen);
} }
if (next == i) if (next == i)
next += utf8CharLen(static_cast<uint8_t>(line[i])); next += utf8CharLenClamped(line, i, lineLen);
cursorX = appendTextSpanAndMeasure(display, cursorX, fontY, line + i, next - i, true, fauxBold && inBold); cursorX = appendTextSpanAndMeasure(display, cursorX, fontY, line + i, next - i, true, fauxBold && inBold);
i = next; i = next;
+21 -2
View File
@@ -239,7 +239,9 @@ CryptoKey Channels::getKey(ChannelIndex chIndex)
memcpy(k.bytes, channelSettings.psk.bytes, channelSettings.psk.size); memcpy(k.bytes, channelSettings.psk.bytes, channelSettings.psk.size);
k.length = channelSettings.psk.size; k.length = channelSettings.psk.size;
if (k.length == 0) { if (k.length == 0) {
if (ch.role == meshtastic_Channel_Role_SECONDARY) { // A secondary with no PSK borrows the primary's key; the chIndex != primaryIndex check
// prevents infinite recursion if the primary slot itself is marked SECONDARY
if (ch.role == meshtastic_Channel_Role_SECONDARY && chIndex != primaryIndex) {
LOG_DEBUG("Unset PSK for secondary channel %s. use primary key", ch.settings.name); LOG_DEBUG("Unset PSK for secondary channel %s. use primary key", ch.settings.name);
k = getKey(primaryIndex); k = getKey(primaryIndex);
} else { } else {
@@ -309,11 +311,28 @@ void Channels::initDefaults()
void Channels::onConfigChanged() void Channels::onConfigChanged()
{ {
// Make sure the phone hasn't mucked anything up // Make sure the phone hasn't mucked anything up
bool hasPrimary = false;
for (int i = 0; i < channelFile.channels_count; i++) { for (int i = 0; i < channelFile.channels_count; i++) {
const meshtastic_Channel &ch = fixupChannel(i); const meshtastic_Channel &ch = fixupChannel(i);
if (ch.role == meshtastic_Channel_Role_PRIMARY) if (ch.role == meshtastic_Channel_Role_PRIMARY) {
primaryIndex = i; primaryIndex = i;
hasPrimary = true;
}
}
// Enforce the invariant that primaryIndex references a PRIMARY channel: a malformed config can
// demote every slot, which would leave all getPrimaryIndex() readers on a stale non-primary slot
if (!hasPrimary) {
meshtastic_Channel &stale = getByIndex(primaryIndex);
if (stale.role == meshtastic_Channel_Role_SECONDARY) {
stale.role = meshtastic_Channel_Role_PRIMARY; // keep the slot's key material
} else {
// Promoting a DISABLED (zeroed) slot would make a plaintext primary; restore the default
primaryIndex = 0;
initDefaultChannel(0);
}
LOG_WARN("Config has no PRIMARY channel, restored one at slot %u", primaryIndex);
fixupChannel(primaryIndex);
} }
#if !MESHTASTIC_EXCLUDE_MQTT #if !MESHTASTIC_EXCLUDE_MQTT
if (channels.anyMqttEnabled() && mqtt && !mqtt->isEnabled()) { if (channels.anyMqttEnabled() && mqtt && !mqtt->isEnabled()) {
+13 -6
View File
@@ -1110,7 +1110,8 @@ bool RadioInterface::checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraCo
} }
} }
} else { } else {
check_bw = bwCodeToKHz(loraConfig.bandwidth); // Clamp at the source so numFreqSlots below can never be 0 (bandwidth 0 is reachable from a crafted set_config)
check_bw = clampBandwidthKHz(bwCodeToKHz(loraConfig.bandwidth));
} }
// Calculate width of slots (aka channels) based on bandwidth and any spacing or padding required by the region: // Calculate width of slots (aka channels) based on bandwidth and any spacing or padding required by the region:
@@ -1142,8 +1143,9 @@ bool RadioInterface::checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraCo
const char *channelName = channels.getName(channels.getPrimaryIndex()); const char *channelName = channels.getName(channels.getPrimaryIndex());
const char *presetNameDisplay = const char *presetNameDisplay =
DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset); DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset);
uint32_t channelNameHashSlot = hash(channelName) % numFreqSlots; // numFreqSlots can still be 0 for an UNSET/degenerate region, and % 0 is a SIGFPE
uint32_t presetNameHashSlot = hash(presetNameDisplay) % numFreqSlots; uint32_t channelNameHashSlot = numFreqSlots ? (hash(channelName) % numFreqSlots) : 0;
uint32_t presetNameHashSlot = numFreqSlots ? (hash(presetNameDisplay) % numFreqSlots) : 0;
if (loraConfig.override_frequency == 0) { if (loraConfig.override_frequency == 0) {
@@ -1245,7 +1247,8 @@ void RadioInterface::applyModemConfig()
newRegion->name); newRegion->name);
clampConfigLora(loraConfig); clampConfigLora(loraConfig);
} }
bw = bwCodeToKHz(loraConfig.bandwidth); // Clamp at the source so numFreqSlots below can never be 0 (a bandwidth-0 config may already be persisted)
bw = clampBandwidthKHz(bwCodeToKHz(loraConfig.bandwidth));
sf = loraConfig.spread_factor; sf = loraConfig.spread_factor;
cr = loraConfig.coding_rate; cr = loraConfig.coding_rate;
} }
@@ -1276,9 +1279,13 @@ void RadioInterface::applyModemConfig()
// Note that channel_num is actually (channel_num - 1), i.e. zero-based, since modulus (%) returns values from 0 to // Note that channel_num is actually (channel_num - 1), i.e. zero-based, since modulus (%) returns values from 0 to
// (numFreqSlots - 1). // (numFreqSlots - 1).
const char *channelName = channels.getName(channels.getPrimaryIndex()); const char *channelName = channels.getName(channels.getPrimaryIndex());
uint32_t channelNameHashSlot = hash(channelName) % numFreqSlots; // Guard the modulo: numFreqSlots can be 0 for an UNSET/degenerate region, and % 0 is a SIGFPE
uint32_t channelNameHashSlot = numFreqSlots ? (hash(channelName) % numFreqSlots) : 0;
uint32_t presetNameHashSlot = uint32_t presetNameHashSlot =
hash(DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset)) % numFreqSlots; numFreqSlots
? (hash(DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset)) %
numFreqSlots)
: 0;
// override if we have a verbatim frequency // override if we have a verbatim frequency
if (loraConfig.override_frequency) { if (loraConfig.override_frequency) {
+1 -1
View File
@@ -22,7 +22,7 @@ struct AdminModule_ObserverData {
*/ */
class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, public Observable<AdminModule_ObserverData *> class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, public Observable<AdminModule_ObserverData *>
{ {
friend class AdminModuleTestShim; // native unit tests reach handleSetConfig + hasOpenEditTransaction friend class AdminModuleTestShim; // test/support/AdminModuleTestShim.h - native tests reach the private handlers/state
public: public:
/** Constructor /** Constructor
+25
View File
@@ -0,0 +1,25 @@
#pragma once
// The one class AdminModule.h befriends (test seam): exposes the protected/private handlers to the
// native suites and defers persistence so setters exercise in-RAM logic without disk/reboot effects.
#include "MeshTypes.h" // packetPool
#include "modules/AdminModule.h"
class AdminModuleTestShim : public AdminModule
{
public:
using AdminModule::handleReceivedProtobuf;
using AdminModule::handleSetConfig;
using AdminModule::handleSetModuleConfig;
// With an "open edit transaction" saveChanges() is a pure no-op: no reloadConfig/saveToDisk/reboot.
void deferSaves() { hasOpenEditTransaction = true; }
// Setters may allocate an error reply from packetPool; drain it each iteration or the pool leaks.
void drainReply()
{
if (myReply) {
packetPool.release(myReply);
myReply = nullptr;
}
}
};
+41
View File
@@ -0,0 +1,41 @@
#pragma once
// Seeded 64-bit LCG (Knuth MMIX constants) shared by the fuzz suites - no rand()/time(), so a failing
// iteration reproduces exactly from the printed seed. `static inline` + per-TU state suits single-file
// Unity suites; each suite keeps its own BASE_SEED/FUZZ_SEED and seeds per group.
#include <cstddef>
#include <cstdint>
static uint64_t g_fuzzRng = 0;
static inline void rngSeed(uint64_t s)
{
g_fuzzRng = s ? s : 0x9E3779B97F4A7C15ULL;
}
static inline uint32_t rngNext()
{
g_fuzzRng = g_fuzzRng * 6364136223846793005ULL + 1442695040888963407ULL;
return (uint32_t)(g_fuzzRng >> 32);
}
static inline uint8_t rngByte()
{
return (uint8_t)(rngNext() & 0xFF);
}
static inline uint32_t rngRange(uint32_t n) // uniform-ish in [0, n)
{
return n ? (rngNext() % n) : 0;
}
static inline void rngFill(void *buf, size_t n)
{
uint8_t *b = (uint8_t *)buf;
for (size_t i = 0; i < n; i++)
b[i] = rngByte();
}
// One of the boundary NodeNums every fuzz suite should hit - 0, 1, broadcast (0xFFFFFFFF ==
// NODENUM_BROADCAST) - plus any suite-specific well-known nodes (local/remote/target) passed in.
static inline uint32_t rngEdgeNodeNum(const uint32_t *wellKnown = nullptr, size_t wellKnownN = 0)
{
static const uint32_t edges[] = {0u, 1u, 0xFFFFFFFFu};
const size_t numEdges = sizeof(edges) / sizeof(edges[0]);
size_t i = rngRange((uint32_t)(numEdges + wellKnownN));
return (i < numEdges) ? edges[i] : wellKnown[i - numEdges];
}
+10
View File
@@ -0,0 +1,10 @@
#pragma once
// Release-to-pool MeshService stub shared by the native suites: handlers that raise client
// notifications must not leak them across thousands of iterations (LSan turns the run RED).
#include "mesh/MeshService.h"
class MockMeshService : public MeshService
{
public:
void sendClientNotification(meshtastic_ClientNotification *n) override { releaseClientNotificationToPool(n); }
};
+3 -15
View File
@@ -21,16 +21,12 @@
#include <unity.h> #include <unity.h>
#include "meshtastic/config.pb.h" #include "meshtastic/config.pb.h"
#include "support/AdminModuleTestShim.h"
#include "support/MockMeshService.h"
// hash() is a file-scope function in RadioInterface.cpp; link it in for slot-formula tests // hash() is a file-scope function in RadioInterface.cpp; link it in for slot-formula tests
extern uint32_t hash(const char *str); extern uint32_t hash(const char *str);
class MockMeshService : public MeshService
{
public:
void sendClientNotification(meshtastic_ClientNotification *n) override { releaseClientNotificationToPool(n); }
};
static MockMeshService *mockMeshService; static MockMeshService *mockMeshService;
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -929,15 +925,7 @@ static void test_channelSpacingCalculation_placeholder()
// handleSetConfig fromOthers dispatch tests // handleSetConfig fromOthers dispatch tests
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
class AdminModuleTestShim : public AdminModule // AdminModuleTestShim comes from test/support - the friend seam AdminModule.h declares.
{
public:
using AdminModule::handleSetConfig;
// Defer persistence so handleSetConfig() exercises the in-RAM config logic without saveToDisk() touching
// the (uninitialized) node database in this lightweight harness.
void deferSaves() { hasOpenEditTransaction = true; }
};
static AdminModuleTestShim *testAdmin; static AdminModuleTestShim *testAdmin;
static meshtastic_Config makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode region, bool usePreset, static meshtastic_Config makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode region, bool usePreset,
+23 -26
View File
@@ -22,7 +22,12 @@
#include "mesh-pb-constants.h" #include "mesh-pb-constants.h"
#include "mesh/generated/meshtastic/admin.pb.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.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/storeforward.pb.h"
#include "mesh/generated/meshtastic/telemetry.pb.h" #include "mesh/generated/meshtastic/telemetry.pb.h"
#include "meshUtils.h" #include "meshUtils.h"
@@ -30,28 +35,8 @@
#include <cstring> #include <cstring>
#include <pb_decode.h> #include <pb_decode.h>
// --------------------------------------------------------------------------- // Deterministic RNG (rngSeed/rngNext/rngByte/rngRange) - shared seeded LCG.
// Deterministic RNG - seeded 64-bit LCG (Knuth MMIX constants). No rand()/time so a failing #include "support/DeterministicRng.h"
// iteration reproduces exactly from the printed base seed.
// ---------------------------------------------------------------------------
static uint64_t g_rng = 0;
static void rngSeed(uint64_t s)
{
g_rng = s ? s : 0x9E3779B97F4A7C15ULL;
}
static uint32_t rngNext()
{
g_rng = g_rng * 6364136223846793005ULL + 1442695040888963407ULL;
return (uint32_t)(g_rng >> 32);
}
static uint8_t rngByte()
{
return (uint8_t)(rngNext() & 0xFF);
}
static uint32_t rngRange(uint32_t n) // uniform-ish in [0, n)
{
return n ? (rngNext() % n) : 0;
}
static constexpr uint64_t BASE_SEED = 0x00C0FFEEULL; static constexpr uint64_t BASE_SEED = 0x00C0FFEEULL;
static constexpr unsigned DECODE_ITERS = 3000; // per type, per pass (ASan-instrumented, keep bounded) static constexpr unsigned DECODE_ITERS = 3000; // per type, per pass (ASan-instrumented, keep bounded)
@@ -75,6 +60,13 @@ union AnyMsg {
meshtastic_Routing routing; meshtastic_Routing routing;
meshtastic_AdminMessage adminMessage; meshtastic_AdminMessage adminMessage;
meshtastic_StoreAndForward storeAndForward; 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 { struct FuzzType {
@@ -94,6 +86,13 @@ static const FuzzType FUZZ_TYPES[] = {
{"Routing", &meshtastic_Routing_msg}, {"Routing", &meshtastic_Routing_msg},
{"AdminMessage", &meshtastic_AdminMessage_msg}, {"AdminMessage", &meshtastic_AdminMessage_msg},
{"StoreAndForward", &meshtastic_StoreAndForward_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 const size_t NUM_FUZZ_TYPES = sizeof(FUZZ_TYPES) / sizeof(FUZZ_TYPES[0]);
@@ -164,8 +163,7 @@ static void decodeFuzzPass(bool protoish, uint64_t seed)
len = genProtoish(buf, sizeof(buf)); len = genProtoish(buf, sizeof(buf));
} else { } else {
len = rngRange(sizeof(buf) + 1); len = rngRange(sizeof(buf) + 1);
for (size_t i = 0; i < len; i++) rngFill(buf, len);
buf[i] = rngByte();
} }
memset(&out, 0, sizeof(out)); memset(&out, 0, sizeof(out));
// Return value intentionally ignored: true or false are both acceptable. What must never // Return value intentionally ignored: true or false are both acceptable. What must never
@@ -328,8 +326,7 @@ void test_D2f_pb_string_length(void)
for (unsigned k = 0; k < 20000; k++) { for (unsigned k = 0; k < 20000; k++) {
uint8_t buf[64]; uint8_t buf[64];
size_t maxLen = 1 + rngRange(sizeof(buf)); size_t maxLen = 1 + rngRange(sizeof(buf));
for (size_t i = 0; i < maxLen; i++) rngFill(buf, maxLen);
buf[i] = rngByte();
size_t len = pb_string_length((const char *)buf, maxLen); size_t len = pb_string_length((const char *)buf, maxLen);
TEST_ASSERT_TRUE_MESSAGE(len <= maxLen, "pb_string_length returned > max_len"); TEST_ASSERT_TRUE_MESSAGE(len <= maxLen, "pb_string_length returned > max_len");
if (len > 0) if (len > 0)
+130
View File
@@ -0,0 +1,130 @@
// Adversarial fuzzing of the emote/UTF-8 width+truncation walkers - the render path a TEXT_MESSAGE
// payload reaches AFTER decode. Text payloads are opaque protobuf `bytes`, so PB_VALIDATE_UTF8 never
// screens them: invalid UTF-8, control bytes and truncated multi-byte lead bytes reach these walkers
// verbatim. The walkers advance by utf8CharLen(lead), which for a 0xC0/0xE0/0xF0 lead near the end of
// the buffer claims more bytes than remain - so an unclamped copy reads past the string.
//
// Each input is placed in an EXACT-sized heap buffer (content + NUL, no slack) so any read past the
// content is a hard heap-buffer-overflow that AddressSanitizer flags. Runs under the coverage env.
#include "TestUtil.h"
#include "configuration.h"
#include <unity.h>
#if HAS_SCREEN
#include "graphics/EmoteRenderer.h"
#include <OLEDDisplay.h>
#include <OLEDDisplayFonts.h>
#include <cstdlib>
#include <cstring>
#include "support/DeterministicRng.h"
static constexpr uint64_t BASE_SEED = 0x00E3070EULL;
// Headless display with a REAL font, so the fuzz exercises the production width path end to end:
// EmoteRenderer's getUtf8ChunkWidth memcpy (guarded by the utf8CharLen clamp) AND the getStringWidth
// helper's byte sanitizer (which keeps the stock library's signed-char font indexing in bounds for
// bytes outside printable ASCII). getStringWidth only walks the font jump table, never a frame buffer.
class FakeDisplay : public OLEDDisplay
{
public:
FakeDisplay() { setFont(ArialMT_Plain_10); }
void display() override {}
int getBufferOffset() override { return 0; }
size_t write(uint8_t) override { return 1; }
};
// Drive both walkers over a NUL-terminated exact-sized copy of [bytes, bytes+len).
static void exercise(FakeDisplay &d, const uint8_t *bytes, size_t len)
{
char *buf = (char *)malloc(len + 1); // exactly content + NUL: reads past index len are OOB
memcpy(buf, bytes, len);
buf[len] = '\0';
(void)graphics::EmoteRenderer::measureStringWithEmotes(&d, buf); // analyzeLineInternal walk
char out[64];
(void)graphics::EmoteRenderer::truncateToWidth(&d, buf, out, sizeof(out), 40); // cut-loop walk
free(buf);
}
// A byte that begins a multi-byte UTF-8 sequence (so utf8CharLen returns 2/3/4).
static uint8_t multibyteLead()
{
static const uint8_t leads[] = {0xC0, 0xE0, 0xF0, 0xE2}; // 0xE2/0xF0 are also emote leads
return leads[rngRange(sizeof(leads))];
}
void test_emote_utf8_fuzz(void)
{
printf(" seed=0x%llx\n", (unsigned long long)BASE_SEED);
rngSeed(BASE_SEED);
FakeDisplay d;
for (unsigned k = 0; k < 20000; k++) {
uint8_t bytes[300];
size_t len = 1 + rngRange(sizeof(bytes)); // 1..300, includes >MAX_MESSAGE_SIZE
// Non-zero fill so strlen() == len (an embedded NUL would just shorten the effective string).
for (size_t i = 0; i < len; i++)
bytes[i] = (uint8_t)(1 + rngRange(255));
// Sprinkle multi-byte leads (some truncated) through the body to reach findEmoteAt / the
// modifier skippers as well as the plain width path.
unsigned sprinkles = rngRange(6);
for (unsigned s = 0; s < sprinkles; s++)
bytes[rngRange(len)] = multibyteLead();
// Half the time, force the LAST byte to a lead: a truncated sequence with no continuation left,
// the exact shape that makes an unclamped walker step past the buffer end.
if (rngRange(2))
bytes[len - 1] = multibyteLead();
exercise(d, bytes, len);
}
TEST_ASSERT_TRUE(true); // reaching here = no ASan fault across all iterations
}
// Fixed regressions for the truncated-lead shape, independent of the RNG.
void test_emote_truncated_lead_edges(void)
{
FakeDisplay d;
const uint8_t loneF0[] = {0xF0};
const uint8_t tailE0[] = {'h', 'i', 0xE0};
const uint8_t tailC0[] = {'a', 'b', 'c', 0xC0};
uint8_t allF0[64];
memset(allF0, 0xF0, sizeof(allF0));
exercise(d, loneF0, sizeof(loneF0));
exercise(d, tailE0, sizeof(tailE0));
exercise(d, tailC0, sizeof(tailC0));
exercise(d, allF0, sizeof(allF0));
TEST_ASSERT_TRUE(true);
}
void setup()
{
initializeTestEnvironment();
UNITY_BEGIN();
RUN_TEST(test_emote_truncated_lead_edges);
RUN_TEST(test_emote_utf8_fuzz);
exit(UNITY_END());
}
void loop() {}
#else // !HAS_SCREEN
void setUp(void) {}
void tearDown(void) {}
void setup()
{
initializeTestEnvironment();
UNITY_BEGIN();
exit(UNITY_END());
}
void loop() {}
#endif
+463 -33
View File
@@ -12,6 +12,15 @@
// Group E2 NodeInfoModule handler over arbitrary User structs // Group E2 NodeInfoModule handler over arbitrary User structs
// Group E3 TraceRoute route-processing over adversarial RouteDiscovery (route/snr count edges) // Group E3 TraceRoute route-processing over adversarial RouteDiscovery (route/snr count edges)
// Group E4 MeshService phone-forward gate (nested-string validation) // Group E4 MeshService phone-forward gate (nested-string validation)
// Group E5 AdminModule dispatch over adversarial AdminMessage (local from==0, setter/node-op tags)
// Group E6 MeshBeaconListenerModule handler over adversarial MeshBeacon (un-terminated message, offer PSK)
// Group E7 NodeDB::updateUser / updateFrom over adversarial nodeId + User / MeshPacket
// Group E8 PositionModule handler over adversarial Position (self/remote origin, fixed_position, RTC path)
// Group E9 DeviceTelemetryModule handler over adversarial Telemetry (all variant tags)
// Group E10 NeighborInfoModule handler over adversarial NeighborInfo (neighbor-count edges)
//
// KeyVerificationModule (handler inert until a prior handshake advances its private state) and
// StoreForwardModule (needs PSRAM + router wiring) are instead covered at the decode level in test_fuzz_decode.
#include "MeshTypes.h" // include BEFORE TestUtil.h #include "MeshTypes.h" // include BEFORE TestUtil.h
#include "TestUtil.h" #include "TestUtil.h"
@@ -25,37 +34,28 @@
#include "mesh/MeshService.h" #include "mesh/MeshService.h"
#include "mesh/NodeDB.h" #include "mesh/NodeDB.h"
#include "mesh/Router.h" #include "mesh/Router.h"
#include "modules/AdminModule.h"
#include "modules/NeighborInfoModule.h"
#include "modules/NodeInfoModule.h" #include "modules/NodeInfoModule.h"
#include "modules/PositionModule.h"
#include "modules/Telemetry/DeviceTelemetry.h"
#include "modules/TraceRouteModule.h" #include "modules/TraceRouteModule.h"
#include <cstdio> #include <cstdio>
#include <cstring> #include <cstring>
#include <pb_decode.h> #include <pb_decode.h>
#include <vector> #include <vector>
#if !MESHTASTIC_EXCLUDE_BEACON
#include "modules/MeshBeaconModule.h"
#endif
static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A; static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A;
static constexpr NodeNum REMOTE_NODE = 0x0B0B0B0B; static constexpr NodeNum REMOTE_NODE = 0x0B0B0B0B;
// --------------------------------------------------------------------------- // Deterministic RNG (rngSeed/rngNext/rngByte/rngRange/rngFill/rngEdgeNodeNum) - shared seeded LCG.
// Deterministic RNG (seeded LCG) #include "support/AdminModuleTestShim.h"
// --------------------------------------------------------------------------- #include "support/DeterministicRng.h"
static uint64_t g_rng = 0; #include "support/MockMeshService.h"
static void rngSeed(uint64_t s)
{
g_rng = s ? s : 0x9E3779B97F4A7C15ULL;
}
static uint32_t rngNext()
{
g_rng = g_rng * 6364136223846793005ULL + 1442695040888963407ULL;
return (uint32_t)(g_rng >> 32);
}
static uint8_t rngByte()
{
return (uint8_t)(rngNext() & 0xFF);
}
static uint32_t rngRange(uint32_t n)
{
return n ? (rngNext() % n) : 0;
}
static constexpr uint64_t BASE_SEED = 0x00BADF00DULL; static constexpr uint64_t BASE_SEED = 0x00BADF00DULL;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -96,9 +96,12 @@ class MockNodeDB : public NodeDB
static MockNodeDB *mockNodeDB = nullptr; static MockNodeDB *mockNodeDB = nullptr;
static MockMeshService *mockService = nullptr;
void setUp(void) void setUp(void)
{ {
config = meshtastic_LocalConfig_init_zero; config = meshtastic_LocalConfig_init_zero;
moduleConfig = meshtastic_LocalModuleConfig_init_zero;
owner = meshtastic_User_init_zero; owner = meshtastic_User_init_zero;
mockNodeDB = new MockNodeDB(); mockNodeDB = new MockNodeDB();
@@ -106,6 +109,9 @@ void setUp(void)
nodeDB = mockNodeDB; nodeDB = mockNodeDB;
myNodeInfo.my_node_num = LOCAL_NODE; myNodeInfo.my_node_num = LOCAL_NODE;
mockService = new MockMeshService();
service = mockService;
channels.initDefaults(); channels.initDefaults();
channels.onConfigChanged(); channels.onConfigChanged();
} }
@@ -115,6 +121,10 @@ void tearDown(void)
delete mockNodeDB; delete mockNodeDB;
mockNodeDB = nullptr; mockNodeDB = nullptr;
nodeDB = nullptr; nodeDB = nullptr;
service = nullptr;
delete mockService;
mockService = nullptr;
} }
// =========================================================================== // ===========================================================================
@@ -144,10 +154,8 @@ void test_E1_perhaps_decode_fuzz(void)
p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
p.pki_encrypted = (rngRange(2) == 0); p.pki_encrypted = (rngRange(2) == 0);
size_t n = rngRange(sizeof(p.encrypted.bytes) + 1); // 0..256, always in bounds p.encrypted.size = rngRange(sizeof(p.encrypted.bytes) + 1); // 0..256, always in bounds
for (size_t i = 0; i < n; i++) rngFill(p.encrypted.bytes, p.encrypted.size);
p.encrypted.bytes[i] = rngByte();
p.encrypted.size = n;
DecodeState st = perhapsDecode(&p); DecodeState st = perhapsDecode(&p);
// Any verdict is fine; the contract is that arbitrary ciphertext never crashes the pipeline. // Any verdict is fine; the contract is that arbitrary ciphertext never crashes the pipeline.
@@ -182,8 +190,7 @@ static meshtastic_User fuzzUser()
u.hw_model = (meshtastic_HardwareModel)rngRange(256); u.hw_model = (meshtastic_HardwareModel)rngRange(256);
u.role = (meshtastic_Config_DeviceConfig_Role)rngRange(16); u.role = (meshtastic_Config_DeviceConfig_Role)rngRange(16);
u.public_key.size = rngRange(33); // 0..32 u.public_key.size = rngRange(33); // 0..32
for (size_t i = 0; i < u.public_key.size && i < sizeof(u.public_key.bytes); i++) rngFill(u.public_key.bytes, u.public_key.size);
u.public_key.bytes[i] = rngByte();
return u; return u;
} }
@@ -273,10 +280,8 @@ void test_E3_traceroute_route_processing_fuzz(void)
if (rngRange(5) == 0) { if (rngRange(5) == 0) {
// Pure-random payload: processUpgradedPacket must reject it at decode and not crash. // Pure-random payload: processUpgradedPacket must reject it at decode and not crash.
size_t n = rngRange(sizeof(mp.decoded.payload.bytes) + 1); mp.decoded.payload.size = rngRange(sizeof(mp.decoded.payload.bytes) + 1);
for (size_t i = 0; i < n; i++) rngFill(mp.decoded.payload.bytes, mp.decoded.payload.size);
mp.decoded.payload.bytes[i] = rngByte();
mp.decoded.payload.size = n;
} else { } else {
meshtastic_RouteDiscovery r = meshtastic_RouteDiscovery r =
makeRoute(rngRange(ROUTE_MAX + 1), rngRange(ROUTE_MAX + 1), rngRange(ROUTE_MAX + 1), rngRange(ROUTE_MAX + 1)); makeRoute(rngRange(ROUTE_MAX + 1), rngRange(ROUTE_MAX + 1), rngRange(ROUTE_MAX + 1), rngRange(ROUTE_MAX + 1));
@@ -350,8 +355,7 @@ void test_E4_phone_forward_gate(void)
for (unsigned k = 0; k < 8000; k++) { for (unsigned k = 0; k < 8000; k++) {
uint8_t buf[64]; uint8_t buf[64];
size_t n = rngRange(sizeof(buf) + 1); size_t n = rngRange(sizeof(buf) + 1);
for (size_t i = 0; i < n; i++) rngFill(buf, n);
buf[i] = rngByte();
meshtastic_PortNum port = (rngRange(2)) ? meshtastic_PortNum_WAYPOINT_APP : meshtastic_PortNum_NODEINFO_APP; meshtastic_PortNum port = (rngRange(2)) ? meshtastic_PortNum_WAYPOINT_APP : meshtastic_PortNum_NODEINFO_APP;
d = makeData(port, buf, n); d = makeData(port, buf, n);
@@ -368,6 +372,412 @@ void test_E4_phone_forward_gate(void)
} }
} }
// ===========================================================================
// Group E5 - AdminModule dispatch over adversarial AdminMessage
// (AdminModuleTestShim comes from test/support - the friend seam AdminModule.h declares.)
// ===========================================================================
// A scoped subset of setter / node-list tags - the ones reachable without a fully-booted module graph.
// EXCLUDES: side-effecting tags even with saves deferred (reboot*, shutdown, *factory_reset*,
// nodedb_reset, ota, enter_dfu, delete_file, *_preferences, exit_simulator, begin/commit_edit_settings);
// get_* reply builders (un-mocked service paths); set_owner (needs global nodeInfoModule) and
// set_fixed_position (needs global positionModule) - their payloads are covered by E2/E7 and
// test_fuzz_decode. set_config and set_channel ARE fuzzed - they found two crashes (a SIGFPE in LoRa
// validation and unbounded getKey recursion), both fixed; E5 re-fuzzes the triggers as regression
// guards. The module-config variant is constrained below only to skip the beacon variant's global.
static const pb_size_t SAFE_ADMIN_TAGS[] = {
meshtastic_AdminMessage_set_config_tag,
meshtastic_AdminMessage_set_module_config_tag,
meshtastic_AdminMessage_set_channel_tag,
meshtastic_AdminMessage_set_favorite_node_tag,
meshtastic_AdminMessage_remove_favorite_node_tag,
meshtastic_AdminMessage_set_ignored_node_tag,
meshtastic_AdminMessage_remove_ignored_node_tag,
meshtastic_AdminMessage_toggle_muted_node_tag,
meshtastic_AdminMessage_remove_by_nodenum_tag,
meshtastic_AdminMessage_add_contact_tag,
meshtastic_AdminMessage_remove_fixed_position_tag,
meshtastic_AdminMessage_set_time_only_tag,
};
static const size_t NUM_SAFE_ADMIN_TAGS = sizeof(SAFE_ADMIN_TAGS) / sizeof(SAFE_ADMIN_TAGS[0]);
// ModuleConfig variants that handleSetModuleConfig applies with only in-RAM copy + (deferred) save.
// Excludes mesh_beacon, whose handler derefs the global meshBeaconBroadcastModule.
static const pb_size_t SAFE_MODULE_CONFIG_TAGS[] = {
meshtastic_ModuleConfig_mqtt_tag,
meshtastic_ModuleConfig_serial_tag,
meshtastic_ModuleConfig_external_notification_tag,
meshtastic_ModuleConfig_store_forward_tag,
meshtastic_ModuleConfig_range_test_tag,
meshtastic_ModuleConfig_telemetry_tag,
meshtastic_ModuleConfig_canned_message_tag,
meshtastic_ModuleConfig_audio_tag,
meshtastic_ModuleConfig_remote_hardware_tag,
meshtastic_ModuleConfig_neighbor_info_tag,
meshtastic_ModuleConfig_ambient_lighting_tag,
meshtastic_ModuleConfig_detection_sensor_tag,
meshtastic_ModuleConfig_paxcounter_tag,
};
static const size_t NUM_SAFE_MODULE_CONFIG_TAGS = sizeof(SAFE_MODULE_CONFIG_TAGS) / sizeof(SAFE_MODULE_CONFIG_TAGS[0]);
// A node number spanning the shared edge pool (0/1/broadcast + self) or broad random.
static NodeNum fuzzNodeNum()
{
return (rngRange(4) == 0) ? rngNext() : rngEdgeNodeNum(&LOCAL_NODE, 1);
}
// Randomize a ChannelSettings name + PSK (shared by the set_channel case and fuzzBeacon). Name is
// random-length but NUL-terminated (nanopb terminates decoded strings, so un-terminated isn't
// wire-reachable); PSK is 0..32 bytes including empty.
static void fuzzChannelSettings(meshtastic_ChannelSettings &s)
{
size_t nameLen = rngRange(sizeof(s.name));
for (size_t i = 0; i < sizeof(s.name); i++)
s.name[i] = (i < nameLen) ? (char)(1 + rngRange(255)) : '\0';
s.psk.size = rngRange(sizeof(s.psk.bytes) + 1);
rngFill(s.psk.bytes, s.psk.size);
}
// Build a wire-plausible-but-adversarial AdminMessage: size-bearing fields (pubkey, psk) stay within
// capacity (as nanopb would enforce on decode) so we find real logic bugs, not fabricated OOB that
// can't arrive over the air.
static meshtastic_AdminMessage fuzzAdminMessage()
{
meshtastic_AdminMessage r = meshtastic_AdminMessage_init_zero;
r.which_payload_variant = SAFE_ADMIN_TAGS[rngRange(NUM_SAFE_ADMIN_TAGS)];
switch (r.which_payload_variant) {
case meshtastic_AdminMessage_set_config_tag:
// LoRa only (position variant would deref global gps). use_preset fuzzed both ways, incl. the
// manual bandwidth==0 path that used to SIGFPE the validator.
r.set_config.which_payload_variant = meshtastic_Config_lora_tag;
r.set_config.payload_variant.lora.region = (meshtastic_Config_LoRaConfig_RegionCode)rngRange(32);
r.set_config.payload_variant.lora.modem_preset = (meshtastic_Config_LoRaConfig_ModemPreset)rngRange(16);
r.set_config.payload_variant.lora.use_preset = (rngRange(2) == 0);
r.set_config.payload_variant.lora.bandwidth = rngRange(512); // includes 0
r.set_config.payload_variant.lora.channel_num = rngNext();
break;
case meshtastic_AdminMessage_set_module_config_tag:
// Non-beacon variant (beacon derefs global meshBeaconBroadcastModule).
r.set_module_config.which_payload_variant = SAFE_MODULE_CONFIG_TAGS[rngRange(NUM_SAFE_MODULE_CONFIG_TAGS)];
break;
case meshtastic_AdminMessage_set_channel_tag: {
// Fuzz role + PSK; an empty-PSK SECONDARY at the primary slot used to recurse forever in
// Channels::getKey.
meshtastic_Channel &c = r.set_channel;
c.index = (int32_t)rngRange(16); // includes out-of-range (getByIndex bounds-checks)
c.has_settings = true;
c.role = (meshtastic_Channel_Role)rngRange(4);
fuzzChannelSettings(c.settings);
break;
}
case meshtastic_AdminMessage_add_contact_tag:
r.add_contact.node_num = fuzzNodeNum();
r.add_contact.has_user = true;
r.add_contact.user = fuzzUser();
r.add_contact.should_ignore = (rngRange(2) == 0);
r.add_contact.manually_verified = (rngRange(2) == 0);
break;
case meshtastic_AdminMessage_remove_fixed_position_tag:
r.remove_fixed_position = (rngRange(2) == 0);
break;
case meshtastic_AdminMessage_set_time_only_tag:
r.set_time_only = rngNext();
break;
default:
// The remaining safe tags all share a uint32 nodenum union member (set/remove_favorite,
// set/remove_ignored, toggle_muted, remove_by_nodenum).
r.set_favorite_node = fuzzNodeNum();
break;
}
return r;
}
void test_E5_admin_dispatch_fuzz(void)
{
printf(" seed=0x%llx\n", (unsigned long long)(BASE_SEED ^ 0xE5));
rngSeed(BASE_SEED ^ 0xE5);
static AdminModuleTestShim admin; // static: MeshModule/OSThread lifetime, see the note in test_E2
// Seed the local node plus a couple of others for the node-list ops to target.
mockNodeDB->addNode(LOCAL_NODE);
mockNodeDB->addNode(REMOTE_NODE);
mockNodeDB->addNode(0x0C0C0C0C);
for (unsigned k = 0; k < 6000; k++) {
admin.deferSaves(); // re-assert each iteration: no disk / reboot regardless of the tag
meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero;
mp.from = 0; // local BLE/USB/TCP client - bypasses the remote auth gates, reaches the switch
mp.to = LOCAL_NODE;
mp.id = rngNext();
mp.channel = (uint8_t)rngRange(8);
mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
mp.decoded.portnum = meshtastic_PortNum_ADMIN_APP;
meshtastic_AdminMessage r = fuzzAdminMessage();
// Contract: never crashes / no OOB regardless of the AdminMessage contents.
(void)admin.handleReceivedProtobuf(mp, &r);
admin.drainReply();
}
// Reaching here = no crash across all iterations. The node-list ops (set_ignored/add_contact) create
// nodes, so the DB may fill and evict our seed nodes - that's legitimate; the invariant is only that
// the count never overran the cap.
TEST_ASSERT_TRUE_MESSAGE(mockNodeDB->getNumMeshNodes() <= (size_t)MAX_NUM_NODES, "NodeDB overran MAX_NUM_NODES");
}
#if !MESHTASTIC_EXCLUDE_BEACON
// ===========================================================================
// Group E6 - MeshBeaconListenerModule handler over adversarial MeshBeacon
// ===========================================================================
class MeshBeaconListenerModuleTestShim : public MeshBeaconListenerModule
{
public:
using MeshBeaconListenerModule::handleReceivedProtobuf;
};
static meshtastic_MeshBeacon fuzzBeacon()
{
meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero;
if (rngRange(2)) {
// Un-terminated: every byte non-NUL, so strnlen(message, sizeof-1) must run to its bound.
for (size_t i = 0; i < sizeof(b.message); i++)
b.message[i] = (char)(1 + rngRange(255));
} else {
// NUL-terminated random-length prefix.
size_t n = rngRange(sizeof(b.message));
for (size_t i = 0; i < sizeof(b.message); i++)
b.message[i] = (i < n) ? (char)(1 + rngRange(255)) : '\0';
}
b.has_offer_channel = (rngRange(2) == 0);
if (b.has_offer_channel)
fuzzChannelSettings(b.offer_channel);
b.offer_region = (meshtastic_Config_LoRaConfig_RegionCode)rngRange(32);
b.has_offer_preset = (rngRange(2) == 0);
b.offer_preset = (meshtastic_Config_LoRaConfig_ModemPreset)rngRange(16);
return b;
}
void test_E6_beacon_listener_fuzz(void)
{
printf(" seed=0x%llx\n", (unsigned long long)(BASE_SEED ^ 0xE6));
rngSeed(BASE_SEED ^ 0xE6);
static MeshBeaconListenerModuleTestShim beacon; // static: MeshModule lifetime, see the note in test_E2
for (unsigned k = 0; k < 6000; k++) {
meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero;
mp.from = fuzzNodeNum();
mp.to = NODENUM_BROADCAST;
mp.id = rngNext();
mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
mp.decoded.portnum = meshtastic_PortNum_MESH_BEACON_APP;
meshtastic_MeshBeacon b = fuzzBeacon();
bool hasOffer =
b.has_offer_channel || b.offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET || b.has_offer_preset;
// The listener must never consume the packet (it flows on to the phone)...
TEST_ASSERT_FALSE(beacon.handleReceivedProtobuf(mp, &b));
// ...and any offer content must land in the client-visible cache, keyed to the sender.
if (hasOffer) {
TEST_ASSERT_TRUE(MeshBeaconListenerModule::lastReceivedOffer.valid);
TEST_ASSERT_EQUAL_UINT32(mp.from, MeshBeaconListenerModule::lastReceivedOffer.sender);
}
}
}
#endif // !MESHTASTIC_EXCLUDE_BEACON
// ===========================================================================
// Group E7 - NodeDB::updateUser / updateFrom over adversarial nodeId + payloads
// ===========================================================================
// E2 fuzzes the NodeInfoModule handler; this drives the NodeDB mutators directly with adversarial
// node numbers (0 / self / broadcast / arbitrary) - the path that stores untrusted identity/telemetry.
void test_E7_nodedb_update_fuzz(void)
{
printf(" seed=0x%llx\n", (unsigned long long)(BASE_SEED ^ 0xE7));
rngSeed(BASE_SEED ^ 0xE7);
for (unsigned k = 0; k < 6000; k++) {
if (rngRange(2)) {
// updateUser: adversarial User (un-terminated strings, oversized-but-bounded pubkey) under
// an arbitrary node id and channel index.
meshtastic_User u = fuzzUser();
NodeNum id = fuzzNodeNum();
uint8_t ch = (uint8_t)rngRange(16);
(void)nodeDB->updateUser(id, u, ch);
} else {
// updateFrom: a decoded packet keyed on an arbitrary `from`, exercising the SNR/RSSI/hops
// ingestion with a random (often un-decodable) inner payload.
meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero;
mp.from = fuzzNodeNum();
mp.to = (rngRange(2)) ? LOCAL_NODE : NODENUM_BROADCAST;
mp.id = rngNext();
mp.rx_snr = (float)((int)rngRange(60) - 30);
mp.rx_rssi = (int32_t)rngRange(256) - 128;
mp.hop_start = (uint8_t)rngRange(8);
mp.hop_limit = (uint8_t)rngRange(8);
mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
mp.decoded.portnum = (meshtastic_PortNum)rngRange(80);
mp.decoded.payload.size = rngRange(sizeof(mp.decoded.payload.bytes) + 1);
rngFill(mp.decoded.payload.bytes, mp.decoded.payload.size);
nodeDB->updateFrom(mp);
}
}
// DB stayed internally consistent (count never ran past capacity).
TEST_ASSERT_TRUE_MESSAGE(mockNodeDB->getNumMeshNodes() <= (size_t)MAX_NUM_NODES, "NodeDB overran MAX_NUM_NODES");
}
// ===========================================================================
// Groups E8-E10 - remaining ProtobufModule handlers driven directly at
// handleReceivedProtobuf. We bypass the ProtobufModule base's allocReply/send
// machinery (calling the handler, not handleReceived), so no router is needed;
// the fixture's nodeDB/service/channels plus the auto-initialized nodeStatus/
// powerStatus globals (defined in main.cpp) are enough. Each module derives from
// OSThread, so each shim is a function-local static (ThreadController lifetime -
// see the note in test_E2). Contract: no crash / no ASan finding on any input.
// ===========================================================================
class PositionModuleTestShim : public PositionModule
{
public:
using PositionModule::handleReceivedProtobuf;
};
class DeviceTelemetryModuleTestShim : public DeviceTelemetryModule
{
public:
using DeviceTelemetryModule::handleReceivedProtobuf;
};
class NeighborInfoModuleTestShim : public NeighborInfoModule
{
public:
using NeighborInfoModule::handleReceivedProtobuf;
};
// Craft an RX MeshPacket header for a decoded-payload handler: adversarial from/to/id/hops, but a
// channel index kept in range (the router resolves and validates mp.channel before dispatch, so an
// out-of-range index can't reach a handler over the air - fuzzing it would test channels.getByIndex,
// not the handler).
static void fuzzRxHeader(meshtastic_MeshPacket &mp, meshtastic_PortNum portnum)
{
mp.from = fuzzNodeNum();
mp.to = (rngRange(2)) ? LOCAL_NODE : NODENUM_BROADCAST;
mp.id = rngNext();
mp.channel = (uint8_t)rngRange(channels.getNumChannels());
mp.rx_snr = (float)((int)rngRange(40) - 20);
mp.rx_rssi = -(int)rngRange(130);
mp.hop_start = (uint8_t)rngRange(8); // 0..7, wire-bounded
mp.hop_limit = (uint8_t)rngRange(8);
mp.want_ack = (rngRange(2) == 0);
mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
mp.decoded.portnum = portnum;
}
void test_E8_position_handler_fuzz(void)
{
printf(" seed=0x%llx\n", (unsigned long long)(BASE_SEED ^ 0xE8));
rngSeed(BASE_SEED ^ 0xE8);
static PositionModuleTestShim shim;
for (unsigned k = 0; k < 6000; k++) {
meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero;
fuzzRxHeader(mp, meshtastic_PortNum_POSITION_APP);
// Occasionally claim to be from us (drives the fixed_position / setLocalPosition self-update path).
if (rngRange(4) == 0)
mp.from = LOCAL_NODE;
config.position.fixed_position = (rngRange(2) == 0);
meshtastic_Position p = meshtastic_Position_init_zero;
p.latitude_i = (int32_t)rngNext();
p.longitude_i = (int32_t)rngNext();
p.altitude = (int32_t)rngNext();
p.altitude_hae = (int32_t)rngNext();
p.PDOP = rngNext();
p.sats_in_view = rngNext();
p.precision_bits = rngRange(40); // includes >32 (the default-precision fallback)
p.time = (rngRange(2) == 0) ? 0 : rngNext();
p.timestamp = rngNext();
(void)shim.handleReceivedProtobuf(mp, &p);
}
TEST_ASSERT_TRUE_MESSAGE(mockNodeDB->getNumMeshNodes() <= (size_t)MAX_NUM_NODES, "NodeDB overran MAX_NUM_NODES");
}
void test_E9_telemetry_handler_fuzz(void)
{
printf(" seed=0x%llx\n", (unsigned long long)(BASE_SEED ^ 0xE9));
rngSeed(BASE_SEED ^ 0xE9);
static DeviceTelemetryModuleTestShim shim;
for (unsigned k = 0; k < 6000; k++) {
meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero;
fuzzRxHeader(mp, meshtastic_PortNum_TELEMETRY_APP);
meshtastic_Telemetry t = meshtastic_Telemetry_init_zero;
t.time = rngNext();
// Cycle across the telemetry variants; only device_metrics is consumed here, the rest must be
// ignored without touching uninitialized union members.
switch (rngRange(5)) {
case 0:
t.which_variant = meshtastic_Telemetry_device_metrics_tag;
t.variant.device_metrics.has_battery_level = (rngRange(2) == 0);
t.variant.device_metrics.battery_level = rngNext();
t.variant.device_metrics.voltage = (float)((int)rngNext());
t.variant.device_metrics.channel_utilization = (float)((int)rngNext());
t.variant.device_metrics.air_util_tx = (float)((int)rngNext());
t.variant.device_metrics.uptime_seconds = rngNext();
break;
case 1:
t.which_variant = meshtastic_Telemetry_environment_metrics_tag;
t.variant.environment_metrics.temperature = (float)((int)rngNext());
t.variant.environment_metrics.relative_humidity = (float)((int)rngNext());
break;
case 2:
t.which_variant = meshtastic_Telemetry_air_quality_metrics_tag;
t.variant.air_quality_metrics.pm10_standard = rngNext();
break;
case 3:
t.which_variant = meshtastic_Telemetry_power_metrics_tag;
t.variant.power_metrics.ch1_voltage = (float)((int)rngNext());
break;
default:
t.which_variant = 0; // no variant set
break;
}
(void)shim.handleReceivedProtobuf(mp, &t);
}
TEST_ASSERT_TRUE_MESSAGE(mockNodeDB->getNumMeshNodes() <= (size_t)MAX_NUM_NODES, "NodeDB overran MAX_NUM_NODES");
}
void test_E10_neighborinfo_handler_fuzz(void)
{
printf(" seed=0x%llx\n", (unsigned long long)(BASE_SEED ^ 0xEA));
rngSeed(BASE_SEED ^ 0xEA);
static const pb_size_t NB_MAX =
sizeof(((meshtastic_NeighborInfo *)0)->neighbors) / sizeof(((meshtastic_NeighborInfo *)0)->neighbors[0]); // 10
static NeighborInfoModuleTestShim shim;
for (unsigned k = 0; k < 6000; k++) {
meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero;
fuzzRxHeader(mp, meshtastic_PortNum_NEIGHBORINFO_APP);
meshtastic_NeighborInfo nb = meshtastic_NeighborInfo_init_zero;
nb.node_id = fuzzNodeNum();
nb.last_sent_by_id = fuzzNodeNum();
nb.node_broadcast_interval_secs = rngNext();
nb.neighbors_count = (pb_size_t)rngRange(NB_MAX + 1); // clamp as nanopb would on decode
for (pb_size_t i = 0; i < nb.neighbors_count; i++) {
nb.neighbors[i].node_id = fuzzNodeNum();
nb.neighbors[i].snr = (float)((int)rngRange(40) - 20);
nb.neighbors[i].last_rx_time = rngNext();
nb.neighbors[i].node_broadcast_interval_secs = rngNext();
}
(void)shim.handleReceivedProtobuf(mp, &nb);
}
TEST_ASSERT_TRUE_MESSAGE(mockNodeDB->getNumMeshNodes() <= (size_t)MAX_NUM_NODES, "NodeDB overran MAX_NUM_NODES");
}
void setup() void setup()
{ {
initializeTestEnvironment(); initializeTestEnvironment();
@@ -385,6 +795,26 @@ void setup()
printf("\n=== Group E4: phone-forward gate ===\n"); printf("\n=== Group E4: phone-forward gate ===\n");
RUN_TEST(test_E4_phone_forward_gate); RUN_TEST(test_E4_phone_forward_gate);
printf("\n=== Group E5: Admin dispatch fuzz ===\n");
RUN_TEST(test_E5_admin_dispatch_fuzz);
#if !MESHTASTIC_EXCLUDE_BEACON
printf("\n=== Group E6: Beacon listener fuzz ===\n");
RUN_TEST(test_E6_beacon_listener_fuzz);
#endif
printf("\n=== Group E7: NodeDB update fuzz ===\n");
RUN_TEST(test_E7_nodedb_update_fuzz);
printf("\n=== Group E8: Position handler fuzz ===\n");
RUN_TEST(test_E8_position_handler_fuzz);
printf("\n=== Group E9: Telemetry handler fuzz ===\n");
RUN_TEST(test_E9_telemetry_handler_fuzz);
printf("\n=== Group E10: NeighborInfo handler fuzz ===\n");
RUN_TEST(test_E10_neighborinfo_handler_fuzz);
exit(UNITY_END()); exit(UNITY_END());
} }
+58
View File
@@ -106,6 +106,10 @@ static uint32_t makeDistributedNodeId(uint32_t baseId, uint32_t ordinal, uint32_
return baseId + salt + (ordinal * 33u); return baseId + salt + (ordinal * 33u);
} }
// Deterministic RNG (rngSeed/rngNext/rngRange) - shared seeded LCG.
#include "support/DeterministicRng.h"
static constexpr uint64_t FUZZ_SEED = 0x00F0B5CA1EULL;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers - mesh topology builders // Helpers - mesh topology builders
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -661,6 +665,57 @@ static void test_memory_layout()
// Unity setup / teardown / main // Unity setup / teardown / main
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Fuzz - crafted-nodenum blitz of the hop histogram
// ---------------------------------------------------------------------------
// The scenario tests above check the scaling *math* with realistic topologies. This one is adversarial:
// it floods samplePacketForHistogram with node numbers chosen to maximize table churn (a tiny colliding
// pool, boundary values, broad random) plus denominator swings and hourly rolls, and asserts the fixed
// 128-entry table's invariants hold on every step. hopCount is kept in 0..MAX_HOP: the wire caps hops at
// 7 (3-bit protobuf field), so out-of-range hops are not a reachable input. Runs under ASan/LSan.
void test_fuzz_nodenum_blitz(void)
{
TEST_MSG_FMT("=== Fuzz: crafted-nodenum histogram blitz (seed=0x%llx) ===", (unsigned long long)FUZZ_SEED);
rngSeed(FUZZ_SEED);
static HopScalingTestShim shim; // static: OSThread-derived (Unity longjmp skips dtors on a failed assert)
shim.clear();
shim.setHistogramDenominator(HopScalingModule::DENOM_MIN); // sample-all: maximum admission / churn
for (unsigned k = 0; k < 40000; k++) {
NodeNum id;
switch (rngRange(4)) {
case 0:
id = rngEdgeNodeNum(&kLocalNode, 1);
break; // shared boundary pool (0/1/broadcast) + local node
case 1:
id = rngNext() & 0xFFu;
break; // tiny pool: forces hash reuse (update path) and collisions
default:
id = ((NodeNum)rngNext() << 1) ^ rngNext();
break; // broad random
}
uint8_t hop = (uint8_t)rngRange(HopScalingModule::MAX_HOP + 1); // 0..7, wire-bounded
shim.samplePacketForHistogram(id, hop);
// Occasionally swing the sampling denominator and roll the hour to drive trim / eviction /
// the hourly rolling + scaling under sustained churn. Denominators stay powers of two (1..128) -
// the only states the module actually produces (its passesFilter uses hash & (denom-1)).
if (rngRange(256) == 0)
shim.setHistogramDenominator((uint8_t)(1u << rngRange(8))); // 1, 2, 4, ... 128
if (rngRange(512) == 0) {
mockTime += ONE_HOUR_MS;
shim.rollHourTest();
}
// Invariants that must hold on every step regardless of input.
TEST_ASSERT_TRUE_MESSAGE(shim.getEntryCount() <= HopScalingModule::CAPACITY, "histogram overran CAPACITY");
TEST_ASSERT_TRUE_MESSAGE(shim.getFillPercentage() <= 100, "fill percentage exceeded 100");
TEST_ASSERT_TRUE_MESSAGE(shim.getLastRequiredHop() <= HOP_MAX, "required-hop recommendation exceeded HOP_MAX");
}
}
void setUp(void) void setUp(void)
{ {
if (!mockNodeDB) if (!mockNodeDB)
@@ -712,6 +767,9 @@ void setup()
RUN_TEST(test_filtering_denom_steps_down_gradually); RUN_TEST(test_filtering_denom_steps_down_gradually);
RUN_TEST(test_full_at_denom_max_drops_entry); RUN_TEST(test_full_at_denom_max_drops_entry);
printf("\n=== Fuzz ===\n");
RUN_TEST(test_fuzz_nodenum_blitz);
printf("\n=== Summary ===\n"); printf("\n=== Summary ===\n");
RUN_TEST(test_memory_layout); RUN_TEST(test_memory_layout);
RUN_TEST(test_scenario_summary_output); RUN_TEST(test_scenario_summary_output);
+5 -18
View File
@@ -24,6 +24,8 @@
#include "airtime.h" #include "airtime.h"
#include "modules/AdminModule.h" #include "modules/AdminModule.h"
#include "modules/MeshBeaconModule.h" #include "modules/MeshBeaconModule.h"
#include "support/AdminModuleTestShim.h"
#include "support/MockMeshService.h"
#include <cstdio> #include <cstdio>
#include <cstring> #include <cstring>
#include <memory> #include <memory>
@@ -50,16 +52,8 @@ namespace
constexpr NodeNum kLocalNode = 0xAAAA0001; constexpr NodeNum kLocalNode = 0xAAAA0001;
constexpr NodeNum kRemoteNode = 0xBBBB0002; constexpr NodeNum kRemoteNode = 0xBBBB0002;
// --------------------------------------------------------------------------- // MockMeshService (test/support) stubs the side-effecting virtuals; handleToRadio is non-virtual so
// Minimal MockMeshService - stubs out side-effecting virtuals. // it runs the real implementation - the router->sendLocal path is guarded by MockRouter below.
// handleToRadio is non-virtual so it runs the real implementation; we guard
// against the router->sendLocal path by setting a MockRouter below.
// ---------------------------------------------------------------------------
class MockMeshService : public MeshService
{
public:
void sendClientNotification(meshtastic_ClientNotification *n) override { releaseClientNotificationToPool(n); }
};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// MockRouter: captures every packet handed to send() instead of transmitting. // MockRouter: captures every packet handed to send() instead of transmitting.
@@ -93,14 +87,7 @@ class MockRouter : public Router
std::vector<meshtastic_ChannelSettings> primaryAtSend; std::vector<meshtastic_ChannelSettings> primaryAtSend;
}; };
// --------------------------------------------------------------------------- // AdminModuleTestShim (test/support) exposes protected handleSetModuleConfig.
// AdminModuleTestShim - exposes protected handleSetModuleConfig.
// ---------------------------------------------------------------------------
class AdminModuleTestShim : public AdminModule
{
public:
using AdminModule::handleSetModuleConfig;
};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// MeshBeaconBroadcastModuleTestShim - exposes private internals for testing. // MeshBeaconBroadcastModuleTestShim - exposes private internals for testing.
+69
View File
@@ -12,6 +12,8 @@
#include "mqtt/MQTT.h" #include "mqtt/MQTT.h"
#include "mqtt/ServiceEnvelope.h" #include "mqtt/ServiceEnvelope.h"
#include "support/DeterministicRng.h" // rngSeed/rngNext/rngByte/rngRange - shared seeded LCG (fuzz group)
#include <PubSubClient.h> #include <PubSubClient.h>
#include <WiFiClient.h> #include <WiFiClient.h>
@@ -275,6 +277,13 @@ class MQTTUnitTest : public MQTT
size_t numBytes = pb_encode_to_bytes(bytes, sizeof(bytes), &meshtastic_ServiceEnvelope_msg, &env); size_t numBytes = pb_encode_to_bytes(bytes, sizeof(bytes), &meshtastic_ServiceEnvelope_msg, &env);
mqttCallback(const_cast<char *>(topic.str().c_str()), bytes, numBytes); mqttCallback(const_cast<char *>(topic.str().c_str()), bytes, numBytes);
} }
// Feed arbitrary bytes straight into the subscription callback - the non-RF ingress a malicious or
// broken broker could push. Mirrors publish()'s final mqttCallback() call but with an unconstrained
// payload, so it exercises DecodedServiceEnvelope decode + onReceiveProto with garbage.
void deliverRaw(const std::string &topic, const uint8_t *bytes, size_t n)
{
mqttCallback(const_cast<char *>(topic.c_str()), const_cast<uint8_t *>(bytes), (unsigned int)n);
}
static void restart() static void restart()
{ {
if (mqtt != NULL) { if (mqtt != NULL) {
@@ -963,6 +972,65 @@ void test_configWithTLSEnabled(void)
#endif #endif
} }
// ===========================================================================
// Fuzz - adversarial MQTT downlink ingress (the non-RF path a broker can push)
// ===========================================================================
// Blitzes the onReceiveProto() chain with (a) raw garbage that must fail envelope decode cleanly and
// (b) well-formed envelopes wrapping crafted inner packets. Contract: no crash, at most one enqueue per envelope.
constexpr uint64_t MQTT_FUZZ_SEED = 0x00E3A71C0FULL;
void test_receiveFuzzServiceEnvelope(void)
{
printf(" seed=0x%llx\n", (unsigned long long)MQTT_FUZZ_SEED);
rngSeed(MQTT_FUZZ_SEED);
const char *channelIds[] = {"test", "PKI", "nope", ""};
const char *gatewayIds[] = {"!12345678", "!87654321", "!00000000"}; // [0] == our node id -> self path
for (unsigned k = 0; k < 4000; k++) {
if (rngRange(3) == 0) {
// (a) Raw bytes: mostly random, must be rejected at DecodedServiceEnvelope without crashing.
uint8_t raw[128];
size_t n = rngRange(sizeof(raw) + 1);
rngFill(raw, n);
unitTest->deliverRaw("msh/2/e/test/!87654321", raw, n);
} else {
// (b) Well-formed envelope around a crafted inner packet.
meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
p.from = (rngRange(3) == 0) ? myNodeInfo.my_node_num : rngNext(); // self origin hits isFromUs
p.to = (rngRange(2)) ? myNodeInfo.my_node_num : rngNext();
p.id = rngNext();
p.channel = (uint8_t)rngByte();
p.hop_limit = (uint8_t)rngRange(10); // includes > HOP_MAX (the invalid-hop reject path)
p.hop_start = (uint8_t)rngRange(10);
p.want_ack = (rngRange(2) == 0);
p.pki_encrypted = (rngRange(2) == 0);
if (rngRange(2) == 0) {
p.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
p.decoded.portnum = (rngRange(6) == 0) ? meshtastic_PortNum_ADMIN_APP : (meshtastic_PortNum)rngRange(80);
p.decoded.want_response = (rngRange(2) == 0);
p.decoded.has_bitfield = (rngRange(2) == 0);
p.decoded.bitfield = (uint32_t)rngNext();
p.decoded.payload.size = rngRange(64);
rngFill(p.decoded.payload.bytes, p.decoded.payload.size);
} else {
p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
p.encrypted.size = rngRange(64);
rngFill(p.encrypted.bytes, p.encrypted.size);
}
unitTest->publish(&p, gatewayIds[rngRange(3)], channelIds[rngRange(4)]);
}
// A single envelope reaches at most one enqueueReceivedMessage; more would be a routing bug.
TEST_ASSERT_TRUE_MESSAGE(mockRouter->packets_.size() <= 1, "MQTT downlink enqueued >1 packet per envelope");
// Drain capture lists so 4000 iterations stay bounded (values already released to their pools).
mockRouter->packets_.clear();
mockRoutingModule->ackNacks_.clear();
mockMeshService->messages_.clear();
mockMeshService->notifications_.clear();
}
}
void setup() void setup()
{ {
initializeTestEnvironment(); initializeTestEnvironment();
@@ -998,6 +1066,7 @@ void setup()
#endif #endif
RUN_TEST(test_receiveIgnoresUnexpectedFields); RUN_TEST(test_receiveIgnoresUnexpectedFields);
RUN_TEST(test_receiveIgnoresInvalidHopLimit); RUN_TEST(test_receiveIgnoresInvalidHopLimit);
RUN_TEST(test_receiveFuzzServiceEnvelope);
RUN_TEST(test_publishTextMessageDirect); RUN_TEST(test_publishTextMessageDirect);
RUN_TEST(test_publishTextMessageWithProxy); RUN_TEST(test_publishTextMessageWithProxy);
RUN_TEST(test_reportToMapDefaultImprecise); RUN_TEST(test_reportToMapDefaultImprecise);
+1 -6
View File
@@ -5,12 +5,7 @@
#include <unity.h> #include <unity.h>
#include "meshtastic/config.pb.h" #include "meshtastic/config.pb.h"
#include "support/MockMeshService.h"
class MockMeshService : public MeshService
{
public:
void sendClientNotification(meshtastic_ClientNotification *n) override { releaseClientNotificationToPool(n); }
};
static MockMeshService *mockMeshService; static MockMeshService *mockMeshService;
@@ -18,6 +18,7 @@
#include "mesh/NodeDB.h" #include "mesh/NodeDB.h"
#include "mesh/Router.h" #include "mesh/Router.h"
#include "modules/TrafficManagementModule.h" #include "modules/TrafficManagementModule.h"
#include "support/DeterministicRng.h" // rngSeed/rngNext/rngRange - shared seeded LCG
#include <climits> #include <climits>
#include <cstring> #include <cstring>
#include <memory> #include <memory>
@@ -1600,6 +1601,92 @@ static void test_tm_lostAndFoundRole_getsAlterReceivedPrecisionClamp(void)
// channels always have a public PSK so getPositionPrecisionForChannel caps at 15. // channels always have a public PSK so getPositionPrecisionForChannel caps at 15.
TEST_ASSERT_EQUAL_UINT32(13, out.precision_bits); TEST_ASSERT_EQUAL_UINT32(13, out.precision_bits);
} }
// ---------------------------------------------------------------------------
// Fuzz - crafted-nodenum blitz of the unified cache
// ---------------------------------------------------------------------------
// Floods handleReceived/alterReceived with crafted packets over a tiny node pool so the fixed-size
// cache churns hard while the virtual clock sweeps the rate/unknown/position windows; no crash, counters bounded.
static constexpr uint64_t FUZZ_SEED = 0x00D07E5701ULL;
static void test_tm_fuzz_nodenum_blitz(void)
{
printf(" seed=0x%llx\n", (unsigned long long)FUZZ_SEED);
rngSeed(FUZZ_SEED);
// Activate the cache-tracked windows (the crafted-nodenum target). The nodeinfo direct-response
// path (nodeinfo_direct_response_max_hops) is left OFF: it calls service->sendToMesh, and driving
// that at fuzz volume needs a fully-wired MeshService/phone queue this fixture doesn't provide - the
// deterministic test_tm_nodeinfo_directResponse_* tests cover it with single packets instead.
moduleConfig.traffic_management.rate_limit_window_secs = 60;
moduleConfig.traffic_management.rate_limit_max_packets = 3;
moduleConfig.traffic_management.unknown_packet_threshold = 4;
moduleConfig.traffic_management.position_min_interval_secs = 300;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
installWellKnownPrimaryChannel();
static TrafficManagementModuleTestShim module; // static: OSThread-derived (see note in test_fuzz_packets E2)
// Shared boundary pool (0/1/broadcast) plus this suite's well-known nodes.
const NodeNum wellKnown[] = {kLocalNode, kRemoteNode, kTargetNode};
const size_t wellKnownN = sizeof(wellKnown) / sizeof(wellKnown[0]);
const meshtastic_PortNum ports[] = {
meshtastic_PortNum_TEXT_MESSAGE_APP, meshtastic_PortNum_NODEINFO_APP, meshtastic_PortNum_POSITION_APP,
meshtastic_PortNum_ROUTING_APP, meshtastic_PortNum_ADMIN_APP, meshtastic_PortNum_TELEMETRY_APP,
};
const size_t portsN = sizeof(ports) / sizeof(ports[0]);
const unsigned ITERS = 30000;
for (unsigned k = 0; k < ITERS; k++) {
meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
p.from = (rngRange(8) == 0) ? (NodeNum)rngNext() : rngEdgeNodeNum(wellKnown, wellKnownN);
p.to = rngEdgeNodeNum(wellKnown, wellKnownN);
p.id = rngNext();
p.channel = 0;
p.hop_start = (uint8_t)rngRange(8); // 0..7, wire-bounded
p.hop_limit = (uint8_t)rngRange(8);
p.decoded.want_response = (rngRange(2) == 0);
if (rngRange(5) == 0) {
// Undecoded / unknown packet - exercises the unknown-packet threshold path.
p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
p.encrypted.size = rngRange(sizeof(p.encrypted.bytes) + 1);
rngFill(p.encrypted.bytes, p.encrypted.size);
} else {
p.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
p.decoded.portnum = (rngRange(8) == 0) ? (meshtastic_PortNum)rngRange(80) : ports[rngRange(portsN)];
p.decoded.has_bitfield = true;
p.decoded.bitfield = (uint32_t)rngNext();
if (p.decoded.portnum == meshtastic_PortNum_POSITION_APP && rngRange(2)) {
meshtastic_Position pos = meshtastic_Position_init_zero;
pos.has_latitude_i = true;
pos.has_longitude_i = true;
pos.latitude_i = (int32_t)rngNext();
pos.longitude_i = (int32_t)rngNext();
pos.precision_bits = rngRange(40); // includes >32 (the default-precision fallback)
p.decoded.payload.size =
pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_Position_msg, &pos);
} else {
// Random payload bytes: TMM's nested User/Position decode must fail cleanly.
p.decoded.payload.size = rngRange(sizeof(p.decoded.payload.bytes) + 1);
rngFill(p.decoded.payload.bytes, p.decoded.payload.size);
}
}
(void)module.handleReceived(p);
if (rngRange(3) == 0)
module.alterReceived(p);
// Advance the virtual clock so rate / unknown / position windows open and close under churn.
if (rngRange(16) == 0)
TrafficManagementModule::s_testNowMs += (rngRange(120) + 1) * 1000u;
if (rngRange(1024) == 0)
(void)module.runOnce(); // maintenance sweep: cache aging / eviction
}
// The cache never inspected more packets than we fed, and the run reached here without an ASan fault.
TEST_ASSERT_TRUE_MESSAGE(module.getStats().packets_inspected <= ITERS, "packets_inspected overcounted");
}
} // namespace } // namespace
void setUp(void) void setUp(void)
@@ -1669,6 +1756,7 @@ TM_TEST_ENTRY void setup()
RUN_TEST(test_tm_lostAndFoundRole_getsAlterReceivedPrecisionClamp); RUN_TEST(test_tm_lostAndFoundRole_getsAlterReceivedPrecisionClamp);
RUN_TEST(test_tm_unknownRole_noDbEntry_appliesFullInterval); RUN_TEST(test_tm_unknownRole_noDbEntry_appliesFullInterval);
RUN_TEST(test_tm_unknownRole_noUserBit_appliesFullInterval); RUN_TEST(test_tm_unknownRole_noUserBit_appliesFullInterval);
RUN_TEST(test_tm_fuzz_nodenum_blitz);
exit(UNITY_END()); exit(UNITY_END());
} }