From 4f12c80717abd48ef844fe251f289d5bb5f9b4e2 Mon Sep 17 00:00:00 2001 From: fukutomiteppei Date: Tue, 7 Apr 2026 23:23:28 +0900 Subject: [PATCH 1/2] dns: fix EDNS0ClientSubnet address truncation for non-octet-aligned prefix lengths When source_plen is set to a value whose ceil(plen/8) byte count exceeds the number of non-zero bytes in the address (e.g. source_plen=23 for 101.132.0.0), _pack_subnet incorrectly stripped the trailing zero byte, producing a 2-byte address instead of the required 3 bytes. Per RFC 7871, the ADDRESS field MUST be truncated to ceil(source_plen/8) bytes. Fix _pack_subnet to accept an optional plen parameter and apply RFC-compliant truncation when it is provided. i2m and i2len now pass pkt.source_plen so the correct byte count is used during packet building. Fixes: https://github.com/secdev/scapy/issues/4942 --- scapy/layers/dns.py | 21 +++++++++++++++------ test/scapy/layers/dns_edns0.uts | 29 +++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/scapy/layers/dns.py b/scapy/layers/dns.py index a45044c5532..8824ba9350e 100644 --- a/scapy/layers/dns.py +++ b/scapy/layers/dns.py @@ -558,9 +558,16 @@ def m2i(self, pkt, x): x = x[: operator.floordiv(self.af_length, 8)] return inet_ntop(self.af_familly, x) - def _pack_subnet(self, subnet): - # type: (bytes) -> bytes + def _pack_subnet(self, subnet, plen=None): + # type: (bytes, Optional[int]) -> bytes packed_subnet = inet_pton(self.af_familly, plain_str(subnet)) + if plen is not None: + # RFC 7871: ADDRESS MUST be truncated to the number of bits + # indicated by SOURCE PREFIX-LENGTH, padded with 0 bits to the + # end of the last octet needed. Use ceil(plen / 8) bytes. + num_bytes = operator.floordiv(plen + 7, 8) + return packed_subnet[:num_bytes] + # When prefix length is not known, strip trailing zero bytes. for i in list(range(operator.floordiv(self.af_length, 8)))[::-1]: if packed_subnet[i] != 0: i += 1 @@ -571,21 +578,23 @@ def i2m(self, pkt, x): # type: (Optional[Packet], Optional[Union[str, Net]]) -> bytes if x is None: return self.af_default + plen = getattr(pkt, 'source_plen', None) try: - return self._pack_subnet(x) + return self._pack_subnet(x, plen) except (OSError, socket.error): pkt.family = 2 - return ClientSubnetv6("", "")._pack_subnet(x) + return ClientSubnetv6("", "")._pack_subnet(x, plen) def i2len(self, pkt, x): # type: (Packet, Any) -> int if x is None: return 1 + plen = getattr(pkt, 'source_plen', None) try: - return len(self._pack_subnet(x)) + return len(self._pack_subnet(x, plen)) except (OSError, socket.error): pkt.family = 2 - return len(ClientSubnetv6("", "")._pack_subnet(x)) + return len(ClientSubnetv6("", "")._pack_subnet(x, plen)) class ClientSubnetv6(ClientSubnetv4): diff --git a/test/scapy/layers/dns_edns0.uts b/test/scapy/layers/dns_edns0.uts index add646d3338..4075c4da241 100644 --- a/test/scapy/layers/dns_edns0.uts +++ b/test/scapy/layers/dns_edns0.uts @@ -154,6 +154,35 @@ assert raw(d) == raw_d d = DNSRROPT(raw_d) assert EDNS0ClientSubnet in d.rdata[0] and d.rdata[0].family == 2 and d.rdata[0].address == "2001:db8::" += source_plen with trailing-zero address byte (GH #4942) +# RFC 7871: ADDRESS must be truncated to ceil(source_plen/8) bytes. +# For source_plen=23, ceil(23/8)=3 bytes are required even when the +# 3rd byte is 0x00 (101.132.0.0 -> 0x65 0x84 0x00). + +b = EDNS0ClientSubnet(source_plen=23, address='101.132.0.0') +raw_b = raw(b) +# optlen must be 7 (4 fixed bytes + 3 address bytes) +assert raw_b == b'\x00\x08\x00\x07\x00\x01\x17\x00\x65\x84\x00', repr(raw_b) + +b2 = EDNS0ClientSubnet(raw_b) +assert b2.source_plen == 23 +assert b2.address == '101.132.0.0' + += source_plen with non-zero last byte (sanity check) +# For source_plen=24 on 10.20.30.0, exactly 3 bytes are needed. +b = EDNS0ClientSubnet(source_plen=24, address='10.20.30.0') +raw_b = raw(b) +assert raw_b == b'\x00\x08\x00\x07\x00\x01\x18\x00\x0a\x14\x1e', repr(raw_b) +b2 = EDNS0ClientSubnet(raw_b) +assert b2.source_plen == 24 +assert b2.address == '10.20.30.0' + += source_plen=0 produces empty address +b = EDNS0ClientSubnet(source_plen=0, address='0.0.0.0') +raw_b = raw(b) +# optlen == 4 (no address bytes), source_plen=0 +assert raw_b == b'\x00\x08\x00\x04\x00\x01\x00\x00', repr(raw_b) + + EDNS0 - Cookie From a7076c77633b7567b8e3f344a71eea5f3ace2377 Mon Sep 17 00:00:00 2001 From: fukutomiteppei Date: Tue, 7 Apr 2026 23:32:13 +0900 Subject: [PATCH 2/2] dns: zero out host bits in partial last byte of EDNS0 ECS address RFC 7871 requires that the ADDRESS field be padded with 0 bits to the end of the last octet. The previous fix correctly computed the number of bytes (ceil(plen/8)) but left host bits set in the final partial byte when plen is not a multiple of 8. For example, source_plen=23 on 101.132.255.0 should produce 0xfe as the third byte, not 0xff. Apply a bitmask to the last byte when plen % 8 != 0, and add regression tests for IPv4 and IPv6 cases where the input address has host bits set. --- scapy/layers/dns.py | 9 +++++++-- test/scapy/layers/dns_edns0.uts | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/scapy/layers/dns.py b/scapy/layers/dns.py index 8824ba9350e..94b23003da9 100644 --- a/scapy/layers/dns.py +++ b/scapy/layers/dns.py @@ -564,9 +564,14 @@ def _pack_subnet(self, subnet, plen=None): if plen is not None: # RFC 7871: ADDRESS MUST be truncated to the number of bits # indicated by SOURCE PREFIX-LENGTH, padded with 0 bits to the - # end of the last octet needed. Use ceil(plen / 8) bytes. + # end of the last octet needed. Use ceil(plen / 8) bytes and + # zero out any host bits in the last partial byte. num_bytes = operator.floordiv(plen + 7, 8) - return packed_subnet[:num_bytes] + result = bytearray(packed_subnet[:num_bytes]) + rem = plen % 8 + if rem and result: + result[-1] &= (0xff << (8 - rem)) & 0xff + return bytes(result) # When prefix length is not known, strip trailing zero bytes. for i in list(range(operator.floordiv(self.af_length, 8)))[::-1]: if packed_subnet[i] != 0: diff --git a/test/scapy/layers/dns_edns0.uts b/test/scapy/layers/dns_edns0.uts index 4075c4da241..88ec31705e4 100644 --- a/test/scapy/layers/dns_edns0.uts +++ b/test/scapy/layers/dns_edns0.uts @@ -183,6 +183,26 @@ raw_b = raw(b) # optlen == 4 (no address bytes), source_plen=0 assert raw_b == b'\x00\x08\x00\x04\x00\x01\x00\x00', repr(raw_b) += host bits in partial last byte are zeroed out (IPv4) +# source_plen=23 means bits 0-22 are significant; bit 23 (LSB of byte 3) +# must be cleared. 101.132.255.0 has byte 3 = 0xff -> should become 0xfe. +b = EDNS0ClientSubnet(source_plen=23, address='101.132.255.0') +raw_b = raw(b) +assert raw_b == b'\x00\x08\x00\x07\x00\x01\x17\x00\x65\x84\xfe', repr(raw_b) +b2 = EDNS0ClientSubnet(raw_b) +assert b2.source_plen == 23 +assert b2.address == '101.132.254.0' + += host bits in partial last byte are zeroed out (IPv6) +# source_plen=33 means 5 bytes needed, last byte has 1 significant bit. +# 2001:db8:ffff:: -> byte 5 = 0xff -> masked to 0x80 +b = EDNS0ClientSubnet(family=2, source_plen=33, address='2001:db8:ffff::') +raw_b = raw(b) +# 4 fixed + 5 address bytes = optlen 9 +assert raw_b[2:4] == b'\x00\x09', repr(raw_b) # optlen=9 +assert raw_b[6] == 33 # source_plen +assert raw_b[-1] == 0x80, repr(raw_b) # last byte masked + + EDNS0 - Cookie