Limit flood advert forwarding and persistent abuse

This commit is contained in:
mikecarper committed 2026-09-09 17:16:00 -07:00
1 parent 27ff4b9063
commit 23fe7c8a9d
15 files changed
+985 -6

No files matched your search

+45 -6
View File
@@ -192,6 +192,7 @@ uint8_t Mesh::getOtaHopLimit() const { return ota::ota_hop_limit(); }
#endif
void Mesh::begin() {
if (auto* limiter = getFloodAdvertLimiter()) limiter->reset();
_active_direct_retry_count = 0;
_active_flood_retry_count = 0;
_waiting_direct_retry_count = 0;
@@ -285,6 +286,7 @@ bool Mesh::hasPendingOtaApply() const {
}
void __attribute__((noinline)) Mesh::serviceLoopMaintenance() {
if (auto* limiter = getFloodAdvertLimiter()) limiter->tick(_ms->getMillis());
if (_waiting_direct_retry_count != 0
&& millisHasNowPassed(_next_direct_retry_timeout)) {
for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) {
@@ -1006,6 +1008,13 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): incomplete advertisement packet", getLogDateTime());
break;
}
// Do not accept unsigned trailing data by truncating it for signature
// verification. An attacker could vary that tail to manufacture distinct
// packet hashes and falsely accumulate abuse against an authentic key.
if (pkt->payload_len > min_advert_len + MAX_ADVERT_DATA_SIZE) {
MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): oversized advertisement packet", getLogDateTime());
break;
}
int i = 0;
Identity id;
@@ -1017,11 +1026,21 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
if (self_id.matches(id.pub_key)) {
MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): receiving SELF advert packet", getLogDateTime());
} else if (!_tables->wasSeen(pkt)) {
_tables->markSeen(pkt);
} else {
const bool seen = _tables->wasSeen(pkt);
auto* limiter = pkt->isRouteFlood() ? getFloodAdvertLimiter() : nullptr;
const uint32_t now = _ms->getMillis();
if (seen && limiter) {
uint8_t hash[MAX_HASH_SIZE];
pkt->calculatePacketHash(hash);
limiter->noteKnownCopy(id.pub_key, hash, now);
}
// Even a duplicate can reveal a shorter route. Re-verify its signature
// before using that evidence, without delivering or relaying it twice.
if (seen && !(limiter && limiter->needsShorterPath(id.pub_key, pkt->getPathHashCount(), now))) break;
if (!seen) _tables->markSeen(pkt);
uint8_t* app_data = &pkt->payload[i];
int app_data_len = pkt->payload_len - i;
if (app_data_len > MAX_ADVERT_DATA_SIZE) { app_data_len = MAX_ADVERT_DATA_SIZE; }
// check that signature is valid
bool is_ok;
@@ -1036,8 +1055,15 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
}
if (is_ok) {
MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): valid advertisement received!", getLogDateTime());
onAdvertRecv(pkt, id, timestamp, app_data, app_data_len);
action = routeRecvPacket(pkt);
if (limiter) {
uint8_t hash[MAX_HASH_SIZE];
pkt->calculatePacketHash(hash);
limiter->observe(id.pub_key, hash, pkt->getPathHashCount(), now, seen);
}
if (!seen) {
onAdvertRecv(pkt, id, timestamp, app_data, app_data_len);
action = routeRecvPacket(pkt);
}
} else {
MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): received advertisement with forged signature! (app_data_len=%d)", getLogDateTime(), app_data_len);
}
@@ -1158,7 +1184,20 @@ DispatcherAction Mesh::routeRecvPacket(Packet* packet) {
uint8_t n = packet->getPathHashCount();
if (packet->isRouteFlood() && !packet->isMarkedDoNotRetransmit()
&& (n + 1)*packet->getPathHashSize() <= MAX_PATH_SIZE && allowPacketForward(packet)) {
&& (n + 1)*packet->getPathHashSize() <= MAX_PATH_SIZE) {
auto* limiter = packet->getPayloadType() == PAYLOAD_TYPE_ADVERT ? getFloodAdvertLimiter() : nullptr;
uint8_t hash[MAX_HASH_SIZE];
const uint32_t now = _ms->getMillis();
if (limiter) {
packet->calculatePacketHash(hash);
auto decision = limiter->check(packet->payload, hash, now);
if (decision != FloodAdvertLimiter::Decision::Allow) {
MESH_DEBUG_PRINTLN("%s Mesh::routeRecvPacket(): advert limited (reason=%u)", getLogDateTime(), unsigned(decision));
return ACTION_RELEASE;
}
}
if (!allowPacketForward(packet)) return ACTION_RELEASE;
if (limiter) limiter->commit(packet->payload, hash, now);
// append this node's hash to 'path'
self_id.copyHashTo(&packet->path[n * packet->getPathHashSize()], packet->getPathHashSize());
packet->setPathHashCount(n + 1);
+4
View File
@@ -1,6 +1,7 @@
#pragma once
#include <Dispatcher.h>
#include <helpers/FloodAdvertLimiter.h>
// OTA-over-LoRa transport is understood by every Mesh role even when the OTA manager/installer is not
// compiled in. Repeaters can therefore relay PAYLOAD_TYPE_OTA opaquely while they are on TempRadio.
@@ -228,6 +229,9 @@ protected:
*/
virtual bool allowPacketForward(const Packet* packet);
// Forwarding roles own the RAM; companions need no advert-limiter table.
virtual FloodAdvertLimiter* getFloodAdvertLimiter() { return nullptr; }
/**
* \returns number of milliseconds delay to apply to retransmitting the given packet.
*/
+2
View File
@@ -1,6 +1,7 @@
#include <Arduino.h>
#include "CommonCLI.h"
#include "CLICommandUtils.h"
#include "FloodAdvertCLI.h"
#include "StorageLayout.h"
#include "radiolib/RadioPowerLimits.h"
#include "radiolib/LR2021SideDetectorConfig.h"
@@ -2460,6 +2461,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
// Observer-only top-level commands (ota check/update, tls.bundletest, alert test)
// live in CommonCLI_Observer.cpp.
if (handleObserverCommand(sender_timestamp, command, reply)) return;
if (mesh::cli::handleFloodAdvertClear(_callbacks->getFloodAdvertLimiter(), command, reply)) return;
if (memcmp(command, "poweroff", 8) == 0 || memcmp(command, "shutdown", 8) == 0) {
_board->powerOff(); // doesn't return
} else if (memcmp(command, "reboot", 6) == 0) {
+1
View File
@@ -446,6 +446,7 @@ struct LegacyObserverTail {
class CommonCLICallbacks {
public:
virtual mesh::FloodAdvertLimiter* getFloodAdvertLimiter() { return nullptr; }
// Ordinary CommonCLI setters mutate NodePrefs and therefore save only the
// common image. Observer setters explicitly request Observer; only
// cross-file migration and mixed-owner setters request Both.
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include "FloodAdvertLimiter.h"
#include <Utils.h>
namespace mesh {
namespace cli {
// Dispatch only after the role's ordinary CLI authorization checks. Requiring
// an explicit "all" avoids treating missing/malformed keys as a global reset.
inline bool handleFloodAdvertClear(FloodAdvertLimiter* limiter, const char* command, char* reply) {
static const char prefix[] = "clear flood.advert";
const size_t prefix_len = sizeof(prefix) - 1;
if (strncmp(command, prefix, prefix_len) != 0) return false;
const char* arg = command + prefix_len;
if (*arg && *arg != ' ' && *arg != '\t') return false;
while (*arg == ' ' || *arg == '\t') ++arg;
const char* end = arg;
while (*end && *end != ' ' && *end != '\t' && *end != '\r' && *end != '\n') ++end;
const size_t len = end - arg;
while (*end == ' ' || *end == '\t' || *end == '\r' || *end == '\n') ++end;
uint8_t key[PUB_KEY_SIZE];
const bool all = len == 3 && strncmp(arg, "all", 3) == 0;
bool valid = all;
if (len == PUB_KEY_SIZE * 2) {
char hex[PUB_KEY_SIZE * 2 + 1];
memcpy(hex, arg, len);
hex[len] = 0;
valid = Utils::fromHex(key, PUB_KEY_SIZE, hex);
}
if (*end || !valid) {
strcpy(reply, "ERR: clear flood.advert all|<64-hex-key>");
} else if (!limiter) {
strcpy(reply, "ERR: advert limiter unavailable on this role");
} else {
if (all) limiter->reset();
else limiter->clear(key);
strcpy(reply, all ? "OK - all advert limit history cleared" : "OK - key advert limit history cleared");
}
return true;
}
} // namespace cli
} // namespace mesh
+186
View File
@@ -0,0 +1,186 @@
#include "FloodAdvertLimiter.h"
namespace mesh {
uint8_t FloodAdvertLimiter::allowance(uint8_t hops) {
static const uint8_t limits[] = {10, 10, 9, 8, 6, 5, 4, 3, 2};
return limits[hops < 8 ? hops : 8];
}
void FloodAdvertLimiter::reset() {
for (size_t i = 0; i < _capacity; ++i) _entries[i].flags = 0;
_last_sweep = 0;
}
FloodAdvertLimiter::Entry* FloodAdvertLimiter::find(const uint8_t* key) {
for (size_t i = 0; i < _capacity; ++i) {
if ((_entries[i].flags & ACTIVE) && memcmp(_entries[i].key, key, PUB_KEY_SIZE) == 0) {
return &_entries[i];
}
}
return nullptr;
}
void FloodAdvertLimiter::clear(const uint8_t* full_key) {
Entry* entry = find(full_key);
if (entry) entry->flags = 0;
}
int FloodAdvertLimiter::hashIndex(const Entry& entry, const uint8_t* hash) {
for (uint8_t i = 0; i < entry.count; ++i) {
if (memcmp(entry.hashes[i], hash, MAX_HASH_SIZE) == 0) return i;
}
return -1;
}
void FloodAdvertLimiter::advance(Entry& entry, uint32_t now) {
if (!(entry.flags & ACTIVE)) return;
if ((entry.flags & HAS_FORWARDED) && uint32_t(now - entry.last_forwarded) >= BAD_INTERVAL_MS) {
entry.flags &= ~HAS_FORWARDED;
}
uint32_t elapsed = now - entry.window_start;
if (elapsed >= WINDOW_MS) {
const bool over = entry.count > allowance(entry.min_hops);
if (over) {
// Recovery starts after the last over-limit window, not after the last
// forwarded packet. Suppressed RX traffic still counts as abuse.
entry.last_violation = entry.window_start + WINDOW_MS;
}
entry.flags &= ~PREVIOUS_OVER;
if (over && elapsed < 2 * WINDOW_MS) entry.flags |= PREVIOUS_OVER;
entry.window_start += (elapsed / WINDOW_MS) * WINDOW_MS;
entry.count = 0;
entry.forwarded = 0;
entry.min_hops = 255;
// Quiet ordinary sources need no history beyond their three-hour window.
// Keep a first strike through the next window; never evict a bad entry.
if (!(entry.flags & (BAD | PREVIOUS_OVER))) entry.flags = 0;
}
if ((entry.flags & BAD) && uint32_t(now - entry.last_violation) >= RECOVERY_MS) {
entry.flags &= ~BAD;
if (entry.count == 0 && !(entry.flags & PREVIOUS_OVER)) entry.flags = 0;
}
}
void FloodAdvertLimiter::refresh(uint32_t now) {
for (size_t i = 0; i < _capacity; ++i) advance(_entries[i], now);
_last_sweep = now;
}
void FloodAdvertLimiter::tick(uint32_t now) {
// Periodic expiry prevents stale history resurrecting after a millis wrap,
// even when no adverts arrive for weeks. Receive operations also expire it
// immediately at the exact window/recovery boundary.
if (uint32_t(now - _last_sweep) >= 60000UL) refresh(now);
}
bool FloodAdvertLimiter::needsShorterPath(const uint8_t* key, uint8_t hops, uint32_t now) {
refresh(now);
Entry* entry = find(key);
return entry && hops < entry->min_hops;
}
void FloodAdvertLimiter::noteKnownCopy(const uint8_t* key, const uint8_t* hash, uint32_t now) {
refresh(now);
Entry* entry = find(key);
// Only the exact retained hash of a previously verified payload can refresh
// last-heard without another signature check. Never trust just the key/path.
if (entry && hashIndex(*entry, hash) >= 0) entry->last_heard = now;
}
void FloodAdvertLimiter::observe(const uint8_t* key, const uint8_t* hash, uint8_t hops,
uint32_t now, bool previously_seen) {
refresh(now);
Entry* entry = find(key);
if (!entry) {
if (previously_seen) return;
uint32_t start = now;
// Keys sharing the first 12 hex characters share a normal quota AND its epoch.
for (size_t i = 0; i < _capacity; ++i) {
if ((_entries[i].flags & ACTIVE) && memcmp(_entries[i].key, key, PREFIX_BYTES) == 0) {
start = _entries[i].window_start;
if (_entries[i].min_hops < hops) hops = _entries[i].min_hops;
break;
}
}
Entry* oldest = nullptr;
for (size_t i = 0; i < _capacity; ++i) {
if (!(_entries[i].flags & ACTIVE)) {
entry = &_entries[i];
break;
}
// Preserve both bad-list entries and first-strike evidence. Evict only
// normal entries, least recently heard first, using rollover-safe ages.
const Entry& candidate = _entries[i];
if (!(candidate.flags & (BAD | PREVIOUS_OVER))
&& candidate.count <= allowance(candidate.min_hops)
&& (!oldest || uint32_t(now - candidate.last_heard) > uint32_t(now - oldest->last_heard))) {
oldest = &_entries[i];
}
}
if (!entry) entry = oldest;
if (!entry) return; // all slots protect abuse history: fail closed
memset(entry, 0, sizeof(*entry));
memcpy(entry->key, key, PUB_KEY_SIZE);
entry->window_start = start;
entry->min_hops = hops;
entry->flags = ACTIVE;
}
entry->last_heard = now;
// The normal level belongs to the prefix, including its shortest RX path.
// Keep that minimum on every colliding full key for consistent window-close
// abuse accounting, but never combine their received counts into a strike.
if (hops < entry->min_hops) entry->min_hops = hops;
for (size_t i = 0; i < _capacity; ++i) {
if ((_entries[i].flags & ACTIVE) && memcmp(_entries[i].key, key, PREFIX_BYTES) == 0
&& hops < _entries[i].min_hops) _entries[i].min_hops = hops;
}
if (previously_seen || hashIndex(*entry, hash) >= 0 || entry->count == HASH_SLOTS) return;
memcpy(entry->hashes[entry->count++], hash, MAX_HASH_SIZE);
if (entry->count > allowance(entry->min_hops)) {
entry->last_violation = now;
if (entry->flags & PREVIOUS_OVER) entry->flags |= BAD;
}
}
FloodAdvertLimiter::Decision FloodAdvertLimiter::check(const uint8_t* key, const uint8_t* hash,
uint32_t now) {
refresh(now);
Entry* entry = find(key);
if (!entry) return Decision::Capacity;
int idx = hashIndex(*entry, hash);
if (idx < 0) return Decision::Quota; // saturated receive history is fail-closed
if (entry->forwarded & (1U << idx)) return Decision::Duplicate;
if ((entry->flags & (BAD | HAS_FORWARDED)) == (BAD | HAS_FORWARDED)
&& uint32_t(now - entry->last_forwarded) < BAD_INTERVAL_MS) return Decision::BadList;
uint8_t hops = entry->min_hops;
unsigned forwarded = 0;
for (size_t i = 0; i < _capacity; ++i) {
const Entry& other = _entries[i];
if (!(other.flags & ACTIVE) || memcmp(other.key, key, PREFIX_BYTES) != 0) continue;
if (other.min_hops < hops) hops = other.min_hops;
for (uint16_t bits = other.forwarded; bits; bits &= bits - 1) ++forwarded;
}
return forwarded < allowance(hops) ? Decision::Allow : Decision::Quota;
}
void FloodAdvertLimiter::commit(const uint8_t* key, const uint8_t* hash, uint32_t now) {
// Caller has passed check() and ALL other forwarding gates, before appending
// its own path hop. Retries of this same advert are not new distinct adverts.
Entry* entry = find(key);
if (!entry) return;
int idx = hashIndex(*entry, hash);
if (idx < 0) return;
entry->forwarded |= uint16_t(1U << idx);
entry->last_forwarded = now;
entry->flags |= HAS_FORWARDED;
}
bool FloodAdvertLimiter::isBad(const uint8_t* key, uint32_t now) {
refresh(now);
Entry* entry = find(key);
return entry && (entry->flags & BAD);
}
} // namespace mesh
+82
View File
@@ -0,0 +1,82 @@
#pragma once
#include <MeshCore.h>
#include <string.h>
#ifndef FLOOD_ADVERT_SOURCE_SLOTS
#if defined(STM32_PLATFORM)
#define FLOOD_ADVERT_SOURCE_SLOTS 8
#elif defined(NRF52_PLATFORM)
// Preserve the runtime heap reserve alongside the fixed 64 KiB mOTA arena.
#define FLOOD_ADVERT_SOURCE_SLOTS 96
#else
#define FLOOD_ADVERT_SOURCE_SLOTS 128
#endif
#endif
namespace mesh {
// Volatile, bounded receive history. Only forwarding roles own this table.
// All times are uint32_t uptime milliseconds, never advert timestamps or RTC.
class FloodAdvertLimiter {
public:
static constexpr uint32_t WINDOW_MS = 180UL * 60UL * 1000UL;
static constexpr uint32_t BAD_INTERVAL_MS = 12UL * 60UL * 60UL * 1000UL;
static constexpr uint32_t RECOVERY_MS = 7UL * 24UL * 60UL * 60UL * 1000UL;
static constexpr uint8_t HASH_SLOTS = 11; // maximum normal allowance + evidence of excess
static constexpr uint8_t PREFIX_BYTES = 6; // first 12 public-key hex characters
enum class Decision : uint8_t { Allow, Duplicate, Quota, BadList, Capacity };
struct Entry {
uint8_t key[PUB_KEY_SIZE];
uint8_t hashes[HASH_SLOTS][MAX_HASH_SIZE];
uint32_t window_start;
uint32_t last_violation;
uint32_t last_forwarded;
uint32_t last_heard;
uint16_t forwarded;
uint8_t min_hops;
uint8_t count;
uint8_t flags;
};
void reset();
void clear(const uint8_t* full_key);
void tick(uint32_t now);
static uint8_t allowance(uint8_t hops);
bool needsShorterPath(const uint8_t* key, uint8_t hops, uint32_t now);
void noteKnownCopy(const uint8_t* key, const uint8_t* hash, uint32_t now);
// Call ONLY after signature validation. Previously seen copies may improve
// the minimum path, but cannot add evidence or consume forwarding quota.
void observe(const uint8_t* key, const uint8_t* hash, uint8_t hops,
uint32_t now, bool previously_seen = false);
Decision check(const uint8_t* key, const uint8_t* hash, uint32_t now);
void commit(const uint8_t* key, const uint8_t* hash, uint32_t now);
bool isBad(const uint8_t* key, uint32_t now);
protected:
FloodAdvertLimiter(Entry* entries, size_t capacity) : _entries(entries), _capacity(capacity) {}
FloodAdvertLimiter(const FloodAdvertLimiter&) = delete;
FloodAdvertLimiter& operator=(const FloodAdvertLimiter&) = delete;
private:
enum : uint8_t { ACTIVE = 1, PREVIOUS_OVER = 2, BAD = 4, HAS_FORWARDED = 8 };
Entry* _entries;
size_t _capacity;
uint32_t _last_sweep = 0;
Entry* find(const uint8_t* key);
static int hashIndex(const Entry& entry, const uint8_t* hash);
static void advance(Entry& entry, uint32_t now);
void refresh(uint32_t now);
};
template<size_t Capacity = FLOOD_ADVERT_SOURCE_SLOTS>
class StaticFloodAdvertLimiter : public FloodAdvertLimiter {
static_assert(Capacity > 0, "Advert limiter needs at least one source slot");
Entry _storage[Capacity];
public:
StaticFloodAdvertLimiter() : FloodAdvertLimiter(_storage, Capacity) { reset(); }
};
} // namespace mesh