mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-26 06:44:04 +00:00
**RED commit:** `65d9f57b` (CI run will appear at https://github.com/Kpa-clawbot/CoreScope/actions after PR opens) Fixes #1211 ## Root cause `decodePath()` returns `bytesConsumed = hash_size * hash_count` where both come straight from the wire-supplied `pathByte` (upper 2 bits → `hash_size`, lower 6 bits → `hash_count`). Max claimable: 4 × 63 = 252 bytes. A malformed packet on the wire claimed `pathByte=0xF6` (hash_size=4, hash_count=54 → 216 path bytes) inside a 15-byte buffer. The inner hop-extraction loop in `decodePath` did break early on overflow — but `bytesConsumed` was still returned at face value (216). `DecodePacket` then did `offset += 216` (offset=218) and `payloadBuf := buf[offset:]` panicked with the prod-observed signature: ``` runtime error: slice bounds out of range [218:15] ``` The handler-level `defer/recover` at `cmd/ingestor/main.go:258-263` caught it, but the message was silently dropped with no usable diagnostic. ## Fix Add a `if offset > len(buf)` guard at BOTH decoder sites (same pattern, same panic potential): - `cmd/ingestor/decoder.go` — DecodePacket after decodePath - `cmd/server/decoder.go` — DecodePacket after decodePath Return a descriptive error citing the claimed length and pathByte hex so operators can reproduce. Also: `cmd/ingestor/main.go` decode-error log now includes `topic`, `observer`, and `rawHexLen` so future malformed packets are reproducible without needing to attach a debugger. ## Tests (TDD red → green) Both packages got two new tests: - **`TestDecodePacketBoundsFromWire_Issue1211`** — feeds the exact wire shape from the prod log (`pathByte=0xF6` inside a 15-byte buf). Asserts `DecodePacket` does NOT panic and returns an error. - **`TestDecodePacketFuzzTruncated_Issue1211`** — sweeps every `(header, pathByte)` combination with tails 0..19 bytes (≈1.3M inputs). Asserts zero panics. ### Red commit proof On commit `65d9f57b` (RED), both tests fail with the panic: ``` === RUN TestDecodePacketBoundsFromWire_Issue1211 decoder_test.go:1996: DecodePacket panicked on malformed input: runtime error: slice bounds out of range [218:15] --- FAIL: TestDecodePacketBoundsFromWire_Issue1211 (0.00s) === RUN TestDecodePacketFuzzTruncated_Issue1211 decoder_test.go:2010: DecodePacket panicked during fuzz: runtime error: slice bounds out of range [3:2] --- FAIL: TestDecodePacketFuzzTruncated_Issue1211 (0.01s) ``` On commit `7a6ae52c` (GREEN), full suites pass: - `cmd/ingestor`: `ok 53.988s` - `cmd/server`: `ok 29.456s` ## Acceptance criteria - [x] Identify the slice op producing `[218:15]` — `payloadBuf := buf[offset:]` in `DecodePacket` (decoder.go), where `offset` had been advanced by an unchecked `bytesConsumed` from `decodePath()`. - [x] Bounds check added at the identified site(s) — both ingestor and server decoders. - [x] Test with crafted payload (length-field > remaining buffer) — `TestDecodePacketBoundsFromWire_Issue1211`. - [x] Log topic, observer ID, payload byte length on drop — updated `MQTT [%s] decode error` log line. - [x] Existing tests stay green — confirmed both packages. ## Out of scope Reconnect-after-disconnect (#1212) — handled by a separate subagent. This PR touches NO reconnect logic. --------- Co-authored-by: corescope-bot <bot@corescope.local> Co-authored-by: openclaw-bot <bot@openclaw.local> Co-authored-by: corescope-bot <bot@corescope>
This commit is contained in:
@@ -140,15 +140,15 @@ func TestZeroHopTransportDirectHashSize(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestZeroHopTransportDirectHashSizeWithNonZeroUpperBits(t *testing.T) {
|
||||
// TRANSPORT_DIRECT (RouteType=3) + REQ (PayloadType=0) → header byte = 0x03
|
||||
// 4 bytes transport codes + pathByte=0xC0 → hash_count=0, hash_size bits=11 → should still get HashSize=0
|
||||
// pathByte=0xC0 → hash_size bits=11 (4, reserved per firmware Packet.cpp:13-18).
|
||||
// Firmware Packet::isValidPathLen rejects this regardless of hash_count,
|
||||
// because hash_size==4 is reserved. Go decoder must mirror that — even
|
||||
// when hash_count==0, an attacker-emitted 0xC0 byte should not be
|
||||
// silently accepted; firmware never emits hash_size==4.
|
||||
hex := "03" + "11223344" + "C0" + repeatHex("AA", 20)
|
||||
pkt, err := DecodePacket(hex, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePacket failed: %v", err)
|
||||
}
|
||||
if pkt.Path.HashSize != 0 {
|
||||
t.Errorf("TRANSPORT_DIRECT zero-hop with hash_size bits set: want HashSize=0, got %d", pkt.Path.HashSize)
|
||||
_, err := DecodePacket(hex, false)
|
||||
if err == nil {
|
||||
t.Fatalf("DecodePacket(pathByte=0xC0) succeeded; want error mirroring firmware Packet.cpp:13-18 (hash_size==4 reserved)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,3 +488,89 @@ func TestDecodePacket_TraceNoSNRValues(t *testing.T) {
|
||||
t.Errorf("expected empty SNRValues, got %v", pkt.Payload.SNRValues)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodePacketBoundsFromWire_Issue1211 — mirror of ingestor test.
|
||||
// Malformed pathByte=0xF6 inside a 15-byte buffer triggered
|
||||
// `slice bounds out of range [218:15]`.
|
||||
func TestDecodePacketBoundsFromWire_Issue1211(t *testing.T) {
|
||||
raw := "12F6"
|
||||
for i := 0; i < 13; i++ {
|
||||
raw += "AA"
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("DecodePacket panicked on malformed input: %v", r)
|
||||
}
|
||||
}()
|
||||
pkt, err := DecodePacket(raw, false)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for malformed packet, got nil; pkt=%+v", pkt)
|
||||
}
|
||||
}
|
||||
|
||||
// Adv M2: see cmd/ingestor/decoder_test.go — sweep gated on !testing.Short();
|
||||
// FuzzDecodePacketTruncated below is the real fuzzing target.
|
||||
func TestDecodePacketFuzzTruncated_Issue1211(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("DecodePacket panicked during fuzz: %v", r)
|
||||
}
|
||||
}()
|
||||
if testing.Short() {
|
||||
t.Skip("skipping exhaustive sweep in -short mode; use FuzzDecodePacketTruncated")
|
||||
}
|
||||
for hdr := 0; hdr < 256; hdr++ {
|
||||
for pb := 0; pb < 256; pb++ {
|
||||
for tail := 0; tail < 20; tail++ {
|
||||
raw := hex.EncodeToString([]byte{byte(hdr), byte(pb)})
|
||||
for i := 0; i < tail; i++ {
|
||||
raw += "00"
|
||||
}
|
||||
_, _ = DecodePacket(raw, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FuzzDecodePacketTruncated — native go fuzz target. Zero panics required.
|
||||
// Run with: go test -fuzz=FuzzDecodePacketTruncated -fuzztime=30s ./cmd/server
|
||||
func FuzzDecodePacketTruncated(f *testing.F) {
|
||||
seeds := [][]byte{
|
||||
{0x12, 0xF6, 0xAA, 0xAA, 0xAA},
|
||||
{0x12, 0x00},
|
||||
{0x03, 0x11, 0x22, 0x33, 0x44, 0xC0, 0xAA, 0xAA, 0xAA},
|
||||
}
|
||||
for _, s := range seeds {
|
||||
f.Add(s)
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, data []byte) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("DecodePacket panicked on input %x: %v", data, r)
|
||||
}
|
||||
}()
|
||||
_, _ = DecodePacket(hex.EncodeToString(data), false)
|
||||
})
|
||||
}
|
||||
|
||||
// TestDecodeAdvertOversizedNameTruncated asserts decodeAdvert truncates the
|
||||
// advert name to firmware's MAX_ADVERT_DATA_SIZE=32 (firmware/src/MeshCore.h:11).
|
||||
// Firmware writes the node name into a 32-byte buffer, so any on-wire advert
|
||||
// carrying >32 bytes of name data is adversarial — the Go decoder must not
|
||||
// surface attacker-controlled bytes beyond what firmware would ever emit.
|
||||
func TestDecodeAdvertOversizedNameTruncated(t *testing.T) {
|
||||
pubkey := repeatHex("AA", 32)
|
||||
timestamp := "78563412"
|
||||
signature := repeatHex("BB", 64)
|
||||
flags := "81" // chat(1) | hasName(0x80), no location, no feat1/2
|
||||
// 64-byte ASCII 'X' name (firmware buffer is only 32 bytes).
|
||||
name := repeatHex("58", 64)
|
||||
hex := "1200" + pubkey + timestamp + signature + flags + name
|
||||
pkt, err := DecodePacket(hex, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePacket: %v", err)
|
||||
}
|
||||
if got := len(pkt.Payload.Name); got > 32 {
|
||||
t.Errorf("name length=%d, want <=32 (MAX_ADVERT_DATA_SIZE firmware/src/MeshCore.h:11)", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user