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