Skip to content

Commit 505b2c2

Browse files
committed
fix: bound NSEC bitmap length against record end in _read_bitmap
A malicious NSEC bitmap_length that overruns the declared record end left self.offset pointing past the next record's header, corrupting every subsequent record in the same packet. The forgiving Python slice also let the parser synthesize attacker-controlled rdtypes from adjacent bytes and cache them on the resulting DNSNsec. Validate the window header is in-record, reject bitmap_length 0 or > 32 (RFC 4034 §4.1.2), and raise IncomingDecodeError so _read_others resets the offset to the NSEC's declared end.
1 parent 9c0e9f3 commit 505b2c2

3 files changed

Lines changed: 69 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: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -807,6 +807,60 @@ 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+
810864
def test_records_same_packet_share_fate():
811865
"""Test records in the same packet all have the same created time."""
812866
out = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA)

0 commit comments

Comments
 (0)