Audit and fuzzing of the RF-packet decode -> dispatch -> display/phone paths for the "crash a node or phone with a crafted packet" surface, beyond the XEdDSA authenticity work. Crash fixes (reproduced under AddressSanitizer / UBSan): - GeoCoord::latLongToUTM/latLongToMGRS read fixed letter tables out of bounds on extreme latitude_i/longitude_i from a received Position, and narrowed out-of-range easting/northing doubles to unsigned (float-cast-overflow UB). Clamp the UTM zone, the easting/northing narrowing, and the band/col/row indices. Regression: test_geocoord_extreme_coords_no_oob. - EnvironmentTelemetry/AirQualityTelemetry render attacker floats via String(float), which on nRF52/RP2040/STM32/portduino formats into a fixed char[33] (dtostrf) and overflows near FLT_MAX. Clamp the rendered metrics via UnitConversions::displaySafeFloat (finite + magnitude <= 1e9), unit-tested in test_type_conversions. Defense-in-depth + robustness: - TraceRouteModule::printRoute: fix an snr_back[-1] OOB read (wrong count in the guard) and stop formatting the INT8_MIN "unknown SNR" sentinel as a dB value. - WaypointModule/NodeDB: sanitize untrusted strings before the OLED renderer and the phone-facing ClientNotification (belt-and-suspenders vs PB_VALIDATE_UTF8). - MeshService::sendToPhone: withhold NODEINFO/WAYPOINT packets whose nested string won't cleanly decode, protecting strict phone protobuf decoders without affecting mesh relay. Tests: new test_fuzz_decode (protobuf decode + UTF-8 sanitizer fuzz) and test_fuzz_packets (perhapsDecode / module-handler / traceroute / phone-gate fuzz), all under AddressSanitizer; native-suite-count 25 -> 27. Full suite 515/515 green.
406 lines
17 KiB
C++
406 lines
17 KiB
C++
// Adversarial fuzzing of the packet path - the decrypt/decode/dispatch pipeline that a crafted RF
|
|
// packet flows through, plus the phone-forward validation gate.
|
|
//
|
|
// Unlike test/test_fuzz_decode (pure functions, no fixture) these drive real firmware machinery:
|
|
// Router::perhapsDecode over a configured channel, module handlers, the traceroute route-processing
|
|
// functions, and MeshService's phone-delivery gate. It reuses the MockNodeDB + channel bring-up from
|
|
// test/test_packet_signing. Runs under the `coverage` env (AddressSanitizer + LeakSanitizer); any
|
|
// out-of-bounds access or leak on adversarial input turns the run RED. Inputs are driven by a seeded
|
|
// LCG so failures reproduce from the printed seed.
|
|
//
|
|
// Group E1 encrypted RX -> perhapsDecode over arbitrary ciphertext
|
|
// 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)
|
|
|
|
#include "MeshTypes.h" // include BEFORE TestUtil.h
|
|
#include "TestUtil.h"
|
|
#include <unity.h>
|
|
|
|
#if !(MESHTASTIC_EXCLUDE_PKI)
|
|
|
|
#include "mesh-pb-constants.h"
|
|
#include "mesh/Channels.h"
|
|
#include "mesh/CryptoEngine.h"
|
|
#include "mesh/MeshService.h"
|
|
#include "mesh/NodeDB.h"
|
|
#include "mesh/Router.h"
|
|
#include "modules/NodeInfoModule.h"
|
|
#include "modules/TraceRouteModule.h"
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <pb_decode.h>
|
|
#include <vector>
|
|
|
|
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;
|
|
}
|
|
static constexpr uint64_t BASE_SEED = 0x00BADF00DULL;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MockNodeDB - mirror of test/test_packet_signing so we can inject nodes.
|
|
// ---------------------------------------------------------------------------
|
|
class MockNodeDB : public NodeDB
|
|
{
|
|
public:
|
|
void clearTestNodes()
|
|
{
|
|
testNodes.clear();
|
|
meshNodes = &testNodes;
|
|
numMeshNodes = 0;
|
|
}
|
|
void addNode(NodeNum num)
|
|
{
|
|
meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero;
|
|
node.num = num;
|
|
testNodes.push_back(node);
|
|
meshNodes = &testNodes;
|
|
numMeshNodes = testNodes.size();
|
|
}
|
|
void setPublicKey(NodeNum num, const uint8_t *pubKey)
|
|
{
|
|
meshtastic_NodeInfoLite *n = getMeshNode(num);
|
|
TEST_ASSERT_NOT_NULL(n);
|
|
n->public_key.size = 32;
|
|
memcpy(n->public_key.bytes, pubKey, 32);
|
|
}
|
|
void setSignerBit(NodeNum num, bool value)
|
|
{
|
|
meshtastic_NodeInfoLite *n = getMeshNode(num);
|
|
TEST_ASSERT_NOT_NULL(n);
|
|
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, value);
|
|
}
|
|
std::vector<meshtastic_NodeInfoLite> testNodes;
|
|
};
|
|
|
|
static MockNodeDB *mockNodeDB = nullptr;
|
|
|
|
void setUp(void)
|
|
{
|
|
config = meshtastic_LocalConfig_init_zero;
|
|
owner = meshtastic_User_init_zero;
|
|
|
|
mockNodeDB = new MockNodeDB();
|
|
mockNodeDB->clearTestNodes();
|
|
nodeDB = mockNodeDB;
|
|
myNodeInfo.my_node_num = LOCAL_NODE;
|
|
|
|
channels.initDefaults();
|
|
channels.onConfigChanged();
|
|
}
|
|
|
|
void tearDown(void)
|
|
{
|
|
delete mockNodeDB;
|
|
mockNodeDB = nullptr;
|
|
nodeDB = nullptr;
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Group E1 - encrypted RX -> perhapsDecode over arbitrary ciphertext
|
|
// ===========================================================================
|
|
void test_E1_perhaps_decode_fuzz(void)
|
|
{
|
|
printf(" seed=0x%llx\n", (unsigned long long)(BASE_SEED ^ 0xE1));
|
|
rngSeed(BASE_SEED ^ 0xE1);
|
|
|
|
// A known signer with a stored key so the verify/downgrade policy branches get exercised whenever
|
|
// random ciphertext happens to decrypt into a plausible signed Data.
|
|
uint8_t pub[32], priv[32];
|
|
crypto->generateKeyPair(pub, priv);
|
|
mockNodeDB->addNode(REMOTE_NODE);
|
|
mockNodeDB->setPublicKey(REMOTE_NODE, pub);
|
|
mockNodeDB->setSignerBit(REMOTE_NODE, true);
|
|
|
|
const NodeNum targets[] = {NODENUM_BROADCAST, LOCAL_NODE, REMOTE_NODE, 0x12345678};
|
|
|
|
for (unsigned k = 0; k < 8000; k++) {
|
|
meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
|
|
p.from = REMOTE_NODE;
|
|
p.to = targets[rngRange(4)];
|
|
p.id = rngNext();
|
|
p.channel = (uint8_t)rngByte(); // channel-hash hint; often no match -> decrypt tries each channel
|
|
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;
|
|
|
|
DecodeState st = perhapsDecode(&p);
|
|
// Any verdict is fine; the contract is that arbitrary ciphertext never crashes the pipeline.
|
|
TEST_ASSERT_TRUE(st == DECODE_SUCCESS || st == DECODE_FAILURE || st == DECODE_FATAL);
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Group E2 - NodeInfoModule handler over arbitrary User structs
|
|
// ===========================================================================
|
|
class NodeInfoTestShim : public NodeInfoModule
|
|
{
|
|
public:
|
|
using NodeInfoModule::handleReceivedProtobuf;
|
|
};
|
|
|
|
// Fill a User with adversarial content: char fields random, sometimes left un-terminated to test that
|
|
// downstream copies stay bounded; byte fields random-sized.
|
|
static meshtastic_User fuzzUser()
|
|
{
|
|
meshtastic_User u = meshtastic_User_init_zero;
|
|
auto fillStr = [](char *s, size_t cap) {
|
|
size_t n = rngRange(cap + 4); // may exceed cap: exercises the un-terminated path
|
|
for (size_t i = 0; i < cap; i++)
|
|
s[i] = (i < n) ? (char)rngByte() : '\0';
|
|
if (rngRange(2)) // half the time force a stray non-NUL in the last slot
|
|
s[cap - 1] = (char)(0x80 + rngRange(0x80));
|
|
};
|
|
fillStr(u.id, sizeof(u.id));
|
|
fillStr(u.long_name, sizeof(u.long_name));
|
|
fillStr(u.short_name, sizeof(u.short_name));
|
|
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();
|
|
return u;
|
|
}
|
|
|
|
void test_E2_nodeinfo_handler_fuzz(void)
|
|
{
|
|
printf(" seed=0x%llx\n", (unsigned long long)(BASE_SEED ^ 0xE2));
|
|
rngSeed(BASE_SEED ^ 0xE2);
|
|
|
|
// Function-local static: NodeInfoModule derives from OSThread, whose ctor registers `this` in a
|
|
// global ThreadController and whose dtor deregisters it. A stack local would be freed on return -
|
|
// and Unity's TEST_* macros exit via longjmp, skipping C++ dtors entirely - leaving a dangling
|
|
// pointer that a later OSThread ctor trips over (ASan stack-use-after-return). A static lives for
|
|
// the whole process, so its registration stays valid regardless of longjmp.
|
|
static NodeInfoTestShim shim;
|
|
mockNodeDB->addNode(REMOTE_NODE); // non-signer: handler falls through to updateUser()
|
|
|
|
for (unsigned k = 0; k < 6000; k++) {
|
|
// Clear any key stored last iteration so updateUser() keeps reaching the string-copy path
|
|
// (CopyUserToNodeInfoLite) instead of early-returning on a key mismatch.
|
|
meshtastic_NodeInfoLite *rn = mockNodeDB->getMeshNode(REMOTE_NODE);
|
|
if (rn)
|
|
rn->public_key.size = 0;
|
|
|
|
// Broadcast so the want_response / service-dependent reply path is skipped.
|
|
meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero;
|
|
mp.from = REMOTE_NODE;
|
|
mp.to = NODENUM_BROADCAST;
|
|
mp.id = rngNext();
|
|
mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
|
|
mp.decoded.portnum = meshtastic_PortNum_NODEINFO_APP;
|
|
mp.xeddsa_signed = (rngRange(2) == 0);
|
|
|
|
meshtastic_User u = fuzzUser();
|
|
// Contract: never crashes and never corrupts NodeDB regardless of the User contents.
|
|
(void)shim.handleReceivedProtobuf(mp, &u);
|
|
}
|
|
// NodeDB survived the fuzzing intact (the node is still present, not corrupted away).
|
|
TEST_ASSERT_NOT_NULL(mockNodeDB->getMeshNode(REMOTE_NODE));
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Group E3 - TraceRoute route-processing over adversarial RouteDiscovery
|
|
// ===========================================================================
|
|
static const pb_size_t ROUTE_MAX = ROUTE_SIZE; // 8
|
|
|
|
// Build a RouteDiscovery with caller-chosen (bounded) counts, filled with arbitrary node numbers.
|
|
static meshtastic_RouteDiscovery makeRoute(pb_size_t rc, pb_size_t st, pb_size_t rbc, pb_size_t sb)
|
|
{
|
|
meshtastic_RouteDiscovery r = meshtastic_RouteDiscovery_init_zero;
|
|
r.route_count = rc;
|
|
for (pb_size_t i = 0; i < rc; i++)
|
|
r.route[i] = rngNext();
|
|
r.snr_towards_count = st;
|
|
for (pb_size_t i = 0; i < st; i++)
|
|
r.snr_towards[i] = (int8_t)rngByte();
|
|
r.route_back_count = rbc;
|
|
for (pb_size_t i = 0; i < rbc; i++)
|
|
r.route_back[i] = rngNext();
|
|
r.snr_back_count = sb;
|
|
for (pb_size_t i = 0; i < sb; i++)
|
|
r.snr_back[i] = (int8_t)rngByte();
|
|
return r;
|
|
}
|
|
|
|
void test_E3_traceroute_route_processing_fuzz(void)
|
|
{
|
|
printf(" seed=0x%llx\n", (unsigned long long)(BASE_SEED ^ 0xE3));
|
|
rngSeed(BASE_SEED ^ 0xE3);
|
|
|
|
static TraceRouteModule tr; // static: OSThread-derived, see the note in test_E2 (ThreadController lifetime)
|
|
|
|
for (unsigned k = 0; k < 6000; k++) {
|
|
// Mostly valid RouteDiscovery encodings, with adversarial count combinations (incl. the
|
|
// snr_back_count==0 / snr_towards_count>0 shape behind the printRoute guard fix); plus a
|
|
// fraction of pure-random payloads that must fail decode cleanly.
|
|
meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero;
|
|
mp.from = (rngRange(2)) ? LOCAL_NODE : REMOTE_NODE; // origin==us drives the printRoute back-path
|
|
mp.to = (rngRange(2)) ? LOCAL_NODE : REMOTE_NODE;
|
|
mp.id = rngNext();
|
|
mp.rx_snr = (float)((int)rngRange(40) - 20);
|
|
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_TRACEROUTE_APP;
|
|
mp.decoded.want_response = (rngRange(2) == 0);
|
|
mp.decoded.request_id = (rngRange(2) == 0) ? 0 : rngNext();
|
|
|
|
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;
|
|
} else {
|
|
meshtastic_RouteDiscovery r =
|
|
makeRoute(rngRange(ROUTE_MAX + 1), rngRange(ROUTE_MAX + 1), rngRange(ROUTE_MAX + 1), rngRange(ROUTE_MAX + 1));
|
|
mp.decoded.payload.size = pb_encode_to_bytes(mp.decoded.payload.bytes, sizeof(mp.decoded.payload.bytes),
|
|
&meshtastic_RouteDiscovery_msg, &r);
|
|
}
|
|
|
|
tr.processUpgradedPacket(mp); // decode -> alterReceivedProtobuf (insert/append/printRoute)
|
|
|
|
// After processing, the re-encoded route arrays must still be within bounds.
|
|
meshtastic_RouteDiscovery out = meshtastic_RouteDiscovery_init_zero;
|
|
if (pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_RouteDiscovery_msg, &out)) {
|
|
TEST_ASSERT_TRUE_MESSAGE(out.route_count <= ROUTE_MAX, "route_count overran ROUTE_SIZE");
|
|
TEST_ASSERT_TRUE_MESSAGE(out.route_back_count <= ROUTE_MAX, "route_back_count overran ROUTE_SIZE");
|
|
TEST_ASSERT_TRUE_MESSAGE(out.snr_towards_count <= ROUTE_MAX, "snr_towards_count overran ROUTE_SIZE");
|
|
TEST_ASSERT_TRUE_MESSAGE(out.snr_back_count <= ROUTE_MAX, "snr_back_count overran ROUTE_SIZE");
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Group E4 - MeshService phone-forward gate
|
|
// ===========================================================================
|
|
static meshtastic_Data makeData(meshtastic_PortNum port, const uint8_t *bytes, size_t n)
|
|
{
|
|
meshtastic_Data d = meshtastic_Data_init_zero;
|
|
TEST_ASSERT_TRUE_MESSAGE(n <= sizeof(d.payload.bytes), "payload exceeds meshtastic_Data capacity");
|
|
if (n > 0)
|
|
TEST_ASSERT_NOT_NULL(bytes);
|
|
d.portnum = port;
|
|
d.payload.size = n;
|
|
for (size_t i = 0; i < n; i++)
|
|
d.payload.bytes[i] = bytes[i];
|
|
return d;
|
|
}
|
|
|
|
void test_E4_phone_forward_gate(void)
|
|
{
|
|
// Waypoint.name is field 6 (tag 0x32); User.long_name is field 2 (tag 0x12); both STRING, so
|
|
// PB_VALIDATE_UTF8 rejects invalid UTF-8 content on decode.
|
|
const uint8_t badWaypoint[] = {0x32, 0x01, 0xFF}; // name = single invalid lead byte
|
|
const uint8_t goodWaypoint[] = {0x32, 0x02, 'O', 'K'}; // name = "OK"
|
|
const uint8_t badUser[] = {0x12, 0x01, 0xFF}; // long_name = invalid
|
|
const uint8_t goodUser[] = {0x12, 0x02, 'O', 'K'}; // long_name = "OK"
|
|
const uint8_t arbitrary[] = {0xFF, 0xFE, 0x00, 0x80};
|
|
|
|
meshtastic_Data d;
|
|
|
|
d = makeData(meshtastic_PortNum_WAYPOINT_APP, badWaypoint, sizeof(badWaypoint));
|
|
TEST_ASSERT_FALSE_MESSAGE(MeshService::phonePayloadIsDecodable(d), "invalid-UTF8 waypoint must be withheld");
|
|
|
|
d = makeData(meshtastic_PortNum_WAYPOINT_APP, goodWaypoint, sizeof(goodWaypoint));
|
|
TEST_ASSERT_TRUE_MESSAGE(MeshService::phonePayloadIsDecodable(d), "valid waypoint must pass");
|
|
|
|
d = makeData(meshtastic_PortNum_NODEINFO_APP, badUser, sizeof(badUser));
|
|
TEST_ASSERT_FALSE_MESSAGE(MeshService::phonePayloadIsDecodable(d), "invalid-UTF8 nodeinfo must be withheld");
|
|
|
|
d = makeData(meshtastic_PortNum_NODEINFO_APP, goodUser, sizeof(goodUser));
|
|
TEST_ASSERT_TRUE_MESSAGE(MeshService::phonePayloadIsDecodable(d), "valid nodeinfo must pass");
|
|
|
|
// Empty payloads decode to all-defaults -> pass.
|
|
d = makeData(meshtastic_PortNum_WAYPOINT_APP, nullptr, 0);
|
|
TEST_ASSERT_TRUE(MeshService::phonePayloadIsDecodable(d));
|
|
|
|
// Non-gated portnums always pass through, even with arbitrary bytes (text is opaque `bytes`).
|
|
d = makeData(meshtastic_PortNum_TEXT_MESSAGE_APP, arbitrary, sizeof(arbitrary));
|
|
TEST_ASSERT_TRUE_MESSAGE(MeshService::phonePayloadIsDecodable(d), "text payload must not be gated");
|
|
|
|
// Fuzz: for gated portnums the gate verdict must exactly match a raw decode, and never crash.
|
|
rngSeed(BASE_SEED ^ 0xE4);
|
|
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();
|
|
meshtastic_PortNum port = (rngRange(2)) ? meshtastic_PortNum_WAYPOINT_APP : meshtastic_PortNum_NODEINFO_APP;
|
|
d = makeData(port, buf, n);
|
|
|
|
bool gate = MeshService::phonePayloadIsDecodable(d);
|
|
bool raw;
|
|
if (port == meshtastic_PortNum_WAYPOINT_APP) {
|
|
meshtastic_Waypoint w = meshtastic_Waypoint_init_zero;
|
|
raw = pb_decode_from_bytes(d.payload.bytes, d.payload.size, &meshtastic_Waypoint_msg, &w);
|
|
} else {
|
|
meshtastic_User u = meshtastic_User_init_zero;
|
|
raw = pb_decode_from_bytes(d.payload.bytes, d.payload.size, &meshtastic_User_msg, &u);
|
|
}
|
|
TEST_ASSERT_EQUAL_MESSAGE(raw, gate, "gate verdict must match nested decode");
|
|
}
|
|
}
|
|
|
|
void setup()
|
|
{
|
|
initializeTestEnvironment();
|
|
UNITY_BEGIN();
|
|
|
|
printf("\n=== Group E1: perhapsDecode ciphertext fuzz ===\n");
|
|
RUN_TEST(test_E1_perhaps_decode_fuzz);
|
|
|
|
printf("\n=== Group E2: NodeInfo handler fuzz ===\n");
|
|
RUN_TEST(test_E2_nodeinfo_handler_fuzz);
|
|
|
|
printf("\n=== Group E3: TraceRoute route-processing fuzz ===\n");
|
|
RUN_TEST(test_E3_traceroute_route_processing_fuzz);
|
|
|
|
printf("\n=== Group E4: phone-forward gate ===\n");
|
|
RUN_TEST(test_E4_phone_forward_gate);
|
|
|
|
exit(UNITY_END());
|
|
}
|
|
|
|
void loop() {}
|
|
|
|
#else // MESHTASTIC_EXCLUDE_PKI
|
|
|
|
void setUp(void) {}
|
|
void tearDown(void) {}
|
|
void setup()
|
|
{
|
|
initializeTestEnvironment();
|
|
UNITY_BEGIN();
|
|
exit(UNITY_END());
|
|
}
|
|
void loop() {}
|
|
|
|
#endif
|