AdminModule: only accept admin responses to requests we sent (#11024)

* AdminModule: only accept admin responses to requests we sent

An admin *_response short-circuited the auth and session-passkey checks that gate
every other admin message, so any node could deliver one. On a channel the module
listens to unauthenticated, a get_module_config_response drives the remote-hardware
pin handler with attacker-supplied values.

Track the destination of outgoing admin requests (per remote, with the pinned PKC
key when there is one) and accept a response only from a node with a matching
outstanding request, inside the same window as the session passkey. Local (from == 0)
admin is unchanged; PhoneAPI already gates it.

Also fix the response dispatch: get_module_config_response.which_payload_variant is a
ModuleConfig oneof tag, but it was compared against the AdminMessage ModuleConfigType
enum (different numbering), so the handler never ran. Compare against the oneof tag.

* AdminModule: rollover-safe request window, bind response to request type

Two review refinements to the request/response pairing:

Use Throttle::isWithinTimespanMs for the outstanding-request expiry instead of
comparing millis()/1000 sums, which mis-expired across the millis() rollover.

Bind each accepted response to a request type actually sent to that node. Each
outstanding record now carries a bitmask of the response variants its requests
authorize, so a get_owner request no longer admits a get_module_config response.
The mask accumulates per remote, so a client may still pipeline several request
types to one node and have every answer accepted.

* AdminModule: track admin requests per-request, not per-node

Reworks the outstanding-request table so each request is its own entry with its own
expiry window and pinned key, replacing the per-node bitmask that shared one timestamp
and one key across every response variant.

That sharing let a later request to the same node extend an earlier one's window and,
worse, clear its PKC pin: an unpinned request cleared keyValid, so a plaintext response
to an earlier PKC-pinned request was then accepted. Per-request entries keep each pin
intact. Identical requests are de-duplicated (a client may fetch several config subtypes,
all answered by one response variant) and eviction compares elapsed time, which is
rollover-safe.

Test: a pinned request's response still requires its key after an unpinned request to the
same node.

* AdminModule: match module-config subtype and consume answered requests

Two refinements to the request/response pairing:

Only remote_hardware get_module_config_response mutates state (the pin table), so it
must answer a request for that exact ModuleConfigType, not just any module-config
request. Each entry records the requested subtype and the gate checks it.

A matched request is now consumed on accept, so a node cannot replay a state-mutating
response within the window. Because one request yields one response, request de-dup is
dropped (a client's N indexed get_channel requests are N entries, each consumed once).

Tests: a non-remote-hardware request does not admit a remote_hardware response, and a
second copy of an answered response is rejected.
This commit is contained in:
Thomas Göttgens
2026-07-17 07:50:41 -05:00
committed by GitHub
co-authored by GitHub
parent f6f3284cfc
commit 8147970957
5 changed files with 382 additions and 6 deletions
+9
View File
@@ -17,6 +17,7 @@
#include "main.h"
#include "mesh-pb-constants.h"
#include "meshUtils.h"
#include "modules/AdminModule.h"
#include "modules/NodeInfoModule.h"
#include "modules/PositionModule.h"
#include "modules/RoutingModule.h"
@@ -259,6 +260,14 @@ void MeshService::handleToRadio(meshtastic_MeshPacket &p)
const StoredMessage &sm = messageStore.addFromPacket(p);
graphics::MessageRenderer::handleNewMessage(nullptr, sm, p); // notify UI
})
#if !MESHTASTIC_EXCLUDE_ADMIN
// Note admin requests on their way out: AdminModule only accepts a response from a remote we
// actually asked. Runs before encryption, while the payload is still readable.
if (adminModule && p.which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
p.decoded.portnum == meshtastic_PortNum_ADMIN_APP)
adminModule->noteOutgoingAdminRequest(p);
#endif
// Send the packet into the mesh
DEBUG_HEAP_BEFORE;
auto a = packetPool.allocCopy(p);
+114 -4
View File
@@ -10,6 +10,7 @@
#include "input/InputBroker.h"
#include "meshUtils.h"
#include <FSCommon.h>
#include <Throttle.h>
#include <ctype.h> // for better whitespace handling
#if defined(ARCH_ESP32) && !MESHTASTIC_EXCLUDE_WIFI
#include "MeshtasticOTA.h"
@@ -125,9 +126,16 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
}
#endif
meshtastic_Channel *ch = &channels.getByIndex(mp.channel);
// Could tighten this up further by tracking the last public_key we went an AdminMessage request to
// and only allowing responses from that remote.
if (messageIsResponse(r)) {
// Only accept a response from a remote we sent the matching request to. from == 0 is a
// local client, which PhoneAPI has already gated.
const pb_size_t moduleConfigTag = r->which_payload_variant == meshtastic_AdminMessage_get_module_config_response_tag
? r->get_module_config_response.which_payload_variant
: 0;
if (mp.from != 0 && !responseIsSolicited(mp, r->which_payload_variant, moduleConfigTag)) {
LOG_INFO("Ignore admin response from 0x%08x, no outstanding request", mp.from);
return handled;
}
LOG_DEBUG("Allow admin response message");
} else if (mp.from == 0) {
// Local admin from a BLE/USB/TCP client. from == 0 cannot arrive from the
@@ -472,8 +480,9 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
}
case meshtastic_AdminMessage_get_module_config_response_tag: {
LOG_INFO("Client received a get_module_config response");
if (fromOthers && r->get_module_config_response.which_payload_variant ==
meshtastic_AdminMessage_ModuleConfigType_REMOTEHARDWARE_CONFIG) {
// which_payload_variant is the ModuleConfig oneof tag, so compare against that tag, not the
// AdminMessage ModuleConfigType enum (whose REMOTEHARDWARE value is a different number).
if (fromOthers && r->get_module_config_response.which_payload_variant == meshtastic_ModuleConfig_remote_hardware_tag) {
handleGetModuleConfigResponse(mp, r);
}
break;
@@ -1821,6 +1830,107 @@ bool AdminModule::messageIsResponse(const meshtastic_AdminMessage *r)
return false;
}
// The response variant that answers a getter request, or 0 if the request has none.
static pb_size_t adminResponseForRequest(pb_size_t requestVariant)
{
switch (requestVariant) {
case meshtastic_AdminMessage_get_channel_request_tag:
return meshtastic_AdminMessage_get_channel_response_tag;
case meshtastic_AdminMessage_get_owner_request_tag:
return meshtastic_AdminMessage_get_owner_response_tag;
case meshtastic_AdminMessage_get_config_request_tag:
return meshtastic_AdminMessage_get_config_response_tag;
case meshtastic_AdminMessage_get_module_config_request_tag:
return meshtastic_AdminMessage_get_module_config_response_tag;
case meshtastic_AdminMessage_get_canned_message_module_messages_request_tag:
return meshtastic_AdminMessage_get_canned_message_module_messages_response_tag;
case meshtastic_AdminMessage_get_device_metadata_request_tag:
return meshtastic_AdminMessage_get_device_metadata_response_tag;
case meshtastic_AdminMessage_get_ringtone_request_tag:
return meshtastic_AdminMessage_get_ringtone_response_tag;
case meshtastic_AdminMessage_get_device_connection_status_request_tag:
return meshtastic_AdminMessage_get_device_connection_status_response_tag;
case meshtastic_AdminMessage_get_node_remote_hardware_pins_request_tag:
return meshtastic_AdminMessage_get_node_remote_hardware_pins_response_tag;
case meshtastic_AdminMessage_get_ui_config_request_tag:
return meshtastic_AdminMessage_get_ui_config_response_tag;
default:
return 0;
}
}
void AdminModule::noteOutgoingAdminRequest(const meshtastic_MeshPacket &p)
{
if (p.which_payload_variant != meshtastic_MeshPacket_decoded_tag || p.decoded.portnum != meshtastic_PortNum_ADMIN_APP)
return;
// Local admin is answered in-process, and a broadcast is never a request to one remote.
if (p.to == 0 || isBroadcast(p.to) || p.to == nodeDB->getNodeNum())
return;
meshtastic_AdminMessage admin = meshtastic_AdminMessage_init_zero;
if (!pb_decode_from_bytes(p.decoded.payload.bytes, p.decoded.payload.size, &meshtastic_AdminMessage_msg, &admin))
return;
const pb_size_t responseVariant = adminResponseForRequest(admin.which_payload_variant);
if (!responseVariant)
return; // not a getter whose response we can pair
const bool keyValid = p.pki_encrypted && p.public_key.size == 32;
// One entry per request (a client sends N indexed get_channel requests, each answered once, so
// entries must not merge). Free slot, else evict the oldest by rollover-safe elapsed time.
OutstandingAdminRequest *slot = nullptr;
for (auto &o : outstandingAdminRequests)
if (o.to == 0) {
slot = &o;
break;
}
if (!slot) {
const uint32_t now = millis();
slot = &outstandingAdminRequests[0];
for (auto &o : outstandingAdminRequests)
if ((uint32_t)(now - o.sentAtMs) > (uint32_t)(now - slot->sentAtMs))
slot = &o;
}
slot->to = p.to;
slot->sentAtMs = millis();
slot->expectedResponse = responseVariant;
slot->moduleConfigType = admin.which_payload_variant == meshtastic_AdminMessage_get_module_config_request_tag
? (uint8_t)admin.get_module_config_request
: 0;
slot->keyValid = keyValid;
if (keyValid)
memcpy(slot->key, p.public_key.bytes, 32);
else
memset(slot->key, 0, 32);
LOG_DEBUG("Admin request sent to 0x%08x, expecting its response", p.to);
}
bool AdminModule::responseIsSolicited(const meshtastic_MeshPacket &mp, pb_size_t responseVariant, pb_size_t moduleConfigTag)
{
// Scan every entry: several requests for the same variant may differ only in pinning, and an
// unpinned one still authorizes the response even if a pinned one does not.
for (auto &o : outstandingAdminRequests) {
if (o.to != mp.from || o.expectedResponse != responseVariant)
continue;
if (!Throttle::isWithinTimespanMs(o.sentAtMs, kOutstandingAdminRequestMs)) {
o.to = 0; // lapsed; free the slot and keep looking for another live match
continue;
}
// A request pinned to a PKC key must be answered over PKC by that same key.
if (o.keyValid && (!mp.pki_encrypted || mp.public_key.size != 32 || memcmp(mp.public_key.bytes, o.key, 32) != 0))
continue;
// remote_hardware is the only module config that mutates state (the pin table), so require
// it to answer a request for that exact subtype, not just any get_module_config_request.
if (moduleConfigTag == meshtastic_ModuleConfig_remote_hardware_tag &&
o.moduleConfigType != meshtastic_AdminMessage_ModuleConfigType_REMOTEHARDWARE_CONFIG)
continue;
o.to = 0; // consume: one request authorizes one response, no replay within the window
return true;
}
return false;
}
bool AdminModule::messageIsRequest(const meshtastic_AdminMessage *r)
{
if (r->which_payload_variant == meshtastic_AdminMessage_get_channel_request_tag ||
+22
View File
@@ -77,7 +77,29 @@ class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, public Obser
public:
void handleSetHamMode(const meshtastic_HamParameters &req);
/// Note an admin request leaving this node for a remote, so that remote's response is
/// accepted. Called from the client-to-mesh path (MeshService::handleToRadio).
void noteOutgoingAdminRequest(const meshtastic_MeshPacket &p);
private:
// An admin response has no session passkey and its sender need not hold an admin key, so a
// request we sent is the only thing vouching for it. Track each request independently (its own
// expiry and pinned key) so a later one can't extend or relax an earlier one.
static constexpr size_t kOutstandingAdminRequests = 8;
static constexpr uint32_t kOutstandingAdminRequestMs = 300 * 1000; // same window as the session passkey
struct OutstandingAdminRequest {
NodeNum to; // 0 = free slot
uint32_t sentAtMs; // millis() when this request went out
pb_size_t expectedResponse; // the one response variant this request authorizes
uint8_t moduleConfigType; // for get_module_config_request: which ModuleConfigType we asked
uint8_t key[32]; // pinned destination key when the request went out over PKC
bool keyValid;
};
OutstandingAdminRequest outstandingAdminRequests[kOutstandingAdminRequests] = {};
/// Whether a response (variant responseVariant, module-config subtype moduleConfigTag or 0)
/// from mp.from answers a request we sent; consumes the matched request so it can't be replayed.
bool responseIsSolicited(const meshtastic_MeshPacket &mp, pb_size_t responseVariant, pb_size_t moduleConfigTag);
void handleStoreDeviceUIConfig(const meshtastic_DeviceUIConfig &uicfg);
void handleSendInputEvent(const meshtastic_AdminMessage_InputEvent &inputEvent);
void reboot(int32_t seconds);