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:
co-authored by
GitHub
Claude Fable 5
parent
6c7ee8afc7
commit
d846780a9b
@@ -15,8 +15,19 @@ static inline int getStringWidth(OLEDDisplay *display, const char *text, size_t
|
||||
#if defined(OLED_UA) || defined(OLED_RU)
|
||||
return display->getStringWidth(text, len, true);
|
||||
#else
|
||||
(void)len;
|
||||
return display->getStringWidth(text);
|
||||
// OLEDDisplay::getStringWidth (utf8=false) indexes the font jump table by (c - firstChar) with a
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -31,6 +42,14 @@ size_t utf8CharLen(uint8_t c)
|
||||
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)
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
|
||||
const size_t charLen = utf8CharLen(static_cast<uint8_t>(line[i]));
|
||||
const size_t charLen = utf8CharLenClamped(line, i, lineLen);
|
||||
if (display)
|
||||
metrics.width += getUtf8ChunkWidth(display, line + i, charLen);
|
||||
i += charLen;
|
||||
@@ -257,7 +276,7 @@ static int appendTextSpanAndMeasure(OLEDDisplay *display, int cursorX, int fontY
|
||||
while (pos < len) {
|
||||
size_t chunkLen = 0;
|
||||
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))
|
||||
break;
|
||||
chunkLen += charLen;
|
||||
@@ -328,7 +347,7 @@ size_t truncateToWidth(OLEDDisplay *display, const char *line, char *out, size_t
|
||||
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);
|
||||
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)
|
||||
break;
|
||||
|
||||
next += utf8CharLen(static_cast<uint8_t>(line[next]));
|
||||
next += utf8CharLenClamped(line, next, lineLen);
|
||||
}
|
||||
|
||||
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);
|
||||
i = next;
|
||||
|
||||
+21
-2
@@ -239,7 +239,9 @@ CryptoKey Channels::getKey(ChannelIndex chIndex)
|
||||
memcpy(k.bytes, channelSettings.psk.bytes, channelSettings.psk.size);
|
||||
k.length = channelSettings.psk.size;
|
||||
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);
|
||||
k = getKey(primaryIndex);
|
||||
} else {
|
||||
@@ -309,11 +311,28 @@ void Channels::initDefaults()
|
||||
void Channels::onConfigChanged()
|
||||
{
|
||||
// Make sure the phone hasn't mucked anything up
|
||||
bool hasPrimary = false;
|
||||
for (int i = 0; i < channelFile.channels_count; i++) {
|
||||
const meshtastic_Channel &ch = fixupChannel(i);
|
||||
|
||||
if (ch.role == meshtastic_Channel_Role_PRIMARY)
|
||||
if (ch.role == meshtastic_Channel_Role_PRIMARY) {
|
||||
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 (channels.anyMqttEnabled() && mqtt && !mqtt->isEnabled()) {
|
||||
|
||||
@@ -1110,7 +1110,8 @@ bool RadioInterface::checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraCo
|
||||
}
|
||||
}
|
||||
} 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:
|
||||
@@ -1142,8 +1143,9 @@ bool RadioInterface::checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraCo
|
||||
const char *channelName = channels.getName(channels.getPrimaryIndex());
|
||||
const char *presetNameDisplay =
|
||||
DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset);
|
||||
uint32_t channelNameHashSlot = hash(channelName) % numFreqSlots;
|
||||
uint32_t presetNameHashSlot = hash(presetNameDisplay) % numFreqSlots;
|
||||
// numFreqSlots can still be 0 for an UNSET/degenerate region, and % 0 is a SIGFPE
|
||||
uint32_t channelNameHashSlot = numFreqSlots ? (hash(channelName) % numFreqSlots) : 0;
|
||||
uint32_t presetNameHashSlot = numFreqSlots ? (hash(presetNameDisplay) % numFreqSlots) : 0;
|
||||
|
||||
if (loraConfig.override_frequency == 0) {
|
||||
|
||||
@@ -1245,7 +1247,8 @@ void RadioInterface::applyModemConfig()
|
||||
newRegion->name);
|
||||
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;
|
||||
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
|
||||
// (numFreqSlots - 1).
|
||||
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 =
|
||||
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
|
||||
if (loraConfig.override_frequency) {
|
||||
|
||||
@@ -22,7 +22,7 @@ struct 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:
|
||||
/** Constructor
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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); }
|
||||
};
|
||||
@@ -21,16 +21,12 @@
|
||||
#include <unity.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
|
||||
extern uint32_t hash(const char *str);
|
||||
|
||||
class MockMeshService : public MeshService
|
||||
{
|
||||
public:
|
||||
void sendClientNotification(meshtastic_ClientNotification *n) override { releaseClientNotificationToPool(n); }
|
||||
};
|
||||
|
||||
static MockMeshService *mockMeshService;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -929,15 +925,7 @@ static void test_channelSpacingCalculation_placeholder()
|
||||
// handleSetConfig fromOthers dispatch tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
class AdminModuleTestShim : public AdminModule
|
||||
{
|
||||
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; }
|
||||
};
|
||||
|
||||
// AdminModuleTestShim comes from test/support - the friend seam AdminModule.h declares.
|
||||
static AdminModuleTestShim *testAdmin;
|
||||
|
||||
static meshtastic_Config makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode region, bool usePreset,
|
||||
|
||||
@@ -22,7 +22,12 @@
|
||||
|
||||
#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"
|
||||
@@ -30,28 +35,8 @@
|
||||
#include <cstring>
|
||||
#include <pb_decode.h>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deterministic RNG - seeded 64-bit LCG (Knuth MMIX constants). No rand()/time so a failing
|
||||
// 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;
|
||||
}
|
||||
// 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)
|
||||
@@ -75,6 +60,13 @@ union AnyMsg {
|
||||
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 {
|
||||
@@ -94,6 +86,13 @@ static const FuzzType FUZZ_TYPES[] = {
|
||||
{"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]);
|
||||
|
||||
@@ -164,8 +163,7 @@ static void decodeFuzzPass(bool protoish, uint64_t seed)
|
||||
len = genProtoish(buf, sizeof(buf));
|
||||
} else {
|
||||
len = rngRange(sizeof(buf) + 1);
|
||||
for (size_t i = 0; i < len; i++)
|
||||
buf[i] = rngByte();
|
||||
rngFill(buf, len);
|
||||
}
|
||||
memset(&out, 0, sizeof(out));
|
||||
// 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++) {
|
||||
uint8_t buf[64];
|
||||
size_t maxLen = 1 + rngRange(sizeof(buf));
|
||||
for (size_t i = 0; i < maxLen; i++)
|
||||
buf[i] = rngByte();
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
@@ -12,6 +12,15 @@
|
||||
// Group E2 NodeInfoModule handler over arbitrary User structs
|
||||
// Group E3 TraceRoute route-processing over adversarial RouteDiscovery (route/snr count edges)
|
||||
// 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 "TestUtil.h"
|
||||
@@ -25,37 +34,28 @@
|
||||
#include "mesh/MeshService.h"
|
||||
#include "mesh/NodeDB.h"
|
||||
#include "mesh/Router.h"
|
||||
#include "modules/AdminModule.h"
|
||||
#include "modules/NeighborInfoModule.h"
|
||||
#include "modules/NodeInfoModule.h"
|
||||
#include "modules/PositionModule.h"
|
||||
#include "modules/Telemetry/DeviceTelemetry.h"
|
||||
#include "modules/TraceRouteModule.h"
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <pb_decode.h>
|
||||
#include <vector>
|
||||
|
||||
#if !MESHTASTIC_EXCLUDE_BEACON
|
||||
#include "modules/MeshBeaconModule.h"
|
||||
#endif
|
||||
|
||||
static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A;
|
||||
static constexpr NodeNum REMOTE_NODE = 0x0B0B0B0B;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deterministic RNG (seeded LCG)
|
||||
// ---------------------------------------------------------------------------
|
||||
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)
|
||||
{
|
||||
return n ? (rngNext() % n) : 0;
|
||||
}
|
||||
// Deterministic RNG (rngSeed/rngNext/rngByte/rngRange/rngFill/rngEdgeNodeNum) - shared seeded LCG.
|
||||
#include "support/AdminModuleTestShim.h"
|
||||
#include "support/DeterministicRng.h"
|
||||
#include "support/MockMeshService.h"
|
||||
static constexpr uint64_t BASE_SEED = 0x00BADF00DULL;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -96,9 +96,12 @@ class MockNodeDB : public NodeDB
|
||||
|
||||
static MockNodeDB *mockNodeDB = nullptr;
|
||||
|
||||
static MockMeshService *mockService = nullptr;
|
||||
|
||||
void setUp(void)
|
||||
{
|
||||
config = meshtastic_LocalConfig_init_zero;
|
||||
moduleConfig = meshtastic_LocalModuleConfig_init_zero;
|
||||
owner = meshtastic_User_init_zero;
|
||||
|
||||
mockNodeDB = new MockNodeDB();
|
||||
@@ -106,6 +109,9 @@ void setUp(void)
|
||||
nodeDB = mockNodeDB;
|
||||
myNodeInfo.my_node_num = LOCAL_NODE;
|
||||
|
||||
mockService = new MockMeshService();
|
||||
service = mockService;
|
||||
|
||||
channels.initDefaults();
|
||||
channels.onConfigChanged();
|
||||
}
|
||||
@@ -115,6 +121,10 @@ void tearDown(void)
|
||||
delete mockNodeDB;
|
||||
mockNodeDB = 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.pki_encrypted = (rngRange(2) == 0);
|
||||
|
||||
size_t n = rngRange(sizeof(p.encrypted.bytes) + 1); // 0..256, always in bounds
|
||||
for (size_t i = 0; i < n; i++)
|
||||
p.encrypted.bytes[i] = rngByte();
|
||||
p.encrypted.size = n;
|
||||
p.encrypted.size = rngRange(sizeof(p.encrypted.bytes) + 1); // 0..256, always in bounds
|
||||
rngFill(p.encrypted.bytes, p.encrypted.size);
|
||||
|
||||
DecodeState st = perhapsDecode(&p);
|
||||
// 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.role = (meshtastic_Config_DeviceConfig_Role)rngRange(16);
|
||||
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++)
|
||||
u.public_key.bytes[i] = rngByte();
|
||||
rngFill(u.public_key.bytes, u.public_key.size);
|
||||
return u;
|
||||
}
|
||||
|
||||
@@ -273,10 +280,8 @@ void test_E3_traceroute_route_processing_fuzz(void)
|
||||
|
||||
if (rngRange(5) == 0) {
|
||||
// Pure-random payload: processUpgradedPacket must reject it at decode and not crash.
|
||||
size_t n = rngRange(sizeof(mp.decoded.payload.bytes) + 1);
|
||||
for (size_t i = 0; i < n; i++)
|
||||
mp.decoded.payload.bytes[i] = rngByte();
|
||||
mp.decoded.payload.size = n;
|
||||
mp.decoded.payload.size = rngRange(sizeof(mp.decoded.payload.bytes) + 1);
|
||||
rngFill(mp.decoded.payload.bytes, mp.decoded.payload.size);
|
||||
} else {
|
||||
meshtastic_RouteDiscovery r =
|
||||
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++) {
|
||||
uint8_t buf[64];
|
||||
size_t n = rngRange(sizeof(buf) + 1);
|
||||
for (size_t i = 0; i < n; i++)
|
||||
buf[i] = rngByte();
|
||||
rngFill(buf, n);
|
||||
meshtastic_PortNum port = (rngRange(2)) ? meshtastic_PortNum_WAYPOINT_APP : meshtastic_PortNum_NODEINFO_APP;
|
||||
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()
|
||||
{
|
||||
initializeTestEnvironment();
|
||||
@@ -385,6 +795,26 @@ void setup()
|
||||
printf("\n=== Group E4: phone-forward gate ===\n");
|
||||
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());
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,10 @@ static uint32_t makeDistributedNodeId(uint32_t baseId, uint32_t ordinal, uint32_
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -661,6 +665,57 @@ static void test_memory_layout()
|
||||
// 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)
|
||||
{
|
||||
if (!mockNodeDB)
|
||||
@@ -712,6 +767,9 @@ void setup()
|
||||
RUN_TEST(test_filtering_denom_steps_down_gradually);
|
||||
RUN_TEST(test_full_at_denom_max_drops_entry);
|
||||
|
||||
printf("\n=== Fuzz ===\n");
|
||||
RUN_TEST(test_fuzz_nodenum_blitz);
|
||||
|
||||
printf("\n=== Summary ===\n");
|
||||
RUN_TEST(test_memory_layout);
|
||||
RUN_TEST(test_scenario_summary_output);
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
#include "airtime.h"
|
||||
#include "modules/AdminModule.h"
|
||||
#include "modules/MeshBeaconModule.h"
|
||||
#include "support/AdminModuleTestShim.h"
|
||||
#include "support/MockMeshService.h"
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
@@ -50,16 +52,8 @@ namespace
|
||||
constexpr NodeNum kLocalNode = 0xAAAA0001;
|
||||
constexpr NodeNum kRemoteNode = 0xBBBB0002;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal MockMeshService - stubs out side-effecting virtuals.
|
||||
// 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); }
|
||||
};
|
||||
// MockMeshService (test/support) stubs the side-effecting virtuals; handleToRadio is non-virtual so
|
||||
// it runs the real implementation - the router->sendLocal path is guarded by MockRouter below.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MockRouter: captures every packet handed to send() instead of transmitting.
|
||||
@@ -93,14 +87,7 @@ class MockRouter : public Router
|
||||
std::vector<meshtastic_ChannelSettings> primaryAtSend;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AdminModuleTestShim - exposes protected handleSetModuleConfig.
|
||||
// ---------------------------------------------------------------------------
|
||||
class AdminModuleTestShim : public AdminModule
|
||||
{
|
||||
public:
|
||||
using AdminModule::handleSetModuleConfig;
|
||||
};
|
||||
// AdminModuleTestShim (test/support) exposes protected handleSetModuleConfig.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MeshBeaconBroadcastModuleTestShim - exposes private internals for testing.
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
#include "mqtt/MQTT.h"
|
||||
#include "mqtt/ServiceEnvelope.h"
|
||||
|
||||
#include "support/DeterministicRng.h" // rngSeed/rngNext/rngByte/rngRange - shared seeded LCG (fuzz group)
|
||||
|
||||
#include <PubSubClient.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);
|
||||
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()
|
||||
{
|
||||
if (mqtt != NULL) {
|
||||
@@ -963,6 +972,65 @@ void test_configWithTLSEnabled(void)
|
||||
#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()
|
||||
{
|
||||
initializeTestEnvironment();
|
||||
@@ -998,6 +1066,7 @@ void setup()
|
||||
#endif
|
||||
RUN_TEST(test_receiveIgnoresUnexpectedFields);
|
||||
RUN_TEST(test_receiveIgnoresInvalidHopLimit);
|
||||
RUN_TEST(test_receiveFuzzServiceEnvelope);
|
||||
RUN_TEST(test_publishTextMessageDirect);
|
||||
RUN_TEST(test_publishTextMessageWithProxy);
|
||||
RUN_TEST(test_reportToMapDefaultImprecise);
|
||||
|
||||
@@ -5,12 +5,7 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include "meshtastic/config.pb.h"
|
||||
|
||||
class MockMeshService : public MeshService
|
||||
{
|
||||
public:
|
||||
void sendClientNotification(meshtastic_ClientNotification *n) override { releaseClientNotificationToPool(n); }
|
||||
};
|
||||
#include "support/MockMeshService.h"
|
||||
|
||||
static MockMeshService *mockMeshService;
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
#include "mesh/NodeDB.h"
|
||||
#include "mesh/Router.h"
|
||||
#include "modules/TrafficManagementModule.h"
|
||||
#include "support/DeterministicRng.h" // rngSeed/rngNext/rngRange - shared seeded LCG
|
||||
#include <climits>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
@@ -1600,6 +1601,92 @@ static void test_tm_lostAndFoundRole_getsAlterReceivedPrecisionClamp(void)
|
||||
// channels always have a public PSK so getPositionPrecisionForChannel caps at 15.
|
||||
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
|
||||
|
||||
void setUp(void)
|
||||
@@ -1669,6 +1756,7 @@ TM_TEST_ENTRY void setup()
|
||||
RUN_TEST(test_tm_lostAndFoundRole_getsAlterReceivedPrecisionClamp);
|
||||
RUN_TEST(test_tm_unknownRole_noDbEntry_appliesFullInterval);
|
||||
RUN_TEST(test_tm_unknownRole_noUserBit_appliesFullInterval);
|
||||
RUN_TEST(test_tm_fuzz_nodenum_blitz);
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user