Skip to content

Commit 405a6bf

Browse files
authored
Strip inner-message padding when sizing unsigned broadcasts (#11083)
1 parent 023351a commit 405a6bf

2 files changed

Lines changed: 161 additions & 3 deletions

File tree

src/mesh/Router.cpp

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,62 @@ void Router::sniffReceived(const meshtastic_MeshPacket *p, const meshtastic_Rout
443443
}
444444

445445
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
446+
/** Size a decoded Data as the sender's signedDataFits() gate would have, with padding stripped:
447+
* unknown fields inside Data.payload survive in payload.size and would otherwise let a forger
448+
* inflate an unsigned broadcast past the signable budget. Returns false only if sizing failed.
449+
* Sizing only what this build's schema decodes, so a signable type that later grows needs its
450+
* legitimate maximum re-checked against the budget or honest unsigned broadcasts get dropped. */
451+
static bool canonicalSignableSize(meshtastic_Data *d, size_t *size)
452+
{
453+
const pb_msgdesc_t *fields = nullptr;
454+
switch (d->portnum) {
455+
case meshtastic_PortNum_POSITION_APP:
456+
fields = &meshtastic_Position_msg;
457+
break;
458+
case meshtastic_PortNum_TELEMETRY_APP:
459+
fields = &meshtastic_Telemetry_msg;
460+
break;
461+
case meshtastic_PortNum_WAYPOINT_APP:
462+
fields = &meshtastic_Waypoint_msg;
463+
break;
464+
case meshtastic_PortNum_NODEINFO_APP:
465+
fields = &meshtastic_User_msg;
466+
break;
467+
default:
468+
break;
469+
}
470+
471+
if (fields) {
472+
// Scratch kept off the stack: these decoded structs are large for the smaller MCU targets.
473+
// Safe as file-static state because both callers of checkXeddsaReceivePolicy hold cryptLock.
474+
static union {
475+
// cppcheck-suppress unusedStructMember ; written by pb_decode through &inner
476+
meshtastic_Position position;
477+
// cppcheck-suppress unusedStructMember ; written by pb_decode through &inner
478+
meshtastic_Telemetry telemetry;
479+
// cppcheck-suppress unusedStructMember ; written by pb_decode through &inner
480+
meshtastic_Waypoint waypoint;
481+
// cppcheck-suppress unusedStructMember ; written by pb_decode through &inner
482+
meshtastic_User user;
483+
} inner;
484+
485+
memset(&inner, 0, sizeof(inner));
486+
size_t canonicalPayload;
487+
if (pb_decode_from_bytes(d->payload.bytes, d->payload.size, fields, &inner) &&
488+
pb_get_encoded_size(&canonicalPayload, fields, &inner) && canonicalPayload <= d->payload.size) {
489+
// Only the length matters when sizing a bytes field, so swap it in place instead of
490+
// copying the whole Data; restored below because modules still need the real payload.
491+
const pb_size_t prevSize = d->payload.size;
492+
d->payload.size = (pb_size_t)canonicalPayload;
493+
const bool sized = pb_get_encoded_size(size, &meshtastic_Data_msg, d);
494+
d->payload.size = prevSize;
495+
return sized;
496+
}
497+
}
498+
499+
return pb_get_encoded_size(size, &meshtastic_Data_msg, d);
500+
}
501+
446502
bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
447503
{
448504
// Only a signature we verify below may mark this packet signed; never trust an inbound flag.
@@ -477,12 +533,13 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
477533
// non-PKI broadcast whose signed encoding would still fit the LoRa frame. Size p->decoded
478534
// canonically so this counts the same fields the sender's signedDataFits() gate counted;
479535
// adding XEDDSA_SIGNATURE_FIELD_BYTES to that unsigned base mirrors it exactly, whatever
480-
// fields the Data carried. Unicast/PKI packets and broadcasts too big to carry a signature
481-
// are never signed, so they must not be hard-failed here even for a known signer.
536+
// fields the Data carried, with padding hidden inside Data.payload stripped. Unicast/PKI
537+
// packets and broadcasts too big to carry a signature are never signed, so they must not be
538+
// hard-failed here even for a known signer.
482539
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from);
483540
if (node && nodeInfoLiteHasXeddsaSigned(node) && !p->pki_encrypted && isBroadcast(p->to)) {
484541
size_t canonicalSize;
485-
if (!pb_get_encoded_size(&canonicalSize, &meshtastic_Data_msg, &p->decoded))
542+
if (!canonicalSignableSize(&p->decoded, &canonicalSize))
486543
return true; // can't size it; never drop on a sizing failure
487544
if (canonicalSize + XEDDSA_SIGNATURE_FIELD_BYTES + MESHTASTIC_HEADER_LENGTH <= MAX_LORA_PAYLOAD_LEN) {
488545
LOG_WARN("Dropping unsigned broadcast from 0x%08x that previously signed", p->from);

test/test_packet_signing/test_main.cpp

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -772,6 +772,103 @@ void test_E9_decoded_partial_signature_from_nonsigner_dropped(void)
772772
TEST_ASSERT_FALSE_MESSAGE(checkXeddsaReceivePolicy(&p), "partial signature must be dropped as malformed");
773773
}
774774

775+
// Build an unsigned broadcast whose inner message is padded with an unknown field, and pin that the
776+
// padding pushes the RAW size past the fit threshold - the exemption the attacker is buying - while
777+
// the frame stays sendable. Without canonical inner sizing these packets are wrongly accepted.
778+
static meshtastic_MeshPacket makePayloadPaddedBroadcast(meshtastic_PortNum port, const pb_msgdesc_t *fields, const void *inner,
779+
size_t padLen)
780+
{
781+
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, port, 0);
782+
const size_t innerLen = pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), fields, inner);
783+
TEST_ASSERT_GREATER_THAN_MESSAGE(0, innerLen, "failed to encode the spoofed inner message");
784+
p.decoded.payload.size =
785+
innerLen + appendUnknownField(p.decoded.payload.bytes + innerLen, sizeof(p.decoded.payload.bytes) - innerLen, padLen);
786+
787+
TEST_ASSERT_FALSE_MESSAGE(signedEncodingFits(&p.decoded), "padding must push the raw size past the fit threshold");
788+
TEST_ASSERT_LESS_OR_EQUAL_MESSAGE(MAX_LORA_PAYLOAD_LEN, encodedDataSize(&p.decoded) + MESHTASTIC_HEADER_LENGTH,
789+
"padded frame must still be one a radio could send");
790+
return p;
791+
}
792+
793+
// E10: unknown fields buried inside Data.payload are discarded by the module's own pb_decode, so
794+
// they must not sway the downgrade decision the way A10 already pins for Data-level unknown fields.
795+
void test_E10_decoded_unsigned_position_padded_inside_payload_dropped(void)
796+
{
797+
mockNodeDB->addNode(REMOTE_NODE);
798+
mockNodeDB->setSignerBit(REMOTE_NODE, true);
799+
800+
meshtastic_Position pos = meshtastic_Position_init_zero;
801+
pos.has_latitude_i = pos.has_longitude_i = true;
802+
pos.latitude_i = 371234567;
803+
pos.longitude_i = -1221234567;
804+
805+
meshtastic_MeshPacket p = makePayloadPaddedBroadcast(meshtastic_PortNum_POSITION_APP, &meshtastic_Position_msg, &pos, 163);
806+
807+
TEST_ASSERT_FALSE_MESSAGE(checkXeddsaReceivePolicy(&p), "payload-padded unsigned Position from a signer must be dropped");
808+
TEST_ASSERT_FALSE(p.xeddsa_signed);
809+
}
810+
811+
// E11: over-correction guard. Telemetry (272 bytes max) and Waypoint (199) can legitimately exceed
812+
// the signable budget, so canonical sizing must not shrink an honest one into the drop range.
813+
void test_E11_decoded_unsigned_oversized_telemetry_from_signer_accepted(void)
814+
{
815+
mockNodeDB->addNode(REMOTE_NODE);
816+
mockNodeDB->setSignerBit(REMOTE_NODE, true);
817+
818+
meshtastic_Telemetry t = meshtastic_Telemetry_init_zero;
819+
t.which_variant = meshtastic_Telemetry_host_metrics_tag;
820+
t.variant.host_metrics.uptime_seconds = 123456;
821+
t.variant.host_metrics.has_user_string = true;
822+
memset(t.variant.host_metrics.user_string, 'x', sizeof(t.variant.host_metrics.user_string) - 1);
823+
824+
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TELEMETRY_APP, 0);
825+
p.decoded.payload.size =
826+
pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_Telemetry_msg, &t);
827+
TEST_ASSERT_GREATER_THAN_MESSAGE(0, p.decoded.payload.size, "failed to encode the oversized Telemetry");
828+
829+
// Every byte here is a field this build understands, so canonical sizing must leave it alone.
830+
TEST_ASSERT_FALSE_MESSAGE(signedEncodingFits(&p.decoded), "telemetry must be too big to sign, else the test is vacuous");
831+
832+
TEST_ASSERT_TRUE_MESSAGE(checkXeddsaReceivePolicy(&p), "honest oversized telemetry from a signer must not be dropped");
833+
}
834+
835+
// E12: E10 for the Waypoint branch of the canonical-sizing switch.
836+
void test_E12_decoded_unsigned_waypoint_padded_inside_payload_dropped(void)
837+
{
838+
mockNodeDB->addNode(REMOTE_NODE);
839+
mockNodeDB->setSignerBit(REMOTE_NODE, true);
840+
841+
meshtastic_Waypoint w = meshtastic_Waypoint_init_zero;
842+
w.id = 42;
843+
w.has_latitude_i = w.has_longitude_i = true;
844+
w.latitude_i = 371234567;
845+
w.longitude_i = -1221234567;
846+
strcpy(w.name, "spoofed");
847+
848+
meshtastic_MeshPacket p = makePayloadPaddedBroadcast(meshtastic_PortNum_WAYPOINT_APP, &meshtastic_Waypoint_msg, &w, 150);
849+
850+
TEST_ASSERT_FALSE_MESSAGE(checkXeddsaReceivePolicy(&p), "payload-padded unsigned Waypoint from a signer must be dropped");
851+
TEST_ASSERT_FALSE(p.xeddsa_signed);
852+
}
853+
854+
// E13: E10 for the NodeInfo/User branch. Router drops it before rebroadcast; NodeInfoModule's own
855+
// check (Group C) is receiver-local and would not stop the packet propagating.
856+
void test_E13_decoded_unsigned_nodeinfo_padded_inside_payload_dropped(void)
857+
{
858+
mockNodeDB->addNode(REMOTE_NODE);
859+
mockNodeDB->setSignerBit(REMOTE_NODE, true);
860+
861+
meshtastic_User u = meshtastic_User_init_zero;
862+
strcpy(u.id, "!0b0b0b0b");
863+
strcpy(u.long_name, "spoofed node");
864+
strcpy(u.short_name, "SPF");
865+
866+
meshtastic_MeshPacket p = makePayloadPaddedBroadcast(meshtastic_PortNum_NODEINFO_APP, &meshtastic_User_msg, &u, 150);
867+
868+
TEST_ASSERT_FALSE_MESSAGE(checkXeddsaReceivePolicy(&p), "payload-padded unsigned NodeInfo from a signer must be dropped");
869+
TEST_ASSERT_FALSE(p.xeddsa_signed);
870+
}
871+
775872
void setup()
776873
{
777874
initializeTestEnvironment();
@@ -817,6 +914,10 @@ void setup()
817914
RUN_TEST(test_E7_decoded_unsigned_pki_from_signer_accepted);
818915
RUN_TEST(test_E8_decoded_partial_signature_from_signer_dropped);
819916
RUN_TEST(test_E9_decoded_partial_signature_from_nonsigner_dropped);
917+
RUN_TEST(test_E10_decoded_unsigned_position_padded_inside_payload_dropped);
918+
RUN_TEST(test_E11_decoded_unsigned_oversized_telemetry_from_signer_accepted);
919+
RUN_TEST(test_E12_decoded_unsigned_waypoint_padded_inside_payload_dropped);
920+
RUN_TEST(test_E13_decoded_unsigned_nodeinfo_padded_inside_payload_dropped);
820921

821922
exit(UNITY_END());
822923
}

0 commit comments

Comments
 (0)