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
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user