VYPR
Medium severity6.5GHSA Advisory· Published Jun 11, 2026· Updated Jun 11, 2026

python-zeroconf: Unbounded TC-deferred queue allows LAN-local memory exhaustion via spoofed-source flood

CVE-2026-48045

Description

Unbounded per-addr deferred queues in zeroconf's AsyncListener allow unauthenticated LAN peers to cause OOM via spoofed-source mDNS floods.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Unbounded per-addr deferred queues in zeroconf's AsyncListener allow unauthenticated LAN peers to cause OOM via spoofed-source mDNS floods.

Vulnerability

The AsyncListener.handle_query_or_defer method in the zeroconf library retains every truncated (TC-bit) incoming mDNS query in a per-address list (self._deferred[addr]) and arms a per-addr timer (self._timers[addr]) to flush reassembled queries after ~500 ms, per RFC 6762 §18.5. Neither the per-addr list nor the number of distinct source addresses is bounded. The dedup check iterates over the per-addr list on every arrival (O(N)). Affected versions include zeroconf prior to 0.149.12.

Exploitation

An unauthenticated attacker on the same Layer-2 segment can send a stream of byte-distinct, TC-flagged mDNS queries over UDP/5353 (multicast 224.0.0.251 / ff02::fb) — each query up to _MAX_MSG_ABSOLUTE = 8966 bytes. By spoofing source IPs, the attacker multiplies the unbounded growth across _deferred and _timers. The O(N) dedup data compare on each per-addr queue burns CPU quadratically as queues grow. No authentication or user interaction is required; the attacker only needs network access to the local link.

Impact

On memory-constrained systems (e.g., Home Assistant on Raspberry Pi), sustained traffic causes process out-of-memory (OOM) termination. Under lighter loads, the per-arrival CPU scan and event-loop starvation disrupt legitimate zeroconf consumers (service discovery, registration, ServiceBrowser callbacks), leading to denial of service.

Mitigation

Fixed in zeroconf version 0.149.12 via commit b22c8ff (PR #1751). Upgrade to >= 0.149.12. There is no in-process workaround; as a network-level mitigation, restrict mDNS (UDP/5353) to trusted Layer-2 segments using AP client isolation, guest-network separation, or host firewall rules [1][2][3].

AI Insight generated on Jun 11, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

1

Patches

1
b22c8ff19c66

fix: bound TC-deferred queues against spoofed-source flood OOM (#1751)

https://github.com/python-zeroconf/python-zeroconfJ. Nick KostonMay 20, 2026via body-scan-shorthand
4 files changed · +146 2
  • src/zeroconf/const.py+14 0 modified
    @@ -77,6 +77,20 @@
     # flooding distinct questions (RFC 6762 §7.3, defense-in-depth).
     _MAX_QUESTION_HISTORY_ENTRIES = 10000
     
    +# Per-addr cap on the number of truncated (TC-bit) packets retained for
    +# RFC 6762 §18.5 reassembly. The spec anticipates only a handful of
    +# segments per truncated query; 16 is well above legitimate need and
    +# keeps the per-arrival dedup scan a constant-time cost under a flood.
    +_MAX_DEFERRED_PER_ADDR = 16
    +
    +# Per-listener cap on the number of distinct addrs with in-flight
    +# TC-deferral state. Each entry can hold up to _MAX_DEFERRED_PER_ADDR
    +# packets of up to _MAX_MSG_ABSOLUTE bytes; 512 leaves headroom for a
    +# legitimate burst (LAN-wide power-resume / boot storm where many
    +# devices announce at once) while bounding worst-case memory at
    +# ~72 MB even when a peer floods with spoofed source IPs.
    +_MAX_DEFERRED_ADDRS = 512
    +
     _DNS_PACKET_HEADER_LEN = 12
     
     _MAX_MSG_TYPICAL = 1460  # unused
    
  • src/zeroconf/_listener.pxd+4 0 modified
    @@ -15,6 +15,8 @@ cdef bint TYPE_CHECKING
     cdef cython.uint _MAX_MSG_ABSOLUTE
     cdef cython.uint _DUPLICATE_PACKET_SUPPRESSION_INTERVAL
     cdef cython.uint _RECENT_PACKETS_MAX
    +cdef cython.uint _MAX_DEFERRED_ADDRS
    +cdef cython.uint _MAX_DEFERRED_PER_ADDR
     
     
     cdef class AsyncListener:
    @@ -41,6 +43,8 @@ cdef class AsyncListener:
     
         cdef _cancel_any_timers_for_addr(self, object addr)
     
    +    cdef _evict_oldest_deferred(self)
    +
         @cython.locals(deadline=object, fire_at=double)
         cdef double _compute_deferred_fire_at(self, object addr, double now, double delay)
     
    
  • src/zeroconf/_listener.py+32 1 modified
    @@ -32,7 +32,13 @@
     from ._protocol.incoming import DNSIncoming
     from ._transport import _WrappedTransport, make_wrapped_transport
     from ._utils.time import current_time_millis, millis_to_seconds
    -from .const import _DUPLICATE_PACKET_SUPPRESSION_INTERVAL, _MAX_MSG_ABSOLUTE, _RECENT_PACKETS_MAX
    +from .const import (
    +    _DUPLICATE_PACKET_SUPPRESSION_INTERVAL,
    +    _MAX_DEFERRED_ADDRS,
    +    _MAX_DEFERRED_PER_ADDR,
    +    _MAX_MSG_ABSOLUTE,
    +    _RECENT_PACKETS_MAX,
    +)
     
     if TYPE_CHECKING:
         from ._core import Zeroconf
    @@ -240,7 +246,17 @@ def handle_query_or_defer(
                 self._respond_query(msg, addr, port, transport, v6_flow_scope)
                 return
     
    +        if addr not in self._deferred and len(self._deferred) >= _MAX_DEFERRED_ADDRS:
    +            # Bound total deferred addrs so a spoofed-source flood
    +            # cannot keep adding distinct entries; evict the oldest
    +            # (insertion-order) entry and discard its in-flight queue.
    +            self._evict_oldest_deferred()
    +
             deferred = self._deferred.setdefault(addr, [])
    +        if len(deferred) >= _MAX_DEFERRED_PER_ADDR:
    +            # Bound per-addr queue length; further fragments from the
    +            # same source are dropped until the timer flushes.
    +            return
             # If we get the same packet we ignore it
             for incoming in reversed(deferred):
                 if incoming.data == msg.data:
    @@ -293,6 +309,21 @@ def _cancel_any_timers_for_addr(self, addr: _str) -> None:
             if addr in self._timers:
                 self._timers.pop(addr).cancel()
     
    +    def _evict_oldest_deferred(self) -> None:
    +        """Discard the oldest deferred addr's reassembly state.
    +
    +        Used when ``_MAX_DEFERRED_ADDRS`` would be exceeded; the
    +        evicted addr's queue and timer are dropped without firing, so
    +        the bound holds even when an attacker rotates source IPs.
    +        Eviction is FIFO (oldest by first-seen, via dict insertion
    +        order) rather than LRU so an active flooder cannot pin its
    +        slots by re-sending into the same addr.
    +        """
    +        oldest_addr = next(iter(self._deferred))
    +        self._cancel_any_timers_for_addr(oldest_addr)
    +        self._deferred_deadlines.pop(oldest_addr, None)
    +        del self._deferred[oldest_addr]
    +
         def _respond_query(
             self,
             msg: DNSIncoming | None,
    
  • tests/test_core.py+96 1 modified
    @@ -19,7 +19,7 @@
     import pytest
     
     import zeroconf as r
    -from zeroconf import NotRunningException, Zeroconf, const, current_time_millis
    +from zeroconf import NotRunningException, Zeroconf, _listener, const, current_time_millis
     from zeroconf._listener import _TC_DELAY_RANDOM_INTERVAL, AsyncListener, _WrappedTransport
     from zeroconf._protocol.incoming import DNSIncoming
     from zeroconf.asyncio import AsyncZeroconf
    @@ -794,6 +794,101 @@ def test_tc_bit_defer_window_is_bounded():
         zc.close()
     
     
    +def _make_distinct_tc_packets(count: int, name_prefix: str = "q") -> list[bytes]:
    +    """Generate ``count`` byte-distinct TC-flagged query packets for flood inputs."""
    +    packets = []
    +    for i in range(count):
    +        out = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_TC)
    +        out.add_question(r.DNSQuestion(f"{name_prefix}{i}._tcp.local.", const._TYPE_PTR, const._CLASS_IN))
    +        packets.append(out.packets()[0])
    +    return packets
    +
    +
    +def _synthetic_source_ip(i: int) -> str:
    +    """Distinct synthetic source IPs from the documentation ranges."""
    +    if i < 256:
    +        return f"203.0.113.{i}"
    +    if i < 512:
    +        return f"198.51.100.{i - 256}"
    +    return f"192.0.2.{i - 512}"
    +
    +
    +def test_tc_bit_per_addr_queue_is_bounded(quick_timing: None) -> None:
    +    """Per-addr deferred queue must not grow past ``_MAX_DEFERRED_PER_ADDR``."""
    +    zc = Zeroconf(interfaces=["127.0.0.1"])
    +    _wait_for_start(zc)
    +    protocol = zc.engine.protocols[0]
    +    source_ip = "203.0.113.21"
    +
    +    extra = 4
    +    packets = _make_distinct_tc_packets(const._MAX_DEFERRED_PER_ADDR + extra)
    +
    +    # Push the reassembly timer well past any possible test runtime
    +    # so the bound under test is the only thing that can drop entries.
    +    with patch.object(_listener, "_TC_DELAY_RANDOM_INTERVAL", (60_000, 60_001)):
    +        for raw in packets:
    +            threadsafe_query(zc, protocol, r.DNSIncoming(raw), source_ip, const._MDNS_PORT, Mock(), ())
    +
    +        assert len(protocol._deferred[source_ip]) == const._MAX_DEFERRED_PER_ADDR
    +        # Last ``extra`` packets must have been dropped, not displaced; the
    +        # earlier ``_MAX_DEFERRED_PER_ADDR`` entries are the ones retained.
    +        retained = [incoming.data for incoming in protocol._deferred[source_ip]]
    +        assert retained == packets[: const._MAX_DEFERRED_PER_ADDR]
    +
    +    zc.close()
    +
    +
    +def test_tc_bit_total_addrs_is_bounded(quick_timing: None) -> None:
    +    """Distinct addrs with deferred state must not exceed ``_MAX_DEFERRED_ADDRS``."""
    +    zc = Zeroconf(interfaces=["127.0.0.1"])
    +    _wait_for_start(zc)
    +    protocol = zc.engine.protocols[0]
    +
    +    raw = _make_distinct_tc_packets(1)[0]
    +    extra = 4
    +    addrs = [_synthetic_source_ip(i) for i in range(const._MAX_DEFERRED_ADDRS + extra)]
    +
    +    # Push the reassembly timer well past any possible test runtime
    +    # so the bound under test is the only thing that can drop entries;
    +    # without this, PyPy / slow runners can fire timers between the
    +    # last enqueue and the assertion.
    +    with patch.object(_listener, "_TC_DELAY_RANDOM_INTERVAL", (60_000, 60_001)):
    +        for source_ip in addrs:
    +            threadsafe_query(zc, protocol, r.DNSIncoming(raw), source_ip, const._MDNS_PORT, Mock(), ())
    +
    +        assert len(protocol._deferred) == const._MAX_DEFERRED_ADDRS
    +        assert len(protocol._timers) == const._MAX_DEFERRED_ADDRS
    +
    +    zc.close()
    +
    +
    +def test_tc_bit_eviction_drops_oldest_addr(quick_timing: None) -> None:
    +    """Adding a new addr at capacity drops the oldest insertion (FIFO)."""
    +    zc = Zeroconf(interfaces=["127.0.0.1"])
    +    _wait_for_start(zc)
    +    protocol = zc.engine.protocols[0]
    +
    +    raw = _make_distinct_tc_packets(1)[0]
    +    fillers = [_synthetic_source_ip(i) for i in range(const._MAX_DEFERRED_ADDRS)]
    +    new_addr = _synthetic_source_ip(const._MAX_DEFERRED_ADDRS)
    +    oldest = fillers[0]
    +
    +    with patch.object(_listener, "_TC_DELAY_RANDOM_INTERVAL", (60_000, 60_001)):
    +        for source_ip in fillers:
    +            threadsafe_query(zc, protocol, r.DNSIncoming(raw), source_ip, const._MDNS_PORT, Mock(), ())
    +        assert len(protocol._deferred) == const._MAX_DEFERRED_ADDRS
    +        assert oldest in protocol._deferred
    +
    +        # One more distinct addr must evict the oldest insertion-order entry.
    +        threadsafe_query(zc, protocol, r.DNSIncoming(raw), new_addr, const._MDNS_PORT, Mock(), ())
    +        assert oldest not in protocol._deferred
    +        assert oldest not in protocol._timers
    +        assert new_addr in protocol._deferred
    +        assert len(protocol._deferred) == const._MAX_DEFERRED_ADDRS
    +
    +    zc.close()
    +
    +
     @pytest.mark.asyncio
     async def test_open_close_twice_from_async() -> None:
         """Test we can close twice from a coroutine when using Zeroconf.
    

Vulnerability mechanics

Root cause

"Missing caps on per-addr deferred queue length and total distinct deferred addresses in AsyncListener.handle_query_or_defer allows an unauthenticated flood of TC-flagged mDNS queries to exhaust memory and CPU."

Attack vector

An unauthenticated attacker on the local link can send UDP/5353 packets to the mDNS multicast address (224.0.0.251 or ff02::fb) with the TC (truncation) bit set. Each packet can be up to 8966 bytes, and the attacker can spoof source IPs to multiply the effect across `_deferred` and `_timers`. The per-arrival O(N) data comparison burns CPU quadratically as each per-addr queue grows, and on memory-constrained devices (e.g., Home Assistant on Raspberry Pi) sustained traffic causes an out-of-memory kill or event-loop starvation [ref_id=1].

Affected code

The vulnerability resides in `AsyncListener.handle_query_or_defer` in `src/zeroconf/_listener.py`. The method stored every truncated (TC-bit) mDNS query in `self._deferred[addr]` without capping the per-addr list or the number of distinct `addr` keys, and the deduplication loop (`for incoming in reversed(deferred): if incoming.data == msg.data`) ran O(N) over the per-addr list on each arrival.

What the fix does

The patch introduces two constants in `src/zeroconf/const.py`: `_MAX_DEFERRED_PER_ADDR=16` caps the per-addr deferred queue, and `_MAX_DEFERRED_ADDRS=512` caps the total number of distinct source addresses tracked. In `handle_query_or_defer`, a new early-return drops packets when the per-addr queue is full, and a new `_evict_oldest_deferred` method evicts the oldest (FIFO) address when the total address limit is reached. This bounds worst-case memory to ~72 MB and keeps the dedup scan constant-time, preventing the OOM and CPU starvation described in the advisory [patch_id=5594802].

Preconditions

  • networkAttacker must be on the same local link (Layer-2 segment) as the target.
  • networkAttacker must be able to send UDP packets to the mDNS multicast address (224.0.0.251 or ff02::fb) on port 5353.
  • authNo authentication is required; the attack is unauthenticated.

Generated on Jun 11, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.