Skip to content

Commit 1d83550

Browse files
bluetoothbotbdraco
andauthored
fix: bound NSEC bitmap length against record end (#1731)
Co-authored-by: J. Nick Koston <nick+github@koston.org> Co-authored-by: J. Nick Koston <nick@home-assistant.io>
1 parent 33fba33 commit 1d83550

3 files changed

Lines changed: 94 additions & 0 deletions

File tree

src/zeroconf/_protocol/incoming.pxd

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ cdef class DNSIncoming:
128128
byte="unsigned int",
129129
i="unsigned int",
130130
bitmap_length="unsigned int",
131+
bitmap_end="unsigned int",
131132
)
132133
cdef list _read_bitmap(self, unsigned int end)
133134

src/zeroconf/_protocol/incoming.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,9 +388,23 @@ def _read_bitmap(self, end: _int) -> list[int]:
388388
offset = self.offset
389389
offset_plus_one = offset + 1
390390
offset_plus_two = offset + 2
391+
# RFC 4034 §4.1.2: each window block is window-number byte +
392+
# bitmap-length byte (1..32) + bitmap. A bitmap_length that walks
393+
# past the record's declared end would otherwise leave self.offset
394+
# pointing inside (or past) the next record header, corrupting
395+
# every subsequent record in the same packet.
396+
if offset_plus_two > end:
397+
raise IncomingDecodeError(
398+
f"NSEC bitmap window header truncated at offset {offset} from {self.source}"
399+
)
391400
window = view[offset]
392401
bitmap_length = view[offset_plus_one]
393402
bitmap_end = offset_plus_two + bitmap_length
403+
if bitmap_length == 0 or bitmap_length > 32 or bitmap_end > end:
404+
raise IncomingDecodeError(
405+
f"NSEC bitmap length {bitmap_length} invalid or overruns record end "
406+
f"at offset {offset} from {self.source}"
407+
)
394408
for i, byte in enumerate(self.data[offset_plus_two:bitmap_end]):
395409
for bit in range(8):
396410
if byte & (0x80 >> bit):

tests/test_protocol.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -807,6 +807,85 @@ def test_parse_packet_with_nsec_record():
807807
assert nsec_record.next_name == "MyHome54 (2)._meshcop._udp.local."
808808

809809

810+
def test_nsec_bitmap_length_overruns_record_end():
811+
"""Reject NSEC bitmap whose declared length runs past the record boundary."""
812+
# 0 questions, 2 answers. Answer 1 is a malformed NSEC: rdlength=9, but the
813+
# bitmap window claims length=255 — overrunning the record. Answer 2 is a
814+
# PTR that must still parse because the offset for the next record stays
815+
# pinned to the NSEC's declared end.
816+
packet = (
817+
b"\x00\x00\x84\x00\x00\x00\x00\x02\x00\x00\x00\x00"
818+
b"\x04test\x05local\x00"
819+
b"\x00\x2f\x80\x01"
820+
b"\x00\x00\x11\x94"
821+
b"\x00\x09"
822+
b"\xc0\x0c"
823+
b"\x00\xff"
824+
b"\x80\x00\x00\x00\x00"
825+
b"\xc0\x0c"
826+
b"\x00\x0c\x00\x01"
827+
b"\x00\x00\x11\x94"
828+
b"\x00\x02"
829+
b"\xc0\x0c"
830+
)
831+
parsed = r.DNSIncoming(packet)
832+
answers = parsed.answers()
833+
ptrs = [a for a in answers if isinstance(a, r.DNSPointer)]
834+
assert len(ptrs) == 1
835+
assert ptrs[0].alias == "test.local."
836+
# The malformed NSEC must not surface — if it did, it would carry rdtypes
837+
# synthesized from bytes past the record boundary.
838+
assert not any(isinstance(a, r.DNSNsec) for a in answers)
839+
840+
841+
def test_nsec_bitmap_zero_length_window_rejected():
842+
"""A bitmap window with length=0 violates RFC 4034 §4.1.2 and must be rejected."""
843+
packet = (
844+
b"\x00\x00\x84\x00\x00\x00\x00\x02\x00\x00\x00\x00"
845+
b"\x04test\x05local\x00"
846+
b"\x00\x2f\x80\x01"
847+
b"\x00\x00\x11\x94"
848+
b"\x00\x04"
849+
b"\xc0\x0c"
850+
b"\x00\x00"
851+
b"\xc0\x0c"
852+
b"\x00\x0c\x00\x01"
853+
b"\x00\x00\x11\x94"
854+
b"\x00\x02"
855+
b"\xc0\x0c"
856+
)
857+
parsed = r.DNSIncoming(packet)
858+
answers = parsed.answers()
859+
ptrs = [a for a in answers if isinstance(a, r.DNSPointer)]
860+
assert len(ptrs) == 1
861+
assert not any(isinstance(a, r.DNSNsec) for a in answers)
862+
863+
864+
def test_nsec_bitmap_truncated_window_header_rejected():
865+
"""Reject NSEC bitmap with a trailing byte too short to hold a window header."""
866+
packet = (
867+
b"\x00\x00\x84\x00\x00\x00\x00\x02\x00\x00\x00\x00"
868+
b"\x04test\x05local\x00"
869+
b"\x00\x2f\x80\x01"
870+
b"\x00\x00\x11\x94"
871+
b"\x00\x06"
872+
b"\xc0\x0c"
873+
b"\x00\x01\x80"
874+
b"\xff"
875+
b"\xc0\x0c"
876+
b"\x00\x0c\x00\x01"
877+
b"\x00\x00\x11\x94"
878+
b"\x00\x02"
879+
b"\xc0\x0c"
880+
)
881+
parsed = r.DNSIncoming(packet)
882+
answers = parsed.answers()
883+
ptrs = [a for a in answers if isinstance(a, r.DNSPointer)]
884+
assert len(ptrs) == 1
885+
assert ptrs[0].alias == "test.local."
886+
assert not any(isinstance(a, r.DNSNsec) for a in answers)
887+
888+
810889
def test_records_same_packet_share_fate():
811890
"""Test records in the same packet all have the same created time."""
812891
out = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA)

0 commit comments

Comments
 (0)